mirror of
https://codeberg.org/emersion/gamja.git
synced 2024-11-28 18:26:19 -05:00
Introduce Client class
This commit is contained in:
parent
65d675b461
commit
eef5609dcf
3 changed files with 629 additions and 581 deletions
738
assets/client.js
738
assets/client.js
|
@ -1,381 +1,89 @@
|
|||
import * as irc from "./irc.js";
|
||||
|
||||
var server = {
|
||||
name: "server",
|
||||
username: null,
|
||||
realname: null,
|
||||
nick: null,
|
||||
pass: null,
|
||||
saslPlain: null,
|
||||
autojoin: [],
|
||||
};
|
||||
|
||||
// Static list of capabilities that are always requested when supported by the
|
||||
// server
|
||||
const permanentCaps = ["message-tags", "server-time", "multi-prefix"];
|
||||
|
||||
var ws = null;
|
||||
var registered = false;
|
||||
var availableCaps = {};
|
||||
var enabledCaps = {};
|
||||
|
||||
var buffers = {};
|
||||
var activeBuffer = null;
|
||||
var serverBuffer = null;
|
||||
|
||||
var bufferListElt = document.querySelector("#buffer-list");
|
||||
var bufferElt = document.querySelector("#buffer");
|
||||
var composerElt = document.querySelector("#composer");
|
||||
var composerInputElt = document.querySelector("#composer input");
|
||||
var connectElt = document.querySelector("#connect");
|
||||
var connectFormElt = document.querySelector("#connect form");
|
||||
|
||||
function djb2(s) {
|
||||
var hash = 5381;
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
hash = (hash << 5) + hash + s.charCodeAt(i);
|
||||
hash = hash >>> 0; // convert to uint32
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function createNickElement(name) {
|
||||
var nick = document.createElement("a");
|
||||
nick.href = "#";
|
||||
nick.className = "nick nick-" + (djb2(name) % 16 + 1);
|
||||
nick.innerText = name;
|
||||
nick.onclick = function(event) {
|
||||
event.preventDefault();
|
||||
switchBuffer(createBuffer(name));
|
||||
export default class Client extends EventTarget {
|
||||
ws = null;
|
||||
nick = null;
|
||||
params = {
|
||||
username: null,
|
||||
realname: null,
|
||||
nick: null,
|
||||
pass: null,
|
||||
saslPlain: null,
|
||||
};
|
||||
return nick;
|
||||
}
|
||||
registered = false;
|
||||
availableCaps = {};
|
||||
enabledCaps = {};
|
||||
|
||||
function createMessageElement(msg) {
|
||||
var date = new Date();
|
||||
if (msg.tags["time"]) {
|
||||
date = new Date(msg.tags["time"]);
|
||||
}
|
||||
constructor(params) {
|
||||
super();
|
||||
|
||||
var line = document.createElement("div");
|
||||
line.className = "logline";
|
||||
this.params = Object.assign(this.params, params);
|
||||
|
||||
var timestamp = document.createElement("a");
|
||||
timestamp.href = "#";
|
||||
timestamp.className = "timestamp";
|
||||
timestamp.innerText = date.toLocaleTimeString(undefined, {
|
||||
timeStyle: "short",
|
||||
hour12: false,
|
||||
});
|
||||
timestamp.onclick = function(event) {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
line.appendChild(timestamp);
|
||||
line.appendChild(document.createTextNode(" "));
|
||||
|
||||
switch (msg.command) {
|
||||
case "NOTICE":
|
||||
case "PRIVMSG":
|
||||
var text = msg.params[1];
|
||||
|
||||
var actionPrefix = "\x01ACTION ";
|
||||
if (text.startsWith(actionPrefix) && text.endsWith("\x01")) {
|
||||
var action = text.slice(actionPrefix.length, -1);
|
||||
|
||||
line.className += " me-tell";
|
||||
|
||||
line.appendChild(document.createTextNode("* "));
|
||||
line.appendChild(createNickElement(msg.prefix.name));
|
||||
line.appendChild(document.createTextNode(" " + action));
|
||||
} else {
|
||||
line.className += " talk";
|
||||
|
||||
line.appendChild(document.createTextNode("<"));
|
||||
line.appendChild(createNickElement(msg.prefix.name));
|
||||
line.appendChild(document.createTextNode("> "));
|
||||
line.appendChild(document.createTextNode(text));
|
||||
try {
|
||||
this.ws = new WebSocket(params.url);
|
||||
} catch (err) {
|
||||
console.error("Failed to create connection:", err);
|
||||
setTimeout(() => this.dispatchEvent(new CustomEvent("close")), 0);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case "JOIN":
|
||||
line.appendChild(createNickElement(msg.prefix.name));
|
||||
line.appendChild(document.createTextNode(" has joined"));
|
||||
break;
|
||||
case "PART":
|
||||
line.appendChild(createNickElement(msg.prefix.name));
|
||||
line.appendChild(document.createTextNode(" has left"));
|
||||
break;
|
||||
case "NICK":
|
||||
var newNick = msg.params[0];
|
||||
line.appendChild(createNickElement(msg.prefix.name));
|
||||
line.appendChild(document.createTextNode(" is now known as "));
|
||||
line.appendChild(createNickElement(newNick));
|
||||
break;
|
||||
case "TOPIC":
|
||||
line.appendChild(createNickElement(msg.prefix.name));
|
||||
line.appendChild(document.createTextNode(" changed the topic to: " + msg.params[1]));
|
||||
break;
|
||||
default:
|
||||
line.appendChild(document.createTextNode(" " + msg.command + " " + msg.params.join(" ")));
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
this.ws.addEventListener("open", this.handleOpen.bind(this));
|
||||
this.ws.addEventListener("message", this.handleMessage.bind(this));
|
||||
|
||||
function createBuffer(name) {
|
||||
if (buffers[name]) {
|
||||
return buffers[name];
|
||||
}
|
||||
|
||||
var a = document.createElement("a");
|
||||
a.href = "#";
|
||||
a.onclick = function(event) {
|
||||
event.preventDefault();
|
||||
switchBuffer(name);
|
||||
};
|
||||
a.innerText = name;
|
||||
|
||||
var li = document.createElement("li");
|
||||
li.appendChild(a);
|
||||
|
||||
var buf = {
|
||||
name: name,
|
||||
li: li,
|
||||
readOnly: false,
|
||||
topic: null,
|
||||
members: {},
|
||||
messages: [],
|
||||
|
||||
addMessage: function(msg) {
|
||||
if (!msg.tags) {
|
||||
msg.tags = {};
|
||||
}
|
||||
// TODO: set time tag if missing
|
||||
|
||||
if (activeBuffer === buf) {
|
||||
bufferElt.appendChild(createMessageElement(msg));
|
||||
}
|
||||
},
|
||||
};
|
||||
buffers[name] = buf;
|
||||
|
||||
bufferListElt.appendChild(li);
|
||||
return buf;
|
||||
}
|
||||
|
||||
function switchBuffer(buf) {
|
||||
if (typeof buf == "string") {
|
||||
buf = buffers[buf];
|
||||
}
|
||||
if (activeBuffer && buf === activeBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeBuffer) {
|
||||
activeBuffer.li.classList.remove("active");
|
||||
}
|
||||
|
||||
activeBuffer = buf;
|
||||
if (!buf) {
|
||||
return;
|
||||
}
|
||||
|
||||
buf.li.classList.add("active");
|
||||
|
||||
bufferElt.innerHTML = "";
|
||||
for (var msg of buf.messages) {
|
||||
bufferElt.appendChild(createMessageElement(msg));
|
||||
}
|
||||
|
||||
composerElt.classList.toggle("read-only", buf.readOnly);
|
||||
if (!buf.readOnly) {
|
||||
composerInputElt.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function showConnectForm() {
|
||||
setConnectFormDisabled(false);
|
||||
connectElt.style.display = "block";
|
||||
}
|
||||
|
||||
function addAvailableCaps(s) {
|
||||
var l = s.split(" ");
|
||||
l.forEach(function(s) {
|
||||
var parts = s.split("=");
|
||||
var k = parts[0];
|
||||
var v = "";
|
||||
if (parts.length > 1) {
|
||||
v = parts[1];
|
||||
}
|
||||
availableCaps[k] = v;
|
||||
});
|
||||
}
|
||||
|
||||
function handleCap(msg) {
|
||||
var subCmd = msg.params[1];
|
||||
var args = msg.params.slice(2);
|
||||
switch (subCmd) {
|
||||
case "LS":
|
||||
addAvailableCaps(args[args.length - 1]);
|
||||
if (args[0] != "*") {
|
||||
console.log("Available server caps:", availableCaps);
|
||||
|
||||
var reqCaps = [];
|
||||
|
||||
var saslCap = availableCaps["sasl"];
|
||||
var supportsSaslPlain = (saslCap !== undefined);
|
||||
if (saslCap.length > 0) {
|
||||
supportsSaslPlain = saslCap.split(",").includes("PLAIN");
|
||||
}
|
||||
|
||||
var capEnd = true;
|
||||
if (server.saslPlain && supportsSaslPlain) {
|
||||
// CAP END is deferred after authentication finishes
|
||||
reqCaps.push("sasl");
|
||||
capEnd = false;
|
||||
}
|
||||
|
||||
permanentCaps.forEach(function(cap) {
|
||||
if (availableCaps[cap] !== undefined) {
|
||||
reqCaps.push(cap);
|
||||
}
|
||||
});
|
||||
|
||||
if (reqCaps.length > 0) {
|
||||
sendMessage({ command: "CAP", params: ["REQ", reqCaps.join(" ")] });
|
||||
}
|
||||
|
||||
if (!registered && capEnd) {
|
||||
sendMessage({ command: "CAP", params: ["END"] });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "NEW":
|
||||
addAvailableCaps(args[0]);
|
||||
console.log("Server added available caps:", args[0]);
|
||||
break;
|
||||
case "DEL":
|
||||
args[0].split(" ").forEach(function(cap) {
|
||||
delete availableCaps[cap];
|
||||
delete enabledCaps[cap];
|
||||
this.ws.addEventListener("close", () => {
|
||||
console.log("Connection closed");
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
});
|
||||
console.log("Server removed available caps:", args[0]);
|
||||
break;
|
||||
case "ACK":
|
||||
console.log("Server ack'ed caps:", args[0]);
|
||||
args[0].split(" ").forEach(function(cap) {
|
||||
enabledCaps[cap] = true;
|
||||
|
||||
if (cap == "sasl" && server.saslPlain) {
|
||||
console.log("Starting SASL PLAIN authentication");
|
||||
sendMessage({ command: "AUTHENTICATE", params: ["PLAIN"] });
|
||||
}
|
||||
this.ws.addEventListener("error", () => {
|
||||
console.error("Connection error");
|
||||
});
|
||||
break;
|
||||
case "NAK":
|
||||
console.log("Server nak'ed caps:", args[0]);
|
||||
if (!registered) {
|
||||
sendMessage({ command: "CAP", params: ["END"] });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthenticate(msg) {
|
||||
var challengeStr = msg.params[0];
|
||||
|
||||
// For now only PLAIN is supported
|
||||
if (challengeStr != "+") {
|
||||
console.error("Expected an empty challenge, got:", challengeStr);
|
||||
sendMessage({ command: "AUTHENTICATE", params: ["*"] });
|
||||
return;
|
||||
}
|
||||
|
||||
var respStr = btoa("\0" + server.saslPlain.username + "\0" + server.saslPlain.password);
|
||||
sendMessage({ command: "AUTHENTICATE", params: [respStr] });
|
||||
}
|
||||
|
||||
function connect() {
|
||||
try {
|
||||
ws = new WebSocket(server.url);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showConnectForm();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = function() {
|
||||
handleOpen() {
|
||||
console.log("Connection opened");
|
||||
|
||||
sendMessage({ command: "CAP", params: ["LS", "302"] });
|
||||
if (server.pass) {
|
||||
sendMessage({ command: "PASS", params: [server.pass] });
|
||||
}
|
||||
sendMessage({ command: "NICK", params: [server.nick] });
|
||||
sendMessage({
|
||||
command: "USER",
|
||||
params: [server.username, "0", "*", server.realname],
|
||||
});
|
||||
};
|
||||
this.nick = this.params.nick;
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
this.send({ command: "CAP", params: ["LS", "302"] });
|
||||
if (this.params.pass) {
|
||||
this.send({ command: "PASS", params: [this.params.pass] });
|
||||
}
|
||||
this.send({ command: "NICK", params: [this.nick] });
|
||||
this.send({
|
||||
command: "USER",
|
||||
params: [this.params.username, "0", "*", this.params.realname],
|
||||
});
|
||||
}
|
||||
|
||||
handleMessage(event) {
|
||||
var msg = irc.parseMessage(event.data);
|
||||
console.log("Received:", msg);
|
||||
|
||||
switch (msg.command) {
|
||||
case irc.RPL_WELCOME:
|
||||
if (server.saslPlain && availableCaps["sasl"] === undefined) {
|
||||
if (this.params.saslPlain && this.availableCaps["sasl"] === undefined) {
|
||||
console.error("Server doesn't support SASL PLAIN");
|
||||
disconnect();
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Registration complete");
|
||||
registered = true;
|
||||
connectElt.style.display = "none";
|
||||
|
||||
if (server.autojoin.length > 0) {
|
||||
sendMessage({
|
||||
command: "JOIN",
|
||||
params: [server.autojoin.join(",")],
|
||||
});
|
||||
}
|
||||
break;
|
||||
case irc.RPL_TOPIC:
|
||||
var channel = msg.params[1];
|
||||
var topic = msg.params[2];
|
||||
|
||||
var buf = buffers[channel];
|
||||
if (!buf) {
|
||||
break;
|
||||
}
|
||||
buf.topic = topic;
|
||||
break;
|
||||
case irc.RPL_NAMREPLY:
|
||||
var channel = msg.params[2];
|
||||
var members = msg.params.slice(3);
|
||||
|
||||
var buf = buffers[channel];
|
||||
if (!buf) {
|
||||
break;
|
||||
}
|
||||
|
||||
members.forEach(function(s) {
|
||||
var member = irc.parseMembership(s);
|
||||
buf.members[member.nick] = member.prefix;
|
||||
});
|
||||
break;
|
||||
case irc.RPL_ENDOFNAMES:
|
||||
this.registered = true;
|
||||
break;
|
||||
case irc.ERR_PASSWDMISMATCH:
|
||||
console.error("Password mismatch");
|
||||
disconnect();
|
||||
this.close();
|
||||
break;
|
||||
case "CAP":
|
||||
handleCap(msg);
|
||||
this.handleCap(msg);
|
||||
break;
|
||||
case "AUTHENTICATE":
|
||||
handleAuthenticate(msg);
|
||||
this.handleAuthenticate(msg);
|
||||
break;
|
||||
case irc.RPL_LOGGEDIN:
|
||||
console.log("Logged in");
|
||||
|
@ -385,8 +93,8 @@ function connect() {
|
|||
break;
|
||||
case irc.RPL_SASLSUCCESS:
|
||||
console.log("SASL authentication success");
|
||||
if (!registered) {
|
||||
sendMessage({ command: "CAP", params: ["END"] });
|
||||
if (!this.registered) {
|
||||
this.send({ command: "CAP", params: ["END"] });
|
||||
}
|
||||
break;
|
||||
case irc.ERR_NICKLOCKED:
|
||||
|
@ -395,256 +103,126 @@ function connect() {
|
|||
case irc.ERR_SASLABORTED:
|
||||
case irc.ERR_SASLALREADY:
|
||||
console.error("SASL error:", msg);
|
||||
disconnect();
|
||||
break;
|
||||
case "NOTICE":
|
||||
case "PRIVMSG":
|
||||
var target = msg.params[0];
|
||||
if (target == server.nick) {
|
||||
target = msg.prefix.name;
|
||||
}
|
||||
var buf;
|
||||
if (target == "*") {
|
||||
buf = serverBuffer;
|
||||
} else {
|
||||
buf = createBuffer(target);
|
||||
}
|
||||
buf.addMessage(msg);
|
||||
break;
|
||||
case "JOIN":
|
||||
var channel = msg.params[0];
|
||||
var buf = createBuffer(channel);
|
||||
buf.members[msg.prefix.name] = null;
|
||||
if (msg.prefix.name != server.nick) {
|
||||
buf.addMessage(msg);
|
||||
}
|
||||
if (channel == server.autojoin[0]) {
|
||||
// TODO: only switch once right after connect
|
||||
switchBuffer(buf);
|
||||
}
|
||||
break;
|
||||
case "PART":
|
||||
var channel = msg.params[0];
|
||||
var buf = createBuffer(channel);
|
||||
delete buf.members[msg.prefix.name];
|
||||
buf.addMessage(msg);
|
||||
this.close();
|
||||
break;
|
||||
case "NICK":
|
||||
var newNick = msg.params[0];
|
||||
if (msg.prefix.name == server.nick) {
|
||||
server.nick = newNick;
|
||||
if (msg.prefix.name == this.nick) {
|
||||
this.nick = newNick;
|
||||
}
|
||||
for (var name in buffers) {
|
||||
var buf = buffers[name];
|
||||
if (buf.members[msg.prefix.name] !== undefined) {
|
||||
buf.members[newNick] = buf.members[msg.prefix.name];
|
||||
delete buf.members[msg.prefix.name];
|
||||
buf.addMessage(msg);
|
||||
break;
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent("message", {
|
||||
detail: { message: msg },
|
||||
}));
|
||||
}
|
||||
|
||||
addAvailableCaps(s) {
|
||||
var l = s.split(" ");
|
||||
l.forEach((s) => {
|
||||
var parts = s.split("=");
|
||||
var k = parts[0];
|
||||
var v = "";
|
||||
if (parts.length > 1) {
|
||||
v = parts[1];
|
||||
}
|
||||
this.availableCaps[k] = v;
|
||||
});
|
||||
}
|
||||
|
||||
handleCap(msg) {
|
||||
var subCmd = msg.params[1];
|
||||
var args = msg.params.slice(2);
|
||||
switch (subCmd) {
|
||||
case "LS":
|
||||
this.addAvailableCaps(args[args.length - 1]);
|
||||
if (args[0] != "*") {
|
||||
console.log("Available server caps:", this.availableCaps);
|
||||
|
||||
var reqCaps = [];
|
||||
|
||||
var saslCap = this.availableCaps["sasl"];
|
||||
var supportsSaslPlain = (saslCap !== undefined);
|
||||
if (saslCap.length > 0) {
|
||||
supportsSaslPlain = saslCap.split(",").includes("PLAIN");
|
||||
}
|
||||
|
||||
var capEnd = true;
|
||||
if (this.params.saslPlain && supportsSaslPlain) {
|
||||
// CAP END is deferred after authentication finishes
|
||||
reqCaps.push("sasl");
|
||||
capEnd = false;
|
||||
}
|
||||
|
||||
permanentCaps.forEach((cap) => {
|
||||
if (this.availableCaps[cap] !== undefined) {
|
||||
reqCaps.push(cap);
|
||||
}
|
||||
});
|
||||
|
||||
if (reqCaps.length > 0) {
|
||||
this.send({ command: "CAP", params: ["REQ", reqCaps.join(" ")] });
|
||||
}
|
||||
|
||||
if (!this.registered && capEnd) {
|
||||
this.send({ command: "CAP", params: ["END"] });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "TOPIC":
|
||||
var channel = msg.params[0];
|
||||
var topic = msg.params[1];
|
||||
var buf = buffers[channel];
|
||||
if (!buf) {
|
||||
break;
|
||||
}
|
||||
buf.topic = topic;
|
||||
buf.addMessage(msg);
|
||||
case "NEW":
|
||||
this.addAvailableCaps(args[0]);
|
||||
console.log("Server added available caps:", args[0]);
|
||||
// TODO: request caps
|
||||
break;
|
||||
case "DEL":
|
||||
args[0].split(" ").forEach((cap) => {
|
||||
delete this.availableCaps[cap];
|
||||
delete this.enabledCaps[cap];
|
||||
});
|
||||
console.log("Server removed available caps:", args[0]);
|
||||
break;
|
||||
case "ACK":
|
||||
console.log("Server ack'ed caps:", args[0]);
|
||||
args[0].split(" ").forEach((cap) => {
|
||||
this.enabledCaps[cap] = true;
|
||||
|
||||
if (cap == "sasl" && this.params.saslPlain) {
|
||||
console.log("Starting SASL PLAIN authentication");
|
||||
this.send({ command: "AUTHENTICATE", params: ["PLAIN"] });
|
||||
}
|
||||
});
|
||||
break;
|
||||
case "NAK":
|
||||
console.log("Server nak'ed caps:", args[0]);
|
||||
if (!this.registered) {
|
||||
this.send({ command: "CAP", params: ["END"] });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
serverBuffer.addMessage(msg);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
ws.onclose = function() {
|
||||
console.log("Connection closed");
|
||||
showConnectForm();
|
||||
};
|
||||
handleAuthenticate(msg) {
|
||||
var challengeStr = msg.params[0];
|
||||
|
||||
ws.onerror = function() {
|
||||
console.error("Connection error");
|
||||
};
|
||||
|
||||
serverBuffer = createBuffer(server.name);
|
||||
serverBuffer.readOnly = true;
|
||||
switchBuffer(serverBuffer);
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
ws.close(1000);
|
||||
registered = false;
|
||||
}
|
||||
|
||||
function sendMessage(msg) {
|
||||
ws.send(irc.formatMessage(msg));
|
||||
console.log("Sent:", msg);
|
||||
}
|
||||
|
||||
function executeCommand(s) {
|
||||
var parts = s.split(" ");
|
||||
var cmd = parts[0].toLowerCase().slice(1);
|
||||
var args = parts.slice(1);
|
||||
switch (cmd) {
|
||||
case "quit":
|
||||
if (localStorage) {
|
||||
localStorage.removeItem("server");
|
||||
}
|
||||
disconnect();
|
||||
break;
|
||||
case "join":
|
||||
var channel = args[0];
|
||||
if (!channel) {
|
||||
console.error("Missing channel name");
|
||||
// For now only PLAIN is supported
|
||||
if (challengeStr != "+") {
|
||||
console.error("Expected an empty challenge, got:", challengeStr);
|
||||
this.send({ command: "AUTHENTICATE", params: ["*"] });
|
||||
return;
|
||||
}
|
||||
sendMessage({ command: "JOIN", params: [channel] });
|
||||
break;
|
||||
case "part":
|
||||
// TODO: part reason
|
||||
if (!activeBuffer || activeBuffer.readOnly) {
|
||||
console.error("Not in a channel");
|
||||
return;
|
||||
}
|
||||
var channel = activeBuffer.name;
|
||||
sendMessage({ command: "PART", params: [channel] });
|
||||
break;
|
||||
case "msg":
|
||||
var target = args[0];
|
||||
var text = args.slice(1).join(" ");
|
||||
sendMessage({ command: "PRIVMSG", params: [target, text] });
|
||||
break;
|
||||
case "nick":
|
||||
var newNick = args[0];
|
||||
sendMessage({ command: "NICK", params: [newNick] });
|
||||
break;
|
||||
default:
|
||||
console.error("Unknwon command '" + cmd + "'");
|
||||
}
|
||||
}
|
||||
|
||||
composerElt.onsubmit = function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
var text = composerInputElt.value;
|
||||
composerInputElt.value = "";
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.startsWith("//")) {
|
||||
text = text.slice(1);
|
||||
} else if (text.startsWith("/")) {
|
||||
executeCommand(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeBuffer || activeBuffer.readOnly) {
|
||||
return;
|
||||
}
|
||||
var target = activeBuffer.name;
|
||||
|
||||
var msg = { command: "PRIVMSG", params: [target, text] };
|
||||
sendMessage(msg);
|
||||
msg.prefix = { name: server.nick };
|
||||
activeBuffer.addMessage(msg);
|
||||
};
|
||||
|
||||
function setConnectFormDisabled(disabled) {
|
||||
connectElt.querySelectorAll("input, button").forEach(function(elt) {
|
||||
elt.disabled = disabled;
|
||||
});
|
||||
}
|
||||
|
||||
function parseQueryString() {
|
||||
var query = window.location.search.substring(1);
|
||||
var params = {};
|
||||
query.split('&').forEach(function(s) {
|
||||
if (!s) {
|
||||
return;
|
||||
}
|
||||
var pair = s.split('=');
|
||||
params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || "");
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
connectFormElt.onsubmit = function(event) {
|
||||
event.preventDefault();
|
||||
setConnectFormDisabled(true);
|
||||
|
||||
server.url = connectFormElt.elements.url.value;
|
||||
server.nick = connectFormElt.elements.nick.value;
|
||||
server.username = connectFormElt.elements.username.value || server.nick;
|
||||
server.realname = connectFormElt.elements.realname.value || server.nick;
|
||||
server.pass = connectFormElt.elements.pass.value;
|
||||
|
||||
server.saslPlain = null;
|
||||
if (connectFormElt.elements.password.value) {
|
||||
server.saslPlain = {
|
||||
username: server.username,
|
||||
password: connectFormElt.elements.password.value,
|
||||
};
|
||||
}
|
||||
|
||||
server.autojoin = [];
|
||||
connectFormElt.elements.autojoin.value.split(",").forEach(function(ch) {
|
||||
ch = ch.trim();
|
||||
if (!ch) {
|
||||
return;
|
||||
}
|
||||
server.autojoin.push(ch);
|
||||
});
|
||||
|
||||
if (localStorage) {
|
||||
if (connectFormElt.elements["remember-me"].checked) {
|
||||
localStorage.setItem("server", JSON.stringify(server));
|
||||
} else {
|
||||
localStorage.removeItem("server");
|
||||
}
|
||||
}
|
||||
|
||||
connect();
|
||||
};
|
||||
|
||||
window.onkeydown = function(event) {
|
||||
if (activeBuffer && activeBuffer.readOnly && event.key == "/" && document.activeElement != composerInputElt) {
|
||||
// Allow typing commands even in read-only buffers
|
||||
composerElt.classList.remove("read-only");
|
||||
composerInputElt.focus();
|
||||
composerInputElt.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
if (localStorage && localStorage.getItem("server")) {
|
||||
server = JSON.parse(localStorage.getItem("server"));
|
||||
connectFormElt.elements.url.value = server.url;
|
||||
connectFormElt.elements.nick.value = server.nick;
|
||||
if (server.username != server.nick) {
|
||||
connectFormElt.elements.username.value = server.username;
|
||||
}
|
||||
if (server.realname != server.nick) {
|
||||
connectFormElt.elements.realname.value = server.realname;
|
||||
}
|
||||
connectFormElt.elements["remember-me"].checked = true;
|
||||
setConnectFormDisabled(true);
|
||||
connect();
|
||||
} else {
|
||||
var params = parseQueryString();
|
||||
|
||||
if (params.server) {
|
||||
connectFormElt.elements.url.value = params.server;
|
||||
} else if (!connectFormElt.elements.url.value) {
|
||||
var host = window.location.host || "localhost:8080";
|
||||
var proto = "wss:";
|
||||
if (window.location.protocol != "https:") {
|
||||
proto = "ws:";
|
||||
}
|
||||
connectFormElt.elements.url.value = proto + "//" + host + "/socket";
|
||||
}
|
||||
|
||||
if (params.channels) {
|
||||
connectFormElt.elements.autojoin.value = params.channels;
|
||||
|
||||
var respStr = btoa("\0" + this.params.saslPlain.username + "\0" + this.params.saslPlain.password);
|
||||
this.send({ command: "AUTHENTICATE", params: [respStr] });
|
||||
}
|
||||
|
||||
send(msg) {
|
||||
this.ws.send(irc.formatMessage(msg));
|
||||
console.log("Sent:", msg);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws.close(1000);
|
||||
this.registered = false;
|
||||
}
|
||||
}
|
||||
|
|
470
assets/index.js
Normal file
470
assets/index.js
Normal file
|
@ -0,0 +1,470 @@
|
|||
import * as irc from "./irc.js";
|
||||
import Client from "./client.js";
|
||||
|
||||
var server = {
|
||||
name: "server",
|
||||
username: null,
|
||||
realname: null,
|
||||
nick: null,
|
||||
pass: null,
|
||||
saslPlain: null,
|
||||
autojoin: [],
|
||||
};
|
||||
|
||||
var client = null;
|
||||
|
||||
var buffers = {};
|
||||
var activeBuffer = null;
|
||||
var serverBuffer = null;
|
||||
|
||||
var bufferListElt = document.querySelector("#buffer-list");
|
||||
var bufferElt = document.querySelector("#buffer");
|
||||
var composerElt = document.querySelector("#composer");
|
||||
var composerInputElt = document.querySelector("#composer input");
|
||||
var connectElt = document.querySelector("#connect");
|
||||
var connectFormElt = document.querySelector("#connect form");
|
||||
|
||||
function djb2(s) {
|
||||
var hash = 5381;
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
hash = (hash << 5) + hash + s.charCodeAt(i);
|
||||
hash = hash >>> 0; // convert to uint32
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function createNickElement(name) {
|
||||
var nick = document.createElement("a");
|
||||
nick.href = "#";
|
||||
nick.className = "nick nick-" + (djb2(name) % 16 + 1);
|
||||
nick.innerText = name;
|
||||
nick.onclick = function(event) {
|
||||
event.preventDefault();
|
||||
switchBuffer(createBuffer(name));
|
||||
};
|
||||
return nick;
|
||||
}
|
||||
|
||||
function createMessageElement(msg) {
|
||||
var date = new Date();
|
||||
if (msg.tags["time"]) {
|
||||
date = new Date(msg.tags["time"]);
|
||||
}
|
||||
|
||||
var line = document.createElement("div");
|
||||
line.className = "logline";
|
||||
|
||||
var timestamp = document.createElement("a");
|
||||
timestamp.href = "#";
|
||||
timestamp.className = "timestamp";
|
||||
timestamp.innerText = date.toLocaleTimeString(undefined, {
|
||||
timeStyle: "short",
|
||||
hour12: false,
|
||||
});
|
||||
timestamp.onclick = function(event) {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
line.appendChild(timestamp);
|
||||
line.appendChild(document.createTextNode(" "));
|
||||
|
||||
switch (msg.command) {
|
||||
case "NOTICE":
|
||||
case "PRIVMSG":
|
||||
var text = msg.params[1];
|
||||
|
||||
var actionPrefix = "\x01ACTION ";
|
||||
if (text.startsWith(actionPrefix) && text.endsWith("\x01")) {
|
||||
var action = text.slice(actionPrefix.length, -1);
|
||||
|
||||
line.className += " me-tell";
|
||||
|
||||
line.appendChild(document.createTextNode("* "));
|
||||
line.appendChild(createNickElement(msg.prefix.name));
|
||||
line.appendChild(document.createTextNode(" " + action));
|
||||
} else {
|
||||
line.className += " talk";
|
||||
|
||||
line.appendChild(document.createTextNode("<"));
|
||||
line.appendChild(createNickElement(msg.prefix.name));
|
||||
line.appendChild(document.createTextNode("> "));
|
||||
line.appendChild(document.createTextNode(text));
|
||||
}
|
||||
break;
|
||||
case "JOIN":
|
||||
line.appendChild(createNickElement(msg.prefix.name));
|
||||
line.appendChild(document.createTextNode(" has joined"));
|
||||
break;
|
||||
case "PART":
|
||||
line.appendChild(createNickElement(msg.prefix.name));
|
||||
line.appendChild(document.createTextNode(" has left"));
|
||||
break;
|
||||
case "NICK":
|
||||
var newNick = msg.params[0];
|
||||
line.appendChild(createNickElement(msg.prefix.name));
|
||||
line.appendChild(document.createTextNode(" is now known as "));
|
||||
line.appendChild(createNickElement(newNick));
|
||||
break;
|
||||
case "TOPIC":
|
||||
line.appendChild(createNickElement(msg.prefix.name));
|
||||
line.appendChild(document.createTextNode(" changed the topic to: " + msg.params[1]));
|
||||
break;
|
||||
default:
|
||||
line.appendChild(document.createTextNode(" " + msg.command + " " + msg.params.join(" ")));
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
function createBuffer(name) {
|
||||
if (buffers[name]) {
|
||||
return buffers[name];
|
||||
}
|
||||
|
||||
var a = document.createElement("a");
|
||||
a.href = "#";
|
||||
a.onclick = function(event) {
|
||||
event.preventDefault();
|
||||
switchBuffer(name);
|
||||
};
|
||||
a.innerText = name;
|
||||
|
||||
var li = document.createElement("li");
|
||||
li.appendChild(a);
|
||||
|
||||
var buf = {
|
||||
name: name,
|
||||
li: li,
|
||||
readOnly: false,
|
||||
topic: null,
|
||||
members: {},
|
||||
messages: [],
|
||||
|
||||
addMessage: function(msg) {
|
||||
if (!msg.tags) {
|
||||
msg.tags = {};
|
||||
}
|
||||
// TODO: set time tag if missing
|
||||
|
||||
if (activeBuffer === buf) {
|
||||
bufferElt.appendChild(createMessageElement(msg));
|
||||
}
|
||||
},
|
||||
};
|
||||
buffers[name] = buf;
|
||||
|
||||
bufferListElt.appendChild(li);
|
||||
return buf;
|
||||
}
|
||||
|
||||
function switchBuffer(buf) {
|
||||
if (typeof buf == "string") {
|
||||
buf = buffers[buf];
|
||||
}
|
||||
if (activeBuffer && buf === activeBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeBuffer) {
|
||||
activeBuffer.li.classList.remove("active");
|
||||
}
|
||||
|
||||
activeBuffer = buf;
|
||||
if (!buf) {
|
||||
return;
|
||||
}
|
||||
|
||||
buf.li.classList.add("active");
|
||||
|
||||
bufferElt.innerHTML = "";
|
||||
for (var msg of buf.messages) {
|
||||
bufferElt.appendChild(createMessageElement(msg));
|
||||
}
|
||||
|
||||
composerElt.classList.toggle("read-only", buf.readOnly);
|
||||
if (!buf.readOnly) {
|
||||
composerInputElt.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function showConnectForm() {
|
||||
setConnectFormDisabled(false);
|
||||
connectElt.style.display = "block";
|
||||
}
|
||||
|
||||
function connect() {
|
||||
client = new Client(server);
|
||||
|
||||
client.addEventListener("close", () => {
|
||||
showConnectForm();
|
||||
});
|
||||
|
||||
client.addEventListener("message", (event) => {
|
||||
var msg = event.detail.message;
|
||||
|
||||
switch (msg.command) {
|
||||
case irc.RPL_WELCOME:
|
||||
connectElt.style.display = "none";
|
||||
|
||||
if (server.autojoin.length > 0) {
|
||||
client.send({
|
||||
command: "JOIN",
|
||||
params: [server.autojoin.join(",")],
|
||||
});
|
||||
}
|
||||
break;
|
||||
case irc.RPL_TOPIC:
|
||||
var channel = msg.params[1];
|
||||
var topic = msg.params[2];
|
||||
|
||||
var buf = buffers[channel];
|
||||
if (!buf) {
|
||||
break;
|
||||
}
|
||||
buf.topic = topic;
|
||||
break;
|
||||
case irc.RPL_NAMREPLY:
|
||||
var channel = msg.params[2];
|
||||
var members = msg.params.slice(3);
|
||||
|
||||
var buf = buffers[channel];
|
||||
if (!buf) {
|
||||
break;
|
||||
}
|
||||
|
||||
members.forEach(function(s) {
|
||||
var member = irc.parseMembership(s);
|
||||
buf.members[member.nick] = member.prefix;
|
||||
});
|
||||
break;
|
||||
case irc.RPL_ENDOFNAMES:
|
||||
break;
|
||||
case "NOTICE":
|
||||
case "PRIVMSG":
|
||||
var target = msg.params[0];
|
||||
if (target == client.nick) {
|
||||
target = msg.prefix.name;
|
||||
}
|
||||
var buf;
|
||||
if (target == "*") {
|
||||
buf = serverBuffer;
|
||||
} else {
|
||||
buf = createBuffer(target);
|
||||
}
|
||||
buf.addMessage(msg);
|
||||
break;
|
||||
case "JOIN":
|
||||
var channel = msg.params[0];
|
||||
var buf = createBuffer(channel);
|
||||
buf.members[msg.prefix.name] = null;
|
||||
if (msg.prefix.name != client.nick) {
|
||||
buf.addMessage(msg);
|
||||
}
|
||||
if (channel == server.autojoin[0]) {
|
||||
// TODO: only switch once right after connect
|
||||
switchBuffer(buf);
|
||||
}
|
||||
break;
|
||||
case "PART":
|
||||
var channel = msg.params[0];
|
||||
var buf = createBuffer(channel);
|
||||
delete buf.members[msg.prefix.name];
|
||||
buf.addMessage(msg);
|
||||
break;
|
||||
case "NICK":
|
||||
var newNick = msg.params[0];
|
||||
for (var name in buffers) {
|
||||
var buf = buffers[name];
|
||||
if (buf.members[msg.prefix.name] !== undefined) {
|
||||
buf.members[newNick] = buf.members[msg.prefix.name];
|
||||
delete buf.members[msg.prefix.name];
|
||||
buf.addMessage(msg);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "TOPIC":
|
||||
var channel = msg.params[0];
|
||||
var topic = msg.params[1];
|
||||
var buf = buffers[channel];
|
||||
if (!buf) {
|
||||
break;
|
||||
}
|
||||
buf.topic = topic;
|
||||
buf.addMessage(msg);
|
||||
break;
|
||||
default:
|
||||
serverBuffer.addMessage(msg);
|
||||
}
|
||||
});
|
||||
|
||||
serverBuffer = createBuffer(server.name);
|
||||
serverBuffer.readOnly = true;
|
||||
switchBuffer(serverBuffer);
|
||||
}
|
||||
|
||||
function executeCommand(s) {
|
||||
var parts = s.split(" ");
|
||||
var cmd = parts[0].toLowerCase().slice(1);
|
||||
var args = parts.slice(1);
|
||||
switch (cmd) {
|
||||
case "quit":
|
||||
if (localStorage) {
|
||||
localStorage.removeItem("server");
|
||||
}
|
||||
disconnect();
|
||||
break;
|
||||
case "join":
|
||||
var channel = args[0];
|
||||
if (!channel) {
|
||||
console.error("Missing channel name");
|
||||
return;
|
||||
}
|
||||
client.send({ command: "JOIN", params: [channel] });
|
||||
break;
|
||||
case "part":
|
||||
// TODO: part reason
|
||||
if (!activeBuffer || activeBuffer.readOnly) {
|
||||
console.error("Not in a channel");
|
||||
return;
|
||||
}
|
||||
var channel = activeBuffer.name;
|
||||
client.send({ command: "PART", params: [channel] });
|
||||
break;
|
||||
case "msg":
|
||||
var target = args[0];
|
||||
var text = args.slice(1).join(" ");
|
||||
client.send({ command: "PRIVMSG", params: [target, text] });
|
||||
break;
|
||||
case "nick":
|
||||
var newNick = args[0];
|
||||
client.send({ command: "NICK", params: [newNick] });
|
||||
break;
|
||||
default:
|
||||
console.error("Unknwon command '" + cmd + "'");
|
||||
}
|
||||
}
|
||||
|
||||
composerElt.onsubmit = function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
var text = composerInputElt.value;
|
||||
composerInputElt.value = "";
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.startsWith("//")) {
|
||||
text = text.slice(1);
|
||||
} else if (text.startsWith("/")) {
|
||||
executeCommand(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeBuffer || activeBuffer.readOnly) {
|
||||
return;
|
||||
}
|
||||
var target = activeBuffer.name;
|
||||
|
||||
var msg = { command: "PRIVMSG", params: [target, text] };
|
||||
client.send(msg);
|
||||
msg.prefix = { name: client.nick };
|
||||
activeBuffer.addMessage(msg);
|
||||
};
|
||||
|
||||
function setConnectFormDisabled(disabled) {
|
||||
connectElt.querySelectorAll("input, button").forEach(function(elt) {
|
||||
elt.disabled = disabled;
|
||||
});
|
||||
}
|
||||
|
||||
function parseQueryString() {
|
||||
var query = window.location.search.substring(1);
|
||||
var params = {};
|
||||
query.split('&').forEach(function(s) {
|
||||
if (!s) {
|
||||
return;
|
||||
}
|
||||
var pair = s.split('=');
|
||||
params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || "");
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
connectFormElt.onsubmit = function(event) {
|
||||
event.preventDefault();
|
||||
setConnectFormDisabled(true);
|
||||
|
||||
server.url = connectFormElt.elements.url.value;
|
||||
server.nick = connectFormElt.elements.nick.value;
|
||||
server.username = connectFormElt.elements.username.value || server.nick;
|
||||
server.realname = connectFormElt.elements.realname.value || server.nick;
|
||||
server.pass = connectFormElt.elements.pass.value;
|
||||
|
||||
server.saslPlain = null;
|
||||
if (connectFormElt.elements.password.value) {
|
||||
server.saslPlain = {
|
||||
username: server.username,
|
||||
password: connectFormElt.elements.password.value,
|
||||
};
|
||||
}
|
||||
|
||||
server.autojoin = [];
|
||||
connectFormElt.elements.autojoin.value.split(",").forEach(function(ch) {
|
||||
ch = ch.trim();
|
||||
if (!ch) {
|
||||
return;
|
||||
}
|
||||
server.autojoin.push(ch);
|
||||
});
|
||||
|
||||
if (localStorage) {
|
||||
if (connectFormElt.elements["remember-me"].checked) {
|
||||
localStorage.setItem("server", JSON.stringify(server));
|
||||
} else {
|
||||
localStorage.removeItem("server");
|
||||
}
|
||||
}
|
||||
|
||||
connect();
|
||||
};
|
||||
|
||||
window.onkeydown = function(event) {
|
||||
if (activeBuffer && activeBuffer.readOnly && event.key == "/" && document.activeElement != composerInputElt) {
|
||||
// Allow typing commands even in read-only buffers
|
||||
composerElt.classList.remove("read-only");
|
||||
composerInputElt.focus();
|
||||
composerInputElt.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
if (localStorage && localStorage.getItem("server")) {
|
||||
server = JSON.parse(localStorage.getItem("server"));
|
||||
connectFormElt.elements.url.value = server.url;
|
||||
connectFormElt.elements.nick.value = server.nick;
|
||||
if (server.username != server.nick) {
|
||||
connectFormElt.elements.username.value = server.username;
|
||||
}
|
||||
if (server.realname != server.nick) {
|
||||
connectFormElt.elements.realname.value = server.realname;
|
||||
}
|
||||
connectFormElt.elements["remember-me"].checked = true;
|
||||
setConnectFormDisabled(true);
|
||||
connect();
|
||||
} else {
|
||||
var params = parseQueryString();
|
||||
|
||||
if (params.server) {
|
||||
connectFormElt.elements.url.value = params.server;
|
||||
} else if (!connectFormElt.elements.url.value) {
|
||||
var host = window.location.host || "localhost:8080";
|
||||
var proto = "wss:";
|
||||
if (window.location.protocol != "https:") {
|
||||
proto = "ws:";
|
||||
}
|
||||
connectFormElt.elements.url.value = proto + "//" + host + "/socket";
|
||||
}
|
||||
|
||||
if (params.channels) {
|
||||
connectFormElt.elements.autojoin.value = params.channels;
|
||||
}
|
||||
}
|
|
@ -71,7 +71,7 @@
|
|||
</section>
|
||||
|
||||
<script type="module">
|
||||
import "./assets/client.js";
|
||||
import "./assets/index.js";
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
Loading…
Reference in a new issue