forked from devs/v5shoutbox
498 lines
16 KiB
JavaScript
498 lines
16 KiB
JavaScript
/*
|
|
# Planète Casio v5 shoutbox
|
|
|
|
This script contains all of the interface logic for the Planète Casio shoutbox.
|
|
It mostly sets up a service worker to use the IRC client and then manages the
|
|
DOM.
|
|
|
|
The shoutbox as a whole is divided into three modules:
|
|
- v5shoutbox.js (this file), handling the interface and coordinating pieces.
|
|
- v5shoutbox_irc.js, which implements the IRC client.
|
|
- v5shoutbox_worker.js, which is the service worker running the IRC client.
|
|
|
|
## The service worker
|
|
|
|
One key aspect of the shoutbox is that it tries to run the IRC client in a
|
|
_service worker_, which is a web worker than runs in the browser's background
|
|
and can interact with multiple pages on the same site [1]. The purpose of the
|
|
worker is to share a single connection to the Planète Casio IRC server between
|
|
all open tabs, avoiding the redundancy of updating each shoutbox separately via
|
|
different connections.
|
|
|
|
Because service workers remain active in the background and can be used by
|
|
multiple pages at once, loading and updating them takes a bit of effort. This
|
|
results in the complex but well-designed _service worker life cycle_ which is
|
|
explained very nicely in [2]. Here, we skip the waiting phase by having all
|
|
versions of the worker claim active shoutboxes because interactions between the
|
|
shoutbox and the worker are limited in scope and it's reasonably easy to handle
|
|
the switch.
|
|
|
|
[1] https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers
|
|
[2] https://web.dev/service-worker-lifecycle/
|
|
*/
|
|
|
|
"use strict";
|
|
import * as IRC from "./v5shoutbox_irc.js";
|
|
|
|
/* IRC-backed chat running in a shared context (service worker) if possible,
|
|
with a default to a local client. This class also provides nice APIs and
|
|
callbacks around the message-passing interface of the IRC client. */
|
|
class SharedChat {
|
|
|
|
/* Registers the service worker that runs the IRC client. Waits for
|
|
registration to complete and returns true on success, false on error.
|
|
If successful, sets up the onControllerChanged callback. */
|
|
async registerServiceWorker() {
|
|
if(!("serviceWorker" in navigator)) {
|
|
console.warn("No service workers, shoutbox will use per-tab IRC client");
|
|
return false;
|
|
}
|
|
|
|
/* Call the update method once if the worker was already there */
|
|
if(navigator.serviceWorker.controller !== null)
|
|
this.onControllerChanged();
|
|
|
|
/* Then call it after first install and every update */
|
|
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
|
this.onControllerChanged();
|
|
});
|
|
|
|
navigator.serviceWorker.addEventListener("message", (e) => {
|
|
this.onMessage(e);
|
|
});
|
|
|
|
return navigator.serviceWorker.register(
|
|
"v5shoutbox_worker.js", { scope: "./" })
|
|
.then((registration) => {
|
|
console.log(registration);
|
|
return true;
|
|
})
|
|
.catch((error) => {
|
|
console.error(`Service worker registration failed with ${error}`);
|
|
return false;
|
|
});
|
|
};
|
|
|
|
/* Handler called when the page is taken over by a service worker. Usually
|
|
the worker manages the page immediately when it starts loading, but this
|
|
also happens after the first install and during live worker updates. */
|
|
onControllerChanged() {
|
|
console.log("onControllerChanged!");
|
|
console.log(navigator.serviceWorker);
|
|
}
|
|
|
|
// TODO: Get this via communication
|
|
getClientState() {
|
|
if(this.irc == null) {
|
|
console.error("todo");
|
|
return null;
|
|
}
|
|
else
|
|
return new IRC.ClientState(this.irc);
|
|
}
|
|
|
|
async init() {
|
|
const ok = await this.registerServiceWorker(() => {
|
|
});
|
|
console.log("SharedChat service worker registration:", ok);
|
|
|
|
if(ok && false)
|
|
this.irc = null;
|
|
else {
|
|
this.irc = new IRC.Client("wss://irc.planet-casio.com:443", undefined);
|
|
this.irc.onMessage = (msg) => this.onMessage(msg);
|
|
}
|
|
}
|
|
|
|
/** API for sending messages to the IRC client **/
|
|
|
|
/* Send a message to the IRC client */
|
|
remoteCall(e) {
|
|
if(this.irc == null)
|
|
navigator.serviceWorker.controller.postMessage(e);
|
|
else
|
|
this.irc.remoteCall(e);
|
|
}
|
|
|
|
connect(login, password) { this.remoteCall({
|
|
type: "connect",
|
|
login: login,
|
|
password: password,
|
|
})}
|
|
|
|
post(channel, message) { this.remoteCall({
|
|
type: "post",
|
|
channel: channel,
|
|
message: message,
|
|
})}
|
|
|
|
/** Handlers for getting messages from the IRC client **/
|
|
|
|
/* Handler called when a message is received from the worker. */
|
|
onMessage(e) {}
|
|
}
|
|
|
|
/* Shoutbox entry point and DOM manipulation. */
|
|
let shoutbox = new function() {
|
|
|
|
const availableChannels = ["#annonces", "#projets", "#hs"];
|
|
|
|
/* Original tab title */
|
|
this.title = undefined;
|
|
/* Whether we currently have focus */
|
|
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, chat) {
|
|
this.title = document.title;
|
|
this.chat = chat;
|
|
this.chat.onMessage = (e) => this.onMessage(e);
|
|
|
|
/* Channel views */
|
|
this.eChannels = root.querySelector(".channels");
|
|
this.eChannelButtons = root.querySelector(".channel-buttons");
|
|
this.eLoginTab = root.querySelector(".tab-login");
|
|
/* Debugging tools */
|
|
this.eLog = root.querySelector(".log");
|
|
this.eStatus = root.querySelector(".status");
|
|
/* Elements of the login form */
|
|
this.eLoginForm = root.querySelector(".login-form");
|
|
this.eLogin = root.querySelector(".login");
|
|
this.ePassword = root.querySelector(".password");
|
|
/* Elements of the shoutbox form */
|
|
this.eShoutboxForm = root.querySelector(".shoutbox-form");
|
|
this.eMessage = root.querySelector(".message");
|
|
|
|
/* Do NOT submit a form and reload the page, even if the script fails,
|
|
thank you. I like to debug things. */
|
|
this.eLoginForm.action = "javascript:void(0)";
|
|
this.eShoutboxForm.action = "javascript:void(0)";
|
|
|
|
this.eLoginForm.addEventListener("submit", e => {
|
|
e.preventDefault();
|
|
shoutbox.connect();
|
|
});
|
|
this.eShoutboxForm.addEventListener("submit", e => {
|
|
e.preventDefault();
|
|
shoutbox.post();
|
|
});
|
|
root.querySelector(".tab-log").addEventListener("click", () => {
|
|
this.selectChannel("\\log");
|
|
});
|
|
root.querySelector(".tab-login").addEventListener("click", () => {
|
|
this.selectChannel("\\login");
|
|
});
|
|
|
|
window.addEventListener("focus", () => {
|
|
shoutbox.focused = true;
|
|
shoutbox.newMessages = 0;
|
|
shoutbox.refreshTitle();
|
|
});
|
|
window.addEventListener("blur", () => {
|
|
shoutbox.focused = false;
|
|
});
|
|
|
|
this.clientState = this.chat.getClientState();
|
|
console.log(this.clientState);
|
|
this.selectChannel("\\login");
|
|
this.refreshView();
|
|
}
|
|
|
|
/*** IRC callbacks ***/
|
|
|
|
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) {
|
|
/* Most consistent escaping method */
|
|
const span = document.createElement("span");
|
|
span.textContent = message + "\n";
|
|
this.eLog.appendChild(span);
|
|
}
|
|
this.log = log.bind(this);
|
|
|
|
this.refreshTitle = function() {
|
|
if(this.newMessages === 0)
|
|
document.title = this.title;
|
|
else
|
|
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 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 = st.stateString();
|
|
}
|
|
|
|
this.eShoutboxForm.style.display = running ? "flex" : "none";
|
|
this.eLoginTab.style.display = running ? "none" : "inline-block";
|
|
|
|
const name = this.channel;
|
|
for(const e of this.eChannels.children) {
|
|
const visible_mode = (e.dataset.channel == "\\log" ? "block" : "flex");
|
|
e.style.display = (e.dataset.channel === name) ? visible_mode : "none";
|
|
}
|
|
for(const b of this.eChannelButtons.children) {
|
|
if(b.dataset.channel === name)
|
|
b.classList.add("current");
|
|
else
|
|
b.classList.remove("current");
|
|
}
|
|
};
|
|
|
|
this.onNewMessageCallback = function(channel, date, author, message) {
|
|
shoutbox.addNewMessage(channel, date, author, message);
|
|
|
|
if(channel === this.channel && !shoutbox.focused) {
|
|
shoutbox.newMessages++;
|
|
shoutbox.refreshTitle();
|
|
}
|
|
if(channel !== this.channel) {
|
|
shoutbox.setChannelBackgroundActivity(channel, true);
|
|
}
|
|
}
|
|
|
|
/*** DOM manipulation ***/
|
|
|
|
this.getChannelView = function(channel) {
|
|
return Array.from(this.eChannels.children)
|
|
.find(e => e.dataset.channel === channel);
|
|
}
|
|
|
|
this.getChannelButton = function(channel) {
|
|
return Array.from(this.eChannelButtons.children)
|
|
.find(e => e.dataset.channel === channel);
|
|
}
|
|
|
|
this.addChannelButton = function(button) {
|
|
/* Insert new tab button before special tabs */
|
|
let firstSpecial = Array.from(this.eChannelButtons.children).find(e =>
|
|
e.dataset.channel.startsWith("\\")) || null;
|
|
this.eChannelButtons.insertBefore(button, firstSpecial);
|
|
}
|
|
|
|
this.clearChannelButtons = function(button) {
|
|
let special = Array.from(this.eChannelButtons.children).filter(e =>
|
|
e.dataset.channel.startsWith("\\"));
|
|
this.eChannelButtons.replaceChildren(special);
|
|
}
|
|
|
|
this.createChannel = function(channel) {
|
|
if(!availableChannels.includes(channel))
|
|
return;
|
|
if(this.getChannelView(channel) !== undefined)
|
|
return;
|
|
|
|
let view = document.createElement("div");
|
|
view.classList.add("channel");
|
|
view.dataset.channel = channel;
|
|
view.style.display = "none";
|
|
this.eChannels.appendChild(view);
|
|
|
|
let button = document.createElement("a");
|
|
button.classList.add("tab");
|
|
button.appendChild(document.createTextNode(channel));
|
|
button.dataset.channel = channel;
|
|
this.addChannelButton(button);
|
|
|
|
button.addEventListener("click", () => {
|
|
shoutbox.selectChannel(button.dataset.channel);
|
|
});
|
|
|
|
if(this.channel === "\\login")
|
|
shoutbox.selectChannel(channel);
|
|
}
|
|
|
|
this.messageToNode = function(message, element) {
|
|
const rURL = /(https?:\/\/|ftp:\/\/|magnet:)/d;
|
|
const fURL = (match) => {
|
|
/* We've found the protocol, now read the arguments until whitespace or
|
|
unbalanced closing parenthesis */
|
|
let i = match.indices[0][1];
|
|
let par_depth = 0;
|
|
|
|
while(i < match.input.length && !/\s/.test(match.input[i])) {
|
|
par_depth += (match.input[i] == "(");
|
|
par_depth -= (match.input[i] == ")");
|
|
if(par_depth < 0)
|
|
break;
|
|
i++;
|
|
}
|
|
|
|
/* Don't count the last character if it's a quote or comma */
|
|
if(i > 0 && /[",]/.test(match.input[i-1]))
|
|
i--;
|
|
|
|
const url = match.input.substring(match.indices[0][0], i);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.target = "_blank";
|
|
a.appendChild(document.createTextNode(url));
|
|
element.appendChild(a);
|
|
return url.length;
|
|
};
|
|
|
|
/* List of matchers: regex, handling function, match object, index. */
|
|
let matchers = [
|
|
[rURL, fURL, null, -1],
|
|
];
|
|
|
|
/* Repeatedly find the next segment to convert. */
|
|
let i = 0;
|
|
while(i < message.length) {
|
|
let next = message.length;
|
|
let next_matcher = null;
|
|
|
|
/* Update the next matches for all regexes and find the one that matches
|
|
the earliest. */
|
|
for(const m of matchers) {
|
|
if(m[3] < 0) {
|
|
m[0].lastIndex = 0;
|
|
m[2] = m[0].exec(message.substring(i));
|
|
m[3] = (m[2] !== null) ? i + m[2].index : message.length;
|
|
}
|
|
if(m[3] >= 0 && m[3] < next) {
|
|
next = m[3];
|
|
next_matcher = m;
|
|
}
|
|
}
|
|
|
|
/* Find the closest one. If it's not at offset 0, do a text node. */
|
|
if(next > i) {
|
|
const sub = message.substring(i, next);
|
|
element.appendChild(document.createTextNode(sub));
|
|
i = next;
|
|
}
|
|
if(next_matcher !== null) {
|
|
i += next_matcher[1](next_matcher[2]);
|
|
next_matcher[2] = null;
|
|
next_matcher[3] = -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.addNewMessage = function(channel, date, author, message) {
|
|
const view = this.getChannelView(channel);
|
|
if(view === undefined)
|
|
return;
|
|
|
|
/* Remove the shoutbox bridge's "[s]" suffix */
|
|
if(author.endsWith("[s]"))
|
|
author = author.substr(0, author.length - 3);
|
|
|
|
let messageElement = document.createElement("div");
|
|
messageElement.classList.add("message");
|
|
messageElement.dataset.author = author;
|
|
let authorElement = document.createElement("div");
|
|
authorElement.appendChild(document.createTextNode(author));
|
|
authorElement.classList.add("message-author");
|
|
messageElement.appendChild(authorElement);
|
|
let messageContentElement = document.createElement("p");
|
|
messageContentElement.classList.add("message-content");
|
|
this.messageToNode(message, messageContentElement);
|
|
messageElement.appendChild(messageContentElement);
|
|
let dateElement = document.createElement("div");
|
|
dateElement.classList.add("message-date");
|
|
dateElement.appendChild(document.createTextNode(date.toLocaleTimeString()));
|
|
messageElement.appendChild(dateElement);
|
|
view.prepend(messageElement);
|
|
}.bind(this);
|
|
|
|
this.setChannelBackgroundActivity = function(channel, activity) {
|
|
const button = this.getChannelButton(channel);
|
|
if(button !== undefined) {
|
|
if(activity)
|
|
button.classList.add("bg-activity");
|
|
else
|
|
button.classList.remove("bg-activity");
|
|
}
|
|
}
|
|
|
|
/*** User interactions ***/
|
|
|
|
this.connect = function() {
|
|
if(this.clientState.isConnected())
|
|
return this.log("Already connected!");
|
|
if(this.eLogin.value === "" || this.ePassword.value === "")
|
|
return this.log("Need login/password to connect!");
|
|
if(this.eLogin.value.includes(" "))
|
|
return this.log("Login should not contain a space!");
|
|
|
|
this.chat.connect(this.eLogin.value, this.ePassword.value);
|
|
}
|
|
|
|
this.selectChannel = function(name) {
|
|
this.channel = name;
|
|
this.setChannelBackgroundActivity(name, false);
|
|
this.refreshView();
|
|
}
|
|
|
|
this.post = function() {
|
|
if(this.eMessage.value === "")
|
|
return;
|
|
if(!this.clientState.isRunning())
|
|
return log("Cannot send message while not connected!");
|
|
if(!this.isOnRemoteChannel())
|
|
return log("Cannot send message as no channel is selected!");
|
|
|
|
this.chat.post(this.channel, this.eMessage.value);
|
|
this.eMessage.value = "";
|
|
}
|
|
};
|
|
|
|
/* We initialize the shoutbox once the SharedChat has finished its async init
|
|
*and* the DOMContentLoaded even has been fired. */
|
|
|
|
let sc = new SharedChat();
|
|
const sc_init_promise = sc.init();
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
sc_init_promise.then(() => {
|
|
shoutbox.init(document.getElementById("v5shoutbox"), sc);
|
|
});
|
|
});
|
|
|
|
await sc_init_promise;
|