(almost) decouple IRC client and shoutbox

IRC client's interface is now by message passing with state updates and
other events. Only missing part is to also use messages to send requests
from the shoutbox to the client, which will come soon.

This setup will allow wrapping the IRC client in a service worker and
service the client's API over the worker's message pipe.

This change also moves the "current channel" tracking to the shoutbox,
since it ended up being unused in the client (only get/set!). This
cleans some of the mess of letting the client know about pseudo-channels
like "\log" and "\login".
This commit is contained in:
Lephenixnoir 2023-07-18 20:19:41 +02:00
parent f8e765018e
commit 9131c36d63
Signed by: Lephenixnoir
GPG Key ID: 1BBA026E13FC0495
3 changed files with 117 additions and 65 deletions

View File

@ -56,6 +56,10 @@ class SharedChat {
this.onControllerChanged();
});
navigator.serviceWorker.addEventListener("message", (e) => {
this.onMessage(e);
});
return navigator.serviceWorker.register(
"v5shoutbox_worker.js", { scope: "./" })
.then((registration) => {
@ -76,6 +80,12 @@ class SharedChat {
console.log(navigator.serviceWorker);
}
/* Handler called when a message is received from the worker. */
onMessage(e) {
console.log("onMessage!");
console.log(e);
}
async init() {
const ok = await this.registerServiceWorker(() => {
});
@ -104,6 +114,10 @@ let shoutbox = new function() {
this.focused = true;
/* Number of messages received since last losing focus */
this.newMessages = 0;
/* Current channel */
this.channel = undefined;
/* Last observed client state */
this.clientState = undefined;
this.init = function(root) {
this.title = document.title;
@ -148,22 +162,47 @@ let shoutbox = new function() {
shoutbox.focused = false;
});
// TODO: Get this via communication
this.clientState = new IRC.ClientState(irc);
this.selectChannel("\\login");
this.refreshView();
}
/*** IRC callbacks ***/
irc.onMessage = (e) => this.onMessage(e);
this.onMessage = function(e) {
/* Update known client state based on info bundled with the message */
if(e.state !== undefined)
this.clientState = e.state;
if(e.type == "log") {
this.log(e.text);
}
else if(e.type == "state_changed") {
console.log(e);
this.refreshView();
}
else if(e.type == "authenticated") {
this.ePassword.value = "";
}
else if(e.type == "new_message") {
this.onNewMessageCallback(e.channel, e.date, e.author, e.message);
}
else if(e.type == "channel_changed") {
for(const [channel, info] of this.clientState.channels) {
this.createChannel(channel);
if(this.channel === channel)
this.refreshView();
}
}
}
function log(message) {
/* TODO: Use a better text element x) */
this.eLog.innerHTML += message + "\n";
}
this.log = log.bind(this);
irc.onLog = log.bind(this);
irc.onAuthenticated = function() {
this.ePassword.value = "";
}.bind(this);
this.refreshTitle = function() {
if(this.newMessages === 0)
@ -172,23 +211,29 @@ let shoutbox = new function() {
document.title = "(" + this.newMessages.toString() + ") " + this.title;
}
this.isOnRemoteChannel = function() {
const c = this.channel;
return (c !== undefined) && (c.startsWith("&") || c.startsWith("#"));
}
this.refreshView = function() {
const running = irc.isRunning();
if(irc.isOnRemoteChannel()) {
const c = irc.currentChannel();
this.eStatus.textContent = ": " + irc.channels.get(c).header;
const st = this.clientState;
const running = st.isRunning();
if(this.isOnRemoteChannel()) {
const c = this.channel;
this.eStatus.textContent = ": " + st.channels.get(c).header;
let code = document.createElement("code");
code.appendChild(document.createTextNode(c));
this.eStatus.insertBefore(code, this.eStatus.childNodes[0]);
}
else {
this.eStatus.textContent = irc.stateString();
this.eStatus.textContent = st.stateString();
}
this.eShoutboxForm.style.display = running ? "flex" : "none";
this.eLoginTab.style.display = running ? "none" : "inline-block";
const name = irc.currentChannel();
const name = this.channel;
for(const e of this.eChannels.children)
e.style.display = (e.dataset.channel === name) ? "flex" : "none";
for(const b of this.eChannelButtons.children) {
@ -198,28 +243,19 @@ let shoutbox = new function() {
b.classList.remove("current");
}
};
irc.onStateChanged = this.refreshView.bind(this);
irc.onNewMessage = function(channel, date, author, message) {
this.onNewMessageCallback = function(channel, date, author, message) {
shoutbox.addNewMessage(channel, date, author, message);
if(channel === irc.currentChannel() && !shoutbox.focused) {
if(channel === this.channel && !shoutbox.focused) {
shoutbox.newMessages++;
shoutbox.refreshTitle();
}
if(channel !== irc.currentChannel()) {
if(channel !== this.channel) {
shoutbox.setChannelBackgroundActivity(channel, true);
}
}
irc.onChannelChanged = function(channel) {
for(const [channel, info] of irc.channels) {
this.createChannel(channel);
if(irc.currentChannel() === channel)
this.refreshView();
}
}.bind(this);
/*** DOM manipulation ***/
this.getChannelView = function(channel) {
@ -267,7 +303,7 @@ let shoutbox = new function() {
shoutbox.selectChannel(button.dataset.channel);
});
if(irc.currentChannel() === "\\login")
if(this.channel === "\\login")
shoutbox.selectChannel(channel);
}
@ -310,7 +346,7 @@ let shoutbox = new function() {
/*** User interactions ***/
this.connect = function() {
if(irc.isConnected())
if(this.clientState.isConnected())
return this.log("Already connected!");
if(this.eLogin.value === "" || this.ePassword.value === "")
return this.log("Need login/password to connect!");
@ -321,7 +357,7 @@ let shoutbox = new function() {
}
this.selectChannel = function(name) {
irc.selectChannel(name);
this.channel = name;
this.setChannelBackgroundActivity(name, false);
this.refreshView();
}
@ -329,12 +365,12 @@ let shoutbox = new function() {
this.post = function() {
if(this.eMessage.value === "")
return;
if(!irc.isRunning())
if(!this.clientState.isRunning())
return log("Cannot send message while not connected!");
if(!irc.isOnRemoteChannel())
if(!this.isOnRemoteChannel())
return log("Cannot send message as no channel is selected!");
irc.postMessage(irc.currentChannel(), this.eMessage.value);
irc.postMessage(this.channel, this.eMessage.value);
this.eMessage.value = "";
}
};

View File

@ -100,6 +100,31 @@ function base64_encode(str) {
}));
}
/* Clonable IRC state passed to user with most messages. Users keep a copy of
this object around so they can display the state without communicating with
the IRC client (which is generally in another thread) all the time. */
export class ClientState {
constructor(client) {
this.state = client.state;
this.channels = client.channels;
}
/* Connection status */
isConnected() {
return this.state !== State.DISCONNECTED;
}
isRunning() {
return this.state === State.READY;
}
stateString() {
switch(this.state) {
case State.DISCONNECTED: return "Disconnected";
case State.CONNECTING: return "Connecting...";
case State.READY: return "Connected";
}
}
}
export class Client {
#ws;
#password;
@ -118,8 +143,6 @@ export class Client {
this.caps = [];
/* List of channels and their information */
this.channels = new Map();
/* Current channel. This remains defined even when DISCONNECTED. */
this.channel = undefined;
/* Username with which we are logged in. Undefined when DISCONNECTED. */
this.username = undefined;
/* Password for connection. Erased after authenticating. */
@ -128,43 +151,34 @@ export class Client {
this.batches = new Map();
}
/*** Accessors/mutators ***/
/*** Callback messaging ***/
isConnected() {
return this.state !== State.DISCONNECTED;
}
isRunning() {
return this.state === State.READY;
}
stateString() {
switch(this.state) {
case State.DISCONNECTED: return "Disconnected";
case State.CONNECTED: return "Connecting...";
case State.READY: return "Connected";
}
onMessage() {}
sendMessage(e) {
e.state = new ClientState(this);
this.onMessage(e);
}
currentChannel() {
return this.channel;
log(text) {
this.sendMessage({ type: "log", text: text });
}
selectChannel(name) {
this.channel = name;
onStateChanged() {
this.sendMessage({ type: "state_changed" });
}
isOnRemoteChannel() {
const c = this.channel;
return (c !== undefined) && (c.startsWith("&") || c.startsWith("#"));
onAuthenticated() {
this.sendMessage({ type: "authenticated" });
}
/*** Overridable hooks and callbacks ***/
onLog(message) {}
onStateChanged() {}
onAuthenticated() {}
onNewMessage(channel, date, author, message) {}
onChannelChanged(channel) {}
log(...args) {
this.onLog(...args);
onNewMessage(channel, date, author, message) {
this.sendMessage({
type: "new_message",
channel: channel,
date: date,
author: author,
message: message,
});
}
onChannelChanged(channel) {
this.sendMessage({ type: "channel_changed", channel: channel });
}
/*** Connection management ***/
@ -181,7 +195,7 @@ export class Client {
ws.onopen = function() {
this.log("[v5shoutbox] Connected.");
this.setState(State.CONNECTED);
this.setState(State.CONNECTING);
this.username = username;
this.#password = password;
this.#startExchange();

View File

@ -29,6 +29,8 @@ self.addEventListener("activate", (e) => {
self.addEventListener("message", (e) => {
log(e);
if(e.data[0] == "!")
postMessage(e.data.substring(1));
if(e.data[0] == "!") {
log("responding");
e.source.postMessage(e.data.substring(1));
}
});