From b449ace4b45ae9c8a055243cdc18ce46d810ddf5 Mon Sep 17 00:00:00 2001
From: Simon Ser <contact@emersion.fr>
Date: Thu, 18 Jun 2020 14:23:08 +0200
Subject: [PATCH] Switch to react

Under the hood, preact is used to reduce dependency size. We still don't
have a build stage, so htm is used instead of JSX.
---
 components/app.js         | 388 +++++++++++++++++++++++++++++++
 components/buffer-list.js |  29 +++
 components/buffer.js      |  95 ++++++++
 components/composer.js    |  56 +++++
 components/connect.js     | 141 ++++++++++++
 index.html                |  71 +-----
 index.js                  | 472 --------------------------------------
 lib/client.js             |   1 +
 lib/index.js              |   5 +
 package-lock.json         |  10 +
 package.json              |   4 +
 11 files changed, 734 insertions(+), 538 deletions(-)
 create mode 100644 components/app.js
 create mode 100644 components/buffer-list.js
 create mode 100644 components/buffer.js
 create mode 100644 components/composer.js
 create mode 100644 components/connect.js
 delete mode 100644 index.js
 create mode 100644 lib/index.js

diff --git a/components/app.js b/components/app.js
new file mode 100644
index 0000000..72f1001
--- /dev/null
+++ b/components/app.js
@@ -0,0 +1,388 @@
+import * as irc from "/lib/irc.js";
+import Client from "/lib/client.js";
+import Buffer from "/components/buffer.js";
+import BufferList from "/components/buffer-list.js";
+import Connect from "/components/connect.js";
+import Composer from "/components/composer.js";
+import { html, Component, createRef } from "/lib/index.js";
+
+const SERVER_BUFFER = "*";
+
+const DISCONNECTED = "disconnected";
+const CONNECTING = "connecting";
+const REGISTERED = "registered";
+
+function parseQueryString() {
+	var query = window.location.search.substring(1);
+	var params = {};
+	query.split('&').forEach((s) => {
+		if (!s) {
+			return;
+		}
+		var pair = s.split('=');
+		params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || "");
+	});
+	return params;
+}
+
+export default class App extends Component {
+	client = null;
+	state = {
+		connectParams: {
+			serverURL: null,
+			serverPass: null,
+			username: null,
+			realname: null,
+			nick: null,
+			saslPlain: null,
+			autojoin: [],
+		},
+		status: DISCONNECTED,
+		buffers: new Map(),
+		activeBuffer: null,
+	};
+	composer = createRef();
+
+	constructor(props) {
+		super(props);
+
+		this.handleConnectSubmit = this.handleConnectSubmit.bind(this);
+		this.handleBufferListClick = this.handleBufferListClick.bind(this);
+		this.handleComposerSubmit = this.handleComposerSubmit.bind(this);
+	}
+
+	setBufferState(name, updater, callback) {
+		this.setState((state) => {
+			var buf = state.buffers.get(name);
+			if (!buf) {
+				return;
+			}
+
+			var newBuf = updater(buf);
+			if (buf === newBuf || !newBuf) {
+				return;
+			}
+
+			var buffers = new Map(state.buffers);
+			buffers.set(name, newBuf);
+			return { buffers };
+		}, callback);
+	}
+
+	createBuffer(name) {
+		this.setState((state) => {
+			if (state.buffers.get(name)) {
+				return;
+			}
+
+			var buffers = new Map(state.buffers);
+			buffers.set(name, {
+				name: name,
+				topic: null,
+				members: new Map(),
+				messages: [],
+			});
+			return { buffers };
+		});
+	}
+
+	switchBuffer(name) {
+		this.setState({ activeBuffer: name }, () => {
+			if (this.composer.current) {
+				this.composer.current.focus();
+			}
+		});
+	}
+
+	addMessage(bufName, msg) {
+		if (!msg.tags) {
+			msg.tags = {};
+		}
+		// TODO: set time tag if missing
+
+		this.createBuffer(bufName);
+		this.setBufferState(bufName, (buf) => {
+			return {
+				...buf,
+				messages: buf.messages.concat(msg),
+			};
+		});
+	}
+
+	connect(params) {
+		this.setState({ status: CONNECTING, connectParams: params });
+
+		this.client = new Client({
+			url: params.serverURL,
+			pass: params.serverPass,
+			nick: params.nick,
+			username: params.username,
+			realname: params.realname,
+			saslPlain: params.saslPlain,
+		});
+
+		this.client.addEventListener("close", () => {
+			this.setState({ status: DISCONNECTED });
+		});
+
+		this.client.addEventListener("message", (event) => {
+			this.handleMessage(event.detail.message);
+		});
+
+		this.createBuffer(SERVER_BUFFER);
+		this.switchBuffer(SERVER_BUFFER);
+	}
+
+	disconnect() {
+		if (!this.client) {
+			return;
+		}
+		this.client.close();
+	}
+
+	handleMessage(msg) {
+		switch (msg.command) {
+		case irc.RPL_WELCOME:
+			this.setState({ status: REGISTERED });
+
+			if (this.state.connectParams.autojoin.length > 0) {
+				this.client.send({
+					command: "JOIN",
+					params: [this.state.connectParams.autojoin.join(",")],
+				});
+			}
+			break;
+		case irc.RPL_TOPIC:
+			var channel = msg.params[1];
+			var topic = msg.params[2];
+
+			this.setBufferState(channel, (buf) => {
+				return { ...buf, topic };
+			});
+			break;
+		case irc.RPL_NAMREPLY:
+			var channel = msg.params[2];
+			var membersList = msg.params.slice(3);
+
+			this.setBufferState(channel, (buf) => {
+				var members = new Map(buf.members);
+				membersList.forEach((s) => {
+					var member = irc.parseMembership(s);
+					members.set(member.nick, member.prefix);
+				});
+
+				return { ...buf, members };
+			});
+			break;
+		case irc.RPL_ENDOFNAMES:
+			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 { ...buf, 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);
+			}
+			break;
+		case "PART":
+			var channel = msg.params[0];
+
+			this.setBufferState(channel, (buf) => {
+				var members = new Map(buf.members);
+				members.delete(msg.prefix.name);
+				return { ...buf, members };
+			});
+			this.addMessage(channel, 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];
+
+			this.setBufferState((buf) => {
+				return { ...buf, topic };
+			});
+			this.addMessage(channel, msg);
+			break;
+		default:
+			this.addMessage(SERVER_BUFFER, msg);
+		}
+	}
+
+	handleConnectSubmit(connectParams) {
+		if (localStorage) {
+			if (connectParams.rememberMe) {
+				localStorage.setItem("autoconnect", JSON.stringify(connectParams));
+			} else {
+				localStorage.removeItem("autoconnect");
+			}
+		}
+
+		this.connect(connectParams);
+	}
+
+	executeCommand(s) {
+		var parts = s.split(" ");
+		var cmd = parts[0].toLowerCase().slice(1);
+		var args = parts.slice(1);
+		switch (cmd) {
+		case "quit":
+			if (localStorage) {
+				localStorage.removeItem("autoconnect");
+			}
+			this.disconnect();
+			break;
+		case "join":
+			var channel = args[0];
+			if (!channel) {
+				console.error("Missing channel name");
+				return;
+			}
+			this.client.send({ command: "JOIN", params: [channel] });
+			break;
+		case "part":
+			// TODO: check whether the buffer is a channel with the ISUPPORT token
+			// TODO: part reason
+			if (!this.state.activeBuffer || this.state.activeBuffer == SERVER_BUFFER) {
+				console.error("Not in a channel");
+				return;
+			}
+			var channel = this.state.activeBuffer;
+			this.client.send({ command: "PART", params: [channel] });
+			break;
+		case "msg":
+			var target = args[0];
+			var text = args.slice(1).join(" ");
+			this.client.send({ command: "PRIVMSG", params: [target, text] });
+			break;
+		case "nick":
+			var newNick = args[0];
+			this.client.send({ command: "NICK", params: [newNick] });
+			break;
+		default:
+			console.error("Unknwon command '" + cmd + "'");
+		}
+	}
+
+	handleComposerSubmit(text) {
+		if (!text) {
+			return;
+		}
+
+		if (text.startsWith("//")) {
+			text = text.slice(1);
+		} else if (text.startsWith("/")) {
+			this.executeCommand(text);
+			return;
+		}
+
+		var target = this.state.activeBuffer;
+		if (!target || target == SERVER_BUFFER) {
+			return;
+		}
+
+		var msg = { command: "PRIVMSG", params: [target, text] };
+		this.client.send(msg);
+		msg.prefix = { name: this.client.nick };
+		this.addMessage(target, msg);
+	}
+
+	handleBufferListClick(name) {
+		this.switchBuffer(name);
+	}
+
+	componentDidMount() {
+		if (localStorage && localStorage.getItem("autoconnect")) {
+			var connectParams = JSON.parse(localStorage.getItem("autoconnect"));
+			this.connect(connectParams);
+		} else {
+			var params = parseQueryString();
+
+			var serverURL = params.server;
+			if (!serverURL) {
+				var host = window.location.host || "localhost:8080";
+				var proto = "wss:";
+				if (window.location.protocol != "https:") {
+					proto = "ws:";
+				}
+				connectParams.serverURL = proto + "//" + host + "/socket";
+			}
+
+			var autojoin = [];
+			if (params.channels) {
+				autojoin = params.channels.split(",");
+			}
+
+			this.setState((state) => {
+				return {
+					connectParams: {
+						...state.connectParams,
+						serverURL,
+						autojoin,
+					},
+				};
+			});
+		}
+	}
+
+	render() {
+		if (this.state.status != REGISTERED) {
+			return html`
+				<section id="connect">
+					<${Connect} params=${this.state.connectParams} disabled=${this.state.status != DISCONNECTED} onSubmit=${this.handleConnectSubmit}/>
+				</section>
+			`;
+		}
+
+		var activeBuffer = null;
+		if (this.state.activeBuffer) {
+			activeBuffer = this.state.buffers.get(this.state.activeBuffer);
+		}
+
+		return html`
+			<section id="sidebar">
+				<${BufferList} buffers=${this.state.buffers} activeBuffer=${this.state.activeBuffer} onBufferClick=${this.handleBufferListClick}/>
+			</section>
+			<section id="buffer">
+				<${Buffer} buffer=${activeBuffer}/>
+			</section>
+			<${Composer} ref=${this.composer} readOnly=${this.state.activeBuffer == SERVER_BUFFER} onSubmit=${this.handleComposerSubmit}/>
+		`;
+	}
+}
diff --git a/components/buffer-list.js b/components/buffer-list.js
new file mode 100644
index 0000000..8b455c0
--- /dev/null
+++ b/components/buffer-list.js
@@ -0,0 +1,29 @@
+import { html, Component } from "/lib/index.js";
+
+function BufferItem(props) {
+	function handleClick(event) {
+		event.preventDefault();
+		props.onClick();
+	}
+
+	var name = props.buffer.name;
+	if (name == "*") {
+		name = "server";
+	}
+
+	return html`
+		<li class=${props.active ? "active" : ""}>
+			<a href="#" onClick=${handleClick}>${name}</a>
+		</li>
+	`;
+}
+
+export default function BufferList(props) {
+	return html`
+		<ul id="buffer-list">
+			${Array.from(this.props.buffers.values()).map(buf => html`
+				<${BufferItem} buffer=${buf} onClick=${() => props.onBufferClick(buf.name)} active=${props.activeBuffer == buf.name}/>
+			`)}
+		</ul>
+	`;
+}
diff --git a/components/buffer.js b/components/buffer.js
new file mode 100644
index 0000000..e145767
--- /dev/null
+++ b/components/buffer.js
@@ -0,0 +1,95 @@
+import { html, Component } from "/lib/index.js";
+
+function djb2(s) {
+	var hash = 5381;
+	for (var i = 0; i < s.length; i++) {
+		hash = (hash << 5) + hash + s.charCodeAt(i);
+		hash = hash >>> 0; // convert to uint32
+	}
+	return hash;
+}
+
+function Nick(props) {
+	function handleClick(event) {
+		event.preventDefault();
+		// TODO
+	}
+
+	var colorIndex = djb2(props.nick) % 16 + 1;
+	return html`
+		<a href="#" class="nick nick-${colorIndex}" onClick=${handleClick}>${props.nick}</a>
+	`;
+}
+
+function LogLine(props) {
+	var msg = props.message;
+
+	var date = new Date();
+	if (msg.tags["time"]) {
+		date = new Date(msg.tags["time"]);
+	}
+
+	var timestamp = date.toLocaleTimeString(undefined, {
+		timeStyle: "short",
+		hour12: false,
+	});
+	var timestampLink = html`
+		<a href="#" class="timestamp" onClick=${(event) => event.preventDefault()}>${timestamp}</a>
+	`;
+
+	var lineClass = "";
+	var content;
+	switch (msg.command) {
+	case "NOTICE":
+	case "PRIVMSG":
+		var text = msg.params[1];
+
+		var actionPrefix = "\x01ACTION ";
+		if (text.startsWith(actionPrefix) && text.endsWith("\x01")) {
+			var action = text.slice(actionPrefix.length, -1);
+
+			lineClass = "me-tell";
+			content = html`* <${Nick} nick=${msg.prefix.name}/> ${action}`;
+		} else {
+			lineClass = "talk";
+			content = html`${"<"}<${Nick} nick=${msg.prefix.name}/>${">"} ${text}`;
+		}
+		break;
+	case "JOIN":
+		content = html`
+			<${Nick} nick=${msg.prefix.name}/> has joined
+		`;
+		break;
+	case "PART":
+		content = html`
+			<${Nick} nick=${msg.prefix.name}/> has left
+		`;
+		break;
+	case "NICK":
+		var newNick = msg.params[0];
+		content = html`
+			<${Nick} nick=${msg.prefix.name}/> is now known as <${Nick} nick=${newNick}/>
+		`;
+		break;
+	case "TOPIC":
+		var topic = msg.params[1];
+		content = html`
+			<${Nick} nick=${msg.prefix.name}/> changed the topic to: ${topic}
+		`;
+		break;
+	default:
+		content = html`${msg.command} ${msg.params.join(" ")}`;
+	}
+
+	return html`
+		<div class="logline ${lineClass}">${timestampLink} ${content}</div>
+	`;
+}
+
+export default function Buffer(props) {
+	if (!props.buffer) {
+		return null;
+	}
+
+	return props.buffer.messages.map((msg) => html`<${LogLine} message=${msg}/>`);
+}
diff --git a/components/composer.js b/components/composer.js
new file mode 100644
index 0000000..992c135
--- /dev/null
+++ b/components/composer.js
@@ -0,0 +1,56 @@
+import { html, Component, createRef } from "/lib/index.js";
+
+export default class Composer extends Component {
+	state = {
+		text: "",
+	};
+	textInput = createRef();
+
+	constructor(props) {
+		super(props);
+
+		this.handleChange = this.handleChange.bind(this);
+		this.handleSubmit = this.handleSubmit.bind(this);
+		this.handleWindowKeyDown = this.handleWindowKeyDown.bind(this);
+	}
+
+	handleChange(event) {
+		this.setState({ [event.target.name]: event.target.value });
+	}
+
+	handleSubmit(event) {
+		event.preventDefault();
+		this.props.onSubmit(this.state.text);
+		this.setState({ text: "" });
+	}
+
+	handleWindowKeyDown(event) {
+		if (document.activeElement == document.body && event.key == "/" && !this.state.text) {
+			event.preventDefault();
+			this.setState({ text: "/" }, () => {
+				this.focus();
+			});
+		}
+	}
+
+	componentDidMount() {
+		window.addEventListener("keydown", this.handleWindowKeyDown);
+	}
+
+	componentWillUnmount() {
+		window.removeEventListener("keydown", this.handleWindowKeyDown);
+	}
+
+	focus() {
+		document.activeElement.blur(); // in case we're read-only
+		this.textInput.current.focus();
+	}
+
+	render() {
+		return html`
+			<form id="composer" class="${this.props.readOnly && !this.state.text ? "read-only" : ""}" onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
+				<input type="text" name="text" ref=${this.textInput} value=${this.state.text} placeholder="Type a message"/>
+			</form>
+		`;
+	}
+}
diff --git a/components/connect.js b/components/connect.js
new file mode 100644
index 0000000..f8971bf
--- /dev/null
+++ b/components/connect.js
@@ -0,0 +1,141 @@
+import { html, Component } from "/lib/index.js";
+
+export default class Connect extends Component {
+	state = {
+		serverURL: "",
+		serverPass: "",
+		nick: "",
+		password: "",
+		rememberMe: false,
+		username: "",
+		realname: "",
+		autojoin: "",
+	};
+
+	constructor(props) {
+		super(props);
+
+		this.handleChange = this.handleChange.bind(this);
+		this.handleSubmit = this.handleSubmit.bind(this);
+
+		if (props.params) {
+			this.state = {
+				...this.state,
+				serverURL: props.params.serverURL || "",
+				nick: props.params.nick || "",
+				rememberMe: props.params.rememberMe || false,
+				username: props.params.username || "",
+				realname: props.params.realname || "",
+				autojoin: (props.params.autojoin || []).join(","),
+			};
+		}
+	}
+
+	handleChange(event) {
+		var target = event.target;
+		var value = target.type == "checkbox" ? target.checked : target.value;
+		this.setState({ [target.name]: value });
+	}
+
+	handleSubmit(event) {
+		event.preventDefault();
+
+		if (this.props.disabled) {
+			return;
+		}
+
+		var params = {
+			serverURL: this.state.serverURL,
+			serverPass: this.state.serverPass,
+			nick: this.state.nick,
+			rememberMe: this.state.rememberMe,
+			username: this.state.username || this.state.nick,
+			realname: this.state.realname || this.state.nick,
+			saslPlain: null,
+			autojoin: [],
+		};
+
+		if (this.state.password) {
+			params.saslPlain = {
+				username: params.username,
+				password: this.state.password,
+			};
+		}
+
+		this.state.autojoin.split(",").forEach(function(ch) {
+			ch = ch.trim();
+			if (!ch) {
+				return;
+			}
+			params.autojoin.push(ch);
+		});
+
+		this.props.onSubmit(params);
+	}
+
+	render() {
+		return html`
+			<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
+				<h2>Connect to IRC</h2>
+
+				<label>
+					Nickname:<br/>
+					<input type="username" name="nick" value=${this.state.nick} disabled=${this.props.disabled} autofocus required/>
+				</label>
+				<br/><br/>
+
+				<label>
+					Password:<br/>
+					<input type="password" name="password" value=${this.state.password} disabled=${this.props.disabled}/>
+				</label>
+				<br/><br/>
+
+				<label>
+					<input type="checkbox" name="rememberMe" checked=${this.state.rememberMe} disabled=${this.props.disabled}/>
+					Remember me
+				</label>
+				<br/><br/>
+
+				<details>
+					<summary>Advanced options</summary>
+
+					<br/>
+
+					<label>
+						Server URL:<br/>
+						<input type="url" name="serverURL" value=${this.state.serverURL} disabled=${this.props.disabled} required/>
+					</label>
+					<br/><br/>
+
+					<label>
+						Username:<br/>
+						<input type="username" name="username" value=${this.state.username} disabled=${this.props.disabled} placeholder="Same as nickname"/>
+					</label>
+					<br/><br/>
+
+					<label>
+						Real name:<br/>
+						<input type="text" name="realname" value=${this.state.realname} disabled=${this.props.disabled} placeholder="Same as nickname"/>
+					</label>
+					<br/><br/>
+
+					<label>
+						Server password:<br/>
+						<input type="text" name="serverPass" value=${this.state.serverPass} disabled=${this.props.disabled} placeholder="None"/>
+					</label>
+					<br/><br/>
+
+					<label>
+						Auto-join channels:<br/>
+						<input type="text" name="autojoin" value=${this.state.autojoin} disabled=${this.props.disabled} placeholder="Comma-separated list of channels"/>
+					</label>
+					<br/>
+				</details>
+
+				<br/>
+
+				<button disabled=${this.props.disabled}>Connect</button>
+			</form>
+		`;
+	}
+}
diff --git a/index.html b/index.html
index 04e6df0..c4df574 100644
--- a/index.html
+++ b/index.html
@@ -3,75 +3,14 @@
 	<head>
 		<meta charset="utf-8">
 		<title>IRC client</title>
