import * as irc from "./lib/irc.js"; import Client from "./lib/client.js"; import { createContext } from "./lib/index.js"; export const SERVER_BUFFER = "*"; export const BufferType = { SERVER: "server", CHANNEL: "channel", NICK: "nick", }; export const ServerStatus = Client.Status; export const Unread = { NONE: "", MESSAGE: "message", HIGHLIGHT: "highlight", compare(a, b) { const priority = { [Unread.NONE]: 0, [Unread.MESSAGE]: 1, [Unread.HIGHLIGHT]: 2, }; return priority[a] - priority[b]; }, union(a, b) { return (Unread.compare(a, b) > 0) ? a : b; }, }; export const ReceiptType = { DELIVERED: "delivered", READ: "read", }; export const BufferEventsDisplayMode = { FOLD: "fold", EXPAND: "expand", HIDE: "hide", }; export const SettingsContext = createContext("settings"); export function getBufferURL(buf) { switch (buf.type) { case BufferType.SERVER: return irc.formatURL(); case BufferType.CHANNEL: return irc.formatURL({ entity: buf.name }); case BufferType.NICK: return irc.formatURL({ entity: buf.name, enttype: "user" }); } throw new Error("Unknown buffer type: " + buf.type); } export function getMessageURL(buf, msg) { let bufURL = getBufferURL(buf); if (msg.tags.msgid) { return bufURL + "?msgid=" + encodeURIComponent(msg.tags.msgid); } else { return bufURL + "?timestamp=" + encodeURIComponent(msg.tags.time); } } export function getServerName(server, bouncerNetwork) { let netName = server.name; if (bouncerNetwork && bouncerNetwork.name && bouncerNetwork.name !== bouncerNetwork.host) { // User has picked a custom name for the network, use that return bouncerNetwork.name; } if (netName) { // Server has specified a name return netName; } if (bouncerNetwork) { return bouncerNetwork.name || bouncerNetwork.host || "server"; } else if (server.isBouncer) { return "bouncer"; } else { return "server"; } } export function receiptFromMessage(msg) { // At this point all messages are supposed to have a time tag. // App.addMessage ensures this is the case even if the server doesn't // support server-time. if (!msg.tags.time) { throw new Error("Missing time message tag"); } return { time: msg.tags.time }; } export function isReceiptBefore(a, b) { if (!b) { return false; } if (!a) { return true; } if (!a.time || !b.time) { throw new Error("Missing receipt time"); } return a.time <= b.time; } export function isMessageBeforeReceipt(msg, receipt) { if (!receipt) { return false; } if (!msg.tags.time) { throw new Error("Missing time message tag"); } if (!receipt.time) { throw new Error("Missing receipt time"); } return msg.tags.time <= receipt.time; } function updateState(state, updater) { let updated; if (typeof updater === "function") { updated = updater(state, state); } else { updated = updater; } if (state === updated || !updated) { return; } return { ...state, ...updated }; } function isServerBuffer(buf) { return buf.type == BufferType.SERVER; } /* Returns 1 if a should appear after b, -1 if a should appear before b, or * 0 otherwise. */ function compareBuffers(a, b) { if (a.server != b.server) { return a.server > b.server ? 1 : -1; } if (isServerBuffer(a) != isServerBuffer(b)) { return isServerBuffer(b) ? 1 : -1; } if (a.name != b.name) { return a.name.localeCompare(b.name); } return 0; } function updateMembership(membership, letter, add, client) { let prefix = client.isupport.prefix(); let prefixPrivs = new Map(irc.parseMembershipModes(prefix).map((membership, i) => { return [membership.prefix, i]; })); if (add) { let i = membership.indexOf(letter); if (i < 0) { membership += letter; membership = Array.from(membership).sort((a, b) => { return prefixPrivs.get(a) - prefixPrivs.get(b); }).join(""); } } else { membership = membership.replace(letter, ""); } return membership; } /* Insert a message in an immutable list of sorted messages. */ function insertMessage(list, msg) { if (list.length == 0) { return [msg]; } else if (!irc.findBatchByType(msg, "chathistory") || list[list.length - 1].tags.time <= msg.tags.time) { return list.concat(msg); } let insertBefore = -1; for (let i = 0; i < list.length; i++) { let other = list[i]; if (msg.tags.time < other.tags.time) { insertBefore = i; break; } } console.assert(insertBefore >= 0, ""); list = [ ...list ]; list.splice(insertBefore, 0, msg); return list; } let lastServerID = 0; let lastBufferID = 0; let lastMessageKey = 0; export const State = { create() { return { servers: new Map(), buffers: new Map(), activeBuffer: null, bouncerNetworks: new Map(), settings: { secondsInTimestamps: true, bufferEvents: BufferEventsDisplayMode.FOLD, }, }; }, updateServer(state, id, updater) { let server = state.servers.get(id); if (!server) { return; } let updated = updateState(server, updater); if (!updated) { return; } let servers = new Map(state.servers); servers.set(id, updated); return { servers }; }, updateBuffer(state, id, updater) { let buf = State.getBuffer(state, id); if (!buf) { return; } let updated = updateState(buf, updater); if (!updated) { return; } let buffers = new Map(state.buffers); buffers.set(buf.id, updated); return { buffers }; }, getActiveServerID(state) { let buf = state.buffers.get(state.activeBuffer); if (!buf) { return null; } return buf.server; }, getBuffer(state, id) { switch (typeof id) { case "number": return state.buffers.get(id); case "object": if (id.id) { return state.buffers.get(id.id); } let serverID = id.server, name = id.name; if (!serverID) { serverID = State.getActiveServerID(state); } if (!name) { name = SERVER_BUFFER; } let cm = irc.CaseMapping.RFC1459; let server = state.servers.get(serverID); if (server) { cm = server.cm; } let nameCM = cm(name); for (let buf of state.buffers.values()) { if (buf.server === serverID && cm(buf.name) === nameCM) { return buf; } } return null; default: throw new Error("Invalid buffer ID type: " + (typeof id)); } }, createServer(state) { lastServerID++; let id = lastServerID; let servers = new Map(state.servers); servers.set(id, { id, name: null, // from ISUPPORT NETWORK status: ServerStatus.DISCONNECTED, cm: irc.CaseMapping.RFC1459, users: new irc.CaseMapMap(null, irc.CaseMapping.RFC1459), account: null, supportsSASLPlain: false, supportsAccountRegistration: false, reliableUserAccounts: false, statusMsg: null, // from ISUPPORT STATUSMSG isBouncer: false, bouncerNetID: null, }); return [id, { servers }]; }, createBuffer(state, name, serverID, client) { let buf = State.getBuffer(state, { server: serverID, name }); if (buf) { return [buf.id, null]; } lastBufferID++; let id = lastBufferID; let type; if (name == SERVER_BUFFER) { type = BufferType.SERVER; } else if (client.isChannel(name)) { type = BufferType.CHANNEL; } else { type = BufferType.NICK; } let bufferList = Array.from(state.buffers.values()); bufferList.push({ id, name, type, server: serverID, serverInfo: null, // if server joined: false, // if channel topic: null, // if channel hasInitialWho: false, // if channel members: new irc.CaseMapMap(null, client.cm), // if channel messages: [], unread: Unread.NONE, prevReadReceipt: null, }); bufferList = bufferList.sort(compareBuffers); let buffers = new Map(bufferList.map((buf) => [buf.id, buf])); return [id, { buffers }]; }, storeBouncerNetwork(state, id, attrs) { let bouncerNetworks = new Map(state.bouncerNetworks); bouncerNetworks.set(id, { ...bouncerNetworks.get(id), ...attrs, }); return { bouncerNetworks }; }, deleteBouncerNetwork(state, id) { let bouncerNetworks = new Map(state.bouncerNetworks); bouncerNetworks.delete(id); return { bouncerNetworks }; }, handleMessage(state, msg, serverID, client) { function updateServer(updater) { return State.updateServer(state, serverID, updater); } function updateBuffer(name, updater) { return State.updateBuffer(state, { server: serverID, name }, updater); } function updateUser(name, updater) { return updateServer((server) => { let users = new irc.CaseMapMap(server.users); let updated = updateState(users.get(name), updater); if (!updated) { return; } users.set(name, updated); return { users }; }); } // Don't update our internal state if it's a chat history message if (irc.findBatchByType(msg, "chathistory")) { return; } let target, channel, topic, targets, who, update, buffers; switch (msg.command) { case irc.RPL_MYINFO: // TODO: parse available modes let serverInfo = { name: msg.params[1], version: msg.params[2], }; return updateBuffer(SERVER_BUFFER, { serverInfo }); case irc.RPL_ISUPPORT: buffers = new Map(state.buffers); state.buffers.forEach((buf) => { if (buf.server != serverID) { return; } let members = new irc.CaseMapMap(buf.members, client.cm); buffers.set(buf.id, { ...buf, members }); }); return { buffers, ...updateServer((server) => { return { name: client.isupport.network(), cm: client.cm, users: new irc.CaseMapMap(server.users, client.cm), reliableUserAccounts: client.isupport.monitor() > 0 && client.isupport.whox(), statusMsg: client.isupport.statusMsg(), bouncerNetID: client.isupport.bouncerNetID(), }; }), }; case "CAP": return updateServer({ supportsSASLPlain: client.supportsSASL("PLAIN"), supportsAccountRegistration: client.caps.enabled.has("draft/account-registration"), isBouncer: client.caps.enabled.has("soju.im/bouncer-networks"), }); case irc.RPL_LOGGEDIN: return updateServer({ account: msg.params[2] }); case irc.RPL_LOGGEDOUT: return updateServer({ account: null }); case "REGISTER": case "VERIFY": if (msg.params[0] === "SUCCESS") { return updateServer({ account: msg.params[1] }); } break; case irc.RPL_NOTOPIC: channel = msg.params[1]; return updateBuffer(channel, { topic: null }); case irc.RPL_TOPIC: channel = msg.params[1]; topic = msg.params[2]; return updateBuffer(channel, { topic }); case irc.RPL_TOPICWHOTIME: // Ignore break; case irc.RPL_ENDOFNAMES: channel = msg.params[1]; return updateBuffer(channel, (buf) => { let members = new irc.CaseMapMap(null, buf.members.caseMap); msg.list.forEach((namreply) => { let membersList = namreply.params[3].split(" "); membersList.forEach((s) => { let member = irc.parseTargetPrefix(s); members.set(member.name, member.prefix); }); }); return { members }; }); case irc.RPL_ENDOFWHO: target = msg.params[1]; if (msg.list.length == 0 && !client.isChannel(target) && target.indexOf("*") < 0) { // Not a channel nor a mask, likely a nick return updateUser(target, (user) => { return { offline: true }; }); } else { return updateServer((server) => { let users = new irc.CaseMapMap(server.users); for (let reply of msg.list) { let who = client.parseWhoReply(reply); if (who.flags !== undefined) { who.away = who.flags.indexOf("G") >= 0; // H for here, G for gone who.operator = who.flags.indexOf("*") >= 0; let botFlag = client.isupport.bot(); if (botFlag) { who.bot = who.flags.indexOf(botFlag) >= 0; } delete who.flags; } who.offline = false; users.set(who.nick, who); } return { users }; }); } case "JOIN": channel = msg.params[0]; if (client.isMyNick(msg.prefix.name)) { let [_id, update] = State.createBuffer(state, channel, serverID, client); state = { ...state, ...update }; } update = updateBuffer(channel, (buf) => { let members = new irc.CaseMapMap(buf.members); members.set(msg.prefix.name, ""); let joined = buf.joined || client.isMyNick(msg.prefix.name); return { members, joined }; }); state = { ...state, ...update }; who = { nick: msg.prefix.name, offline: false }; if (msg.prefix.user) { who.username = msg.prefix.user; } if (msg.prefix.host) { who.hostname = msg.prefix.host; } if (msg.params.length > 2) { who.account = msg.params[1]; if (who.account === "*") { who.account = null; } who.realname = msg.params[2]; } update = updateUser(msg.prefix.name, who); state = { ...state, ...update }; return state; case "PART": channel = msg.params[0]; return updateBuffer(channel, (buf) => { let members = new irc.CaseMapMap(buf.members); members.delete(msg.prefix.name); let joined = buf.joined && !client.isMyNick(msg.prefix.name); return { members, joined }; }); case "KICK": channel = msg.params[0]; let nick = msg.params[1]; return updateBuffer(channel, (buf) => { let members = new irc.CaseMapMap(buf.members); members.delete(nick); let joined = buf.joined && !client.isMyNick(nick); return { members, joined }; }); case "QUIT": buffers = new Map(state.buffers); state.buffers.forEach((buf) => { if (buf.server != serverID) { return; } if (!buf.members.has(msg.prefix.name)) { return; } let members = new irc.CaseMapMap(buf.members); members.delete(msg.prefix.name); buffers.set(buf.id, { ...buf, members }); }); state = { ...state, buffers }; update = updateUser(msg.prefix.name, (user) => { if (!user) { return; } return { offline: true }; }); state = { ...state, ...update }; return state; case "NICK": let newNick = msg.params[0]; buffers = new Map(state.buffers); state.buffers.forEach((buf) => { if (buf.server != serverID) { return; } if (!buf.members.has(msg.prefix.name)) { return; } let members = new irc.CaseMapMap(buf.members); members.set(newNick, members.get(msg.prefix.name)); members.delete(msg.prefix.name); buffers.set(buf.id, { ...buf, members }); }); state = { ...state, buffers }; update = updateServer((server) => { let users = new irc.CaseMapMap(server.users); let user = users.get(msg.prefix.name); if (!user) { return; } users.set(newNick, user); users.delete(msg.prefix.name); return { users }; }); state = { ...state, ...update }; return state; case "SETNAME": return updateUser(msg.prefix.name, { realname: msg.params[0] }); case "CHGHOST": return updateUser(msg.prefix.name, { username: msg.params[0], hostname: msg.params[1], }); case "ACCOUNT": let account = msg.params[0]; if (account === "*") { account = null; } return updateUser(msg.prefix.name, { account }); case "AWAY": let awayMessage = msg.params[0]; return updateUser(msg.prefix.name, { away: !!awayMessage }); case "TOPIC": channel = msg.params[0]; topic = msg.params[1]; return updateBuffer(channel, { topic }); case "MODE": target = msg.params[0]; if (!client.isChannel(target)) { return; // TODO: handle user mode changes too } let prefix = client.isupport.prefix(); let prefixByMode = new Map(irc.parseMembershipModes(prefix).map((membership) => { return [membership.mode, membership.prefix]; })); return updateBuffer(target, (buf) => { let members = new irc.CaseMapMap(buf.members); irc.forEachChannelModeUpdate(msg, client.isupport, (mode, add, arg) => { if (prefixByMode.has(mode)) { let nick = arg; let membership = members.get(nick); let letter = prefixByMode.get(mode); members.set(nick, updateMembership(membership, letter, add, client)); } }); return { members }; }); case irc.RPL_MONONLINE: case irc.RPL_MONOFFLINE: targets = msg.params[1].split(","); for (let target of targets) { let prefix = irc.parsePrefix(target); let update = updateUser(prefix.name, { offline: msg.command == irc.RPL_MONOFFLINE }); state = { ...state, ...update }; } return state; } }, addMessage(state, msg, bufID) { lastMessageKey++; msg.key = lastMessageKey; return State.updateBuffer(state, bufID, (buf) => { let messages = insertMessage(buf.messages, msg); return { messages }; }); }, };