~emersion/gamja

8972130252df76a7ed677a6bb0dc63a9877aa482 — Simon Ser 10 days ago 4db845a
s/var/let/
M commands.js => commands.js +40 -40
@@ 2,7 2,7 @@ import * as irc from "./lib/irc.js";
import { SERVER_BUFFER, BufferType } from "./state.js";

function getActiveClient(app) {
	var buf = app.state.buffers.get(app.state.activeBuffer);
	let buf = app.state.buffers.get(app.state.activeBuffer);
	if (!buf) {
		throw new Error("Not connected to server");
	}


@@ 10,7 10,7 @@ function getActiveClient(app) {
}

function getActiveTarget(app) {
	var activeBuffer = app.state.buffers.get(app.state.activeBuffer);
	let activeBuffer = app.state.buffers.get(app.state.activeBuffer);
	if (!activeBuffer) {
		throw new Error("Not in a buffer");
	}


@@ 18,7 18,7 @@ function getActiveTarget(app) {
}

function getActiveChannel(app) {
	var activeBuffer = app.state.buffers.get(app.state.activeBuffer);
	let activeBuffer = app.state.buffers.get(app.state.activeBuffer);
	if (!activeBuffer || activeBuffer.type !== BufferType.CHANNEL) {
		throw new Error("Not in a channel");
	}


@@ 26,12 26,12 @@ function getActiveChannel(app) {
}

function setUserHostMode(app, args, mode) {
	var nick = args[0];
	let nick = args[0];
	if (!nick) {
		throw new Error("Missing nick");
	}
	var activeChannel = getActiveChannel(app);
	var client = getActiveClient(app);
	let activeChannel = getActiveChannel(app);
	let client = getActiveClient(app);
	client.whois(nick).then((whois) => {
		const info = whois[irc.RPL_WHOISUSER].params;
		const user = info[2];


@@ 47,7 47,7 @@ const join = {
	usage: "<name>",
	description: "Join a channel",
	execute: (app, args) => {
		var channel = args[0];
		let channel = args[0];
		if (!channel) {
			throw new Error("Missing channel name");
		}


@@ 59,9 59,9 @@ const kick = {
	usage: "<nick>",
	description: "Remove a user from the channel",
	execute: (app, args) => {
		var nick = args[0];
		var activeChannel = getActiveChannel(app);
		var params = [activeChannel, nick];
		let nick = args[0];
		let activeChannel = getActiveChannel(app);
		let params = [activeChannel, nick];
		if (args.length > 1) {
			params.push(args.slice(1).join(" "));
		}


@@ 71,11 71,11 @@ const kick = {

function givemode(app, args, mode) {
	// TODO: Handle several users at once
	var nick = args[0];
	let nick = args[0];
	if (!nick) {
		throw new Error("Missing nick");
	}
	var activeChannel = getActiveChannel(app);
	let activeChannel = getActiveChannel(app);
	getActiveClient(app).send({
		command: "MODE",
		params: [activeChannel, mode, nick],


@@ 88,7 88,7 @@ export default {
		description: "Ban a user from the channel, or display the current ban list",
		execute: (app, args) => {
			if (args.length == 0) {
				var activeChannel = getActiveChannel(app);
				let activeChannel = getActiveChannel(app);
				getActiveClient(app).send({
					command: "MODE",
					params: [activeChannel, "+b"],


@@ 102,8 102,8 @@ export default {
		usage: "<name>",
		description: "Switch to a buffer",
		execute: (app, args) => {
			var name = args[0];
			for (var buf of app.state.buffers.values()) {
			let name = args[0];
			for (let buf of app.state.buffers.values()) {
				if (buf.name === name) {
					app.switchBuffer(buf);
					return;


@@ 115,7 115,7 @@ export default {
	"close": {
		description: "Close the current buffer",
		execute: (app, args) => {
			var activeBuffer = app.state.buffers.get(app.state.activeBuffer);
			let activeBuffer = app.state.buffers.get(app.state.activeBuffer);
			if (!activeBuffer || activeBuffer.type == BufferType.SERVER) {
				throw new Error("Not in a user or channel buffer");
			}


@@ 148,11 148,11 @@ export default {
		usage: "<nick>",
		description: "Invite a user to the channel",
		execute: (app, args) => {
			var nick = args[0];
			let nick = args[0];
			if (!nick) {
				throw new Error("Missing nick");
			}
			var activeChannel = getActiveChannel(app);
			let activeChannel = getActiveChannel(app);
			getActiveClient(app).send({ command: "INVITE", params: [
				nick, activeChannel,
			]});


@@ 180,9 180,9 @@ export default {
		usage: "<action>",
		description: "Send an action message to the current buffer",
		execute: (app, args) => {
			var action = args.join(" ");
			var target = getActiveTarget(app);
			var text = `\x01ACTION ${action}\x01`;
			let action = args.join(" ");
			let target = getActiveTarget(app);
			let text = `\x01ACTION ${action}\x01`;
			app.privmsg(target, text);
		},
	},


@@ 190,9 190,9 @@ export default {
		usage: "[target] [modes] [mode args...]",
		description: "Query or change a channel or user mode",
		execute: (app, args) => {
			var target = args[0];
			let target = args[0];
			if (!target || target.startsWith("+") || target.startsWith("-")) {
				var activeChannel = getActiveChannel(app);
				let activeChannel = getActiveChannel(app);
				args = [activeChannel, ...args];
			}
			getActiveClient(app).send({ command: "MODE", params: args });


@@ 209,8 209,8 @@ export default {
		usage: "<target> <message>",
		description: "Send a message to a nickname or a channel",
		execute: (app, args) => {
			var target = args[0];
			var text = args.slice(1).join(" ");
			let target = args[0];
			let text = args.slice(1).join(" ");
			getActiveClient(app).send({ command: "PRIVMSG", params: [target, text] });
		},
	},


@@ 218,7 218,7 @@ export default {
		usage: "<nick>",
		description: "Change current nickname",
		execute: (app, args) => {
			var newNick = args[0];
			let newNick = args[0];
			getActiveClient(app).send({ command: "NICK", params: [newNick] });
		},
	},


@@ 226,8 226,8 @@ export default {
		usage: "<target> <message>",
		description: "Send a notice to a nickname or a channel",
		execute: (app, args) => {
			var target = args[0];
			var text = args.slice(1).join(" ");
			let target = args[0];
			let text = args.slice(1).join(" ");
			getActiveClient(app).send({ command: "NOTICE", params: [target, text] });
		},
	},


@@ 240,9 240,9 @@ export default {
		usage: "[reason]",
		description: "Leave a channel",
		execute: (app, args) => {
			var reason = args.join(" ");
			var activeChannel = getActiveChannel(app);
			var params = [activeChannel];
			let reason = args.join(" ");
			let activeChannel = getActiveChannel(app);
			let params = [activeChannel];
			if (reason) {
				params.push(reason);
			}


@@ 253,7 253,7 @@ export default {
		usage: "<nick>",
		description: "Open a buffer to send messages to a nickname",
		execute: (app, args) => {
			var nick = args[0];
			let nick = args[0];
			if (!nick) {
				throw new Error("Missing nickname");
			}


@@ 270,7 270,7 @@ export default {
		usage: "<command>",
		description: "Send a raw IRC command to the server",
		execute: (app, args) => {
			var msg;
			let msg;
			try {
				msg = irc.parseMessage(args.join(" "));
			} catch (err) {


@@ 289,8 289,8 @@ export default {
		usage: "<realname>",
		description: "Change current realname",
		execute: (app, args) => {
			var newRealname = args.join(" ");
			var client = getActiveClient(app);
			let newRealname = args.join(" ");
			let client = getActiveClient(app);
			if (!client.enabledCaps["setname"]) {
				throw new Error("Server doesn't support changing the realname");
			}


@@ 301,11 301,11 @@ export default {
		usage: "<query> [server]",
		description: "Request server statistics",
		execute: (app, args) => {
			var query = args[0];
			let query = args[0];
			if (!query) {
				throw new Error("Missing query");
			}
			var params = [query];
			let params = [query];
			if (args.length > 1) {
				params.push(args.slice(1).join(" "));
			}


@@ 316,8 316,8 @@ export default {
		usage: "<topic>",
		description: "Change the topic of the current channel",
		execute: (app, args) => {
			var activeChannel = getActiveChannel(app);
			var params = [activeChannel];
			let activeChannel = getActiveChannel(app);
			let params = [activeChannel];
			if (args.length > 0) {
				params.push(args.join(" "));
			}


@@ 347,7 347,7 @@ export default {
		usage: "<nick>",
		description: "Retrieve information about a user",
		execute: (app, args) => {
			var nick = args[0];
			let nick = args[0];
			if (!nick) {
				throw new Error("Missing nick");
			}

M components/app.js => components/app.js +146 -146
@@ 35,28 35,28 @@ const configPromise = fetch("./config.json")

const CHATHISTORY_MAX_SIZE = 4000;

var messagesCount = 0;
let messagesCount = 0;

function parseQueryString() {
	var query = window.location.search.substring(1);
	var params = {};
	let query = window.location.search.substring(1);
	let params = {};
	query.split('&').forEach((s) => {
		if (!s) {
			return;
		}
		var pair = s.split('=');
		let pair = s.split('=');
		params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || "");
	});
	return params;
}

function fillConnectParams(params) {
	var host = window.location.host || "localhost:8080";
	var proto = "wss:";
	let host = window.location.host || "localhost:8080";
	let proto = "wss:";
	if (window.location.protocol != "https:") {
		proto = "ws:";
	}
	var path = window.location.pathname || "/";
	let path = window.location.pathname || "/";
	if (!window.location.host) {
		path = "/";
	}


@@ 78,7 78,7 @@ function fillConnectParams(params) {
}

function debounce(f, delay) {
	var timeout = null;
	let timeout = null;
	return (...args) => {
		clearTimeout(timeout);
		timeout = setTimeout(() => {


@@ 177,7 177,7 @@ export default class App extends Component {
	handleConfig(config) {
		this.config = config;

		var connectParams = {};
		let connectParams = {};

		if (config.server) {
			connectParams.url = config.server.url;


@@ 188,7 188,7 @@ export default class App extends Component {
			}
		}

		var autoconnect = store.autoconnect.load();
		let autoconnect = store.autoconnect.load();
		if (autoconnect) {
			connectParams = {
				...connectParams,


@@ 197,7 197,7 @@ export default class App extends Component {
			};
		}

		var queryParams = parseQueryString();
		let queryParams = parseQueryString();
		if (queryParams.server) {
			connectParams.url = queryParams.server;
		}


@@ 245,10 245,10 @@ export default class App extends Component {
	}

	createBuffer(serverID, name) {
		var id = null;
		let id = null;
		this.setState((state) => {
			var client = this.clients.get(serverID);
			var updated;
			let client = this.clients.get(serverID);
			let updated;
			[id, updated] = State.createBuffer(state, name, serverID, client);
			return updated;
		});


@@ 256,7 256,7 @@ export default class App extends Component {
	}

	switchBuffer(id) {
		var buf;
		let buf;
		this.setState((state) => {
			buf = State.getBuffer(state, id);
			if (!buf) {


@@ 268,7 268,7 @@ export default class App extends Component {
				return;
			}

			var lastReadReceipt = this.getReceipt(buf.name, ReceiptType.READ);
			let lastReadReceipt = this.getReceipt(buf.name, ReceiptType.READ);
			// TODO: only mark as read if user scrolled at the bottom
			this.setBufferState(buf.id, {
				unread: Unread.NONE,


@@ 282,7 282,7 @@ export default class App extends Component {
			if (buf.messages.length == 0) {
				return;
			}
			var lastMsg = buf.messages[buf.messages.length - 1];
			let lastMsg = buf.messages[buf.messages.length - 1];
			this.setReceipt(buf.name, ReceiptType.READ, lastMsg);
		});
	}


@@ 292,7 292,7 @@ export default class App extends Component {
	}

	getReceipt(target, type) {
		var receipts = this.receipts.get(target);
		let receipts = this.receipts.get(target);
		if (!receipts) {
			return undefined;
		}


@@ 300,12 300,12 @@ export default class App extends Component {
	}

	hasReceipt(target, type, msg) {
		var receipt = this.getReceipt(target, type);
		let receipt = this.getReceipt(target, type);
		return receipt && msg.tags.time <= receipt.time;
	}

	setReceipt(target, type, msg) {
		var receipt = this.getReceipt(target, type);
		let receipt = this.getReceipt(target, type);
		if (this.hasReceipt(target, type, msg)) {
			return;
		}


@@ 318,9 318,9 @@ export default class App extends Component {
	}

	latestReceipt(type) {
		var last = null;
		let last = null;
		this.receipts.forEach((receipts, target) => {
			var delivery = receipts[type];
			let delivery = receipts[type];
			if (target == "*" || !delivery || !delivery.time) {
				return;
			}


@@ 332,7 332,7 @@ export default class App extends Component {
	}

	addMessage(serverID, bufName, msg) {
		var client = this.clients.get(serverID);
		let client = this.clients.get(serverID);

		msg.key = messagesCount;
		messagesCount++;


@@ 346,15 346,15 @@ export default class App extends Component {
			msg.tags.time = irc.formatDate(new Date());
		}

		var isDelivered = this.hasReceipt(bufName, ReceiptType.DELIVERED, msg);
		var isRead = this.hasReceipt(bufName, ReceiptType.READ, msg);
		let isDelivered = this.hasReceipt(bufName, ReceiptType.DELIVERED, msg);
		let isRead = this.hasReceipt(bufName, ReceiptType.READ, msg);
		// TODO: messages coming from infinite scroll shouldn't trigger notifications

		var msgUnread = Unread.NONE;
		let msgUnread = Unread.NONE;
		if ((msg.command == "PRIVMSG" || msg.command == "NOTICE") && !isRead) {
			var text = msg.params[1];
			let text = msg.params[1];

			var kind;
			let kind;
			if (msg.isHighlight) {
				msgUnread = Unread.HIGHLIGHT;
				kind = "highlight";


@@ 366,11 366,11 @@ export default class App extends Component {
			}

			if (msgUnread == Unread.HIGHLIGHT && !isDelivered && !irc.parseCTCP(msg)) {
				var title = "New " + kind + " from " + msg.prefix.name;
				let title = "New " + kind + " from " + msg.prefix.name;
				if (client.isChannel(bufName)) {
					title += " in " + bufName;
				}
				var notif = showNotification(title, {
				let notif = showNotification(title, {
					body: stripANSI(text),
					requireInteraction: true,
					tag: "msg," + msg.prefix.name + "," + bufName,


@@ 384,8 384,8 @@ export default class App extends Component {
		if (msg.command === "INVITE" && client.isMyNick(msg.params[0])) {
			msgUnread = Unread.HIGHLIGHT;

			var channel = msg.params[1];
			var notif = new Notification("Invitation to " + channel, {
			let channel = msg.params[1];
			let notif = new Notification("Invitation to " + channel, {
				body: msg.prefix.name + " has invited you to " + channel,
				requireInteraction: true,
				tag: "invite," + msg.prefix.name + "," + channel,


@@ 411,12 411,12 @@ export default class App extends Component {

		this.setReceipt(bufName, ReceiptType.DELIVERED, msg);

		var bufID = { server: serverID, name: bufName };
		let bufID = { server: serverID, name: bufName };
		this.setState((state) => State.addMessage(state, msg, bufID));
		this.setBufferState(bufID, (buf) => {
			// TODO: set unread if scrolled up
			var unread = buf.unread;
			var lastReadReceipt = buf.lastReadReceipt;
			let unread = buf.unread;
			let lastReadReceipt = buf.lastReadReceipt;
			if (this.state.activeBuffer != buf.id) {
				unread = Unread.union(unread, msgUnread);
			} else {


@@ 428,15 428,15 @@ export default class App extends Component {
	}

	connect(params) {
		var serverID = null;
		let serverID = null;
		this.setState((state) => {
			var update;
			let update;
			[serverID, update] = State.createServer(state);
			return update;
		});
		this.setState({ connectParams: params });

		var client = new Client(fillConnectParams(params));
		let client = new Client(fillConnectParams(params));
		this.clients.set(serverID, client);
		this.setServerState(serverID, { status: client.status });



@@ 474,7 474,7 @@ export default class App extends Component {
			serverID = State.getActiveServerID(this.state);
		}

		var client = this.clients.get(serverID);
		let client = this.clients.get(serverID);
		if (client) {
			this.clients.delete(serverID);
			client.disconnect();


@@ 486,14 486,14 @@ export default class App extends Component {
			serverID = State.getActiveServerID(this.state);
		}

		var client = this.clients.get(serverID);
		let client = this.clients.get(serverID);
		if (client) {
			client.reconnect();
		}
	}

	serverFromBouncerNetwork(bouncerNetworkID) {
		for (var [id, client] of this.clients) {
		for (let [id, client] of this.clients) {
			if (client.params.bouncerNetwork === bouncerNetworkID) {
				return id;
			}


@@ 502,11 502,12 @@ export default class App extends Component {
	}

	handleMessage(serverID, msg) {
		var client = this.clients.get(serverID);
		var chatHistoryBatch = irc.findBatchByType(msg, "chathistory");
		let client = this.clients.get(serverID);
		let chatHistoryBatch = irc.findBatchByType(msg, "chathistory");

		this.setState((state) => State.handleMessage(state, msg, serverID, client));

		let target, channel, affectedBuffers;
		switch (msg.command) {
		case irc.RPL_WELCOME:
			if (this.state.connectParams.autojoin.length > 0) {


@@ 516,23 517,23 @@ export default class App extends Component {
				});
			}

			var lastReceipt = this.latestReceipt(ReceiptType.READ);
			let lastReceipt = this.latestReceipt(ReceiptType.READ);
			if (lastReceipt && lastReceipt.time && client.enabledCaps["draft/chathistory"] && (!client.enabledCaps["soju.im/bouncer-networks"] || client.params.bouncerNetwork)) {
				var now = irc.formatDate(new Date());
				let now = irc.formatDate(new Date());
				client.fetchHistoryTargets(now, lastReceipt.time).then((targets) => {
					targets.forEach((target) => {
						var from = this.getReceipt(target, ReceiptType.READ);
						let from = this.getReceipt(target, ReceiptType.READ);
						if (!from) {
							from = lastReceipt;
						}
						var to = { time: msg.tags.time || irc.formatDate(new Date()) };
						let to = { time: msg.tags.time || irc.formatDate(new Date()) };
						this.fetchBacklog(client, target.name, from, to);
					});
				});
			}
			break;
		case "MODE":
			var target = msg.params[0];
			target = msg.params[0];
			if (client.isChannel(target)) {
				this.addMessage(serverID, target, msg);
			}


@@ 542,7 543,7 @@ export default class App extends Component {
			break;
		case "NOTICE":
		case "PRIVMSG":
			var target = msg.params[0];
			target = msg.params[0];
			if (client.isMyNick(target)) {
				if (client.cm(msg.prefix.name) === client.cm(client.serverPrefix.name)) {
					target = SERVER_BUFFER;


@@ 555,9 556,9 @@ export default class App extends Component {
				target = SERVER_BUFFER;
			}

			var allowedPrefixes = client.isupport.get("STATUSMSG");
			let allowedPrefixes = client.isupport.get("STATUSMSG");
			if (allowedPrefixes) {
				var parts = irc.parseTargetPrefix(target, allowedPrefixes);
				let parts = irc.parseTargetPrefix(target, allowedPrefixes);
				if (client.isChannel(parts.name)) {
					target = parts.name;
				}


@@ 566,7 567,7 @@ export default class App extends Component {
			this.addMessage(serverID, target, msg);
			break;
		case "JOIN":
			var channel = msg.params[0];
			channel = msg.params[0];

			if (!client.isMyNick(msg.prefix.name)) {
				this.addMessage(serverID, channel, msg);


@@ 577,7 578,7 @@ export default class App extends Component {
			}
			break;
		case "PART":
			var channel = msg.params[0];
			channel = msg.params[0];

			this.addMessage(serverID, channel, msg);



@@ 587,16 588,16 @@ export default class App extends Component {
			}
			break;
		case "KICK":
			var channel = msg.params[0];
			channel = msg.params[0];
			this.addMessage(serverID, channel, msg);
			break;
		case "QUIT":
			var affectedBuffers = [];
			affectedBuffers = [];
			if (chatHistoryBatch) {
				affectedBuffers.push(chatHistoryBatch.params[0]);
			} else {
				this.setState((state) => {
					var buffers = new Map(state.buffers);
					let buffers = new Map(state.buffers);
					state.buffers.forEach((buf) => {
						if (buf.server != serverID) {
							return;


@@ 604,9 605,9 @@ export default class App extends Component {
						if (!buf.members.has(msg.prefix.name) && client.cm(buf.name) !== client.cm(msg.prefix.name)) {
							return;
						}
						var members = new irc.CaseMapMap(buf.members);
						let members = new irc.CaseMapMap(buf.members);
						members.delete(msg.prefix.name);
						var offline = client.cm(buf.name) === client.cm(msg.prefix.name);
						let offline = client.cm(buf.name) === client.cm(msg.prefix.name);
						buffers.set(buf.id, { ...buf, members, offline });
						affectedBuffers.push(buf.name);
					});


@@ 617,14 618,14 @@ export default class App extends Component {
			affectedBuffers.forEach((name) => this.addMessage(serverID, name, msg));
			break;
		case "NICK":
			var newNick = msg.params[0];
			let newNick = msg.params[0];

			var affectedBuffers = [];
			affectedBuffers = [];
			if (chatHistoryBatch) {
				affectedBuffers.push(chatHistoryBatch.params[0]);
			} else {
				this.setState((state) => {
					var buffers = new Map(state.buffers);
					let buffers = new Map(state.buffers);
					state.buffers.forEach((buf) => {
						if (buf.server != serverID) {
							return;


@@ 632,7 633,7 @@ export default class App extends Component {
						if (!buf.members.has(msg.prefix.name)) {
							return;
						}
						var members = new irc.CaseMapMap(buf.members);
						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 });


@@ 645,14 646,14 @@ export default class App extends Component {
			affectedBuffers.forEach((name) => this.addMessage(serverID, name, msg));
			break;
		case "TOPIC":
			var channel = msg.params[0];
			channel = msg.params[0];
			this.addMessage(serverID, channel, msg);
			break;
		case "INVITE":
			var channel = msg.params[1];
			channel = msg.params[1];

			// TODO: find a more reliable way to do this
			var bufName = channel;
			let bufName = channel;
			if (!State.getBuffer(this.state, { server: serverID, name: channel })) {
				bufName = SERVER_BUFFER;
			}


@@ 670,19 671,19 @@ export default class App extends Component {
				break;
			}

			var id = msg.params[1];
			var attrs = null;
			let id = msg.params[1];
			let attrs = null;
			if (msg.params[2] !== "*") {
				attrs = irc.parseTags(msg.params[2]);
			}

			var isNew = false;
			let isNew = false;
			this.setState((state) => {
				var bouncerNetworks = new Map(state.bouncerNetworks);
				let bouncerNetworks = new Map(state.bouncerNetworks);
				if (!attrs) {
					bouncerNetworks.delete(id);
				} else {
					var prev = bouncerNetworks.get(id);
					let prev = bouncerNetworks.get(id);
					isNew = prev === undefined;
					attrs = { ...prev, ...attrs };
					bouncerNetworks.set(id, attrs);


@@ 690,7 691,7 @@ export default class App extends Component {
				return { bouncerNetworks };
			}, () => {
				if (!attrs) {
					var serverID = this.serverFromBouncerNetwork(id);
					let serverID = this.serverFromBouncerNetwork(id);
					if (serverID) {
						this.close({ server: serverID, name: SERVER_BUFFER });
					}


@@ 710,7 711,7 @@ export default class App extends Component {
		case irc.RPL_ENDOFEXCEPTLIST:
		case irc.RPL_BANLIST:
		case irc.RPL_ENDOFBANLIST:
			var channel = msg.params[1];
			channel = msg.params[1];
			this.addMessage(serverID, channel, msg);
			break;
		case irc.RPL_MYINFO:


@@ 734,7 735,7 @@ export default class App extends Component {
			break;
		default:
			if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
				var description = msg.params[msg.params.length - 1];
				let description = msg.params[msg.params.length - 1];
				this.setState({ error: description });
			}
			this.addMessage(serverID, SERVER_BUFFER, msg);


@@ 754,8 755,8 @@ export default class App extends Component {
	}

	handleChannelClick(channel) {
		var serverID = State.getActiveServerID(this.state);
		var buf = State.getBuffer(this.state, { server: serverID, name: channel });
		let serverID = State.getActiveServerID(this.state);
		let buf = State.getBuffer(this.state, { server: serverID, name: channel });
		if (buf) {
			this.switchBuffer(buf.id);
		} else {


@@ 780,7 781,7 @@ export default class App extends Component {
			serverID = State.getActiveServerID(this.state);
		}

		var client = this.clients.get(serverID);
		let client = this.clients.get(serverID);
		if (client.isServer(target)) {
			this.switchBuffer({ server: serverID });
		} else if (client.isChannel(target)) {


@@ 794,22 795,23 @@ export default class App extends Component {
	}

	close(id) {
		var buf = State.getBuffer(this.state, id);
		let buf = State.getBuffer(this.state, id);
		if (!buf) {
			return;
		}

		let client = this.clients.get(buf.server);
		switch (buf.type) {
		case BufferType.SERVER:
			this.setState((state) => {
				var buffers = new Map(state.buffers);
				for (var [id, b] of state.buffers) {
				let buffers = new Map(state.buffers);
				for (let [id, b] of state.buffers) {
					if (b.server === buf.server) {
						buffers.delete(id);
					}
				}

				var activeBuffer = state.activeBuffer;
				let activeBuffer = state.activeBuffer;
				if (activeBuffer && state.buffers.get(activeBuffer).server === buf.server) {
					if (buffers.size > 0) {
						activeBuffer = buffers.keys().next().value;


@@ 821,16 823,15 @@ export default class App extends Component {
				return { buffers, activeBuffer };
			});

			var client = this.clients.get(buf.server);
			var disconnectAll = client && !client.params.bouncerNetwork && client.enabledCaps["soju.im/bouncer-networks"];
			let disconnectAll = client && !client.params.bouncerNetwork && client.enabledCaps["soju.im/bouncer-networks"];

			this.disconnect(buf.server);

			this.setState((state) => {
				var servers = new Map(state.servers);
				let servers = new Map(state.servers);
				servers.delete(buf.server);

				var connectForm = state.connectForm;
				let connectForm = state.connectForm;
				if (servers.size == 0) {
					connectForm = true;
				}


@@ 839,7 840,7 @@ export default class App extends Component {
			});

			if (disconnectAll) {
				for (var serverID of this.clients.keys()) {
				for (let serverID of this.clients.keys()) {
					this.close({ server: serverID, name: SERVER_BUFFER });
				}
			}


@@ 850,13 851,12 @@ export default class App extends Component {
			}
			break;
		case BufferType.CHANNEL:
			var client = this.clients.get(buf.server);
			client.send({ command: "PART", params: [buf.name] });
			// fallthrough
		case BufferType.NICK:
			this.switchBuffer({ name: SERVER_BUFFER });
			this.setState((state) => {
				var buffers = new Map(state.buffers);
				let buffers = new Map(state.buffers);
				buffers.delete(buf.id);
				return { buffers };
			});


@@ 868,11 868,11 @@ export default class App extends Component {
	}

	executeCommand(s) {
		var parts = s.split(" ");
		var name = parts[0].toLowerCase().slice(1);
		var args = parts.slice(1);
		let parts = s.split(" ");
		let name = parts[0].toLowerCase().slice(1);
		let args = parts.slice(1);

		var cmd = commands[name];
		let cmd = commands[name];
		if (!cmd) {
			this.setState({ error: `Unknown command "${name}" (run "/help" to get a command list)` });
			return;


@@ 892,10 892,10 @@ export default class App extends Component {
			return;
		}

		var serverID = State.getActiveServerID(this.state);
		var client = this.clients.get(serverID);
		let serverID = State.getActiveServerID(this.state);
		let client = this.clients.get(serverID);

		var msg = { command: "PRIVMSG", params: [target, text] };
		let msg = { command: "PRIVMSG", params: [target, text] };
		client.send(msg);

		if (!client.enabledCaps["echo-message"]) {


@@ 916,7 916,7 @@ export default class App extends Component {
			return;
		}

		var buf = this.state.buffers.get(this.state.activeBuffer);
		let buf = this.state.buffers.get(this.state.activeBuffer);
		if (!buf) {
			return;
		}


@@ 931,7 931,7 @@ export default class App extends Component {

	toggleBufferList() {
		this.setState((state) => {
			var openPanels = {
			let openPanels = {
				...state.openPanels,
				bufferList: !state.openPanels.bufferList,
			};


@@ 941,7 941,7 @@ export default class App extends Component {

	toggleMemberList() {
		this.setState((state) => {
			var openPanels = {
			let openPanels = {
				...state.openPanels,
				memberList: !state.openPanels.memberList,
			};


@@ 951,7 951,7 @@ export default class App extends Component {

	closeBufferList() {
		this.setState((state) => {
			var openPanels = {
			let openPanels = {
				...state.openPanels,
				bufferList: false,
			};


@@ 961,7 961,7 @@ export default class App extends Component {

	closeMemberList() {
		this.setState((state) => {
			var openPanels = {
			let openPanels = {
				...state.openPanels,
				memberList: false,
			};


@@ 974,7 974,7 @@ export default class App extends Component {
	}

	handleJoinSubmit(data) {
		var client = this.clients.get(this.state.joinDialog.server);
		let client = this.clients.get(this.state.joinDialog.server);

		this.switchToChannel = data.channel;
		client.send({ command: "JOIN", params: [data.channel] });


@@ 985,8 985,8 @@ export default class App extends Component {
	autocomplete(prefix) {
		function fromList(l, prefix) {
			prefix = prefix.toLowerCase();
			var repl = null;
			for (var item of l) {
			let repl = null;
			for (let item of l) {
				if (item.toLowerCase().startsWith(prefix)) {
					if (repl) {
						return null;


@@ 998,14 998,14 @@ export default class App extends Component {
		}

		if (prefix.startsWith("/")) {
			var repl = fromList(Object.keys(commands), prefix.slice(1));
			let repl = fromList(Object.keys(commands), prefix.slice(1));
			if (repl) {
				repl = "/" + repl;
			}
			return repl;
		}

		var buf = this.state.buffers.get(this.state.activeBuffer);
		let buf = this.state.buffers.get(this.state.activeBuffer);
		if (!buf || !buf.members) {
			return null;
		}


@@ 1017,12 1017,12 @@ export default class App extends Component {
	}

	handleBufferScrollTop() {
		var buf = this.state.buffers.get(this.state.activeBuffer);
		let buf = this.state.buffers.get(this.state.activeBuffer);
		if (!buf || buf.type == BufferType.SERVER) {
			return;
		}

		var client = this.clients.get(buf.server);
		let client = this.clients.get(buf.server);

		if (!client || !client.enabledCaps["draft/chathistory"] || !client.enabledCaps["server-time"]) {
			return;


@@ 1031,7 1031,7 @@ export default class App extends Component {
			return;
		}

		var before;
		let before;
		if (buf.messages.length > 0) {
			before = buf.messages[0].tags["time"];
		} else {


@@ 1055,9 1055,9 @@ export default class App extends Component {
	}

	handleManageNetworkClick(serverID) {
		var server = this.state.servers.get(serverID);
		var bouncerNetID = server.isupport.get("BOUNCER_NETID");
		var bouncerNetwork = this.state.bouncerNetworks.get(bouncerNetID);
		let server = this.state.servers.get(serverID);
		let bouncerNetID = server.isupport.get("BOUNCER_NETID");
		let bouncerNetwork = this.state.bouncerNetworks.get(bouncerNetID);
		this.setState({
			dialog: "network",
			networkDialog: {


@@ 1068,7 1068,7 @@ export default class App extends Component {
	}

	handleNetworkSubmit(attrs) {
		var client = this.clients.values().next().value;
		let client = this.clients.values().next().value;

		if (this.state.networkDialog && this.state.networkDialog.id) {
			if (Object.keys(attrs).length == 0) {


@@ 1092,7 1092,7 @@ export default class App extends Component {
	}

	handleNetworkRemove() {
		var client = this.clients.values().next().value;
		let client = this.clients.values().next().value;

		client.send({
			command: "BOUNCER",


@@ 1103,29 1103,29 @@ export default class App extends Component {
	}

	handleMode(serverID, msg) {
		var client = this.clients.get(serverID);
		var chanmodes = client.isupport.get("CHANMODES") || irc.STD_CHANMODES;
		var prefix = client.isupport.get("PREFIX") || "";
		let client = this.clients.get(serverID);
		let chanmodes = client.isupport.get("CHANMODES") || irc.STD_CHANMODES;
		let prefix = client.isupport.get("PREFIX") || "";

		var prefixByMode = new Map(irc.parseMembershipModes(prefix).map((membership) => {
		let prefixByMode = new Map(irc.parseMembershipModes(prefix).map((membership) => {
			return [membership.mode, membership.prefix];
		}));

		var typeByMode = new Map();
		var [a, b, c, d] = chanmodes.split(",");
		let typeByMode = new Map();
		let [a, b, c, d] = chanmodes.split(",");
		Array.from(a).forEach((mode) => typeByMode.set(mode, "A"));
		Array.from(b).forEach((mode) => typeByMode.set(mode, "B"));
		Array.from(c).forEach((mode) => typeByMode.set(mode, "C"));
		Array.from(d).forEach((mode) => typeByMode.set(mode, "D"));
		prefixByMode.forEach((prefix, mode) => typeByMode.set(mode, "B"));

		var channel = msg.params[0];
		var change = msg.params[1];
		var args = msg.params.slice(2);
		let channel = msg.params[0];
		let change = msg.params[1];
		let args = msg.params.slice(2);

		var plusMinus = null;
		var j = 0;
		for (var i = 0; i < change.length; i++) {
		let plusMinus = null;
		let j = 0;
		for (let i = 0; i < change.length; i++) {
			if (change[i] === "+" || change[i] === "-") {
				plusMinus = change[i];
				continue;


@@ 1134,15 1134,15 @@ export default class App extends Component {
				throw new Error("malformed mode string: missing plus/minus");
			}

			var mode = change[i];
			var add = plusMinus === "+";
			let mode = change[i];
			let add = plusMinus === "+";

			var modeType = typeByMode.get(mode);
			let modeType = typeByMode.get(mode);
			if (!modeType) {
				continue;
			}

			var arg = null;
			let arg = null;
			if (modeType === "A" || modeType === "B" || (modeType === "C" && add)) {
				arg = args[j];
				j++;


@@ 1159,18 1159,18 @@ export default class App extends Component {
	}

	handlePrefixChange(serverID, channel, nick, letter, add) {
		var client = this.clients.get(serverID);
		var prefix = client.isupport.get("PREFIX") || "";
		let client = this.clients.get(serverID);
		let prefix = client.isupport.get("PREFIX") || "";

		var prefixPrivs = new Map(irc.parseMembershipModes(prefix).map((membership, i) => {
		let prefixPrivs = new Map(irc.parseMembershipModes(prefix).map((membership, i) => {
			return [membership.prefix, i];
		}));

		this.setBufferState({ server: serverID, name: channel }, (buf) => {
			var members = new irc.CaseMapMap(buf.members);
			var membership = members.get(nick);
			let members = new irc.CaseMapMap(buf.members);
			let membership = members.get(nick);
			if (add) {
				var i = membership.indexOf(letter);
				let i = membership.indexOf(letter);
				if (i < 0) {
					membership += letter;
				}


@@ 1190,24 1190,24 @@ export default class App extends Component {
	}

	render() {
		var activeBuffer = null, activeServer = null, activeBouncerNetwork = null;
		var isBouncer = false;
		let activeBuffer = null, activeServer = null, activeBouncerNetwork = null;
		let isBouncer = false;
		if (this.state.buffers.get(this.state.activeBuffer)) {
			activeBuffer = this.state.buffers.get(this.state.activeBuffer);
			activeServer = this.state.servers.get(activeBuffer.server);

			var activeClient = this.clients.get(activeBuffer.server);
			let activeClient = this.clients.get(activeBuffer.server);
			isBouncer = activeClient && activeClient.enabledCaps["soju.im/bouncer-networks"];

			var bouncerNetID = activeServer.isupport.get("BOUNCER_NETID");
			let bouncerNetID = activeServer.isupport.get("BOUNCER_NETID");
			if (bouncerNetID) {
				activeBouncerNetwork = this.state.bouncerNetworks.get(bouncerNetID);
			}
		}

		if (this.state.connectForm) {
			var status = activeServer ? activeServer.status : ServerStatus.DISCONNECTED;
			var connecting = status === ServerStatus.CONNECTING || status === ServerStatus.REGISTERING;
			let status = activeServer ? activeServer.status : ServerStatus.DISCONNECTED;
			let connecting = status === ServerStatus.CONNECTING || status === ServerStatus.REGISTERING;
			// TODO: using key=connectParams trashes the ConnectForm state on update
			return html`
				<section id="connect">


@@ 1223,7 1223,7 @@ export default class App extends Component {
			`;
		}

		var bufferHeader = null;
		let bufferHeader = null;
		if (activeBuffer) {
			bufferHeader = html`
				<section id="buffer-header">


@@ 1242,7 1242,7 @@ export default class App extends Component {
			`;
		}

		var memberList = null;
		let memberList = null;
		if (activeBuffer && activeBuffer.type == BufferType.CHANNEL) {
			memberList = html`
				<section


@@ 1269,10 1269,10 @@ export default class App extends Component {
			`;
		}

		var dialog = null;
		let dialog = null;
		switch (this.state.dialog) {
		case "network":
			var title = this.state.networkDialog ? "Edit network" : "Add network";
			let title = this.state.networkDialog ? "Edit network" : "Add network";
			dialog = html`
				<${Dialog} title=${title} onDismiss=${this.handleDialogDismiss}>
					<${NetworkForm}


@@ 1299,7 1299,7 @@ export default class App extends Component {
			break;
		}

		var error = null;
		let error = null;
		if (this.state.error) {
			error = html`
				<p id="error-msg">


@@ 1310,7 1310,7 @@ export default class App extends Component {
			`;
		}

		var composerReadOnly = false;
		let composerReadOnly = false;
		if (activeBuffer && activeBuffer.type === BufferType.SERVER) {
			composerReadOnly = true;
		}

M components/buffer-header.js => components/buffer-header.js +8 -8
@@ 10,12 10,12 @@ const UserStatus = {
};

function NickStatus(props) {
	var textMap = {
	let textMap = {
		[UserStatus.HERE]: "User is online",
		[UserStatus.GONE]: "User is away",
		[UserStatus.OFFLINE]: "User is offline",
	};
	var text = textMap[props.status];
	let text = textMap[props.status];
	return html`<span class="status status-${props.status}" title=${text}>●</span>`;
}



@@ 37,7 37,7 @@ export default function BufferHeader(props) {
		props.onManageNetwork();
	}

	var description = null, actions = null;
	let description = null, actions = null;
	switch (props.buffer.type) {
	case BufferType.SERVER:
		switch (props.server.status) {


@@ 65,7 65,7 @@ export default function BufferHeader(props) {
					break;
				}
			} else if (props.buffer.serverInfo) {
				var serverInfo = props.buffer.serverInfo;
				let serverInfo = props.buffer.serverInfo;
				description = `Connected to ${serverInfo.name}`;
			} else {
				description = "Connected";


@@ 126,11 126,11 @@ export default function BufferHeader(props) {
		break;
	case BufferType.NICK:
		if (props.buffer.who) {
			var who = props.buffer.who;
			let who = props.buffer.who;

			var realname = stripANSI(who.realname || "");
			let realname = stripANSI(who.realname || "");

			var status = UserStatus.HERE;
			let status = UserStatus.HERE;
			if (who.away) {
				status = UserStatus.GONE;
			}


@@ 154,7 154,7 @@ export default function BufferHeader(props) {
		break;
	}

	var name = props.buffer.name;
	let name = props.buffer.name;
	if (props.buffer.type == BufferType.SERVER) {
		name = getServerName(props.server, props.bouncerNetwork, props.isBouncer);
	}

M components/buffer-list.js => components/buffer-list.js +6 -6
@@ 8,12 8,12 @@ function BufferItem(props) {
		props.onClick();
	}

	var name = props.buffer.name;
	let name = props.buffer.name;
	if (props.buffer.type == BufferType.SERVER) {
		name = getServerName(props.server, props.bouncerNetwork, props.isBouncer);
	}

	var classes = ["type-" + props.buffer.type];
	let classes = ["type-" + props.buffer.type];
	if (props.active) {
		classes.push("active");
	}


@@ 30,11 30,11 @@ function BufferItem(props) {


export default function BufferList(props) {
	var items = Array.from(props.buffers.values()).map((buf) => {
		var server = props.servers.get(buf.server);
	let items = Array.from(props.buffers.values()).map((buf) => {
		let server = props.servers.get(buf.server);

		var bouncerNetwork = null;
		var bouncerNetID = server.isupport.get("BOUNCER_NETID");
		let bouncerNetwork = null;
		let bouncerNetID = server.isupport.get("BOUNCER_NETID");
		if (bouncerNetID) {
			bouncerNetwork = props.bouncerNetworks.get(bouncerNetID);
		}

M components/buffer.js => components/buffer.js +51 -51
@@ 5,8 5,8 @@ import { strip as stripANSI } from "../lib/ansi.js";
import { BufferType, getNickURL, getChannelURL, getMessageURL } from "../state.js";

function djb2(s) {
	var hash = 5381;
	for (var i = 0; i < s.length; i++) {
	let hash = 5381;
	for (let i = 0; i < s.length; i++) {
		hash = (hash << 5) + hash + s.charCodeAt(i);
		hash = hash >>> 0; // convert to uint32
	}


@@ 19,7 19,7 @@ function Nick(props) {
		props.onClick();
	}

	var colorIndex = djb2(props.nick) % 16 + 1;
	let colorIndex = djb2(props.nick) % 16 + 1;
	return html`
		<a href=${getNickURL(props.nick)} class="nick nick-${colorIndex}" onClick=${handleClick}>${props.nick}</a>
	`;


@@ 30,10 30,10 @@ function Timestamp({ date, url }) {
		return html`<spam class="timestamp">--:--:--</span>`;
	}

	var hh = date.getHours().toString().padStart(2, "0");
	var mm = date.getMinutes().toString().padStart(2, "0");
	var ss = date.getSeconds().toString().padStart(2, "0");
	var timestamp = `${hh}:${mm}:${ss}`;
	let hh = date.getHours().toString().padStart(2, "0");
	let mm = date.getMinutes().toString().padStart(2, "0");
	let ss = date.getSeconds().toString().padStart(2, "0");
	let timestamp = `${hh}:${mm}:${ss}`;
	return html`
		<a href=${url} class="timestamp" onClick=${(event) => event.preventDefault()}>${timestamp}</a>
	`;


@@ 62,10 62,10 @@ class LogLine extends Component {
	}

	render() {
		var msg = this.props.message;
		let msg = this.props.message;

		var onNickClick = this.props.onNickClick;
		var onChannelClick = this.props.onChannelClick;
		let onNickClick = this.props.onNickClick;
		let onChannelClick = this.props.onChannelClick;
		function createNick(nick) {
			return html`
				<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>


@@ 83,14 83,14 @@ class LogLine extends Component {
			`;
		}

		var lineClass = "";
		var content;
		let lineClass = "";
		let content;
		switch (msg.command) {
		case "NOTICE":
		case "PRIVMSG":
			var text = msg.params[1];
			let text = msg.params[1];

			var ctcp = irc.parseCTCP(msg);
			let ctcp = irc.parseCTCP(msg);
			if (ctcp) {
				if (ctcp.command == "ACTION") {
					lineClass = "me-tell";


@@ 102,7 102,7 @@ class LogLine extends Component {
				}
			} else {
				lineClass = "talk";
				var prefix = "<", suffix = ">";
				let prefix = "<", suffix = ">";
				if (msg.command == "NOTICE") {
					prefix = suffix = "-";
				}


@@ 129,7 129,7 @@ class LogLine extends Component {
			`;
			break;
		case "NICK":
			var newNick = msg.params[0];
			let newNick = msg.params[0];
			content = html`
				${createNick(msg.prefix.name)} is now known as ${createNick(newNick)}
			`;


@@ 145,14 145,14 @@ class LogLine extends Component {
			`;
			break;
		case "TOPIC":
			var topic = msg.params[1];
			let topic = msg.params[1];
			content = html`
				${createNick(msg.prefix.name)} changed the topic to: ${linkify(stripANSI(topic), onChannelClick)}
			`;
			break;
		case "INVITE":
			var invitee = msg.params[0];
			var channel = msg.params[1];
			let invitee = msg.params[0];
			let channel = msg.params[1];
			// TODO: instead of checking buffer type, check if invitee is our nick
			if (this.props.buffer.type === BufferType.SERVER) {
				lineClass = "talk";


@@ 193,7 193,7 @@ function createNickList(nicks, createNick) {
		return createNick(nicks[0]);
	}

	var l = nicks.slice(0, nicks.length - 1).map((nick, i) => {
	let l = nicks.slice(0, nicks.length - 1).map((nick, i) => {
		if (i === 0) {
			return createNick(nick);
		} else {


@@ 214,17 214,17 @@ class FoldGroup extends Component {
	}

	render() {
		var msgs = this.props.messages;
		var buf = this.props.buffer;
		let msgs = this.props.messages;
		let buf = this.props.buffer;

		var onNickClick = this.props.onNickClick;
		let onNickClick = this.props.onNickClick;
		function createNick(nick) {
			return html`
				<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
			`;
		}

		var byCommand = {
		let byCommand = {
			"JOIN": [],
			"PART": [],
			"QUIT": [],


@@ 234,15 234,15 @@ class FoldGroup extends Component {
			byCommand[msg.command].push(msg);
		});

		var first = true;
		var content = [];
		let first = true;
		let content = [];
		["JOIN", "PART", "QUIT"].forEach((cmd) => {
			if (byCommand[cmd].length === 0) {
				return;
			}

			var plural = byCommand[cmd].length > 1;
			var action;
			let plural = byCommand[cmd].length > 1;
			let action;
			switch (cmd) {
			case "JOIN":
				action = plural ? "have joined" : "has joined";


@@ 261,7 261,7 @@ class FoldGroup extends Component {
				content.push(", ");
			}

			var nicks = byCommand[cmd].map((msg) => msg.prefix.name);
			let nicks = byCommand[cmd].map((msg) => msg.prefix.name);

			content.push(createNickList(nicks, createNick));
			content.push(" " + action);


@@ 274,16 274,16 @@ class FoldGroup extends Component {
				content.push(", ");
			}

			var newNick = msg.params[0];
			let newNick = msg.params[0];
			content.push(html`
				${createNick(msg.prefix.name)} is now known as ${createNick(newNick)}
			`);
		});

		var lastMsg = msgs[msgs.length - 1];
		var firstDate = new Date(msgs[0].tags.time);
		var lastDate = new Date(lastMsg.tags.time);
		var timestamp = html`
		let lastMsg = msgs[msgs.length - 1];
		let firstDate = new Date(msgs[0].tags.time);
		let lastDate = new Date(lastMsg.tags.time);
		let timestamp = html`
			<${Timestamp} date=${firstDate} url=${getMessageURL(buf, msgs[0])}/>
		`;
		if (lastDate - firstDate > 60 * 100) {


@@ 307,7 307,7 @@ class FoldGroup extends Component {
}

// Workaround for https://bugs.chromium.org/p/chromium/issues/detail?id=481856
var notificationsSupported = false;
let notificationsSupported = false;
if (window.Notification) {
	notificationsSupported = true;
	if (Notification.permission === "default") {


@@ 369,11 369,11 @@ class DateSeparator extends Component {
	}

	render() {
		var date = this.props.date;
		var YYYY = date.getFullYear().toString().padStart(4, "0");
		var MM = (date.getMonth() + 1).toString().padStart(2, "0");
		var DD = date.getDate().toString().padStart(2, "0");
		var text = `${YYYY}-${MM}-${DD}`;
		let date = this.props.date;
		let YYYY = date.getFullYear().toString().padStart(4, "0");
		let MM = (date.getMonth() + 1).toString().padStart(2, "0");
		let DD = date.getDate().toString().padStart(2, "0");
		let text = `${YYYY}-${MM}-${DD}`;
		return html`
			<div class="separator date-separator">
				${text}


@@ 396,18 396,18 @@ export default class Buffer extends Component {
	}

	render() {
		var buf = this.props.buffer;
		let buf = this.props.buffer;
		if (!buf) {
			return null;
		}

		var children = [];
		let children = [];
		if (buf.type == BufferType.SERVER) {
			children.push(html`<${NotificationNagger}/>`);
		}

		var onChannelClick = this.props.onChannelClick;
		var onNickClick = this.props.onNickClick;
		let onChannelClick = this.props.onChannelClick;
		let onNickClick = this.props.onNickClick;
		function createLogLine(msg) {
			return html`
				<${LogLine}


@@ 421,8 421,8 @@ export default class Buffer extends Component {
		}
		function createFoldGroup(msgs) {
			// Filter out PART → JOIN pairs
			var partIndexes = new Map();
			var keep = [];
			let partIndexes = new Map();
			let keep = [];
			msgs.forEach((msg, i) => {
				if (msg.command === "PART" || msg.command === "QUIT") {
					partIndexes.set(msg.prefix.name, i);


@@ 452,18 452,18 @@ export default class Buffer extends Component {
			`;
		}

		var hasUnreadSeparator = false;
		var prevDate = new Date();
		var foldMessages = [];
		let hasUnreadSeparator = false;
		let prevDate = new Date();
		let foldMessages = [];
		buf.messages.forEach((msg) => {
			var sep = [];
			let sep = [];

			if (!hasUnreadSeparator && buf.type != BufferType.SERVER && buf.lastReadReceipt && msg.tags.time > buf.lastReadReceipt.time) {
				sep.push(html`<${UnreadSeparator} key="unread"/>`);
				hasUnreadSeparator = true;
			}

			var date = new Date(msg.tags.time);
			let date = new Date(msg.tags.time);
			if (!sameDate(prevDate, date)) {
				sep.push(html`<${DateSeparator} key=${"date-" + date} date=${date}/>`);
			}

M components/composer.js => components/composer.js +4 -4
@@ 34,21 34,21 @@ export default class Composer extends Component {
			return;
		}

		var text = this.state.text;
		var i;
		let text = this.state.text;
		let i;
		for (i = text.length - 1; i >= 0; i--) {
			if (text[i] === " ") {
				break;
			}
		}
		var prefix = text.slice(i + 1);
		let prefix = text.slice(i + 1);
		if (!prefix) {
			return;
		}

		event.preventDefault();

		var repl = this.props.autocomplete(prefix);
		let repl = this.props.autocomplete(prefix);
		if (!repl) {
			return;
		}

M components/connect-form.js => components/connect-form.js +7 -7
@@ 32,8 32,8 @@ export default class ConnectForm extends Component {
	}

	handleChange(event) {
		var target = event.target;
		var value = target.type == "checkbox" ? target.checked : target.value;
		let target = event.target;
		let value = target.type == "checkbox" ? target.checked : target.value;
		this.setState({ [target.name]: value });
	}



@@ 44,7 44,7 @@ export default class ConnectForm extends Component {
			return;
		}

		var params = {
		let params = {
			url: this.state.url,
			pass: this.state.pass,
			nick: this.state.nick,


@@ 74,9 74,9 @@ export default class ConnectForm extends Component {
	}

	render() {
		var disabled = this.props.connecting;
		let disabled = this.props.connecting;

		var serverURL = null;
		let serverURL = null;
		if (!this.props.params || !this.props.params.url) {
			serverURL = html`
				<label>


@@ 87,7 87,7 @@ export default class ConnectForm extends Component {
			`;
		}

		var status = null;
		let status = null;
		if (this.props.connecting) {
			status = html`
				<p>Connecting...</p>


@@ 98,7 98,7 @@ export default class ConnectForm extends Component {
			`;
		}

		var auth = null;
		let auth = null;
		if (this.props.auth !== "disabled") {
			auth = html`
				<label>

M components/dialog.js => components/dialog.js +1 -1
@@ 35,7 35,7 @@ export default class Dialog extends Component {
	componentDidMount() {
		window.addEventListener("keydown", this.handleKeyDown);

		var autofocus = this.body.current.querySelector("input[autofocus]");
		let autofocus = this.body.current.querySelector("input[autofocus]");
		if (autofocus) {
			autofocus.focus();
		}

M components/help.js => components/help.js +5 -5
@@ 3,8 3,8 @@ import { keybindings } from "../keybindings.js";
import commands from "../commands.js";

function KeyBindingsHelp() {
	var l = keybindings.map((binding) => {
		var keys = [];
	let l = keybindings.map((binding) => {
		let keys = [];
		if (binding.ctrlKey) {
			keys.psuh("Ctrl");
		}


@@ 37,10 37,10 @@ function KeyBindingsHelp() {
}

function CommandsHelp() {
	var l = Object.keys(commands).map((name) => {
		var cmd = commands[name];
	let l = Object.keys(commands).map((name) => {
		let cmd = commands[name];

		var usage = "/" + name;
		let usage = "/" + name;
		if (cmd.usage) {
			usage += " " + cmd.usage;
		}

M components/join-form.js => components/join-form.js +3 -3
@@ 13,15 13,15 @@ export default class JoinForm extends Component {
	}

	handleChange(event) {
		var target = event.target;
		var value = target.type == "checkbox" ? target.checked : target.value;
		let target = event.target;
		let value = target.type == "checkbox" ? target.checked : target.value;
		this.setState({ [target.name]: value });
	}

	handleSubmit(event) {
		event.preventDefault();

		var params = {
		let params = {
			channel: this.state.channel,
		};


M components/member-list.js => components/member-list.js +2 -2
@@ 50,10 50,10 @@ class MemberItem extends Component {
}

function sortMembers(a, b) {
	var [nickA, membA] = a, [nickB, membB] = b;
	let [nickA, membA] = a, [nickB, membB] = b;

	const prefixPrivs = ["~", "&", "@", "%", "+"]; // TODO: grab it from ISUPPORT PREFIX
	var i = prefixPrivs.indexOf(membA[0]), j = prefixPrivs.indexOf(membB[0]);
	let i = prefixPrivs.indexOf(membA[0]), j = prefixPrivs.indexOf(membB[0]);
	if (i < 0) {
		i = prefixPrivs.length;
	}

M components/network-form.js => components/network-form.js +4 -4
@@ 38,15 38,15 @@ export default class NetworkForm extends Component {
	}

	handleChange(event) {
		var target = event.target;
		var value = target.type == "checkbox" ? target.checked : target.value;
		let target = event.target;
		let value = target.type == "checkbox" ? target.checked : target.value;
		this.setState({ [target.name]: value });
	}

	handleSubmit(event) {
		event.preventDefault();

		var params = {};
		let params = {};
		Object.keys(defaultParams).forEach((k) => {
			if (this.prevParams[k] == this.state[k]) {
				return;


@@ 58,7 58,7 @@ export default class NetworkForm extends Component {
	}

	render() {
		var removeNetwork = null;
		let removeNetwork = null;
		if (!this.state.isNew) {
			removeNetwork = html`
				<button type="button" onClick=${() => this.props.onRemove()}>

M components/scroll-manager.js => components/scroll-manager.js +10 -10
@@ 1,6 1,6 @@
import { html, Component } from "../lib/index.js";

var store = new Map();
let store = new Map();

export default class ScrollManager extends Component {
	constructor(props) {


@@ 10,18 10,18 @@ export default class ScrollManager extends Component {
	}

	isAtBottom() {
		var target = this.props.target.current;
		let target = this.props.target.current;
		return target.scrollTop >= target.scrollHeight - target.offsetHeight;
	}

	saveScrollPosition() {
		var target = this.props.target.current;
		let target = this.props.target.current;

		var sticky = target.querySelectorAll(this.props.stickTo);
		var stickToKey = null;
		let sticky = target.querySelectorAll(this.props.stickTo);
		let stickToKey = null;
		if (!this.isAtBottom()) {
			for (var i = 0; i < sticky.length; i++) {
				var el = sticky[i];
			for (let i = 0; i < sticky.length; i++) {
				let el = sticky[i];
				if (el.offsetTop >= target.scrollTop + target.offsetTop) {
					stickToKey = el.dataset.key;
					break;


@@ 33,13 33,13 @@ export default class ScrollManager extends Component {
	}

	restoreScrollPosition() {
		var target = this.props.target.current;
		let target = this.props.target.current;

		var stickToKey = store.get(this.props.scrollKey);
		let stickToKey = store.get(this.props.scrollKey);
		if (!stickToKey) {
			target.firstChild.scrollIntoView({ block: "end" });
		} else {
			var stickTo = target.querySelector("[data-key=\"" + stickToKey + "\"]");
			let stickTo = target.querySelector("[data-key=\"" + stickToKey + "\"]");
			if (stickTo) {
				stickTo.scrollIntoView();
			}

M keybindings.js => keybindings.js +11 -11
@@ 1,8 1,8 @@
import { ReceiptType, Unread, BufferType, SERVER_BUFFER } from "./state.js";

function getSiblingBuffer(buffers, bufID, delta) {
	var bufList = Array.from(buffers.values());
	var i = bufList.findIndex((buf) => buf.id === bufID);
	let bufList = Array.from(buffers.values());
	let i = bufList.findIndex((buf) => buf.id === bufID);
	if (i < 0) {
		return null;
	}


@@ 17,10 17,10 @@ export const keybindings = [
		description: "Mark all messages as read",
		execute: (app) => {
			app.setState((state) => {
				var buffers = new Map();
				let buffers = new Map();
				state.buffers.forEach((buf) => {
					if (buf.messages.length > 0) {
						var lastMsg = buf.messages[buf.messages.length - 1];
						let lastMsg = buf.messages[buf.messages.length - 1];
						app.setReceipt(buf.name, ReceiptType.READ, lastMsg);
					}
					buffers.set(buf.id, {


@@ 38,9 38,9 @@ export const keybindings = [
		description: "Jump to next buffer with activity",
		execute: (app) => {
			// TODO: order by age if same priority
			var firstServerBuffer = null;
			var target = null;
			for (var buf of app.state.buffers.values()) {
			let firstServerBuffer = null;
			let target = null;
			for (let buf of app.state.buffers.values()) {
				if (!firstServerBuffer && buf.type === BufferType.SERVER) {
					firstServerBuffer = buf;
				}


@@ 66,7 66,7 @@ export const keybindings = [
		altKey: true,
		description: "Jump to the previous buffer",
		execute: (app) => {
			var prev = getSiblingBuffer(app.state.buffers, app.state.activeBuffer, -1);
			let prev = getSiblingBuffer(app.state.buffers, app.state.activeBuffer, -1);
			if (prev) {
				app.switchBuffer(prev);
			}


@@ 77,7 77,7 @@ export const keybindings = [
		altKey: true,
		description: "Jump to the next buffer",
		execute: (app) => {
			var next = getSiblingBuffer(app.state.buffers, app.state.activeBuffer, 1);
			let next = getSiblingBuffer(app.state.buffers, app.state.activeBuffer, 1);
			if (next) {
				app.switchBuffer(next);
			}


@@ 86,7 86,7 @@ export const keybindings = [
];

export function setup(app) {
	var byKey = {};
	let byKey = {};
	keybindings.forEach((binding) => {
		if (!byKey[binding.key]) {
			byKey[binding.key] = [];


@@ 95,7 95,7 @@ export function setup(app) {
	});

	window.addEventListener("keydown", (event) => {
		var candidates = byKey[event.key];
		let candidates = byKey[event.key];
		if (!candidates) {
			return;
		}

M lib/ansi.js => lib/ansi.js +3 -3
@@ 15,9 15,9 @@ function isDigit(ch) {
}

export function strip(text) {
	var out = "";
	for (var i = 0; i < text.length; i++) {
		var ch = text[i];
	let out = "";
	for (let i = 0; i < text.length; i++) {
		let ch = text[i];
		switch (ch) {
		case BOLD:
		case ITALIC:

M lib/client.js => lib/client.js +54 -53
@@ 21,7 21,7 @@ const permanentCaps = [

const RECONNECT_DELAY_SEC = 10;

var lastLabel = 0;
let lastLabel = 0;

export default class Client extends EventTarget {
	static Status = {


@@ 65,7 65,7 @@ export default class Client extends EventTarget {
	}

	reconnect() {
		var autoReconnect = this.autoReconnect;
		let autoReconnect = this.autoReconnect;
		this.disconnect();
		this.autoReconnect = autoReconnect;



@@ 150,7 150,7 @@ export default class Client extends EventTarget {
	}

	handleMessage(event) {
		var msg = irc.parseMessage(event.data);
		let msg = irc.parseMessage(event.data);
		console.debug("Received:", msg);

		// If the prefix is missing, assume it's coming from the server on the


@@ 159,7 159,7 @@ export default class Client extends EventTarget {
			msg.prefix = this.serverPrefix;
		}

		var msgBatch = null;
		let msgBatch = null;
		if (msg.tags["batch"]) {
			msgBatch = this.batches.get(msg.tags["batch"]);
			if (msgBatch) {


@@ 167,7 167,7 @@ export default class Client extends EventTarget {
			}
		}

		var deleteBatch = null;
		let deleteBatch = null;
		switch (msg.command) {
		case irc.RPL_WELCOME:
			if (this.params.saslPlain && this.availableCaps["sasl"] === undefined) {


@@ 184,8 184,8 @@ export default class Client extends EventTarget {
			this.setStatus(Client.Status.REGISTERED);
			break;
		case irc.RPL_ISUPPORT:
			var tokens = msg.params.slice(1, -1);
			var changed = irc.parseISUPPORT(tokens, this.isupport);
			let tokens = msg.params.slice(1, -1);
			let changed = irc.parseISUPPORT(tokens, this.isupport);
			if (changed.indexOf("CASEMAPPING") >= 0) {
				this.setCaseMapping(this.isupport.get("CASEMAPPING"));
			}


@@ 225,7 225,7 @@ export default class Client extends EventTarget {
		case irc.RPL_WHOISIDLE:
		case irc.RPL_WHOISCHANNELS:
		case irc.RPL_ENDOFWHOIS:
			var nick = msg.params[1];
			let nick = msg.params[1];
			if (!this.whoisDB.has(nick)) {
				this.whoisDB.set(nick, {});
			}


@@ 243,16 243,16 @@ export default class Client extends EventTarget {
			this.send({ command: "PONG", params: [msg.params[0]] });
			break;
		case "NICK":
			var newNick = msg.params[0];
			let newNick = msg.params[0];
			if (msg.prefix.name == this.nick) {
				this.nick = newNick;
			}
			break;
		case "BATCH":
			var enter = msg.params[0].startsWith("+");
			var name = msg.params[0].slice(1);
			let enter = msg.params[0].startsWith("+");
			let name = msg.params[0].slice(1);
			if (enter) {
				var batch = {
				let batch = {
					name,
					type: msg.params[1],
					params: msg.params.slice(2),


@@ 302,8 302,8 @@ export default class Client extends EventTarget {
	}

	who(mask) {
		var msg = { command: "WHO", params: [mask] };
		var l = [];
		let msg = { command: "WHO", params: [mask] };
		let l = [];
		return this.roundtrip(msg, (msg) => {
			switch (msg.command) {
			case irc.RPL_WHOREPLY:


@@ 320,18 320,19 @@ export default class Client extends EventTarget {
	}

	whois(target) {
		var targetCM = this.cm(target);
		var msg = { command: "WHOIS", params: [target] };
		let targetCM = this.cm(target);
		let msg = { command: "WHOIS", params: [target] };
		return this.roundtrip(msg, (msg) => {
			let nick;
			switch (msg.command) {
			case irc.RPL_ENDOFWHOIS:
				var nick = msg.params[1];
				nick = msg.params[1];
				if (this.cm(nick) === targetCM) {
					return this.whoisDB.get(nick);
				}
				break;
			case irc.ERR_NOSUCHNICK:
				var nick = msg.params[1];
				nick = msg.params[1];
				if (this.cm(nick) === targetCM) {
					throw msg;
				}


@@ 341,11 342,11 @@ export default class Client extends EventTarget {
	}

	addAvailableCaps(s) {
		var l = s.split(" ");
		let l = s.split(" ");
		l.forEach((s) => {
			var parts = s.split("=");
			var k = parts[0].toLowerCase();
			var v = "";
			let parts = s.split("=");
			let k = parts[0].toLowerCase();
			let v = "";
			if (parts.length > 1) {
				v = parts[1];
			}


@@ 354,7 355,7 @@ export default class Client extends EventTarget {
	}

	supportsSASL(mech) {
		var saslCap = this.availableCaps["sasl"];
		let saslCap = this.availableCaps["sasl"];
		if (saslCap === undefined) {
			return false;
		}


@@ 362,7 363,7 @@ export default class Client extends EventTarget {
	}

	requestCaps(extra) {
		var reqCaps = extra || [];
		let reqCaps = extra || [];

		permanentCaps.forEach((cap) => {
			if (this.availableCaps[cap] !== undefined && !this.enabledCaps[cap]) {


@@ 376,16 377,16 @@ export default class Client extends EventTarget {
	}

	handleCap(msg) {
		var subCmd = msg.params[1];
		var args = msg.params.slice(2);
		let subCmd = msg.params[1];
		let 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 capEnd = true;
				let reqCaps = [];
				let capEnd = true;
				if (this.params.saslPlain && this.supportsSASL("PLAIN")) {
					// CAP END is deferred after authentication finishes
					reqCaps.push("sasl");


@@ 438,7 439,7 @@ export default class Client extends EventTarget {
	}

	handleAuthenticate(msg) {
		var challengeStr = msg.params[0];
		let challengeStr = msg.params[0];

		// For now only PLAIN is supported
		if (challengeStr != "+") {


@@ 447,7 448,7 @@ export default class Client extends EventTarget {
			return;
		}

		var respStr = btoa("\0" + this.params.saslPlain.username + "\0" + this.params.saslPlain.password);
		let respStr = btoa("\0" + this.params.saslPlain.username + "\0" + this.params.saslPlain.password);
		this.send({ command: "AUTHENTICATE", params: [respStr] });
	}



@@ 478,7 479,7 @@ export default class Client extends EventTarget {
	}

	isChannel(name) {
		var chanTypes = this.isupport.get("CHANTYPES") || irc.STD_CHANTYPES;
		let chanTypes = this.isupport.get("CHANTYPES") || irc.STD_CHANTYPES;
		return chanTypes.indexOf(name[0]) >= 0;
	}



@@ 500,7 501,7 @@ export default class Client extends EventTarget {
	/* Execute a command that expects a response. `done` is called with message
	 * events until it returns a truthy value. */
	roundtrip(msg, done) {
		var label;
		let label;
		if (this.enabledCaps["labeled-response"]) {
			lastLabel++;
			label = String(lastLabel);


@@ 508,15 509,15 @@ export default class Client extends EventTarget {
		}

		return new Promise((resolve, reject) => {
			var handleMessage = (event) => {
				var msg = event.detail.message;
			let handleMessage = (event) => {
				let msg = event.detail.message;

				var msgLabel = irc.getMessageLabel(msg);
				let msgLabel = irc.getMessageLabel(msg);
				if (msgLabel && msgLabel != label) {
					return;
				}

				var result;
				let result;
				try {
					result = done(msg);
				} catch (err) {


@@ 537,11 538,11 @@ export default class Client extends EventTarget {
	}

	fetchBatch(msg, batchType) {
		var batchName = null;
		var messages = [];
		let batchName = null;
		let messages = [];
		return this.roundtrip(msg, (msg) => {
			if (batchName) {
				var batch = msg.batch;
				let batch = msg.batch;
				while (batch) {
					if (batch.name === batchName) {
						messages.push(msg);


@@ 553,8 554,8 @@ export default class Client extends EventTarget {

			switch (msg.command) {
			case "BATCH":
				var enter = msg.params[0].startsWith("+");
				var name = msg.params[0].slice(1);
				let enter = msg.params[0].startsWith("+");
				let name = msg.params[0].slice(1);
				if (enter && msg.params[1] === batchType) {
					batchName = name;
					break;


@@ 576,7 577,7 @@ export default class Client extends EventTarget {
		// Don't send multiple CHATHISTORY commands in parallel, we can't
		// properly handle batches and errors.
		this.pendingHistory = this.pendingHistory.catch(() => {}).then(() => {
			var msg = {
			let msg = {
				command: "CHATHISTORY",
				params,
			};


@@ 587,7 588,7 @@ export default class Client extends EventTarget {

	chatHistoryPageSize() {
		if (this.isupport.has("CHATHISTORY")) {
			var pageSize = parseInt(this.isupport.get("CHATHISTORY"), 10);
			let pageSize = parseInt(this.isupport.get("CHATHISTORY"), 10);
			if (pageSize > 0) {
				return pageSize;
			}


@@ 597,8 598,8 @@ export default class Client extends EventTarget {

	/* Fetch one page of history before the given date. */
	fetchHistoryBefore(target, before, limit) {
		var max = Math.min(limit, this.chatHistoryPageSize());
		var params = ["BEFORE", target, "timestamp=" + before, max];
		let max = Math.min(limit, this.chatHistoryPageSize());
		let params = ["BEFORE", target, "timestamp=" + before, max];
		return this.roundtripChatHistory(params).then((messages) => {
			return { more: messages.length >= max };
		});


@@ 606,8 607,8 @@ export default class Client extends EventTarget {

	/* Fetch history in ascending order. */
	fetchHistoryBetween(target, after, before, limit) {
		var max = Math.min(limit, this.chatHistoryPageSize());
		var params = ["AFTER", target, "timestamp=" + after.time, max];
		let max = Math.min(limit, this.chatHistoryPageSize());
		let params = ["AFTER", target, "timestamp=" + after.time, max];
		return this.roundtripChatHistory(params).then((messages) => {
			limit -= messages.length;
			if (limit <= 0) {


@@ 623,7 624,7 @@ export default class Client extends EventTarget {
	}

	fetchHistoryTargets(t1, t2) {
		var msg = {
		let msg = {
			command: "CHATHISTORY",
			params: ["TARGETS", "timestamp=" + t1, "timestamp=" + t2, 1000],
		};


@@ 645,13 646,13 @@ export default class Client extends EventTarget {
			return Promise.reject(new Error("Server doesn't support the BOUNCER extension"));
		}

		var req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
		let req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
		return this.fetchBatch(req, "soju.im/bouncer-networks").then((batch) => {
			var networks = new Map();
			for (var msg of batch.messages) {
			let networks = new Map();
			for (let msg of batch.messages) {
				console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK");
				var id = msg.params[1];
				var params = irc.parseTags(msg.params[2]);
				let id = msg.params[1];
				let params = irc.parseTags(msg.params[2]);
				networks.set(id, params);
			}
			return networks;

M lib/irc.js => lib/irc.js +58 -58
@@ 71,14 71,14 @@ function unescapeTag(s) {
}

export function parseTags(s) {
	var tags = {};
	let tags = {};
	s.split(";").forEach((s) => {
		if (!s) {
			return;
		}
		var parts = s.split("=", 2);
		var k = parts[0];
		var v = null;
		let parts = s.split("=", 2);
		let k = parts[0];
		let v = null;
		if (parts.length == 2) {
			v = unescapeTag(parts[1]);
			if (v.endsWith("\\")) {


@@ 91,26 91,26 @@ export function parseTags(s) {
}

export function formatTags(tags) {
	var l = [];
	for (var k in tags) {
	let l = [];
	for (let k in tags) {
		if (tags[k] === undefined || tags[k] === null) {
			l.push(k);
			continue;
		}
		var v = escapeTag(tags[k]);
		let v = escapeTag(tags[k]);
		l.push(k + "=" + v);
	}
	return l.join(";");
}

function parsePrefix(s) {
	var prefix = {
	let prefix = {
		name: null,
		user: null,
		host: null,
	};

	var i = s.indexOf("@");
	let i = s.indexOf("@");
	if (i < 0) {
		prefix.name = s;
		return prefix;


@@ 118,7 118,7 @@ function parsePrefix(s) {
	prefix.host = s.slice(i + 1);
	s = s.slice(0, i);

	var i = s.indexOf("!");
	i = s.indexOf("!");
	if (i < 0) {
		prefix.name = s;
		return prefix;


@@ 143,7 143,7 @@ export function parseMessage(s) {
		s = s.slice(0, s.length - 2);
	}

	var msg = {
	let msg = {
		tags: {},
		prefix: null,
		command: null,


@@ 151,7 151,7 @@ export function parseMessage(s) {
	};

	if (s.startsWith("@")) {
		var i = s.indexOf(" ");
		let i = s.indexOf(" ");
		if (i < 0) {
			throw new Error("expected a space after tags");
		}


@@ 160,7 160,7 @@ export function parseMessage(s) {
	}

	if (s.startsWith(":")) {
		var i = s.indexOf(" ");
		let i = s.indexOf(" ");
		if (i < 0) {
			throw new Error("expected a space after prefix");
		}


@@ 168,7 168,7 @@ export function parseMessage(s) {
		s = s.slice(i + 1);
	}

	var i = s.indexOf(" ");
	let i = s.indexOf(" ");
	if (i < 0) {
		msg.command = s;
		return msg;


@@ 196,7 196,7 @@ export function parseMessage(s) {
}

export function formatMessage(msg) {
	var s = "";
	let s = "";
	if (msg.tags && Object.keys(msg.tags).length > 0) {
		s += "@" + formatTags(msg.tags) + " ";
	}


@@ 205,7 205,7 @@ export function formatMessage(msg) {
	}
	s += msg.command;
	if (msg.params && msg.params.length > 0) {
		var last = msg.params[msg.params.length - 1];
		let last = msg.params[msg.params.length - 1];
		if (msg.params.length > 1) {
			s += " " + msg.params.slice(0, -1).join(" ");
		}


@@ 217,7 217,7 @@ export function formatMessage(msg) {

/** Split a prefix and a name out of a target. */
export function parseTargetPrefix(s, allowedPrefixes = STD_MEMBERSHIPS) {
	var i;
	let i;
	for (i = 0; i < s.length; i++) {
		if (allowedPrefixes.indexOf(s[i]) < 0) {
			break;


@@ 262,15 262,15 @@ export function isHighlight(msg, nick, cm) {
		return false; // Our own messages aren't highlights
	}

	var text = cm(msg.params[1]);
	let text = cm(msg.params[1]);
	while (true) {
		var i = text.indexOf(nick);
		let i = text.indexOf(nick);
		if (i < 0) {
			return false;
		}

		// Detect word boundaries
		var left = "\x00", right = "\x00";
		let left = "\x00", right = "\x00";
		if (i > 0) {
			left = text[i - 1];
		}


@@ 305,13 305,13 @@ export function isError(cmd) {

export function formatDate(date) {
	// ISO 8601
	var YYYY = date.getUTCFullYear().toString().padStart(4, "0");
	var MM = (date.getUTCMonth() + 1).toString().padStart(2, "0");
	var DD = date.getUTCDate().toString().padStart(2, "0");
	var hh = date.getUTCHours().toString().padStart(2, "0");
	var mm = date.getUTCMinutes().toString().padStart(2, "0");
	var ss = date.getUTCSeconds().toString().padStart(2, "0");
	var sss = date.getUTCMilliseconds().toString().padStart(3, "0");
	let YYYY = date.getUTCFullYear().toString().padStart(4, "0");
	let MM = (date.getUTCMonth() + 1).toString().padStart(2, "0");
	let DD = date.getUTCDate().toString().padStart(2, "0");
	let hh = date.getUTCHours().toString().padStart(2, "0");
	let mm = date.getUTCMinutes().toString().padStart(2, "0");
	let ss = date.getUTCSeconds().toString().padStart(2, "0");
	let sss = date.getUTCMilliseconds().toString().padStart(3, "0");
	return `${YYYY}-${MM}-${DD}T${hh}:${mm}:${ss}.${sss}Z`;
}



@@ 320,7 320,7 @@ export function parseCTCP(msg) {
		return null;
	}

	var text = msg.params[1];
	let text = msg.params[1];
	if (!text.startsWith("\x01")) {
		return null;
	}


@@ 329,8 329,8 @@ export function parseCTCP(msg) {
		text = text.slice(0, -1);
	}

	var ctcp;
	var i = text.indexOf(" ");
	let ctcp;
	let i = text.indexOf(" ");
	if (i >= 0) {
		ctcp = { command: text.slice(0, i), param: text.slice(i + 1) };
	} else {


@@ 341,16 341,16 @@ export function parseCTCP(msg) {
}

export function parseISUPPORT(tokens, params) {
	var changed = [];
	let changed = [];
	tokens.forEach((tok) => {
		if (tok.startsWith("-")) {
			var k = tok.slice(1);
			let k = tok.slice(1);
			params.delete(k.toUpperCase());
			return;
		}

		var i = tok.indexOf("=");
		var k = tok, v = "";
		let i = tok.indexOf("=");
		let k = tok, v = "";
		if (i >= 0) {
			k = tok.slice(0, i);
			v = tok.slice(i + 1);


@@ 366,9 366,9 @@ export function parseISUPPORT(tokens, params) {

export const CaseMapping = {
	ASCII(str) {
		var out = "";
		for (var i = 0; i < str.length; i++) {
			var ch = str[i];
		let out = "";
		for (let i = 0; i < str.length; i++) {
			let ch = str[i];
			if ("A" <= ch && ch <= "Z") {
				ch = ch.toLowerCase();
			}


@@ 378,9 378,9 @@ export const CaseMapping = {
	},

	RFC1459(str) {
		var out = "";
		for (var i = 0; i < str.length; i++) {
			var ch = str[i];
		let out = "";
		for (let i = 0; i < str.length; i++) {
			let ch = str[i];
			if ("A" <= ch && ch <= "Z") {
				ch = ch.toLowerCase();
			} else if (ch == "{") {


@@ 398,9 398,9 @@ export const CaseMapping = {
	},

	RFC1459Strict(str) {
		var out = "";
		for (var i = 0; i < str.length; i++) {
			var ch = str[i];
		let out = "";
		for (let i = 0; i < str.length; i++) {
			let ch = str[i];
			if ("A" <= ch && ch <= "Z") {
				ch = ch.toLowerCase();
			} else if (ch == "{") {


@@ 429,7 429,7 @@ export const CaseMapping = {
};

function createIterator(next) {
	var it = { next };
	let it = { next };
	// Not defining this can lead to surprises when feeding the iterator
	// to e.g. Array.from
	it[Symbol.iterator] = () => it;


@@ 438,7 438,7 @@ function createIterator(next) {

function mapIterator(it, f) {
	return createIterator(() => {
		var { value, done } = it.next();
		let { value, done } = it.next();
		if (done) {
			return { done: true };
		}


@@ 464,7 464,7 @@ export class CaseMapMap {
			this.map = new Map();

			if (iterable) {
				for (var [key, value] of iterable) {
				for (let [key, value] of iterable) {
					this.set(key, value);
				}
			}


@@ 480,7 480,7 @@ export class CaseMapMap {
	}

	get(key) {
		var kv = this.map.get(this.caseMap(key));
		let kv = this.map.get(this.caseMap(key));
		if (kv) {
			return kv.value;
		}


@@ 496,21 496,21 @@ export class CaseMapMap {
	}

	entries() {
		var it = this.map.values();
		let it = this.map.values();
		return mapIterator(it, (kv) => {
			return [kv.key, kv.value];
		});
	}

	keys() {
		var it = this.map.values();
		let it = this.map.values();
		return mapIterator(it, (kv) => {
			return kv.key;
		});
	}

	values() {
		var it = this.map.values();
		let it = this.map.values();
		return mapIterator(it, (kv) => {
			return kv.value;
		});


@@ 527,23 527,23 @@ export function parseMembershipModes(str) {
		throw new Error("malformed ISUPPORT PREFIX value: expected opening parenthesis");
	}

	var sep = str.indexOf(")");
	let sep = str.indexOf(")");
	if (sep < 0) {
		throw new Error("malformed ISUPPORT PREFIX value: expected closing parenthesis");
	}

	var n = str.length - sep - 1;
	var memberships = [];
	for (var i = 0; i < n; i++) {
		var mode = str[i + 1];
		var prefix = str[sep + i + 1];
	let n = str.length - sep - 1;
	let memberships = [];
	for (let i = 0; i < n; i++) {
		let mode = str[i + 1];
		let prefix = str[sep + i + 1];
		memberships.push({ mode, prefix });
	}
	return memberships;
}

export function findBatchByType(msg, type) {
	var batch = msg.batch;
	let batch = msg.batch;
	while (batch) {
		if (batch.type === type) {
			return batch;


@@ 558,7 558,7 @@ export function getMessageLabel(msg) {
		return msg.tags.label;
	}

	var batch = msg.batch;
	let batch = msg.batch;
	while (batch) {
		if (batch.tags.label) {
			return batch.tags.label;

M lib/linkify.js => lib/linkify.js +10 -10
@@ 4,12 4,12 @@ function linkifyChannel(text, transformChannel) {
	// Don't match punctuation at the end of the channel name
	const channelRegex = /(?:^|\s)(#[^\s]+[^\s.?!…():;,])/gid;

	var children = [];
	var match;
	var last = 0;
	let children = [];
	let match;
	let last = 0;
	while ((match = channelRegex.exec(text)) !== null) {
		var channel = match[1];
		var [start, end] = match.indices[1];
		let channel = match[1];
		let [start, end] = match.indices[1];

		children.push(text.substring(last, start));
		children.push(transformChannel(channel));


@@ 34,20 34,20 @@ export default function linkify(text, onChannelClick) {
			>${channel}</a>`;
	}

	var links = anchorme.list(text);
	let links = anchorme.list(text);

	var children = [];
	var last = 0;
	let children = [];
	let last = 0;
	links.forEach((match) => {
		const prefix = text.substring(last, match.start)
		children.push(...linkifyChannel(prefix, transformChannel));

		var proto = match.protocol || "https://";
		let proto = match.protocol || "https://";
		if (match.isEmail) {
			proto = "mailto:";
		}

		var url = match.string;
		let url = match.string;
		if (!url.startsWith(proto)) {
			url = proto + url;
		}

M state.js => state.js +56 -55
@@ 55,7 55,7 @@ export function getBufferURL(buf) {
}

export function getMessageURL(buf, msg) {
	var bufURL = getBufferURL(buf);
	let bufURL = getBufferURL(buf);
	if (msg.tags.msgid) {
		return bufURL + "?msgid=" + encodeURIComponent(msg.tags.msgid);
	} else {


@@ 71,7 71,7 @@ export function getServerName(server, bouncerNetwork, isBouncer) {
		return "bouncer";
	}

	var netName = server.isupport.get("NETWORK");
	let netName = server.isupport.get("NETWORK");
	if (netName) {
		return netName;
	}


@@ 80,7 80,7 @@ export function getServerName(server, bouncerNetwork, isBouncer) {
}

function updateState(state, updater) {
	var updated;
	let updated;
	if (typeof updater === "function") {
		updated = updater(state, state);
	} else {


@@ 119,9 119,9 @@ function insertMessage(list, msg) {
		return list.concat(msg);
	}

	var insertBefore = -1;
	for (var i = 0; i < list.length; i++) {
		var other = list[i];
	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;


@@ 134,42 134,42 @@ function insertMessage(list, msg) {
	return list;
}

var lastServerID = 0;
var lastBufferID = 0;
let lastServerID = 0;
let lastBufferID = 0;

export const State = {
	updateServer(state, id, updater) {
		var server = state.servers.get(id);
		let server = state.servers.get(id);
		if (!server) {
			return;
		}

		var updated = updateState(server, updater);
		let updated = updateState(server, updater);
		if (!updated) {
			return;
		}

		var servers = new Map(state.servers);
		let servers = new Map(state.servers);
		servers.set(id, updated);
		return { servers };
	},
	updateBuffer(state, id, updater) {
		var buf = State.getBuffer(state, id);
		let buf = State.getBuffer(state, id);
		if (!buf) {
			return;
		}

		var updated = updateState(buf, updater);
		let updated = updateState(buf, updater);
		if (!updated) {
			return;
		}

		var buffers = new Map(state.buffers);
		let buffers = new Map(state.buffers);
		buffers.set(buf.id, updated);
		return { buffers };
	},
	getActiveServerID(state) {
		var buf = state.buffers.get(state.activeBuffer);
		let buf = state.buffers.get(state.activeBuffer);
		if (!buf) {
			return null;
		}


@@ 184,7 184,7 @@ export const State = {
				return state.buffers.get(id.id);
			}

			var serverID = id.server, name = id.name;
			let serverID = id.server, name = id.name;
			if (!serverID) {
				serverID = State.getActiveServerID(state);
			}


@@ 192,14 192,14 @@ export const State = {
				name = SERVER_BUFFER;
			}

			var cm = irc.CaseMapping.RFC1459;
			var server = state.servers.get(serverID);
			let cm = irc.CaseMapping.RFC1459;
			let server = state.servers.get(serverID);
			if (server) {
				cm = irc.CaseMapping.byName(server.isupport.get("CASEMAPPING")) || cm;
			}

			var nameCM = cm(name);
			for (var buf of state.buffers.values()) {
			let nameCM = cm(name);
			for (let buf of state.buffers.values()) {
				if (buf.server === serverID && cm(buf.name) === nameCM) {
					return buf;
				}


@@ 211,9 211,9 @@ export const State = {
	},
	createServer(state) {
		lastServerID++;
		var id = lastServerID;
		let id = lastServerID;

		var servers = new Map(state.servers);
		let servers = new Map(state.servers);
		servers.set(id, {
			id,
			status: ServerStatus.DISCONNECTED,


@@ 222,15 222,15 @@ export const State = {
		return [id, { servers }];
	},
	createBuffer(state, name, serverID, client) {
		var buf = State.getBuffer(state, { server: serverID, name });
		let buf = State.getBuffer(state, { server: serverID, name });
		if (buf) {
			return [buf.id, null];
		}

		lastBufferID++;
		var id = lastBufferID;
		let id = lastBufferID;

		var type;
		let type;
		if (name == SERVER_BUFFER) {
			type = BufferType.SERVER;
		} else if (client.isChannel(name)) {


@@ 239,7 239,7 @@ export const State = {
			type = BufferType.NICK;
		}

		var bufferList = Array.from(state.buffers.values());
		let bufferList = Array.from(state.buffers.values());
		bufferList.push({
			id,
			name,


@@ 254,7 254,7 @@ export const State = {
			unread: Unread.NONE,
		});
		bufferList = bufferList.sort(compareBuffers);
		var buffers = new Map(bufferList.map((buf) => [buf.id, buf]));
		let buffers = new Map(bufferList.map((buf) => [buf.id, buf]));
		return [id, { buffers }];
	},
	handleMessage(state, msg, serverID, client) {


@@ 270,21 270,22 @@ export const State = {
			return;
		}

		let channel, topic;
		switch (msg.command) {
		case irc.RPL_MYINFO:
			// TODO: parse available modes
			var serverInfo = {
			let serverInfo = {
				name: msg.params[1],
				version: msg.params[2],
			};
			return updateBuffer(SERVER_BUFFER, { serverInfo });
		case irc.RPL_ISUPPORT:
			var buffers = new Map(state.buffers);
			let buffers = new Map(state.buffers);
			state.buffers.forEach((buf) => {
				if (buf.server != serverID) {
					return;
				}
				var members = new irc.CaseMapMap(buf.members, client.cm);
				let members = new irc.CaseMapMap(buf.members, client.cm);
				buffers.set(buf.id, { ...buf, members });
			});
			return {


@@ 292,23 293,23 @@ export const State = {
				...updateServer({ isupport: new Map(client.isupport) }),
			};
		case irc.RPL_NOTOPIC:
			var channel = msg.params[1];
			channel = msg.params[1];
			return updateBuffer(channel, { topic: null });
		case irc.RPL_TOPIC:
			var channel = msg.params[1];
			var topic = msg.params[2];
			channel = msg.params[1];
			topic = msg.params[2];
			return updateBuffer(channel, { topic });
		case irc.RPL_TOPICWHOTIME:
			// Ignore
			break;
		case irc.RPL_NAMREPLY:
			var channel = msg.params[2];
			var membersList = msg.params[3].split(" ");
			channel = msg.params[2];
			let membersList = msg.params[3].split(" ");

			return updateBuffer(channel, (buf) => {
				var members = new irc.CaseMapMap(buf.members);
				let members = new irc.CaseMapMap(buf.members);
				membersList.forEach((s) => {
					var member = irc.parseTargetPrefix(s);
					let member = irc.parseTargetPrefix(s);
					members.set(member.name, member.prefix);
				});
				return { members };


@@ 316,8 317,8 @@ export const State = {
		case irc.RPL_ENDOFNAMES:
			break;
		case irc.RPL_WHOREPLY:
			var last = msg.params[msg.params.length - 1];
			var who = {
			let last = msg.params[msg.params.length - 1];
			let who = {
				username: msg.params[2],
				hostname: msg.params[3],
				server: msg.params[4],


@@ 327,7 328,7 @@ export const State = {
			};
			return updateBuffer(who.nick, { who, offline: false });
		case irc.RPL_ENDOFWHO:
			var target = msg.params[1];
			let target = msg.params[1];
			if (!client.isChannel(target) && target.indexOf("*") < 0) {
				// Not a channel nor a mask, likely a nick
				return updateBuffer(target, (buf) => {


@@ 341,57 342,57 @@ export const State = {
			}
			break;
		case "JOIN":
			var channel = msg.params[0];
			channel = msg.params[0];

			if (client.isMyNick(msg.prefix.name)) {
				var [id, update] = State.createBuffer(state, channel, serverID, client);
				let [id, update] = State.createBuffer(state, channel, serverID, client);
				state = { ...state, ...update };
			}

			var update = updateBuffer(channel, (buf) => {
				var members = new irc.CaseMapMap(buf.members);
			let update = updateBuffer(channel, (buf) => {
				let members = new irc.CaseMapMap(buf.members);
				members.set(msg.prefix.name, "");
				return { members };
			});
			return { ...state, ...update };
		case "PART":
			var channel = msg.params[0];
			channel = msg.params[0];

			return updateBuffer(channel, (buf) => {
				var members = new irc.CaseMapMap(buf.members);
				let members = new irc.CaseMapMap(buf.members);
				members.delete(msg.prefix.name);
				return { members };
			});
		case "KICK":
			var channel = msg.params[0];
			var nick = msg.params[1];
			channel = msg.params[0];
			let nick = msg.params[1];

			return updateBuffer(channel, (buf) => {
				var members = new irc.CaseMapMap(buf.members);
				let members = new irc.CaseMapMap(buf.members);
				members.delete(nick);
				return { members };
			});
		case "SETNAME":
			return updateBuffer(msg.prefix.name, (buf) => {
				var who = { ...buf.who, realname: msg.params[0] };
				let who = { ...buf.who, realname: msg.params[0] };
				return { who };
			});
		case "AWAY":
			var awayMessage = msg.params[0];
			let awayMessage = msg.params[0];

			return updateBuffer(msg.prefix.name, (buf) => {
				var who = { ...buf.who, away: !!awayMessage };
				let who = { ...buf.who, away: !!awayMessage };
				return { who };
			});
		case "TOPIC":
			var channel = msg.params[0];
			var topic = msg.params[1];
			channel = msg.params[0];
			topic = msg.params[1];
			return updateBuffer(channel, { topic });
		}
	},
	addMessage(state, msg, bufID) {
		return State.updateBuffer(state, bufID, (buf) => {
			var messages = insertMessage(buf.messages, msg);
			let messages = insertMessage(buf.messages, msg);
			return { messages };
		});
	},

M store.js => store.js +2 -2
@@ 15,7 15,7 @@ class Item {
	}

	load() {
		var v = localStorage.getItem(this.k);
		let v = localStorage.getItem(this.k);
		if (!v) {
			return null;
		}


@@ 37,7 37,7 @@ const rawReceipts = new Item("receipts");

export const receipts = {
	load() {
		var v = rawReceipts.load();
		let v = rawReceipts.load();
		return new Map(Object.entries(v || {}));
	},
	put(m) {