v5shoutbox/v5shoutbox_ui.js

432 lines
13 KiB
JavaScript

/* User Interface class for the entire shoutbox. */
class UIShoutbox {
constructor(shc, root) {
// TODO: Better handle the "available channels" limit (in IRCClient?)
this.availableChannels = ["#annonces", "#projets", "#hs"];
this.shc = shc;
/* Original tab title */
this.title = document.title;
/* Whether we currently have focus */
this.focused = true;
/* Number of messages received since last losing focus */
this.newMessages = 0;
/* Current channel */
this.channel = "\\login";
/*** Get DOM elements ***/
this.eRoot = root;
this.eStatus = root.querySelector(".status");
/* Channel views */
this.eChannels = root.querySelector(".channels");
this.eChannelButtons = root.querySelector(".channel-buttons");
this.eLoginTab = root.querySelector(".tab-login");
/* Elements of the login form */
this.eLoginForm = root.querySelector(".login-form");
this.eLogin = root.querySelector(".login");
this.ePassword = root.querySelector(".password");
this.eAuthenticationError = root.querySelector(".error");
/* Elements of the shoutbox form */
this.eShoutboxForm = root.querySelector(".shoutbox-form");
this.eMessage = root.querySelector(".message-input");
/*** Sub-UI ***/
this.uiDebug = new UIDebug(shc,
root.querySelector(".tab-debug"),
root.querySelector(".debugchannel"));
/*** Event setups ***/
/* 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();
this.eventConnect();
});
this.eShoutboxForm.addEventListener("submit", e => {
e.preventDefault();
this.eventPost();
});
root.querySelector(".tab-login").addEventListener("click", () => {
this.selectChannel("\\login");
});
root.querySelector(".tab-settings").addEventListener("click", () => {
this.selectChannel("\\settings");
});
root.querySelector(".tab-debug").addEventListener("click", () => {
this.selectChannel("\\debug");
});
window.addEventListener("focus", () => {
this.focused = true;
this.newMessages = 0;
this.refreshTitle();
});
window.addEventListener("blur", () => {
this.focused = false;
});
/*** Settings updates ***/
const settingShowDebug = root.querySelector(".setting-show-debug");
settingShowDebug.checked = false;
settingShowDebug.addEventListener("change", e => {
this.uiDebug.setTabVisibility(e.target.checked);
});
/*** Callbacks ***/
this.shc.onDebugLog = (ctgy, message) =>
this.uiDebug.logMessage(ctgy, message);
this.shc.onStateChanged = () =>
this.refreshView();
this.shc.onIRCChannelChanged = (channel, info) =>
this.refreshView();
this.shc.onIRCAuthResult = (successful) => {
this.ePassword.value = "";
this.eAuthenticationError.style.display = successful ? "none" : "block";
if(successful)
this.getAllHistory();
}
/* Execute the welcome eagerly if we've already been welcomed! */
if(this.shc.state().isLocallySetup())
this.getAllHistory();
this.shc.onWelcome = () =>
this.getAllHistory();
this.shc.onIRCNewMessage = this.handleIRCNewMessage.bind(this);
this.shc.onIRCHistoryReceived = this.handleIRCHistoryReceived.bind(this);
this.refreshView();
}
isOnRemoteChannel() {
const c = this.channel;
return (c !== null) && (c.startsWith("&") || c.startsWith("#"));
}
/*** View refresh mechanism ***/
refreshTitle() {
if(this.newMessages === 0)
document.title = this.title;
else
document.title = `(${this.newMessages}) ${this.title}`;
}
refreshView() {
const state = this.shc.state();
const running = state.isRunning();
const activating =
this.eRoot.classList.contains("notrunning") && running;
if(state.irc !== null) {
for(const [channel, info] of state.irc.channels)
this.refreshChannel(channel, activating);
}
const viewingChannel = this.isOnRemoteChannel();
/* Update connection information at root */
if(!running)
this.eRoot.classList.add("notrunning");
else
this.eRoot.classList.remove("notrunning");
/* Update status/channel line */
if(running && viewingChannel) {
const c = this.channel;
this.eStatus.textContent = " : " + state.irc.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 =
state.SHCConnString() + " · " + state.IRCConnString();
}
/* Update visibility of forms */
this.eShoutboxForm.style.display =
running && viewingChannel ? "flex" : "none";
this.eLoginTab.style.display =
running ? "none" : "inline-block";
/* Update which tab's contents is visible */
for(const e of this.eChannels.children) {
const normal = (e.dataset.channel === "\\settings") ? "block" : "flex";
e.style.display = (e.dataset.channel === this.channel) ? normal : "none";
}
/* Update tab widgets' properties */
for(const b of this.eChannelButtons.children) {
if(b.dataset.channel === this.channel)
b.classList.add("current");
else
b.classList.remove("current");
}
/* Update status table */
this.uiDebug.refreshView();
}
refreshChannel(channel, activating) {
if(!this.availableChannels.includes(channel))
return;
if(this.getChannelView(channel) === undefined) {
activating = true;
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.classList.add("tab-channel");
button.appendChild(document.createTextNode(channel));
button.dataset.channel = channel;
this.addChannelButton(button);
button.addEventListener("click", () => {
this.selectChannel(button.dataset.channel);
});
}
/* Automatically select channels when they appear. */
if(activating && (this.channel === null || this.channel === "\\login"))
this.selectChannel(channel);
}
/*** Event handlers ***/
eventConnect() {
if(this.eLogin.value === "" || this.ePassword.value === "" ||
this.shc.state().isTalkingToIRCServer())
return;
if(this.eLogin.value.includes(" ")) {
this.uiDebug.logMessage("UI", "Login should not contain a space!");
return;
}
this.shc.connect(this.eLogin.value, this.ePassword.value);
}
selectChannel(name) {
this.channel = name;
this.setChannelBackgroundActivity(name, false);
this.refreshView();
}
eventPost() {
if(this.eMessage.value === "" || !this.shc.state().isRunning() ||
!this.isOnRemoteChannel())
return;
this.shc.postMessage(this.channel, this.eMessage.value);
this.eMessage.value = "";
}
handleIRCNewMessage(id, channel, date, author, message) {
this.addNewMessage(id, channel, date, author, message);
if(channel === this.channel && !this.focused) {
this.newMessages++;
this.refreshTitle();
}
if(channel !== this.channel) {
this.setChannelBackgroundActivity(channel, true);
}
}
handleIRCHistoryReceived(channel, messages) {
messages.forEach(msg => this.addNewMessage(
msg.id, msg.channel, msg.date, msg.source, msg.message, true));
}
getAllHistory() {
const state = this.shc.state();
console.log("Getting history:", state);
// TODO: Get history also queries shoutbox-excluded channels
if(state.isRunning())
state.irc.channels.forEach((_, c) => this.shc.getHistory(c));
}
/*** DOM manipulation ***/
getChannelView(channel) {
return Array.from(this.eChannels.children)
.find(e => e.dataset.channel === channel);
}
getChannelButton(channel) {
return Array.from(this.eChannelButtons.children)
.find(e => e.dataset.channel === channel);
}
addChannelButton(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);
}
clearChannelButtons(button) {
let special = Array.from(this.eChannelButtons.children).filter(e =>
e.dataset.channel.startsWith("\\"));
this.eChannelButtons.replaceChildren(special);
}
addNewMessage(id, channel, date, author, message, history=false) {
const view = this.getChannelView(channel);
if(view === undefined)
return;
for(const msg of view.children) {
if(msg.dataset.id === id)
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");
if(history)
messageElement.classList.add("history");
messageElement.dataset.author = author;
messageElement.dataset.id = id;
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");
const fmt = new IRCFormatter();
fmt.format(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);
}
setChannelBackgroundActivity(channel, activity) {
const button = this.getChannelButton(channel);
if(button !== undefined) {
if(activity)
button.classList.add("bg-activity");
else
button.classList.remove("bg-activity");
}
}
};
/* DOM manipulation class handling the debug tab of the client. */
class UIDebug {
constructor(shc, tabButton, root) {
this.shc = shc;
/* UI elements */
this.eTab = tabButton;
this.eLog = root.querySelector(".log pre");
this.eStatusTable = root.querySelector("table");
/* Log filters */
this.eFilterIRC = root.querySelector(".debug-log-irc");
this.eFilterWorker = root.querySelector(".debug-log-worker");
this.eFilterOther = root.querySelector(".debug-log-other");
this.eFilterIRC.checked = true;
this.eFilterWorker.checked = true;
this.eFilterOther.checked = true;
this.eFilterIRC.addEventListener("change", () =>
this.runLogFilter());
this.eFilterWorker.addEventListener("change", () =>
this.runLogFilter());
this.eFilterOther.addEventListener("change", () =>
this.runLogFilter());
/* Button actions */
root.querySelector(".action-disconnect").addEventListener("click", () => {
this.shc.disconnect();
});
root.querySelector(".get-history").addEventListener("click", () => {
const state = this.shc.state();
// TODO: Get history debug also queries shoutbox-excluded channels
if(state.isRunning())
state.irc.channels.forEach((_, c) => this.shc.getHistory(c));
});
}
/* Set whether the debug tab should be visible. */
setTabVisibility(visible) {
this.eTab.style.display = visible ? "inline-block" : "none";
}
/* Add a message to the debug log. Category can be `ircin`, `ircout`,
`shcin`, `shcout`, or a general categor string. */
logMessage(ctgy, message) {
const line = document.createElement("div");
if(ctgy !== "")
line.classList.add(ctgy);
/* Most consistent escaping method */
line.appendChild(document.createTextNode(message));
this.eLog.appendChild(line);
}
refreshView() {
const state = this.shc.state();
var props = [
["SHCConn", state.SHCConnString()],
["IRCConn", state.IRCConnString()],
];
while(this.eStatusTable.firstChild)
this.eStatusTable.removeChild(this.eStatusTable.lastChild);
for(const [name, value] of props) {
var tr = document.createElement("tr");
var td1 = document.createElement("td");
var td2 = document.createElement("td");
td1.appendChild(document.createTextNode(name));
td2.appendChild(document.createTextNode(value));
tr.appendChild(td1);
tr.appendChild(td2);
this.eStatusTable.appendChild(tr);
}
}
runLogFilter() {
const enabled = [
this.eFilterIRC.checked,
this.eFilterWorker.checked,
this.eFilterOther.checked,
];
const classToCategory = {
ircin: 0, ircout: 0,
shcin: 1, shcout: 1,
};
for(const child of this.eLog.children) {
let cat = classToCategory[child.className];
cat = (cat === undefined) ? 2 : cat;
child.style.display = enabled[cat] ? "block" : "none";
}
}
}