-		<link rel="stylesheet" href="style.css">
+		<link rel="stylesheet" href="/style.css">
 	</head>
 	<body>
-		<section id="sidebar">
-			<ul id="buffer-list">
-				<!--<li class="active"><a href="#">##soju-playground</a></li>-->
-			</ul>
-		</section>
-		<section id="buffer">
-			<!--<div class="logline">
-				<a href="#" class="timestamp">12:27:42</a>
-				&lt;<a href="#" class="nick">emersion</a>&gt;
-				Hi there!
-			</div>-->
-		</section>
-		<form id="composer">
-			<input type="text" placeholder="Type a message">
-		</form>
-
-		<section id="connect">
-			<form>
-				<h2>Connect to IRC</h2>
-
-				<label for="connect-nick">Nickname:</label><br/>
-				<input type="username" name="nick" id="connect-nick" autofocus required/>
-				<br/><br/>
-
-				<label for="connect-password">Password:</label><br/>
-				<input type="password" name="password" id="connect-password"/>
-				<br/><br/>
-
-				<input type="checkbox" name="remember-me" id="connect-remember-me"/>
-				<label for="connect-remember-me">Remember me</label>
-				<br/><br/>
-
-				<details>
-					<summary>Advanced options</summary>
-
-					<br/>
-
-					<label for="connect-url">Server URL:</label><br/>
-					<input type="url" name="url" id="connect-url"/>
-					<br/><br/>
-
-					<label for="connect-username">Username:</label><br/>
-					<input type="username" name="username" id="connect-username" placeholder="Same as nickname"/>
-					<br/><br/>
-
-					<label for="connect-realname">Real name:</label><br/>
-					<input type="text" name="realname" id="connect-realname" placeholder="Same as nickname"/>
-					<br/><br/>
-
-					<label for="connect-pass">Server password:</label><br/>
-					<input type="text" name="pass" id="connect-pass" placeholder="None"/>
-					<br/><br/>
-
-					<label for="connect-autojoin">Auto-join channels:</label><br/>
-					<input type="text" name="autojoin" id="connect-autojoin" placeholder="Comma-separated list of channels"/>
-					<br/>
-				</details>
-
-				<br/>
-
-				<button>Connect</button>
-			</form>
-		</section>
-
 		<script type="module">
