2026-09-02 22:32:45 +01:00
|
|
|
const chatContainer = document.getElementById("chat");
|
2026-09-04 00:58:11 +01:00
|
|
|
const end = new URLSearchParams(location.search).get("end") !== null;
|
|
|
|
|
|
|
|
|
|
class ChatMessage {
|
|
|
|
|
get username() { return this._username; }
|
|
|
|
|
set username(username) {
|
|
|
|
|
this._username = username;
|
|
|
|
|
this._usernameEl.innerText = username;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get content() { return this._content; }
|
|
|
|
|
set content(content) {
|
|
|
|
|
this._content = content;
|
|
|
|
|
this._contentEl.innerText = content;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get colour() { return this._colour; }
|
|
|
|
|
set colour(colour) {
|
|
|
|
|
this._colour = colour;
|
|
|
|
|
if (colour != undefined)
|
|
|
|
|
this._container.style.setProperty("--colour", colour);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get element() { return this._container; }
|
|
|
|
|
|
|
|
|
|
destroy() {
|
|
|
|
|
this._container.remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
constructor(message) {
|
|
|
|
|
this._headerEl = document.createElement('div');
|
|
|
|
|
this._headerEl.className = "header";
|
|
|
|
|
|
|
|
|
|
// TODO: assemble badges in header, before username
|
|
|
|
|
|
|
|
|
|
this._usernameEl = document.createElement('p');
|
|
|
|
|
this._usernameEl.className = "username";
|
|
|
|
|
this.username = message.username;
|
|
|
|
|
this._headerEl.appendChild(this._usernameEl);
|
|
|
|
|
|
|
|
|
|
this._contentEl = document.createElement('p');
|
|
|
|
|
this._contentEl.className = "content";
|
|
|
|
|
|
|
|
|
|
// TODO: build message content from fragments
|
|
|
|
|
// TODO: 7TV emotes (stretch goal: FFZ emotes)
|
|
|
|
|
this.content = message.text;
|
|
|
|
|
|
|
|
|
|
this._container = document.createElement("div");
|
|
|
|
|
this._container.className = "message";
|
|
|
|
|
this._container.dataset.id = message.id;
|
|
|
|
|
this._container.dataset.username = message.username;
|
|
|
|
|
|
|
|
|
|
// TODO: generate and cache random colour for users that do not have one
|
|
|
|
|
this.colour = message.colour;
|
|
|
|
|
|
|
|
|
|
this._container.appendChild(this._headerEl);
|
|
|
|
|
this._container.appendChild(this._contentEl);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class SystemMessage {
|
|
|
|
|
get text() { return this._text; }
|
|
|
|
|
set text(text) {
|
|
|
|
|
this._text = text;
|
|
|
|
|
this._textEl.innerText = text;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get type() { return this._type; }
|
|
|
|
|
set type(type) {
|
|
|
|
|
this._type = type;
|
|
|
|
|
this._container.className = ["message", "system", type].join(" ").trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get element() { return this._container; }
|
|
|
|
|
|
|
|
|
|
destroy() {
|
|
|
|
|
this._container.remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
constructor(text, type) {
|
|
|
|
|
// TODO: actual icon designs for system messages
|
|
|
|
|
this._iconEl = document.createElement('div');
|
|
|
|
|
this._iconEl.className = "icon";
|
|
|
|
|
|
|
|
|
|
this._textEl = document.createElement('p');
|
|
|
|
|
this._textEl.className = "text";
|
|
|
|
|
this.text = text;
|
|
|
|
|
|
|
|
|
|
this._container = document.createElement("div");
|
|
|
|
|
this.type = type;
|
|
|
|
|
|
|
|
|
|
this._container.appendChild(this._iconEl);
|
|
|
|
|
this._container.appendChild(this._textEl);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-02 22:32:45 +01:00
|
|
|
|
|
|
|
|
async function sync() {
|
|
|
|
|
let eventSource;
|
|
|
|
|
try {
|
|
|
|
|
eventSource = new EventSource("/twitch/sse/chat");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("Failed to connect to event source", err);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
eventSource.addEventListener("error", () => {
|
|
|
|
|
eventSource.close();
|
|
|
|
|
console.error("Connection lost, or an error has occurred.");
|
|
|
|
|
console.log("Attempting to reconnect...");
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
sync();
|
|
|
|
|
}, 1000);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
eventSource.addEventListener("chat", event => {
|
2026-09-04 00:58:11 +01:00
|
|
|
handleChatMessage(JSON.parse(event.data));
|
|
|
|
|
});
|
|
|
|
|
eventSource.addEventListener("system", event => {
|
|
|
|
|
handleSystemMessage(JSON.parse(event.data));
|
|
|
|
|
});
|
|
|
|
|
eventSource.addEventListener("delete", event => {
|
|
|
|
|
const data = JSON.parse(event.data);
|
|
|
|
|
const username = data.username;
|
|
|
|
|
const message_id = data.id;
|
|
|
|
|
handleDeleteMessage(username, message_id);
|
2026-09-02 22:32:45 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
eventSource.addEventListener("open", () => {
|
|
|
|
|
console.log("Connected.");
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 00:58:11 +01:00
|
|
|
function handleChatMessage(data) {
|
|
|
|
|
console.log(`[CHAT] ${data.username}: ${data.text}`);
|
|
|
|
|
|
|
|
|
|
const chatMessage = new ChatMessage(data);
|
|
|
|
|
addMessageAndTruncateChat(chatMessage.element);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleSystemMessage(data) {
|
|
|
|
|
if (data.type)
|
|
|
|
|
console.log(`[SYSTEM] [${data.type}] ${data.text}`);
|
|
|
|
|
else
|
|
|
|
|
console.log(`[SYSTEM] ${data.text}`);
|
|
|
|
|
|
|
|
|
|
const systemMessage = new SystemMessage(data.text, data.type || "");
|
|
|
|
|
addMessageAndTruncateChat(systemMessage.element);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleDeleteMessage(username, message_id) {
|
|
|
|
|
if (username == "*" && message_id == "*") {
|
|
|
|
|
chatContainer.innerHTML = "";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (username.length > 0 && message_id == "*") {
|
|
|
|
|
chatContainer.querySelectorAll(`.message[data-username=${username}]`)
|
|
|
|
|
.forEach(msg => {
|
|
|
|
|
msg.remove();
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (username.length == 0 && message_id.length > 0) {
|
|
|
|
|
const msg = chatContainer.querySelector(`.message[data-id=${message_id}]`);
|
|
|
|
|
if (msg) msg.remove();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CHAT_LIMIT = 50;
|
|
|
|
|
function addMessageAndTruncateChat(element) {
|
|
|
|
|
chatContainer.appendChild(element);
|
|
|
|
|
window.scrollTo(0, document.body.scrollHeight);
|
|
|
|
|
|
|
|
|
|
if (chatContainer.childElementCount <= CHAT_LIMIT) return;
|
|
|
|
|
chatContainer.children[0].remove();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 22:32:45 +01:00
|
|
|
function twitchEmoteURL(emoteID) {
|
2026-09-04 00:58:11 +01:00
|
|
|
return `https://static-cdn.jtvnw.net/emoticons/v2/${emoteID}/default/dark/4.0#e=0`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function twitchBadgeURL(badgeID) {
|
|
|
|
|
return `https://static-cdn.jtvnw.net/badges/v1/${badgeID}/3`
|
2026-09-02 22:32:45 +01:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 00:58:11 +01:00
|
|
|
if (end)
|
|
|
|
|
chatContainer.classList.add("end")
|
2026-09-02 22:32:45 +01:00
|
|
|
sync();
|