// , the live half of the chat page. // // The server renders the room's first window, the composer, and every gate // around them. This element adds the three things a page load cannot give you: // messages arriving without a refresh, sending without leaving the page, and // removing one that a moderator or its author deleted. // // Everything it decides lives in chat/chat.logic.ts and chat/reconnect.logic.ts, // which have no DOM in them and are unit tested. What is left here is the // wiring: attributes in, elements out, one EventSource. // // With the script blocked the page is still a working room. The backlog is // server-rendered and the composer is a real form posting to the same endpoint, // which answers with JSON rather than a redirect, so the no-JS experience is // send-and-see-a-blob rather than send-and-nothing. That is the accepted floor: // chat without a client is not chat, and the gates that matter are all // server-side regardless. import { MtElement, define } from "./base.ts"; import { ChatLedger, MAX_MESSAGE_LEN, checkBody, mentionsViewer, newNonce, parseFrame, streamUrl, type IncomingMessage, } from "./chat/chat.logic.ts"; import { ReconnectPolicy } from "./chat/reconnect.logic.ts"; /** The five frames the server sends. Dispatch reads the tag inside the payload. */ const FRAME_EVENTS = ["message", "delete", "purge", "wipe", "gap"] as const; /** * How close to the bottom counts as "following the room", in pixels. * * Someone reading back through the log must not be yanked to the bottom every * time a message lands, and someone at the bottom must not have to chase it. * The tolerance covers the fractional scroll heights a zoomed page produces. */ const FOLLOW_THRESHOLD_PX = 40; class ChatRoom extends MtElement { #slug = ""; #viewer: string | null = null; #viewerName: string | null = null; #moderator = false; #maxLength = MAX_MESSAGE_LEN; #log!: HTMLOListElement; #status!: HTMLParagraphElement; #empty: HTMLElement | null = null; #ledger!: ChatLedger; readonly #policy = new ReconnectPolicy(); #source: EventSource | null = null; #retry: ReturnType | null = null; /** * Cancels the listeners this element put on `window` and `document`. * * Those two outlive the element, so without this a room htmx swapped away * would still be woken by a tab becoming visible, and would open a stream * nothing is reading. That connection holds a slot against the per-user cap * until the tab closes, so the room the user actually moved to is the one * that gets refused. */ readonly #listeners = new AbortController(); protected init(): void { const log = this.querySelector("#chat-log"); if (!log) return; // not the chat page's markup; nothing to enhance this.#log = log; this.#slug = this.dataset["slug"] ?? ""; this.#viewer = this.dataset["viewer"] ?? null; this.#viewerName = this.dataset["viewerName"] ?? null; this.#moderator = this.dataset["moderator"] === "true"; this.#maxLength = Number(this.dataset["maxLength"]) || MAX_MESSAGE_LEN; this.#ledger = new ChatLedger(Number(this.dataset["cursor"])); this.#empty = this.querySelector("#chat-empty"); // Built here rather than in the template: with the script blocked there is // no connection to have an opinion about, so an empty status line would be // markup that only ever says nothing. this.#status = document.createElement("p"); this.#status.className = "chat-status"; this.#status.setAttribute("role", "status"); this.#status.hidden = true; this.#log.after(this.#status); for (const message of this.#log.querySelectorAll(".chat-message")) { this.#decorate(message); } this.#composer()?.addEventListener("submit", (event) => { event.preventDefault(); void this.#send(); }); // Both events say something about this client, not about the server, so // honoring an accumulated backoff after either would leave the room looking // broken for up to a minute for no reason. const signal = this.#listeners.signal; addEventListener("online", () => this.#reconnectNow(), { signal }); document.addEventListener( "visibilitychange", () => { if (!document.hidden) this.#reconnectNow(); }, { signal }, ); this.#connect(); this.#scrollToEnd(); } /** * htmx swaps the page body, and a swapped-away element keeps its connection * open unless something closes it. A leaked SSE connection holds a slot * against the per-user cap, so the room refuses to open in the new tab the * user just moved to. */ disconnectedCallback(): void { this.#close(); this.#listeners.abort(); if (this.#retry !== null) clearTimeout(this.#retry); } // The stream #connect(): void { this.#close(); const source = new EventSource(streamUrl(this.#slug, this.#ledger.cursor)); this.#source = source; source.addEventListener("open", () => { this.#policy.succeed(); this.#clearStatus(); }); for (const name of FRAME_EVENTS) { source.addEventListener(name, (event) => this.#frame(event as MessageEvent)); } // EventSource reconnects on its own schedule, which is a fixed few seconds // with no jitter and no ceiling on attempts. Every open room drops at the // same instant on a deploy, so that default is the thundering herd the // backoff exists to prevent. Closing here takes the retry back. source.addEventListener("error", () => this.#dropped()); } #close(): void { this.#source?.close(); this.#source = null; } #dropped(): void { this.#close(); const action = this.#policy.fail(); if (action.kind === "give-up") { this.#offerReconnect(); return; } this.#say("Reconnecting..."); this.#retry = setTimeout(() => this.#connect(), action.delayMs); } /** Drop the accumulated wait and try again immediately. */ #reconnectNow(): void { if (this.#source !== null) return; if (this.#retry !== null) clearTimeout(this.#retry); this.#policy.resetForImmediateRetry(); this.#connect(); } #frame(event: MessageEvent): void { const frame = parseFrame(event.data); if (frame === null) return; // one malformed frame must not kill the stream switch (frame.type) { case "message": this.#received(frame); break; case "delete": this.#remove(`[data-id="${CSS.escape(String(frame.id))}"]`); break; case "purge": this.#remove(`[data-author="${CSS.escape(frame.author_id)}"]`); break; case "wipe": this.#wipe(); break; case "gap": // The connection fell behind and the hub dropped frames for it. // Reopening at the cursor replays exactly the hole. this.#connect(); break; } } #received(message: IncomingMessage): void { const outcome = this.#ledger.receive(message); if (outcome.kind === "duplicate") return; const following = this.#atEnd(); if (outcome.kind === "reconcile") { const pending = this.#pending(outcome.nonce); if (pending) { pending.replaceWith(this.#render(message)); if (following) this.#scrollToEnd(); return; } // The placeholder is gone (the tab was swapped, the user dismissed a // failure). The message is still real, so show it. } this.#append(this.#render(message)); if (following) this.#scrollToEnd(); } // Sending async #send(): Promise { const input = this.querySelector("#chat-input"); if (!input) return; const check = checkBody(input.value); if (check.kind === "empty") return; if (check.kind === "too-long") { this.#say(`Too long by ${check.length - this.#maxLength} characters.`); return; } const nonce = newNonce(); const placeholder = this.#renderPending(check.body, nonce); this.#ledger.track(nonce); this.#append(placeholder); this.#scrollToEnd(); input.value = ""; this.#clearStatus(); const outcome = await this.#post(check.body, nonce); if (outcome.ok) { // The echo does the un-greying, because it carries the rendered body and // the author as the room should show them. All this needs to do is make // the message addressable in case a delete frame arrives first. if (placeholder.isConnected) placeholder.dataset["id"] = String(outcome.id); return; } this.#ledger.untrack(nonce); this.#fail(placeholder, check.body, outcome.message); } async #post( body: string, nonce: string, ): Promise<{ ok: true; id: number } | { ok: false; message: string }> { const token = document.querySelector('meta[name="csrf-token"]')?.content; try { const response = await fetch(`/p/${encodeURIComponent(this.#slug)}/chat/send`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", ...(token ? { "X-CSRF-Token": token } : {}), }, body: new URLSearchParams({ body, nonce }).toString(), }); if (!response.ok) { // Every refusal the send path can produce comes back as plain text // written for the sender: rate limits, mutes, bans, a room that just // went read-only. Showing it beats a generic failure. const message = (await response.text().catch(() => "")).trim(); return { ok: false, message: message || "Message not sent." }; } const sent = (await response.json()) as { id: number }; return { ok: true, id: sent.id }; } catch { return { ok: false, message: "Message not sent. Check your connection." }; } } /** Mark a placeholder as failed, with the text preserved and one way back. */ #fail(placeholder: HTMLLIElement, body: string, message: string): void { placeholder.classList.remove("is-pending"); placeholder.classList.add("is-failed"); delete placeholder.dataset["nonce"]; const retry = document.createElement("button"); retry.type = "button"; retry.className = "chat-retry"; retry.textContent = "Retry"; retry.addEventListener("click", () => { const input = this.querySelector("#chat-input"); if (input) { // Back into the composer rather than resent behind the user's back: by // the time a send fails the reason is usually something they need to // see, and a silent retry loop against a rate limit is how a client // earns a ban. input.value = body; input.focus(); } placeholder.remove(); }); const note = document.createElement("span"); note.className = "chat-error"; note.textContent = message; placeholder.append(note, retry); } // Rendering // // The markup below is the same shape templates/pages/chat.html renders, so // one set of CSS rules covers both and a message looks identical before and // after a reload. #render(message: IncomingMessage): HTMLLIElement { const li = document.createElement("li"); li.className = "chat-message"; li.dataset["id"] = String(message.id); li.dataset["author"] = message.author_id; const avatar = message.author?.avatar_url; if (avatar !== undefined) { const img = document.createElement("img"); img.className = "chat-avatar"; img.src = avatar; img.alt = ""; img.width = 24; img.height = 24; li.append(img); } const author = document.createElement("span"); author.className = "chat-author"; author.textContent = message.author?.display_name ?? "Unknown"; const body = document.createElement("span"); body.className = "chat-body"; // Rendered and sanitized server-side by docengine's chat preset, then // carried verbatim through the transport. The client never renders markdown // and never sanitizes: a second sanitizer would be a second policy to keep // in step with the first. body.innerHTML = message.body_html; li.append(author, this.#time(message.created_at), body); this.#decorate(li); return li; } /** The sender's own message, on screen before the server has confirmed it. */ #renderPending(body: string, nonce: string): HTMLLIElement { const li = document.createElement("li"); li.className = "chat-message is-pending"; li.dataset["nonce"] = nonce; if (this.#viewer !== null) li.dataset["author"] = this.#viewer; const author = document.createElement("span"); author.className = "chat-author"; author.textContent = this.#selfName(); const text = document.createElement("span"); text.className = "chat-body"; // The raw text the user typed, as text. What comes back from the server is // the rendered version, and this is replaced by it wholesale. text.textContent = body; li.append(author, this.#time(Math.floor(Date.now() / 1000)), text); return li; } /** * Fill in a timestamp. * * The server writes unix seconds into `datetime`, which is not a value the * attribute is allowed to hold, and leaves the text empty because only the * client knows the reader's timezone. Both are settled here: the attribute * becomes a real ISO instant and the text becomes a local clock time. */ #time(seconds: number): HTMLTimeElement { const time = document.createElement("time"); time.className = "chat-time"; this.#fillTime(time, seconds); return time; } #fillTime(time: HTMLTimeElement, seconds: number): void { const at = new Date(seconds * 1000); time.dateTime = at.toISOString(); time.textContent = at.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); time.title = at.toLocaleString(); } /** * Add what the server could not: a readable timestamp, and a delete control * for a message this viewer is allowed to remove. * * The button is an affordance, never the check. The handler re-derives * authorship and moderator status server-side, so hiding it is a courtesy to * the honest and nothing more. */ #decorate(message: HTMLLIElement): void { const time = message.querySelector(".chat-time"); if (time) { const seconds = Number(time.dateTime); if (Number.isFinite(seconds) && seconds > 0) this.#fillTime(time, seconds); } // In-room highlight only, never a notification. Done here rather than at // render time so it covers the server-rendered backlog as well: someone // returning to the page should see they were named while they were away, // and nothing else is going to tell them. const body = message.querySelector(".chat-body"); if (body && mentionsViewer(body.innerHTML, this.#viewerName)) { message.classList.add("is-mention"); } const id = message.dataset["id"]; if (id === undefined) return; if (!this.#moderator && message.dataset["author"] !== this.#viewer) return; if (message.querySelector(".chat-remove")) return; const remove = document.createElement("button"); remove.type = "button"; remove.className = "chat-remove"; remove.title = "Delete this message"; remove.setAttribute("aria-label", "Delete this message"); remove.textContent = "x"; remove.addEventListener("click", () => void this.#delete(id)); message.append(remove); } /** * Ask the server to remove a message. * * Nothing happens to the DOM on success: the removal comes back as a `delete` * frame, which every reader in the room including this one acts on. Removing * it locally as well would race that frame for no benefit, and would show the * message gone in the one case where the server refused. */ async #delete(id: string): Promise { const token = document.querySelector('meta[name="csrf-token"]')?.content; const body = new URLSearchParams(token ? { csrf_token: token } : {}).toString(); try { const response = await fetch( `/p/${encodeURIComponent(this.#slug)}/chat/messages/${encodeURIComponent(id)}/delete`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", ...(token ? { "X-CSRF-Token": token } : {}), }, body, }, ); if (!response.ok) this.#say("That message could not be deleted."); } catch { this.#say("That message could not be deleted."); } } // Small helpers over the log #append(message: HTMLLIElement): void { this.#log.append(message); if (this.#empty) this.#empty.hidden = true; } #remove(selector: string): void { for (const gone of this.#log.querySelectorAll(selector)) gone.remove(); if (this.#empty && this.#log.childElementCount === 0) this.#empty.hidden = false; } /** * The owner emptied the room. * * Everything goes, including a message still waiting for its echo: the send * either landed before the wipe and was deleted with the rest, or lands after * it and arrives on the stream like any other message. Either way the ledger * is left alone — the cursor is a high-water mark over ids the server has * issued, and a wipe does not un-issue them. */ #wipe(): void { this.#log.replaceChildren(); if (this.#empty) this.#empty.hidden = false; this.#say("The owner cleared the chat history."); } #pending(nonce: string): HTMLLIElement | null { return this.#log.querySelector(`[data-nonce="${CSS.escape(nonce)}"]`); } #composer(): HTMLFormElement | null { return this.querySelector("#chat-composer"); } /** * What to call the sender on their own optimistic message. * * Taken from the last message they sent in this room, falling back to "You". * The page does not carry a display name for the viewer, and the placeholder * is replaced by the server's own rendering within a round trip, so this is a * label with a short life and no consequences. */ #selfName(): string { if (this.#viewer === null) return "You"; const mine = this.#log.querySelectorAll( `.chat-message[data-author="${CSS.escape(this.#viewer)}"] .chat-author`, ); return mine[mine.length - 1]?.textContent?.trim() || "You"; } #atEnd(): boolean { const { scrollTop, scrollHeight, clientHeight } = this.#log; return scrollHeight - scrollTop - clientHeight <= FOLLOW_THRESHOLD_PX; } #scrollToEnd(): void { this.#log.scrollTop = this.#log.scrollHeight; } #say(message: string): void { this.#status.textContent = message; this.#status.hidden = false; } #clearStatus(): void { this.#status.textContent = ""; this.#status.hidden = true; } /** Backoff exhausted: stop retrying into the void and hand it to the user. */ #offerReconnect(): void { this.#clearStatus(); this.#status.textContent = "Disconnected. "; const button = document.createElement("button"); button.type = "button"; button.className = "chat-retry"; button.textContent = "Reconnect"; button.addEventListener("click", () => this.#reconnectNow()); this.#status.append(button); this.#status.hidden = false; } } define("mt-chat-room", ChatRoom);