~emersion/public-inbox

Add error reporting on connect and main page v3 PROPOSED

bbworld1: 1
 Add error reporting on connect and main page

 8 files changed, 422 insertions(+), 313 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~emersion/public-inbox/patches/11744/mbox | git am -3
Learn more about email & git
View this thread in the archives

[PATCH v3] Add error reporting on connect and main page Export this patch

---

This should be the last patch I think. Hopefully. I fixed all the indentation errors pointed out.
Let me know if there's any other issues.
I'm really sorry for all the patches. I've never used the Git email workflow before, so still working it out.

Hubert Hirtz <hubert@hirtzfr.eu> asked for a 25 page report on why catch (e) shows up in the diff. I'm
assuming he's joking; please let me know if he's not.

 .editorconfig         |   9 +
 README.md             |   8 +
 components/app.js     | 406 +++++++++++++++++++++---------------------
 components/connect.js |   3 +-
 keybindings.js        |  64 +++++++
 lib/client.js         | 225 ++++++++++++-----------
 state.js              |   5 +
 style.css             |  15 ++
 8 files changed, 422 insertions(+), 313 deletions(-)
 create mode 100644 .editorconfig
 create mode 100644 keybindings.js

diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..c229150
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,9 @@
root = true

[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
indent_style = tab
indent_size = 4
diff --git a/README.md b/README.md
index f17fed8..549e900 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@

A bare-bones IRC web client.

![screenshot](https://l.sr.ht/7Npm.png)

## Usage

Requires an IRC WebSocket server.
@@ -39,6 +41,10 @@ Start your IRC WebSocket server, e.g. on port 8080. Then run:
This will start a development HTTP server for gamja. Connect to it and append
`?server=ws://localhost:8080` to the URL.

## Contributing

Send patches on the [mailing list], report bugs on the [issue tracker].

## License

AGPLv3, see LICENSE.
@@ -48,3 +54,5 @@ Copyright (C) 2020 The gamja Contributors
[gamja]: https://sr.ht/~emersion/gamja/
[soju]: https://soju.im
[webircgateway]: https://github.com/kiwiirc/webircgateway
[mailing list]: https://lists.sr.ht/~emersion/public-inbox
[issue tracker]: https://todo.sr.ht/~emersion/gamja
diff --git a/components/app.js b/components/app.js
index e255b70..37cbc8a 100644
--- a/components/app.js
+++ b/components/app.js
@@ -8,17 +8,13 @@ import Connect from "/components/connect.js";
import Composer from "/components/composer.js";
import ScrollManager from "/components/scroll-manager.js";
import { html, Component, createRef } from "/lib/index.js";
import { SERVER_BUFFER, BufferType, Status, Unread } from "/state.js";
import { SERVER_BUFFER, BufferType, ReceiptType, Status, Unread } from "/state.js";
import commands from "/commands.js";
import { setup as setupKeybindings } from "/keybindings.js";

const CHATHISTORY_PAGE_SIZE = 100;
const CHATHISTORY_MAX_SIZE = 4000;

const ReceiptType = {
	DELIVERED: "delivered",
	READ: "read",
};

var messagesCount = 0;

function parseQueryString() {
@@ -84,6 +80,7 @@ export default class App extends Component {
		status: Status.DISCONNECTED,
		buffers: new Map(),
		activeBuffer: null,
		error: null
	};
	pendingHistory = Promise.resolve(null);
	endOfHistory = new Map();
@@ -337,191 +334,197 @@ export default class App extends Component {
			this.handleMessage(event.detail.message);
		});

		this.client.addEventListener("error", (event) => {
			this.setState({
				error: event.detail
			});
		});

		this.createBuffer(SERVER_BUFFER);
		this.switchBuffer(SERVER_BUFFER);
	}

	handleMessage(msg) {
		switch (msg.command) {
		case irc.RPL_WELCOME:
			this.setState({ status: Status.REGISTERED });
			case irc.RPL_WELCOME:
				this.setState({ status: Status.REGISTERED });

				if (this.state.connectParams.autojoin.length > 0) {
					this.client.send({
						command: "JOIN",
						params: [this.state.connectParams.autojoin.join(",")],
					});
				}
				break;
			case irc.RPL_MYINFO:
				// TODO: parse available modes
				var serverInfo = {
					name: msg.params[1],
					version: msg.params[2],
				};
				this.setBufferState(SERVER_BUFFER, { serverInfo });
				break;
			case irc.RPL_TOPIC:
				var channel = msg.params[1];
				var topic = msg.params[2];

				this.setBufferState(channel, { topic });
				break;
			case irc.RPL_NAMREPLY:
				var channel = msg.params[2];
				var membersList = msg.params[3].split(" ");

				this.setBufferState(channel, (buf) => {
					var members = new Map(buf.members);
					membersList.forEach((s) => {
						var member = irc.parseMembership(s);
						members.set(member.nick, member.prefix);
					});

			if (this.state.connectParams.autojoin.length > 0) {
				this.client.send({
					command: "JOIN",
					params: [this.state.connectParams.autojoin.join(",")],
					return { members };
				});
			}
			break;
		case irc.RPL_MYINFO:
			// TODO: parse available modes
			var serverInfo = {
				name: msg.params[1],
				version: msg.params[2],
			};
			this.setBufferState(SERVER_BUFFER, { serverInfo });
			break;
		case irc.RPL_TOPIC:
			var channel = msg.params[1];
			var topic = msg.params[2];
				break;
			case irc.RPL_ENDOFNAMES:
				break;
			case irc.RPL_WHOREPLY:
				var last = msg.params[msg.params.length - 1];
				var who = {
					username: msg.params[2],
					hostname: msg.params[3],
					server: msg.params[4],
					nick: msg.params[5],
					away: msg.params[6] == 'G', // H for here, G for gone
					realname: last.slice(last.indexOf(" ") + 1),
				};

				this.setBufferState(who.nick, { who, offline: false });
				break;
			case irc.RPL_ENDOFWHO:
				var target = msg.params[1];
				if (!this.isChannel(target) && target.indexOf("*") < 0) {
					// Not a channel nor a mask, likely a nick
					this.setBufferState(target, (buf) => {
						// TODO: mark user offline if we have old WHO info but this
						// WHO reply is empty
						if (buf.who) {
							return;
						}
						return { offline: true };
					});
				}
				break;
			case "NOTICE":
			case "PRIVMSG":
				var target = msg.params[0];
				if (target == this.client.nick) {
					target = msg.prefix.name;
				}
				this.addMessage(target, msg);
				break;
			case "JOIN":
				var channel = msg.params[0];

			this.setBufferState(channel, { topic });
			break;
		case irc.RPL_NAMREPLY:
			var channel = msg.params[2];
			var membersList = msg.params[3].split(" ");

			this.setBufferState(channel, (buf) => {
				var members = new Map(buf.members);
				membersList.forEach((s) => {
					var member = irc.parseMembership(s);
					members.set(member.nick, member.prefix);
				this.createBuffer(channel);
				this.setBufferState(channel, (buf) => {
					var members = new Map(buf.members);
					members.set(msg.prefix.name, null);
					return { members };
				});
				if (msg.prefix.name != this.client.nick) {
					this.addMessage(channel, msg);
				}
				if (channel == this.state.connectParams.autojoin[0]) {
					// TODO: only switch once right after connect
					this.switchBuffer(channel);
				}

				return { members };
			});
			break;
		case irc.RPL_ENDOFNAMES:
			break;
		case irc.RPL_WHOREPLY:
			var last = msg.params[msg.params.length - 1];
			var who = {
				username: msg.params[2],
				hostname: msg.params[3],
				server: msg.params[4],
				nick: msg.params[5],
				away: msg.params[6] == 'G', // H for here, G for gone
				realname: last.slice(last.indexOf(" ") + 1),
			};
				var receipt = this.getReceipt(channel, ReceiptType.READ);
				if (msg.prefix.name == this.client.nick && receipt) {
					var after = receipt;
					var before = { time: msg.tags.time || irc.formatDate(new Date()) };
					this.fetchHistoryBetween(channel, after, before, CHATHISTORY_MAX_SIZE).catch((err) => {
						this.setState({ error: "Failed to fetch history:" + err });
						this.receipts.delete(channel);
						this.saveReceipts();
					});
				}
				break;
			case "PART":
				var channel = msg.params[0];

			this.setBufferState(who.nick, { who, offline: false });
			break;
		case irc.RPL_ENDOFWHO:
			var target = msg.params[1];
			if (!this.isChannel(target) && target.indexOf("*") < 0) {
				// Not a channel nor a mask, likely a nick
				this.setBufferState(target, (buf) => {
					// TODO: mark user offline if we have old WHO info but this
					// WHO reply is empty
					if (buf.who) {
						return;
					}
					return { offline: true };
				this.setBufferState(channel, (buf) => {
					var members = new Map(buf.members);
					members.delete(msg.prefix.name);
					return { members };
				});
			}
			break;
		case "NOTICE":
		case "PRIVMSG":
			var target = msg.params[0];
			if (target == this.client.nick) {
				target = msg.prefix.name;
			}
			this.addMessage(target, msg);
			break;
		case "JOIN":
			var channel = msg.params[0];

			this.createBuffer(channel);
			this.setBufferState(channel, (buf) => {
				var members = new Map(buf.members);
				members.set(msg.prefix.name, null);
				return { members };
			});
			if (msg.prefix.name != this.client.nick) {
				this.addMessage(channel, msg);
			}
			if (channel == this.state.connectParams.autojoin[0]) {
				// TODO: only switch once right after connect
				this.switchBuffer(channel);
			}

			var receipt = this.getReceipt(channel, ReceiptType.READ);
			if (msg.prefix.name == this.client.nick && receipt) {
				var after = receipt;
				var before = { time: msg.tags.time || irc.formatDate(new Date()) };
				this.fetchHistoryBetween(channel, after, before, CHATHISTORY_MAX_SIZE).catch((err) => {
					console.error("Failed to fetch history:", err);
				if (msg.prefix.name == this.client.nick) {
					this.receipts.delete(channel);
					this.saveReceipts();
				}
				break;
			case "QUIT":
				var affectedBuffers = [];
				this.setState((state) => {
					var buffers = new Map(state.buffers);
					state.buffers.forEach((buf) => {
						if (!buf.members.has(msg.prefix.name) && buf.name != msg.prefix.name) {
							return;
						}
						var members = new Map(buf.members);
						members.delete(msg.prefix.name);
						var offline = buf.name == msg.prefix.name;
						buffers.set(buf.name, { ...buf, members, offline });
						affectedBuffers.push(buf.name);
					});
					return { buffers };
				});
			}
			break;
		case "PART":
			var channel = msg.params[0];

			this.setBufferState(channel, (buf) => {
				var members = new Map(buf.members);
				members.delete(msg.prefix.name);
				return { members };
			});
			this.addMessage(channel, msg);

			if (msg.prefix.name == this.client.nick) {
				this.receipts.delete(channel);
				this.saveReceipts();
			}
			break;
		case "QUIT":
			var affectedBuffers = [];
			this.setState((state) => {
				var buffers = new Map(state.buffers);
				state.buffers.forEach((buf) => {
					if (!buf.members.has(msg.prefix.name) && buf.name != msg.prefix.name) {
						return;
					}
					var members = new Map(buf.members);
					members.delete(msg.prefix.name);
					var offline = buf.name == msg.prefix.name;
					buffers.set(buf.name, { ...buf, members, offline });
					affectedBuffers.push(buf.name);
				affectedBuffers.forEach((name) => this.addMessage(name, msg));
				break;
			case "NICK":
				var newNick = msg.params[0];

				var affectedBuffers = [];
				this.setState((state) => {
					var buffers = new Map(state.buffers);
					state.buffers.forEach((buf) => {
						if (!buf.members.has(msg.prefix.name)) {
							return;
						}
						var members = new Map(buf.members);
						members.set(newNick, members.get(msg.prefix.name));
						members.delete(msg.prefix.name);
						buffers.set(buf.name, { ...buf, members });
						affectedBuffers.push(buf.name);
					});
					return { buffers };
				});
				return { buffers };
			});
			affectedBuffers.forEach((name) => this.addMessage(name, msg));
			break;
		case "NICK":
			var newNick = msg.params[0];

			var affectedBuffers = [];
			this.setState((state) => {
				var buffers = new Map(state.buffers);
				state.buffers.forEach((buf) => {
					if (!buf.members.has(msg.prefix.name)) {
						return;
					}
					var members = new Map(buf.members);
					members.set(newNick, members.get(msg.prefix.name));
					members.delete(msg.prefix.name);
					buffers.set(buf.name, { ...buf, members });
					affectedBuffers.push(buf.name);
				});
				return { buffers };
			});
			affectedBuffers.forEach((name) => this.addMessage(name, msg));
			break;
		case "TOPIC":
			var channel = msg.params[0];
			var topic = msg.params[1];
				affectedBuffers.forEach((name) => this.addMessage(name, msg));
				break;
			case "TOPIC":
				var channel = msg.params[0];
				var topic = msg.params[1];

			this.setBufferState(channel, { topic });
			this.addMessage(channel, msg);
			break;
		case "AWAY":
			var awayMessage = msg.params[0];
				this.setBufferState(channel, { topic });
				this.addMessage(channel, msg);
				break;
			case "AWAY":
				var awayMessage = msg.params[0];

			this.setBufferState(msg.prefix.name, (buf) => {
				var who = { ...buf.who, away: !!awayMessage };
				return { who };
			});
			break;
		case "CAP":
		case "AUTHENTICATE":
		case "PING":
		case "BATCH":
			// Ignore these
			break;
		default:
			this.addMessage(SERVER_BUFFER, msg);
				this.setBufferState(msg.prefix.name, (buf) => {
					var who = { ...buf.who, away: !!awayMessage };
					return { who };
				});
				break;
			case "CAP":
			case "AUTHENTICATE":
			case "PING":
			case "BATCH":
				// Ignore these
				break;
			default:
				this.addMessage(SERVER_BUFFER, msg);
		}
	}

@@ -584,20 +587,20 @@ export default class App extends Component {

		var cmd = commands[name];
		if (!cmd) {
			console.error("Unknwon command '" + name + "'");
			this.setState({ error: "Unknown command '" + name + "'" });
			return;
		}

		try {
			cmd(this, args);
		} catch (err) {
			console.error(err);
		} catch (error) {
			this.setState({ error });
		}
	}

	privmsg(target, text) {
		if (target == SERVER_BUFFER) {
			console.error("Cannot send message in server buffer");
			this.setState({ error: "Cannot send message in server buffer" });
			return;
		}

@@ -686,22 +689,22 @@ export default class App extends Component {
				var msg = event.detail.message;

				switch (msg.command) {
				case "BATCH":
					var enter = msg.params[0].startsWith("+");
					var name = msg.params[0].slice(1);
					if (enter) {
					case "BATCH":
						var enter = msg.params[0].startsWith("+");
						var name = msg.params[0].slice(1);
						if (enter) {
							break;
						}
						var batch = this.client.batches.get(name);
						if (batch.type == "chathistory") {
							return batch;
						}
						break;
					case "FAIL":
						if (msg.params[0] == "CHATHISTORY") {
							throw msg;
						}
						break;
					}
					var batch = this.client.batches.get(name);
					if (batch.type == "chathistory") {
						return batch;
					}
					break;
				case "FAIL":
					if (msg.params[0] == "CHATHISTORY") {
						throw msg;
					}
					break;
				}
			});
		});
@@ -758,13 +761,15 @@ export default class App extends Component {
		if (this.state.connectParams.autoconnect) {
			this.connect(this.state.connectParams);
		}

		setupKeybindings(this);
	}

	render() {
		if (this.state.status != Status.REGISTERED) {
			return html`
				<section id="connect">
					<${Connect} params=${this.state.connectParams} disabled=${this.state.status != Status.DISCONNECTED} onSubmit=${this.handleConnectSubmit}/>
					<${Connect} error=${this.state.error} params=${this.state.connectParams} disabled=${this.state.status != Status.DISCONNECTED} onSubmit=${this.handleConnectSubmit}/>
				</section>
			`;
		}
@@ -789,9 +794,9 @@ export default class App extends Component {
				<section id="member-list-header">
					${activeBuffer.members.size} users
				</section>
				<section id="member-list">
					<${MemberList} members=${activeBuffer.members} onNickClick=${this.handleNickClick}/>
				</section>
					<section id="member-list">
						<${MemberList} members=${activeBuffer.members} onNickClick=${this.handleNickClick}/>
					</section>
			`;
		}

@@ -803,13 +808,16 @@ export default class App extends Component {
				</div>
			</section>
			${bufferHeader}
			<${ScrollManager} target=${this.buffer} scrollKey=${this.state.activeBuffer} onScrollTop=${this.handleBufferScrollTop}>
				<section id="buffer" ref=${this.buffer}>
					<${Buffer} buffer=${activeBuffer} onNickClick=${this.handleNickClick}/>
				</section>
			</>
				<${ScrollManager} target=${this.buffer} scrollKey=${this.state.activeBuffer} onScrollTop=${this.handleBufferScrollTop}>
					<section id="buffer" ref=${this.buffer}>
						<${Buffer} buffer=${activeBuffer} onNickClick=${this.handleNickClick}/>
					</section>
				</>
			${memberList}
			<${Composer} ref=${this.composer} readOnly=${this.state.activeBuffer == SERVER_BUFFER} onSubmit=${this.handleComposerSubmit} autocomplete=${this.autocomplete}/>
				<${Composer} ref=${this.composer} readOnly=${this.state.activeBuffer == SERVER_BUFFER} onSubmit=${this.handleComposerSubmit} autocomplete=${this.autocomplete}/>
			${this.state.error ? html`
				<p id="error-msg">${this.state.error} <a href="#" onClick=${this.dismissError}>&times;</a></p>
			`: ""}
		`;
	}
}
diff --git a/components/connect.js b/components/connect.js
index 97589e0..7f1cb36 100644
--- a/components/connect.js
+++ b/components/connect.js
@@ -79,7 +79,7 @@ export default class Connect extends Component {
			rememberMe = html`
				<label>
					<input type="checkbox" name="rememberMe" checked=${this.state.rememberMe} disabled=${this.props.disabled}/>
					Remember me
						Remember me
				</label>
				<br/><br/>
			`;
@@ -140,6 +140,7 @@ export default class Connect extends Component {
				</details>

				<br/>
				<p style=${{color: "red"}}>${this.props.error || ""}</p>

				<button disabled=${this.props.disabled}>Connect</button>
			</form>
diff --git a/keybindings.js b/keybindings.js
new file mode 100644
index 0000000..524b720
--- /dev/null
+++ b/keybindings.js
@@ -0,0 +1,64 @@
import { ReceiptType, Unread } from "/state.js";

export const keybindings = [
	{
		key: "h",
		altKey: true,
		description: "Mark all messages as read",
		execute: (app) => {
			app.setState((state) => {
				var buffers = new Map();
				state.buffers.forEach((buf) => {
					if (buf.messages.length > 0) {
						var lastMsg = buf.messages[buf.messages.length - 1];
						app.setReceipt(buf.name, ReceiptType.READ, lastMsg);
					}
					buffers.set(buf.name, {
						...buf,
						unread: Unread.NONE,
					});
				});
				return { buffers };
			});
		},
	},
	{
		key: "a",
		altKey: true,
		description: "Jump to next buffer with activity",
		execute: (app) => {
			// TODO: order by priority, then by age
			for (var buf of app.state.buffers.values()) {
				if (buf.unread != Unread.NONE) {
					app.switchBuffer(buf.name);
					break;
				}
			}
		},
	},
];

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

	window.addEventListener("keydown", (event) => {
		var candidates = byKey[event.key];
		if (!candidates) {
			return;
		}
		candidates = candidates.filter((binding) => {
			return !!binding.altKey == event.altKey && !!binding.ctrlKey == event.ctrlKey;
		});
		if (candidates.length != 1) {
			return;
		}
		event.preventDefault();
		candidates[0].execute(app);
	});
}
diff --git a/lib/client.js b/lib/client.js
index 01fd67b..eac4765 100644
--- a/lib/client.js
+++ b/lib/client.js
@@ -36,11 +36,10 @@ export default class Client extends EventTarget {
		try {
			this.ws = new WebSocket(params.url);
		} catch (err) {
			console.error("Failed to create connection:", err);
			this.dispatchEvent(new CustomEvent("error", { detail: "Failed to create connection: " + err }));
			setTimeout(() => this.dispatchEvent(new CustomEvent("close")), 0);
			return;
		}

		this.ws.addEventListener("open", this.handleOpen.bind(this));
		this.ws.addEventListener("message", this.handleMessage.bind(this));

@@ -50,7 +49,7 @@ export default class Client extends EventTarget {
		});

		this.ws.addEventListener("error", () => {
			console.error("Connection error");
			this.dispatchEvent(new CustomEvent("error", { detail: "Connection error: could not open WebSocket connection" }));
		});
	}

@@ -84,71 +83,71 @@ export default class Client extends EventTarget {

		var deleteBatch = null;
		switch (msg.command) {
		case irc.RPL_WELCOME:
			if (this.params.saslPlain && this.availableCaps["sasl"] === undefined) {
				console.error("Server doesn't support SASL PLAIN");
				this.close();
				return;
			}
			case irc.RPL_WELCOME:
				if (this.params.saslPlain && this.availableCaps["sasl"] === undefined) {
					console.error("Server doesn't support SASL PLAIN");
					this.close();
					return;
				}

			console.log("Registration complete");
			this.registered = true;
			break;
		case irc.ERR_PASSWDMISMATCH:
			console.error("Password mismatch");
			this.close();
			break;
		case "CAP":
			this.handleCap(msg);
			break;
		case "AUTHENTICATE":
			this.handleAuthenticate(msg);
			break;
		case irc.RPL_LOGGEDIN:
			console.log("Logged in");
			break;
		case irc.RPL_LOGGEDOUT:
			console.log("Logged out");
			break;
		case irc.RPL_SASLSUCCESS:
			console.log("SASL authentication success");
			if (!this.registered) {
				this.send({ command: "CAP", params: ["END"] });
			}
			break;
		case irc.ERR_NICKLOCKED:
		case irc.ERR_SASLFAIL:
		case irc.ERR_SASLTOOLONG:
		case irc.ERR_SASLABORTED:
		case irc.ERR_SASLALREADY:
			console.error("SASL error:", msg);
			this.close();
			break;
		case "PING":
			this.send({ command: "PONG", params: [msg.params[0]] });
			break;
		case "NICK":
			var 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);
			if (enter) {
				var batch = {
					name,
					type: msg.params[1],
					params: msg.params.slice(2),
					parent: msgBatch,
					messages: [],
				};
				this.batches.set(name, batch);
			} else {
				deleteBatch = name;
			}
			break;
				console.log("Registration complete");
				this.registered = true;
				break;
			case irc.ERR_PASSWDMISMATCH:
				this.dispatchEvent(new CustomEvent("error", { detail: "Password mismatch" }));
				this.close();
				break;
			case "CAP":
				this.handleCap(msg);
				break;
			case "AUTHENTICATE":
				this.handleAuthenticate(msg);
				break;
			case irc.RPL_LOGGEDIN:
				console.log("Logged in");
				break;
			case irc.RPL_LOGGEDOUT:
				console.log("Logged out");
				break;
			case irc.RPL_SASLSUCCESS:
				console.log("SASL authentication success");
				if (!this.registered) {
					this.send({ command: "CAP", params: ["END"] });
				}
				break;
			case irc.ERR_NICKLOCKED:
			case irc.ERR_SASLFAIL:
			case irc.ERR_SASLTOOLONG:
			case irc.ERR_SASLABORTED:
			case irc.ERR_SASLALREADY:
				this.dispatchEvent(new CustomEvent("error", { detail: "SASL error: " + msg }));
				this.close();
				break;
			case "PING":
				this.send({ command: "PONG", params: [msg.params[0]] });
				break;
			case "NICK":
				var 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);
				if (enter) {
					var batch = {
						name,
						type: msg.params[1],
						params: msg.params.slice(2),
						parent: msgBatch,
						messages: [],
					};
					this.batches.set(name, batch);
				} else {
					deleteBatch = name;
				}
				break;
		}

		this.dispatchEvent(new CustomEvent("message", {
@@ -201,55 +200,55 @@ export default class Client extends EventTarget {
		var subCmd = msg.params[1];
		var args = msg.params.slice(2);
		switch (subCmd) {
		case "LS":
			this.addAvailableCaps(args[args.length - 1]);
			if (args[0] != "*") {
				console.log("Available server caps:", this.availableCaps);

				var reqCaps = [];
				var capEnd = true;
				if (this.params.saslPlain && this.supportsSASL("PLAIN")) {
					// CAP END is deferred after authentication finishes
					reqCaps.push("sasl");
					capEnd = false;
				}
			case "LS":
				this.addAvailableCaps(args[args.length - 1]);
				if (args[0] != "*") {
					console.log("Available server caps:", this.availableCaps);

					var reqCaps = [];
					var capEnd = true;
					if (this.params.saslPlain && this.supportsSASL("PLAIN")) {
						// CAP END is deferred after authentication finishes
						reqCaps.push("sasl");
						capEnd = false;
					}

				this.requestCaps(reqCaps);
					this.requestCaps(reqCaps);

				if (!this.registered && capEnd) {
					this.send({ command: "CAP", params: ["END"] });
					if (!this.registered && capEnd) {
						this.send({ command: "CAP", params: ["END"] });
					}
				}
			}
			break;
		case "NEW":
			this.addAvailableCaps(args[0]);
			console.log("Server added available caps:", args[0]);
			this.requestCaps();
			break;
		case "DEL":
			args[0].split(" ").forEach((cap) => {
				delete this.availableCaps[cap];
				delete this.enabledCaps[cap];
			});
			console.log("Server removed available caps:", args[0]);
			break;
		case "ACK":
			console.log("Server ack'ed caps:", args[0]);
			args[0].split(" ").forEach((cap) => {
				this.enabledCaps[cap] = true;

				if (cap == "sasl" && this.params.saslPlain) {
					console.log("Starting SASL PLAIN authentication");
					this.send({ command: "AUTHENTICATE", params: ["PLAIN"] });
				break;
			case "NEW":
				this.addAvailableCaps(args[0]);
				console.log("Server added available caps:", args[0]);
				this.requestCaps();
				break;
			case "DEL":
				args[0].split(" ").forEach((cap) => {
					delete this.availableCaps[cap];
					delete this.enabledCaps[cap];
				});
				console.log("Server removed available caps:", args[0]);
				break;
			case "ACK":
				console.log("Server ack'ed caps:", args[0]);
				args[0].split(" ").forEach((cap) => {
					this.enabledCaps[cap] = true;

					if (cap == "sasl" && this.params.saslPlain) {
						console.log("Starting SASL PLAIN authentication");
						this.send({ command: "AUTHENTICATE", params: ["PLAIN"] });
					}
				});
				break;
			case "NAK":
				console.log("Server nak'ed caps:", args[0]);
				if (!this.registered) {
					this.send({ command: "CAP", params: ["END"] });
				}
			});
			break;
		case "NAK":
			console.log("Server nak'ed caps:", args[0]);
			if (!this.registered) {
				this.send({ command: "CAP", params: ["END"] });
			}
			break;
				break;
		}
	}

@@ -258,7 +257,7 @@ export default class Client extends EventTarget {

		// For now only PLAIN is supported
		if (challengeStr != "+") {
			console.error("Expected an empty challenge, got:", challengeStr);
			this.dispatchEvent(new CustomEvent("error", { detail: "Expected an empty challenge, got: " + challengeStr }));
			this.send({ command: "AUTHENTICATE", params: ["*"] });
			return;
		}
diff --git a/state.js b/state.js
index bd98d77..0409e0d 100644
--- a/state.js
+++ b/state.js
@@ -27,6 +27,11 @@ export const Unread = {
	},
};

export const ReceiptType = {
	DELIVERED: "delivered",
	READ: "read",
};

export function getNickURL(nick) {
	return "irc:///" + encodeURIComponent(nick) + ",isnick";
}
diff --git a/style.css b/style.css
index 3d1166f..e184687 100644
--- a/style.css
+++ b/style.css
@@ -234,3 +234,18 @@ details summary {
#buffer .nick-16 {
	color: #ec273e;
}

#error-msg {
	color: white;
	background-color: red;
	position: fixed;
	bottom: 2rem;
	right: 0;
	padding: 0.5rem;
	margin: 0.5rem;
}

#error-msg a {
	color: white;
	text-decoration: none;
}
-- 
2.27.0
Thanks for sending this new version, however it still seems like you'll
need to send a new one! :P

This patch includes unrelated changes, I think the changes are coming
from recent commits pushed to master. Maybe this can help:

    git fetch
    # Delete the broken commit, but keeps the local changes:
    git reset origin/master
    # And then create a new commit as usual

See [1] for a good resource about editing Git history.

[1]: https://git-rebase.io/