443 行
17 KiB
JavaScript
443 行
17 KiB
JavaScript
/* token */
|
|
const token = document.body.dataset.token;
|
|
|
|
/* insert js-only markup */
|
|
const jsmarkup_style_color = '<style id="style-color"></style>'
|
|
const jsmarkup_style_tripcode_display = '<style id="style-tripcode-display"></style>'
|
|
const jsmarkup_style_tripcode_colors = '<style id="style-tripcode-colors"></style>'
|
|
const jsmarkup_info = '<div id="info_js"></div>';
|
|
const jsmarkup_info_title = '<header id="info_js__title" data-js="true"></header>';
|
|
const jsmarkup_chat_messages = '<ol id="chat-messages_js" data-js="true"></ol>';
|
|
const jsmarkup_chat_form = `\
|
|
<form id="chat-form_js" data-js="true" action="/chat" method="post">
|
|
<input id="chat-form_js__nonce" type="hidden" name="nonce" value="">
|
|
<textarea id="chat-form_js__comment" name="comment" maxlength="512" required placeholder="Send a message..." rows="1"></textarea>
|
|
<div id="chat-live">
|
|
<span id="chat-live__ball"></span>
|
|
<span id="chat-live__status">Not connected to chat</span>
|
|
</div>
|
|
<input id="chat-form_js__submit" type="submit" value="Chat" accesskey="p" disabled>
|
|
</form>`;
|
|
|
|
const insert_jsmarkup = () => {
|
|
if (document.getElementById("style-color") === null) {
|
|
const parent = document.head;
|
|
parent.insertAdjacentHTML("beforeend", jsmarkup_style_color);
|
|
}
|
|
if (document.getElementById("style-tripcode-display") === null) {
|
|
const parent = document.head;
|
|
parent.insertAdjacentHTML("beforeend", jsmarkup_style_tripcode_display);
|
|
}
|
|
if (document.getElementById("style-tripcode-colors") === null) {
|
|
const parent = document.head;
|
|
parent.insertAdjacentHTML("beforeend", jsmarkup_style_tripcode_colors);
|
|
}
|
|
if (document.getElementById("info_js") === null) {
|
|
const parent = document.getElementById("info");
|
|
parent.insertAdjacentHTML("beforeend", jsmarkup_info);
|
|
}
|
|
if (document.getElementById("info_js__title") === null) {
|
|
const parent = document.getElementById("info_js");
|
|
parent.insertAdjacentHTML("beforeend", jsmarkup_info_title);
|
|
}
|
|
if (document.getElementById("chat-messages_js") === null) {
|
|
const parent = document.getElementById("chat__messages");
|
|
parent.insertAdjacentHTML("beforeend", jsmarkup_chat_messages);
|
|
}
|
|
if (document.getElementById("chat-form_js") === null) {
|
|
const parent = document.getElementById("chat__form");
|
|
parent.insertAdjacentHTML("beforeend", jsmarkup_chat_form);
|
|
}
|
|
}
|
|
|
|
insert_jsmarkup();
|
|
const stylesheet_color = document.styleSheets[1];
|
|
const stylesheet_tripcode_display = document.styleSheets[2];
|
|
const stylesheet_tripcode_colors = document.styleSheets[3];
|
|
|
|
/* create websocket */
|
|
const info_title = document.getElementById("info_js__title");
|
|
const chat_messages = document.getElementById("chat-messages_js");
|
|
|
|
const create_chat_message = (object) => {
|
|
const user = users[object.token_hash];
|
|
|
|
const chat_message = document.createElement("li");
|
|
chat_message.classList.add("chat-message");
|
|
chat_message.dataset.seq = object.seq;
|
|
chat_message.dataset.tokenHash = object.token_hash;
|
|
|
|
const chat_message_name = document.createElement("span");
|
|
chat_message_name.classList.add("chat-message__name");
|
|
chat_message_name.innerText = get_user_name({user});
|
|
//chat_message_name.dataset.color = user.color; // not working in any browser
|
|
|
|
const chat_message_tripcode_nbsp = document.createElement("span");
|
|
chat_message_tripcode_nbsp.classList.add("for-tripcode");
|
|
chat_message_tripcode_nbsp.innerHTML = " ";
|
|
|
|
const chat_message_tripcode = document.createElement("span");
|
|
chat_message_tripcode.classList.add("tripcode");
|
|
chat_message_tripcode.classList.add("for-tripcode");
|
|
if (user.tripcode !== null) {
|
|
chat_message_tripcode.innerHTML = user.tripcode.digest;
|
|
}
|
|
|
|
const chat_message_markup = document.createElement("span");
|
|
chat_message_markup.classList.add("chat-message__markup");
|
|
chat_message_markup.innerHTML = object.markup;
|
|
|
|
chat_message.insertAdjacentElement("beforeend", chat_message_name);
|
|
chat_message.insertAdjacentElement("beforeend", chat_message_tripcode_nbsp);
|
|
chat_message.insertAdjacentElement("beforeend", chat_message_tripcode);
|
|
chat_message.insertAdjacentHTML("beforeend", ": ");
|
|
chat_message.insertAdjacentElement("beforeend", chat_message_markup);
|
|
|
|
return chat_message
|
|
}
|
|
const create_and_add_chat_message = (object) => {
|
|
const chat_message = create_chat_message(object);
|
|
chat_messages.insertAdjacentElement("beforeend", chat_message);
|
|
while (chat_messages.children.length > max_chat_scrollback) {
|
|
chat_messages.children[0].remove();
|
|
}
|
|
}
|
|
|
|
let users = {};
|
|
let default_name = {true: "Broadcaster", false: "Anonymous"};
|
|
let max_chat_scrollback = 256;
|
|
const tidy_stylesheet = ({stylesheet, selector_regex, ignore_condition}) => {
|
|
const to_delete = [];
|
|
const to_ignore = new Set();
|
|
for (let index = 0; index < stylesheet.cssRules.length; index++) {
|
|
const css_rule = stylesheet.cssRules[index];
|
|
const match = css_rule.selectorText.match(selector_regex);
|
|
const token_hash = match === null ? null : match[1];
|
|
const user = token_hash === null ? null : users[token_hash];
|
|
if (user === null || user === undefined) {
|
|
to_delete.push(index);
|
|
} else if (!ignore_condition(token_hash, user, css_rule)) {
|
|
to_delete.push(index);
|
|
} else {
|
|
to_ignore.add(token_hash);
|
|
}
|
|
}
|
|
return {to_delete, to_ignore};
|
|
}
|
|
const equal = (color1, color2) => {
|
|
/* comparing css colors is annoying */
|
|
// when this is working, remove `ignore_other_token_hashes` from functions below
|
|
return false;
|
|
}
|
|
const update_user_colors = (token_hash=null) => {
|
|
ignore_other_token_hashes = token_hash !== null;
|
|
token_hashes = token_hash === null ? Object.keys(users) : [token_hash];
|
|
const {to_delete, to_ignore} = tidy_stylesheet({
|
|
stylesheet: stylesheet_color,
|
|
selector_regex: /\.chat-message\[data-token-hash="([a-z2-7]{26})"\] > \.chat-message__name/,
|
|
ignore_condition: (this_token_hash, this_user, css_rule) => {
|
|
const irrelevant = ignore_other_token_hashes && this_token_hash !== token_hash;
|
|
const correct_color = equal(css_rule.style.color, this_user.color);
|
|
return irrelevant || correct_color;
|
|
},
|
|
});
|
|
// update colors
|
|
for (const this_token_hash of token_hashes) {
|
|
if (!to_ignore.has(this_token_hash)) {
|
|
const user = users[this_token_hash];
|
|
stylesheet_color.insertRule(
|
|
`.chat-message[data-token-hash="${this_token_hash}"] > .chat-message__name { color: ${user.color}; }`,
|
|
stylesheet_color.cssRules.length,
|
|
);
|
|
}
|
|
}
|
|
// delete css rules
|
|
for (const index of to_delete.reverse()) {
|
|
stylesheet_color.deleteRule(index);
|
|
}
|
|
}
|
|
const get_user_name({user=null, token_hash}) {
|
|
const user = user || users[token_hash]
|
|
return user.name || default_name[user.broadcaster];
|
|
}
|
|
const update_user_names = (token_hash=null) => {
|
|
const token_hashes = token_hash === null ? Object.keys(users) : [token_hash];
|
|
for (const chat_message of chat_messages.children) {
|
|
const this_token_hash = chat_message.dataset.tokenHash;
|
|
if (token_hashes.includes(this_token_hash) {
|
|
const chat_message_name = chat_message.querySelector(".chat-message__name");
|
|
chat_message_name.innerText = get_user_name({token_hash: this_token_hash});
|
|
}
|
|
}
|
|
}
|
|
const update_user_tripcodes = (token_hash=null) => {
|
|
ignore_other_token_hashes = token_hash !== null;
|
|
token_hashes = token_hash === null ? Object.keys(users) : [token_hash];
|
|
const {to_delete: to_delete_display, to_ignore: to_ignore_display} = tidy_stylesheet({
|
|
stylesheet: stylesheet_tripcode_display,
|
|
selector_regex: /\.chat-message\[data-token-hash="([a-z2-7]{26})"\] > \.for-tripcode/,
|
|
ignore_condition: (this_token_hash, this_user, css_rule) => {
|
|
const irrelevant = ignore_other_token_hashes && this_token_hash !== token_hash;
|
|
const correctly_hidden = this_user.tripcode === null && css_rule.style.display === "none";
|
|
const correctly_showing = this_user.tripcode !== null && css_rule.style.display === "inline";
|
|
return irrelevant || correctly_hidden || correctly_showing;
|
|
},
|
|
});
|
|
const {to_delete: to_delete_colors, to_ignore: to_ignore_colors} = tidy_stylesheet({
|
|
stylesheet: stylesheet_tripcode_colors,
|
|
selector_regex: /\.chat-message\[data-token-hash="([a-z2-7]{26})"\] > \.tripcode/,
|
|
ignore_condition: (this_token_hash, this_user, css_rule) => {
|
|
const irrelevant = ignore_other_token_hashes && this_token_hash !== token_hash;
|
|
const correctly_blank = (
|
|
this_user.tripcode === null
|
|
&& css_rule.style.backgroundColor === "initial"
|
|
&& css_rule.style.color === "initial"
|
|
);
|
|
const correctly_colored = (
|
|
this_user.tripcode !== null
|
|
&& equal(css_rule.style.backgroundColor, this_user.tripcode.background_color)
|
|
&& equal(css_rule.style.color, this_user.tripcode.foreground_color)
|
|
);
|
|
return irrelevant || correctly_blank || correctly_colored;
|
|
},
|
|
});
|
|
|
|
// update colors
|
|
for (const this_token_hash of token_hashes) {
|
|
const tripcode = users[this_token_hash].tripcode;
|
|
if (tripcode === null) {
|
|
if (!to_ignore_display.has(token_hash)) {
|
|
stylesheet_tripcode_display.insertRule(
|
|
`.chat-message[data-token-hash="${this_token_hash}"] > .for-tripcode { display: none; }`,
|
|
stylesheet_tripcode_display.cssRules.length,
|
|
);
|
|
}
|
|
if (!to_ignore_colors.has(token_hash)) {
|
|
stylesheet_tripcode_colors.insertRule(
|
|
`.chat-message[data-token-hash="${this_token_hash}"] > .tripcode { background-color: initial; color: initial; }`,
|
|
stylesheet_tripcode_colors.cssRules.length,
|
|
);
|
|
}
|
|
} else {
|
|
if (!to_ignore_display.has(token_hash)) {
|
|
stylesheet_tripcode_display.insertRule(
|
|
`.chat-message[data-token-hash="${this_token_hash}"] > .for-tripcode { display: inline; }`,
|
|
stylesheet_tripcode_display.cssRules.length,
|
|
);
|
|
}
|
|
if (!to_ignore_colors.has(token_hash)) {
|
|
stylesheet_tripcode_colors.insertRule(
|
|
`.chat-message[data-token-hash="${this_token_hash}"] > .tripcode { background-color: ${tripcode.background_color}; color: ${tripcode.foreground_color}; }`,
|
|
stylesheet_tripcode_colors.cssRules.length,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// delete css rules
|
|
for (const index of to_delete_display.reverse()) {
|
|
stylesheet_tripcode_display.deleteRule(index);
|
|
}
|
|
for (const index of to_delete_colors.reverse()) {
|
|
stylesheet_tripcode_colors.deleteRule(index);
|
|
}
|
|
|
|
// update inner texts
|
|
for (const chat_message of chat_messages.children) {
|
|
const this_token_hash = chat_message.dataset.tokenHash;
|
|
const tripcode = users[this_token_hash].tripcode;
|
|
if (token_hashes.includes(this_token_hash)) {
|
|
const chat_message_tripcode = chat_message.querySelector(".tripcode");
|
|
chat_message_tripcode.innerText = tripcode === null ? "" : tripcode.digest;
|
|
}
|
|
}
|
|
}
|
|
|
|
const on_websocket_message = (event) => {
|
|
console.log("websocket message", event);
|
|
const receipt = JSON.parse(event.data);
|
|
switch (receipt.type) {
|
|
case "error":
|
|
console.log("ws error", receipt);
|
|
break;
|
|
|
|
case "init":
|
|
console.log("ws init", receipt);
|
|
|
|
chat_form_nonce.value = receipt.nonce;
|
|
info_title.innerText = receipt.title;
|
|
|
|
default_name = receipt.default;
|
|
max_chat_scrollback = receipt.scrollback;
|
|
users = receipt.users;
|
|
update_user_names();
|
|
update_user_colors();
|
|
update_user_tripcodes();
|
|
|
|
const seqs = new Set(receipt.messages.map((message) => {return message.seq;}));
|
|
const to_delete = [];
|
|
for (const chat_message of chat_messages.children) {
|
|
const chat_message_seq = parseInt(chat_message.dataset.seq);
|
|
if (!seqs.has(chat_message_seq)) {
|
|
to_delete.push(chat_message);
|
|
}
|
|
}
|
|
for (const chat_message of to_delete) {
|
|
chat_message.remove();
|
|
}
|
|
|
|
const last = chat_messages.children.length == 0 ? null : chat_messages.children[chat_messages.children.length - 1];
|
|
const last_seq = last === null ? null : parseInt(last.dataset.seq);
|
|
for (const message of receipt.messages) {
|
|
if (message.seq > last_seq) {
|
|
create_and_add_chat_message(message);
|
|
}
|
|
}
|
|
|
|
break;
|
|
|
|
case "title":
|
|
console.log("ws title", receipt);
|
|
info_title.innerText = receipt.title;
|
|
break;
|
|
|
|
case "ack":
|
|
console.log("ws ack", receipt);
|
|
if (chat_form_nonce.value === receipt.nonce) {
|
|
chat_form_comment.value = "";
|
|
} else {
|
|
console.log("nonce does not match ack", chat_form_nonce, receipt);
|
|
}
|
|
chat_form_submit.disabled = false;
|
|
chat_form_nonce.value = receipt.next;
|
|
break;
|
|
|
|
case "reject":
|
|
console.log("ws reject", receipt);
|
|
alert(`Rejected: ${receipt.notice}`);
|
|
chat_form_submit.disabled = false;
|
|
chat_form_nonce.value = receipt.next;
|
|
break;
|
|
|
|
case "chat":
|
|
console.log("ws chat", receipt);
|
|
create_and_add_chat_message(receipt);
|
|
chat_messages.scrollTo({
|
|
left: 0,
|
|
top: chat_messages.scrollTopMax,
|
|
behavior: "smooth",
|
|
});
|
|
break;
|
|
|
|
case "add-user":
|
|
console.log("ws add-user", receipt);
|
|
users[receipt.token_hash] = receipt.user;
|
|
update_user_colors(receipt.token_hash);
|
|
update_user_tripcodes(receipt.token_hash);
|
|
break;
|
|
|
|
case "mut-user":
|
|
console.log("ws mut-user", receipt);
|
|
const user = users[receipt.token_hash];
|
|
user.name = receipt.name;
|
|
user.color = receipt.color;
|
|
user.tripcode = receipt.tripcode;
|
|
update_user_names(receipt.token_hash);
|
|
update_user_colors(receipt.token_hash);
|
|
update_user_tripcodes(receipt.token_hash);
|
|
break;
|
|
|
|
case "rem-users":
|
|
console.log("ws rem-users", receipt);
|
|
for (const token_hash of receipt.token_hashes) {
|
|
delete users[token_hash];
|
|
}
|
|
update_user_styles();
|
|
break;
|
|
|
|
default:
|
|
console.log("incomprehensible websocket message", receipt);
|
|
}
|
|
};
|
|
const chat_live_ball = document.getElementById("chat-live__ball");
|
|
const chat_live_status = document.getElementById("chat-live__status");
|
|
let ws;
|
|
let websocket_backoff = 2000; // 2 seconds
|
|
const connect_websocket = () => {
|
|
if (ws !== undefined && (ws.readyState === ws.CONNECTING || ws.readyState === ws.OPEN)) {
|
|
console.log("refusing to open another websocket");
|
|
return;
|
|
}
|
|
chat_live_ball.style.borderColor = "gold";
|
|
chat_live_status.innerText = "Connecting to chat...";
|
|
ws = new WebSocket(`ws://${document.domain}:${location.port}/live?token=${encodeURIComponent(token)}`);
|
|
ws.addEventListener("open", (event) => {
|
|
console.log("websocket open", event);
|
|
chat_form_submit.disabled = false;
|
|
chat_live_ball.style.borderColor = "green";
|
|
chat_live_status.innerText = "Connected to chat";
|
|
// When the server is offline, a newly opened websocket can take a second
|
|
// to close. This timeout tries to ensure the backoff doesn't instantly
|
|
// (erroneously) reset to 2 seconds in that case.
|
|
setTimeout(() => {
|
|
if (event.target === ws) {
|
|
websocket_backoff = 2000; // 2 seconds
|
|
}
|
|
},
|
|
websocket_backoff + 4000,
|
|
);
|
|
});
|
|
ws.addEventListener("close", (event) => {
|
|
console.log("websocket close", event);
|
|
chat_form_submit.disabled = true;
|
|
chat_live_ball.style.borderColor = "maroon";
|
|
chat_live_status.innerText = "Disconnected from chat";
|
|
if (!ws.successor) {
|
|
ws.successor = true;
|
|
setTimeout(connect_websocket, websocket_backoff);
|
|
websocket_backoff = Math.min(32000, websocket_backoff * 2);
|
|
}
|
|
});
|
|
ws.addEventListener("error", (event) => {
|
|
console.log("websocket error", event);
|
|
chat_form_submit.disabled = true;
|
|
chat_live_ball.style.borderColor = "maroon";
|
|
chat_live_status.innerText = "Error connecting to chat";
|
|
});
|
|
ws.addEventListener("message", on_websocket_message);
|
|
}
|
|
|
|
connect_websocket();
|
|
|
|
/* override js-only chat form */
|
|
const chat_form = document.getElementById("chat-form_js");
|
|
const chat_form_nonce = document.getElementById("chat-form_js__nonce");
|
|
const chat_form_comment = document.getElementById("chat-form_js__comment");
|
|
const chat_form_submit = document.getElementById("chat-form_js__submit");
|
|
chat_form.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const payload = {comment: chat_form_comment.value, nonce: chat_form_nonce.value};
|
|
chat_form_submit.disabled = true;
|
|
ws.send(JSON.stringify(payload));
|
|
});
|
|
|
|
/* when chat is being resized, peg its bottom in place (instead of its top) */
|
|
const track_scroll = (element) => {
|
|
chat_messages.dataset.scrollTop = chat_messages.scrollTop;
|
|
chat_messages.dataset.scrollTopMax = chat_messages.scrollTopMax;
|
|
}
|
|
const peg_bottom = (entries) => {
|
|
for (const entry of entries) {
|
|
const element = entry.target;
|
|
const bottom = chat_messages.dataset.scrollTopMax - chat_messages.dataset.scrollTop;
|
|
element.scrollTop = chat_messages.scrollTopMax - bottom;
|
|
track_scroll(element);
|
|
}
|
|
}
|
|
const resize = new ResizeObserver(peg_bottom);
|
|
resize.observe(chat_messages);
|
|
chat_messages.addEventListener("scroll", (event) => {
|
|
track_scroll(chat_messages);
|
|
});
|
|
track_scroll(chat_messages);
|