-			import "./index.js";
+			import { html, render } from "/lib/index.js";
+			import App from "/components/app.js";
+
+			render(html`<${App}/>`, document.body);
 		</script>
 	</body>
 </html>
diff --git a/index.js b/index.js
deleted file mode 100644
index 7d7b07a..0000000
--- a/index.js
+++ /dev/null
@@ -1,472 +0,0 @@
-import * as irc from "./lib/irc.js";
-import Client from "./lib/client.js";
-
-var server = {
-	name: "server",
-	username: null,
-	realname: null,
-	nick: null,
-	pass: null,
-	saslPlain: null,
-	autojoin: [],
-};
-
-var client = null;
-
-var buffers = {};
-var activeBuffer = null;
-var serverBuffer = null;
-
-var bufferListElt = document.querySelector("#buffer-list");
-var bufferElt = document.querySelector("#buffer");
-var composerElt = document.querySelector("#composer");
-var composerInputElt = document.querySelector("#composer input");
-var connectElt = document.querySelector("#connect");
-var connectFormElt = document.querySelector("#connect form");
-
-function djb2(s) {
-	var hash = 5381;
-	for (var i = 0; i < s.length; i++) {
-		hash = (hash << 5) + hash + s.charCodeAt(i);
-		hash = hash >>> 0; // convert to uint32
-	}
-	return hash;
-}
-
-function createNickElement(name) {
-	var nick = document.createElement("a");
-	nick.href = "#";
-	nick.className = "nick nick-" + (djb2(name) % 16 + 1);
-	nick.innerText = name;
-	nick.onclick = function(event) {
-		event.preventDefault();
-		switchBuffer(createBuffer(name));
-	};
-	return nick;
-}
-
-function createMessageElement(msg) {
-	var date = new Date();
-	if (msg.tags["time"]) {
-		date = new Date(msg.tags["time"]);
-	}
-
-	var line = document.createElement("div");
-	line.className = "logline";
-
-	var timestamp = document.createElement("a");
-	timestamp.href = "#";
-	timestamp.className = "timestamp";
-	timestamp.innerText = date.toLocaleTimeString(undefined, {
-		timeStyle: "short",
-		hour12: false,
-	});
-	timestamp.onclick = function(event) {
-		event.preventDefault();
-	};
-
-	line.appendChild(timestamp);
-	line.appendChild(document.createTextNode(" "));
-
-	switch (msg.command) {
-	case "NOTICE":
-	case "PRIVMSG":
-		var text = msg.params[1];
-
-		var actionPrefix = "\x01ACTION ";
-		if (text.startsWith(actionPrefix) && text.endsWith("\x01")) {
-			var action = text.slice(actionPrefix.length, -1);
-
-			line.className += " me-tell";
-
-			line.appendChild(document.createTextNode("* "));
-			line.appendChild(createNickElement(msg.prefix.name));
-			line.appendChild(document.createTextNode(" " + action));
-		} else {
-			line.className += " talk";
-
-			line.appendChild(document.createTextNode("<"));
-			line.appendChild(createNickElement(msg.prefix.name));
-			line.appendChild(document.createTextNode("> "));
-			line.appendChild(document.createTextNode(text));
-		}
-		break;
-	case "JOIN":
-		line.appendChild(createNickElement(msg.prefix.name));
-		line.appendChild(document.createTextNode(" has joined"));
-		break;
-	case "PART":
-		line.appendChild(createNickElement(msg.prefix.name));
-		line.appendChild(document.createTextNode(" has left"));
-		break;
-	case "NICK":
-		var newNick = msg.params[0];
-		line.appendChild(createNickElement(msg.prefix.name));
-		line.appendChild(document.createTextNode(" is now known as "));
-		line.appendChild(createNickElement(newNick));
-		break;
-	case "TOPIC":
-		line.appendChild(createNickElement(msg.prefix.name));
-		line.appendChild(document.createTextNode(" changed the topic to: " + msg.params[1]));
-		break;
-	default:
-		line.appendChild(document.createTextNode(" " + msg.command + " " + msg.params.join(" ")));
-	}
-
-	return line;
-}
-
-function createBuffer(name) {
-	if (buffers[name]) {
-		return buffers[name];
-	}
-
-	var a = document.createElement("a");
-	a.href = "#";
-	a.onclick = function(event) {
-		event.preventDefault();
-		switchBuffer(name);
-	};
-	a.innerText = name;
-
-	var li = document.createElement("li");
-	li.appendChild(a);
-
-	var buf = {
-		name: name,
-		li: li,
-		readOnly: false,
-		topic: null,
-		members: {},
-		messages: [],
-
-		addMessage: function(msg) {
-			if (!msg.tags) {
-				msg.tags = {};
-			}
-			// TODO: set time tag if missing
-
-			buf.messages.push(msg);
-
-			if (activeBuffer === buf) {
-				bufferElt.appendChild(createMessageElement(msg));
-			}
-		},
-	};
-	buffers[name] = buf;
-
-	bufferListElt.appendChild(li);
-	return buf;
-}
-
-function switchBuffer(buf) {
-	if (typeof buf == "string") {
-		buf = buffers[buf];
-	}
-	if (activeBuffer && buf === activeBuffer) {
-		return;
-	}
-
-	if (activeBuffer) {
-		activeBuffer.li.classList.remove("active");
-	}
-
-	activeBuffer = buf;
-	if (!buf) {
-		return;
-	}
-
-	buf.li.classList.add("active");
-
-	bufferElt.innerHTML = "";
-	for (var msg of buf.messages) {
-		bufferElt.appendChild(createMessageElement(msg));
-	}
-
-	composerElt.classList.toggle("read-only", buf.readOnly);
-	if (!buf.readOnly) {
-		composerInputElt.focus();
-	}
-}
-
-function showConnectForm() {
-	setConnectFormDisabled(false);
-	connectElt.style.display = "block";
-}
-
-function connect() {
-	client = new Client(server);
-
-	client.addEventListener("close", () => {
-		showConnectForm();
-	});
-
-	client.addEventListener("message", (event) => {
-		var msg = event.detail.message;
-
-		switch (msg.command) {
-		case irc.RPL_WELCOME:
-			connectElt.style.display = "none";
-
-			if (server.autojoin.length > 0) {
-				client.send({
-					command: "JOIN",
-					params: [server.autojoin.join(",")],
-				});
-			}
-			break;
-		case irc.RPL_TOPIC:
-			var channel = msg.params[1];
-			var topic = msg.params[2];
-
-			var buf = buffers[channel];
-			if (!buf) {
-				break;
-			}
-			buf.topic = topic;
-			break;
-		case irc.RPL_NAMREPLY:
-			var channel = msg.params[2];
-			var members = msg.params.slice(3);
-
-			var buf = buffers[channel];
-			if (!buf) {
-				break;
-			}
-
-			members.forEach(function(s) {
-				var member = irc.parseMembership(s);
-				buf.members[member.nick] = member.prefix;
-			});
-			break;
-		case irc.RPL_ENDOFNAMES:
-			break;
-		case "NOTICE":
-		case "PRIVMSG":
-			var target = msg.params[0];
-			if (target == client.nick) {
-				target = msg.prefix.name;
-			}
-			var buf;
-			if (target == "*") {
-				buf = serverBuffer;
-			} else {
-				buf = createBuffer(target);
-			}
-			buf.addMessage(msg);
-			break;
-		case "JOIN":
-			var channel = msg.params[0];
-			var buf = createBuffer(channel);
-			buf.members[msg.prefix.name] = null;
-			if (msg.prefix.name != client.nick) {
-				buf.addMessage(msg);
-			}
-			if (channel == server.autojoin[0]) {
-				// TODO: only switch once right after connect
-				switchBuffer(buf);
-			}
-			break;
-		case "PART":
-			var channel = msg.params[0];
-			var buf = createBuffer(channel);
-			delete buf.members[msg.prefix.name];
-			buf.addMessage(msg);
-			break;
-		case "NICK":
-			var newNick = msg.params[0];
-			for (var name in buffers) {
-				var buf = buffers[name];
-				if (buf.members[msg.prefix.name] !== undefined) {
-					buf.members[newNick] = buf.members[msg.prefix.name];
-					delete buf.members[msg.prefix.name];
-					buf.addMessage(msg);
-				}
-			}
-			break;
-		case "TOPIC":
-			var channel = msg.params[0];
-			var topic = msg.params[1];
-			var buf = buffers[channel];
-			if (!buf) {
-				break;
-			}
-			buf.topic = topic;
-			buf.addMessage(msg);
-			break;
-		default:
-			serverBuffer.addMessage(msg);
-		}
-	});
-
-	serverBuffer = createBuffer(server.name);
-	serverBuffer.readOnly = true;
-	switchBuffer(serverBuffer);
-}
-
-function executeCommand(s) {
-	var parts = s.split(" ");
-	var cmd = parts[0].toLowerCase().slice(1);
-	var args = parts.slice(1);
-	switch (cmd) {
-	case "quit":
-		if (localStorage) {
-			localStorage.removeItem("server");
-		}
-		disconnect();
-		break;
-	case "join":
-		var channel = args[0];
-		if (!channel) {
-			console.error("Missing channel name");
-			return;
-		}
-		client.send({ command: "JOIN", params: [channel] });
-		break;
-	case "part":
-		// TODO: part reason
-		if (!activeBuffer || activeBuffer.readOnly) {
-			console.error("Not in a channel");
-			return;
-		}
-		var channel = activeBuffer.name;
-		client.send({ command: "PART", params: [channel] });
-		break;
-	case "msg":
-		var target = args[0];
-		var text = args.slice(1).join(" ");
-		client.send({ command: "PRIVMSG", params: [target, text] });
-		break;
-	case "nick":
-		var newNick = args[0];
-		client.send({ command: "NICK", params: [newNick] });
-		break;
-	default:
-		console.error("Unknwon command '" + cmd + "'");
-	}
-}
-
-composerElt.onsubmit = function(event) {
-	event.preventDefault();
-
-	var text = composerInputElt.value;
-	composerInputElt.value = "";
-	if (!text) {
-		return;
-	}
-
-	if (text.startsWith("//")) {
-		text = text.slice(1);
-	} else if (text.startsWith("/")) {
-		executeCommand(text);
-		return;
-	}
-
-	if (!activeBuffer || activeBuffer.readOnly) {
-		return;
-	}
-	var target = activeBuffer.name;
-
-	var msg = { command: "PRIVMSG", params: [target, text] };
-	client.send(msg);
-	msg.prefix = { name: client.nick };
-	activeBuffer.addMessage(msg);
-};
-
-function setConnectFormDisabled(disabled) {
-	connectElt.querySelectorAll("input, button").forEach(function(elt) {
-		elt.disabled = disabled;
-	});
-}
-
-function parseQueryString() {
-	var query = window.location.search.substring(1);
-	var params = {};
-	query.split('&').forEach(function(s) {
-		if (!s) {
-			return;
-		}
-		var pair = s.split('=');
-		params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || "");
-	});
-	return params;
-}
-
-connectFormElt.onsubmit = function(event) {
-	event.preventDefault();
-	setConnectFormDisabled(true);
-
-	server.url = connectFormElt.elements.url.value;
-	server.nick = connectFormElt.elements.nick.value;
-	server.username = connectFormElt.elements.username.value || server.nick;
-	server.realname = connectFormElt.elements.realname.value || server.nick;
-	server.pass = connectFormElt.elements.pass.value;
-
-	server.saslPlain = null;
-	if (connectFormElt.elements.password.value) {
-		server.saslPlain = {
-			username: server.username,
-			password: connectFormElt.elements.password.value,
-		};
-	}
-
-	server.autojoin = [];
-	connectFormElt.elements.autojoin.value.split(",").forEach(function(ch) {
-		ch = ch.trim();
-		if (!ch) {
-			return;
-		}
-		server.autojoin.push(ch);
-	});
-
-	if (localStorage) {
-		if (connectFormElt.elements["remember-me"].checked) {
-			localStorage.setItem("server", JSON.stringify(server));
-		} else {
-			localStorage.removeItem("server");
-		}
-	}
-
-	connect();
-};
-
-window.onkeydown = function(event) {
-	if (activeBuffer && activeBuffer.readOnly && event.key == "/" && document.activeElement != composerInputElt) {
-		// Allow typing commands even in read-only buffers
-		composerElt.classList.remove("read-only");
-		composerInputElt.focus();
-		composerInputElt.value = "";
-	}
-};
-
-if (localStorage && localStorage.getItem("server")) {
-	server = JSON.parse(localStorage.getItem("server"));
-	connectFormElt.elements.url.value = server.url;
-	connectFormElt.elements.nick.value = server.nick;
-	if (server.username != server.nick) {
-		connectFormElt.elements.username.value = server.username;
-	}
-	if (server.realname != server.nick) {
-		connectFormElt.elements.realname.value = server.realname;
-	}
-	connectFormElt.elements["remember-me"].checked = true;
-	setConnectFormDisabled(true);
-	connect();
-} else {
-	var params = parseQueryString();
-
-	if (params.server) {
-		connectFormElt.elements.url.value = params.server;
-	} else if (!connectFormElt.elements.url.value) {
-		var host = window.location.host || "localhost:8080";
-		var proto = "wss:";
-		if (window.location.protocol != "https:") {
-			proto = "ws:";
-		}
-		connectFormElt.elements.url.value = proto + "//" + host + "/socket";
-	}
-
-	if (params.channels) {
-		connectFormElt.elements.autojoin.value = params.channels;
-	}
-}
diff --git a/lib/client.js b/lib/client.js
index 3b9835e..3582a8b 100644
--- a/lib/client.js
+++ b/lib/client.js
@@ -8,6 +8,7 @@ export default class Client extends EventTarget {
 	ws = null;
 	nick = null;
 	params = {
+		url: null,
 		username: null,
 		realname: null,
 		nick: null,
diff --git a/lib/index.js b/lib/index.js
new file mode 100644
index 0000000..9f5679d
--- /dev/null
+++ b/lib/index.js
@@ -0,0 +1,5 @@
+export * from "/node_modules/preact/dist/preact.module.js";
+
+import { h } from "/node_modules/preact/dist/preact.module.js";
+import htm from "/node_modules/htm/dist/htm.module.js";
+export const html = htm.bind(h);
diff --git a/package-lock.json b/package-lock.json
index 1ead304..9b3a017 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -72,6 +72,11 @@
       "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
       "dev": true
     },
+    "htm": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/htm/-/htm-3.0.4.tgz",
+      "integrity": "sha512-VRdvxX3tmrXuT/Ovt59NMp/ORMFi4bceFMDjos1PV4E0mV+5votuID8R60egR9A4U8nLt238R/snlJGz3UYiTQ=="
+    },
     "http-proxy": {
       "version": "1.18.1",
       "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
@@ -151,6 +156,11 @@
         "mkdirp": "^0.5.1"
       }
     },
+    "preact": {
+      "version": "10.4.4",
+      "resolved": "https://registry.npmjs.org/preact/-/preact-10.4.4.tgz",
+      "integrity": "sha512-EaTJrerceyAPatQ+vfnadoopsMBZAOY7ak9ogVdUi5xbpR8SoHgtLryXnW+4mQOwt21icqoVR1brkU2dq7pEBA=="
+    },
     "qs": {
       "version": "6.9.4",
       "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz",
diff --git a/package.json b/package.json
index da4a2a4..c954214 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,9 @@
 {
   "name": "gamja",
+  "dependencies": {
+    "htm": "^3.0.4",
+    "preact": "^10.4.4"
+  },
   "devDependencies": {
     "http-server": "^0.12.3"
   },