diff --git a/assets/client.js b/assets/client.js index 55e2be4..3b9835e 100644 --- a/assets/client.js +++ b/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; } } diff --git a/assets/index.js b/assets/index.js new file mode 100644 index 0000000..efc21d2 --- /dev/null +++ b/assets/index.js @@ -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; + } +} diff --git a/index.html b/index.html index 902248f..c9e33b9 100644 --- a/index.html +++ b/index.html @@ -71,7 +71,7 @@