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.
This commit is contained in:
Simon Ser
2020-06-18 14:23:08 +02:00
parent 62300746d3
commit b449ace4b4
11 changed files with 734 additions and 538 deletions

388
components/app.js Normal file
View File

@@ -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}/>
`;
}
}

29
components/buffer-list.js Normal file
View File

@@ -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>
`;
}

95
components/buffer.js Normal file
View File

@@ -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}/>`);
}

56
components/composer.js Normal file
View File

@@ -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>
`;
}
}

141
components/connect.js Normal file
View File

@@ -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>
`;
}
}