1
0
Fork 0
v5shoutbox-better-css/v5shoutbox.js

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;