Skip to main content

max / makenotwork

Add the chat client island The server side of Multithreaded chat has been complete since 64f9fad9; the page it renders was static. This is the client that makes it a room. chat/chat.logic.ts holds the parts worth testing on their own: the body check (a mirror of MessageBody::parse, counting code points rather than UTF-16 units so the client and the server agree on 500), frame parsing, and the ledger that answers whether a message is new, ours, or one we already have. Two subtleties are encoded there rather than discovered later. The cursor is a high-water mark, which is all that is needed to dedup the overlap that subscribe-before-backlog deliberately creates. And the nonce is checked before the cursor: two people sending at once can have their inserts ordered one way and their broadcasts the other, so the sender's own echo can arrive below a message already on screen, and reading the cursor first would leave their message greyed out for good. chat.ts is the wiring. It takes over EventSource's own retry, whose fixed few-second delay with no jitter is the thundering herd the ported backoff exists to prevent, and closes the stream when the element leaves the document so a swapped-away room does not sit on a connection slot. Adds viewer_id to ChatTemplate so the island can draw a delete control on the viewer's own messages. It is an affordance and not a check; the handler re-derives authorship server-side.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 14:59 UTC
Signed with PGP, not checked
Commit: db132ecb6f44a859cec54d0028215734ff659800
Parent: ac5d54a
8 files changed, +1209 insertions, -4 deletions
@@ -1598,6 +1598,143 @@
1598 1598 margin-left: 0.25rem;
1599 1599 }
1600 1600
1601 + /* CHAT ROOM */
1602 +
1603 + .chat-room {
1604 + display: flex;
1605 + flex-direction: column;
1606 + border: 1px solid var(--border);
1607 + background: var(--light-background);
1608 + }
1609 +
1610 + /* The log scrolls, the composer does not. A fixed height rather than a growing
1611 + page is what lets the island keep the newest message in view without moving
1612 + the thing the reader is typing into. */
1613 + .chat-log {
1614 + list-style: none;
1615 + margin: 0;
1616 + padding: 0.5rem;
1617 + height: 60vh;
1618 + min-height: 15rem;
1619 + overflow-y: auto;
1620 + overscroll-behavior: contain;
1621 + }
1622 +
1623 + .chat-message {
1624 + display: flex;
1625 + align-items: baseline;
1626 + gap: 0.4rem;
1627 + padding: 0.25rem 0.35rem;
1628 + line-height: 1.45;
1629 + }
1630 +
1631 + .chat-message:hover {
1632 + background: var(--surface-alt);
1633 + }
1634 +
1635 + .chat-avatar {
1636 + align-self: center;
1637 + border-radius: 50%;
1638 + flex-shrink: 0;
1639 + }
1640 +
1641 + .chat-author {
1642 + font-weight: bold;
1643 + color: var(--detail);
1644 + white-space: nowrap;
1645 + }
1646 +
1647 + .chat-time {
1648 + font-family: "IBM Plex Mono", monospace;
1649 + font-size: 0.75rem;
1650 + color: var(--text-muted);
1651 + white-space: nowrap;
1652 + }
1653 +
1654 + .chat-body {
1655 + flex: 1;
1656 + min-width: 0;
1657 + overflow-wrap: anywhere;
1658 + }
1659 +
1660 + /* Sent, not yet echoed back by the server. Dimmed rather than hidden: the
1661 + message is on screen the instant it is typed, and the dimming is the only
1662 + thing saying it is not confirmed. */
1663 + .chat-message.is-pending {
1664 + opacity: 0.55;
1665 + }
1666 +
1667 + .chat-message.is-failed {
1668 + opacity: 1;
1669 + border-left: 2px solid var(--danger);
1670 + }
1671 +
1672 + .chat-error {
1673 + color: var(--danger);
1674 + font-size: 0.85rem;
1675 + }
1676 +
1677 + /* Delete, and the retry on a failed send. Both stay out of the way until the
1678 + message is under the pointer, and both are always reachable by keyboard. */
1679 + .chat-remove,
1680 + .chat-retry {
1681 + background: none;
1682 + border: 1px solid transparent;
1683 + color: var(--text-muted);
1684 + cursor: pointer;
1685 + font-family: "IBM Plex Mono", monospace;
1686 + font-size: 0.75rem;
1687 + padding: 0 0.3rem;
1688 + }
1689 +
1690 + .chat-remove {
1691 + opacity: 0;
1692 + margin-left: auto;
1693 + }
1694 +
1695 + .chat-message:hover .chat-remove,
1696 + .chat-remove:focus-visible {
1697 + opacity: 1;
1698 + }
1699 +
1700 + .chat-remove:hover,
1701 + .chat-retry:hover {
1702 + color: var(--danger);
1703 + border-color: var(--border);
1704 + }
1705 +
1706 + .chat-status {
1707 + margin: 0;
1708 + padding: 0.35rem 0.6rem;
1709 + border-top: 1px solid var(--border);
1710 + background: var(--surface-alt);
1711 + color: var(--text-muted);
1712 + font-size: 0.85rem;
1713 + }
1714 +
1715 + .chat-composer {
1716 + display: flex;
1717 + gap: 0.5rem;
1718 + padding: 0.5rem;
1719 + border-top: 1px solid var(--border);
1720 + }
1721 +
1722 + .chat-composer input[type="text"] {
1723 + flex: 1;
1724 + min-width: 0;
1725 + background: var(--input-background);
1726 + border: 1px solid var(--border);
1727 + color: var(--detail);
1728 + padding: 0.45rem 0.6rem;
1729 + }
1730 +
1731 + .chat-notice {
1732 + padding: 0.6rem;
1733 + border-top: 1px solid var(--border);
1734 + color: var(--text-muted);
1735 + font-size: 0.9rem;
1736 + }
1737 +
1601 1738 /* RESPONSIVE, 768px (tablet) */
1602 1739
1603 1740 @media (max-width: 768px) {
@@ -1717,4 +1854,17 @@
1717 1854 flex-direction: column;
1718 1855 gap: 0.2rem;
1719 1856 }
1857 +
1858 + /* The composer and the on-screen keyboard together take most of a phone, so
1859 + the log gives up height rather than pushing the input off the bottom. */
1860 + .chat-log {
1861 + height: 55vh;
1862 + padding: 0.35rem;
1863 + }
1864 +
1865 + /* No hover on a touch screen, so a delete control that only appears on hover
1866 + is a control that does not exist. */
1867 + .chat-remove {
1868 + opacity: 1;
1869 + }
1720 1870 }
@@ -200,6 +200,7 @@
200 200 read_only: room.state == RoomState::ReadOnly,
201 201 can_send,
202 202 is_moderator,
203 + viewer_id: user.as_ref().map(|u| u.user_id.to_string()),
203 204 max_message_len: livechat::MAX_MESSAGE_LEN,
204 205 messages: messages.iter().map(chat_message_row).collect(),
205 206 cursor: messages.last().map_or(0, |m| m.id.0),
@@ -664,6 +664,11 @@
664 664 /// handler rather than three conditions here.
665 665 pub can_send: bool,
666 666 pub is_moderator: bool,
667 + /// The viewer's own account id, so the island can offer a delete control on
668 + /// their own messages. `None` when logged out, and never load-bearing: the
669 + /// delete handler re-derives authorship server-side, so this only decides
670 + /// whether a button is drawn.
671 + pub viewer_id: Option<String>,
667 672 pub max_message_len: usize,
668 673 pub messages: Vec<ChatMessageRow>,
669 674 /// Highest message id in the first paint; the island resumes from it.
@@ -1,4 +1,5 @@
1 1 {% extends "base.html" %}
2 + {% import "_island.html" as island %}
2 3
3 4 {% block title %}Chat — {{ community_name }} — Multithreaded{% endblock %}
4 5
@@ -22,14 +23,17 @@
22 23 </div>
23 24
24 25 {# The island reads its configuration from data attributes rather than an
25 - inline script, so the page needs no script-src exception. #}
26 - <div id="chat-room"
26 + inline script, so the page needs no script-src exception. Everything
27 + inside is server-rendered and stays rendered: the element adds arriving
28 + messages and sends without a page load, and adds nothing else. #}
29 + <mt-chat-room id="chat-room"
27 30 class="chat-room"
28 31 data-slug="{{ community_slug }}"
29 32 data-cursor="{{ cursor }}"
30 33 data-max-length="{{ max_message_len }}"
31 34 data-can-send="{{ can_send }}"
32 - data-moderator="{{ is_moderator }}">
35 + data-moderator="{{ is_moderator }}"
36 + {% if let Some(viewer) = viewer_id %}data-viewer="{{ viewer }}"{% endif %}>
33 37
34 38 <ol class="chat-log" id="chat-log" aria-live="polite" aria-label="Chat messages">
35 39 {% for message in messages %}
@@ -66,6 +70,8 @@
66 70 {% else %}
67 71 <div class="chat-notice"><a href="/auth/login">Sign in</a> to join the conversation.</div>
68 72 {% endif %}
69 - </div>
73 + </mt-chat-room>
70 74 </div>
71 75 {% endblock %}
76 +
77 + {% block scripts %}{% call island::island("chat") %}{% endcall %}{% endblock %}
@@ -1,0 +1,46 @@
1 + /**
2 + * Islands infrastructure.
3 + *
4 + * An island is a custom element that attaches behavior to server-rendered
5 + * markup. It never re-renders what the server sent, so a page keeps working
6 + * with the script blocked and the element only adds what needs a client.
7 + *
8 + * The payoff over the `DOMContentLoaded` + `htmx:afterSwap` re-init dance that
9 + * static/mt.js does by hand: the browser upgrades a custom element whenever it
10 + * is connected to the document, including markup htmx swapped in, so an island
11 + * needs no manual re-initialization.
12 + *
13 + * Ported from MNW/server/frontend/src/islands/base.ts, deliberately as a copy.
14 + * The two apps share no workspace and no registry, and 30 lines duplicated is
15 + * cheaper than the mechanism that would let them share it. See the Client
16 + * section of the livechat design note.
17 + *
18 + * Pure logic goes in a sibling `*.logic.ts` with no DOM imports, so
19 + * `node --test` exercises it without a DOM; the element file wires that logic
20 + * over the light-DOM children.
21 + */
22 +
23 + /**
24 + * Base class: runs `init()` exactly once, the first time the element is
25 + * connected. Moving the element in the document will not re-run it.
26 + */
27 + export abstract class MtElement extends HTMLElement {
28 + #initialized = false;
29 +
30 + connectedCallback(): void {
31 + if (this.#initialized) return;
32 + this.#initialized = true;
33 + this.init();
34 + }
35 +
36 + /** Wire behavior over the already-rendered light-DOM children. */
37 + protected abstract init(): void;
38 + }
39 +
40 + /**
41 + * Define a custom element once. Idempotent, so importing the module from more
42 + * than one entry point does not throw on the second definition.
43 + */
44 + export function define(name: string, ctor: CustomElementConstructor): void {
45 + if (!customElements.get(name)) customElements.define(name, ctor);
46 + }
@@ -1,0 +1,524 @@
1 + // <mt-chat-room>, the live half of the chat page.
2 + //
3 + // The server renders the room's first window, the composer, and every gate
4 + // around them. This element adds the three things a page load cannot give you:
5 + // messages arriving without a refresh, sending without leaving the page, and
6 + // removing one that a moderator or its author deleted.
7 + //
8 + // Everything it decides lives in chat/chat.logic.ts and chat/reconnect.logic.ts,
9 + // which have no DOM in them and are unit tested. What is left here is the
10 + // wiring: attributes in, elements out, one EventSource.
11 + //
12 + // With the script blocked the page is still a working room. The backlog is
13 + // server-rendered and the composer is a real form posting to the same endpoint,
14 + // which answers with JSON rather than a redirect, so the no-JS experience is
15 + // send-and-see-a-blob rather than send-and-nothing. That is the accepted floor:
16 + // chat without a client is not chat, and the gates that matter are all
17 + // server-side regardless.
18 +
19 + import { MtElement, define } from "./base.ts";
20 + import {
21 + ChatLedger,
22 + MAX_MESSAGE_LEN,
23 + checkBody,
24 + newNonce,
25 + parseFrame,
26 + streamUrl,
27 + type IncomingMessage,
28 + } from "./chat/chat.logic.ts";
29 + import { ReconnectPolicy } from "./chat/reconnect.logic.ts";
30 +
31 + /** The four frames the server sends. Dispatch reads the tag inside the payload. */
32 + const FRAME_EVENTS = ["message", "delete", "purge", "gap"] as const;
33 +
34 + /**
35 + * How close to the bottom counts as "following the room", in pixels.
36 + *
37 + * Someone reading back through the log must not be yanked to the bottom every
38 + * time a message lands, and someone at the bottom must not have to chase it.
39 + * The tolerance covers the fractional scroll heights a zoomed page produces.
40 + */
41 + const FOLLOW_THRESHOLD_PX = 40;
42 +
43 + class ChatRoom extends MtElement {
44 + #slug = "";
45 + #viewer: string | null = null;
46 + #moderator = false;
47 + #maxLength = MAX_MESSAGE_LEN;
48 +
49 + #log!: HTMLOListElement;
50 + #status!: HTMLParagraphElement;
51 + #empty: HTMLElement | null = null;
52 +
53 + #ledger!: ChatLedger;
54 + readonly #policy = new ReconnectPolicy();
55 + #source: EventSource | null = null;
56 + #retry: ReturnType<typeof setTimeout> | null = null;
57 +
58 + /**
59 + * Cancels the listeners this element put on `window` and `document`.
60 + *
61 + * Those two outlive the element, so without this a room htmx swapped away
62 + * would still be woken by a tab becoming visible, and would open a stream
63 + * nothing is reading. That connection holds a slot against the per-user cap
64 + * until the tab closes, so the room the user actually moved to is the one
65 + * that gets refused.
66 + */
67 + readonly #listeners = new AbortController();
68 +
69 + protected init(): void {
70 + const log = this.querySelector<HTMLOListElement>("#chat-log");
71 + if (!log) return; // not the chat page's markup; nothing to enhance
72 + this.#log = log;
73 +
74 + this.#slug = this.dataset["slug"] ?? "";
75 + this.#viewer = this.dataset["viewer"] ?? null;
76 + this.#moderator = this.dataset["moderator"] === "true";
77 + this.#maxLength = Number(this.dataset["maxLength"]) || MAX_MESSAGE_LEN;
78 + this.#ledger = new ChatLedger(Number(this.dataset["cursor"]));
79 + this.#empty = this.querySelector<HTMLElement>("#chat-empty");
80 +
81 + // Built here rather than in the template: with the script blocked there is
82 + // no connection to have an opinion about, so an empty status line would be
83 + // markup that only ever says nothing.
84 + this.#status = document.createElement("p");
85 + this.#status.className = "chat-status";
86 + this.#status.setAttribute("role", "status");
87 + this.#status.hidden = true;
88 + this.#log.after(this.#status);
89 +
90 + for (const message of this.#log.querySelectorAll<HTMLLIElement>(".chat-message")) {
91 + this.#decorate(message);
92 + }
93 +
94 + this.#composer()?.addEventListener("submit", (event) => {
95 + event.preventDefault();
96 + void this.#send();
97 + });
98 +
99 + // Both events say something about this client, not about the server, so
100 + // honoring an accumulated backoff after either would leave the room looking
101 + // broken for up to a minute for no reason.
102 + const signal = this.#listeners.signal;
103 + addEventListener("online", () => this.#reconnectNow(), { signal });
104 + document.addEventListener(
105 + "visibilitychange",
106 + () => {
107 + if (!document.hidden) this.#reconnectNow();
108 + },
109 + { signal },
110 + );
111 +
112 + this.#connect();
113 + this.#scrollToEnd();
114 + }
115 +
116 + /**
117 + * htmx swaps the page body, and a swapped-away element keeps its connection
118 + * open unless something closes it. A leaked SSE connection holds a slot
119 + * against the per-user cap, so the room refuses to open in the new tab the
120 + * user just moved to.
121 + */
122 + disconnectedCallback(): void {
123 + this.#close();
124 + this.#listeners.abort();
125 + if (this.#retry !== null) clearTimeout(this.#retry);
126 + }
127 +
128 + // The stream
129 +
130 + #connect(): void {
131 + this.#close();
132 + const source = new EventSource(streamUrl(this.#slug, this.#ledger.cursor));
133 + this.#source = source;
134 +
135 + source.addEventListener("open", () => {
136 + this.#policy.succeed();
137 + this.#clearStatus();
138 + });
139 +
140 + for (const name of FRAME_EVENTS) {
141 + source.addEventListener(name, (event) => this.#frame(event as MessageEvent<string>));
142 + }
143 +
144 + // EventSource reconnects on its own schedule, which is a fixed few seconds
145 + // with no jitter and no ceiling on attempts. Every open room drops at the
146 + // same instant on a deploy, so that default is the thundering herd the
147 + // backoff exists to prevent. Closing here takes the retry back.
148 + source.addEventListener("error", () => this.#dropped());
149 + }
150 +
151 + #close(): void {
152 + this.#source?.close();
153 + this.#source = null;
154 + }
155 +
156 + #dropped(): void {
157 + this.#close();
158 + const action = this.#policy.fail();
159 + if (action.kind === "give-up") {
160 + this.#offerReconnect();
161 + return;
162 + }
163 + this.#say("Reconnecting...");
164 + this.#retry = setTimeout(() => this.#connect(), action.delayMs);
165 + }
166 +
167 + /** Drop the accumulated wait and try again immediately. */
168 + #reconnectNow(): void {
169 + if (this.#source !== null) return;
170 + if (this.#retry !== null) clearTimeout(this.#retry);
171 + this.#policy.resetForImmediateRetry();
172 + this.#connect();
173 + }
174 +
175 + #frame(event: MessageEvent<string>): void {
176 + const frame = parseFrame(event.data);
177 + if (frame === null) return; // one malformed frame must not kill the stream
178 +
179 + switch (frame.type) {
180 + case "message":
181 + this.#received(frame);
182 + break;
183 + case "delete":
184 + this.#remove(`[data-id="${CSS.escape(String(frame.id))}"]`);
185 + break;
186 + case "purge":
187 + this.#remove(`[data-author="${CSS.escape(frame.author_id)}"]`);
188 + break;
189 + case "gap":
190 + // The connection fell behind and the hub dropped frames for it.
191 + // Reopening at the cursor replays exactly the hole.
192 + this.#connect();
193 + break;
194 + }
195 + }
196 +
197 + #received(message: IncomingMessage): void {
198 + const outcome = this.#ledger.receive(message);
199 + if (outcome.kind === "duplicate") return;
200 +
201 + const following = this.#atEnd();
202 +
203 + if (outcome.kind === "reconcile") {
204 + const pending = this.#pending(outcome.nonce);
205 + if (pending) {
206 + pending.replaceWith(this.#render(message));
207 + if (following) this.#scrollToEnd();
208 + return;
209 + }
210 + // The placeholder is gone (the tab was swapped, the user dismissed a
211 + // failure). The message is still real, so show it.
212 + }
213 +
214 + this.#append(this.#render(message));
215 + if (following) this.#scrollToEnd();
216 + }
217 +
218 + // Sending
219 +
220 + async #send(): Promise<void> {
221 + const input = this.querySelector<HTMLInputElement>("#chat-input");
222 + if (!input) return;
223 +
224 + const check = checkBody(input.value);
225 + if (check.kind === "empty") return;
226 + if (check.kind === "too-long") {
227 + this.#say(`Too long by ${check.length - this.#maxLength} characters.`);
228 + return;
229 + }
230 +
231 + const nonce = newNonce();
232 + const placeholder = this.#renderPending(check.body, nonce);
233 + this.#ledger.track(nonce);
234 + this.#append(placeholder);
235 + this.#scrollToEnd();
236 + input.value = "";
237 + this.#clearStatus();
238 +
239 + const outcome = await this.#post(check.body, nonce);
240 + if (outcome.ok) {
241 + // The echo does the un-greying, because it carries the rendered body and
242 + // the author as the room should show them. All this needs to do is make
243 + // the message addressable in case a delete frame arrives first.
244 + if (placeholder.isConnected) placeholder.dataset["id"] = String(outcome.id);
245 + return;
246 + }
247 +
248 + this.#ledger.untrack(nonce);
249 + this.#fail(placeholder, check.body, outcome.message);
250 + }
251 +
252 + async #post(
253 + body: string,
254 + nonce: string,
255 + ): Promise<{ ok: true; id: number } | { ok: false; message: string }> {
256 + const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content;
257 + try {
258 + const response = await fetch(`/p/${encodeURIComponent(this.#slug)}/chat/send`, {
259 + method: "POST",
260 + headers: {
261 + "Content-Type": "application/x-www-form-urlencoded",
262 + ...(token ? { "X-CSRF-Token": token } : {}),
263 + },
264 + body: new URLSearchParams({ body, nonce }).toString(),
265 + });
266 +
267 + if (!response.ok) {
268 + // Every refusal the send path can produce comes back as plain text
269 + // written for the sender: rate limits, mutes, bans, a room that just
270 + // went read-only. Showing it beats a generic failure.
271 + const message = (await response.text().catch(() => "")).trim();
272 + return { ok: false, message: message || "Message not sent." };
273 + }
274 +
275 + const sent = (await response.json()) as { id: number };
276 + return { ok: true, id: sent.id };
277 + } catch {
278 + return { ok: false, message: "Message not sent. Check your connection." };
279 + }
280 + }
281 +
282 + /** Mark a placeholder as failed, with the text preserved and one way back. */
283 + #fail(placeholder: HTMLLIElement, body: string, message: string): void {
284 + placeholder.classList.remove("is-pending");
285 + placeholder.classList.add("is-failed");
286 + delete placeholder.dataset["nonce"];
287 +
288 + const retry = document.createElement("button");
289 + retry.type = "button";
290 + retry.className = "chat-retry";
291 + retry.textContent = "Retry";
292 + retry.addEventListener("click", () => {
293 + const input = this.querySelector<HTMLInputElement>("#chat-input");
294 + if (input) {
295 + // Back into the composer rather than resent behind the user's back: by
296 + // the time a send fails the reason is usually something they need to
297 + // see, and a silent retry loop against a rate limit is how a client
298 + // earns a ban.
299 + input.value = body;
300 + input.focus();
301 + }
302 + placeholder.remove();
303 + });
304 +
305 + const note = document.createElement("span");
306 + note.className = "chat-error";
307 + note.textContent = message;
308 + placeholder.append(note, retry);
309 + }
310 +
311 + // Rendering
312 + //
313 + // The markup below is the same shape templates/pages/chat.html renders, so
314 + // one set of CSS rules covers both and a message looks identical before and
315 + // after a reload.
316 +
317 + #render(message: IncomingMessage): HTMLLIElement {
318 + const li = document.createElement("li");
319 + li.className = "chat-message";
320 + li.dataset["id"] = String(message.id);
321 + li.dataset["author"] = message.author_id;
322 +
323 + const avatar = message.author?.avatar_url;
324 + if (avatar !== undefined) {
325 + const img = document.createElement("img");
326 + img.className = "chat-avatar";
327 + img.src = avatar;
328 + img.alt = "";
329 + img.width = 24;
330 + img.height = 24;
331 + li.append(img);
332 + }
333 +
334 + const author = document.createElement("span");
335 + author.className = "chat-author";
336 + author.textContent = message.author?.display_name ?? "Unknown";
337 +
338 + const body = document.createElement("span");
339 + body.className = "chat-body";
340 + // Rendered and sanitized server-side by docengine's chat preset, then
341 + // carried verbatim through the transport. The client never renders markdown
342 + // and never sanitizes: a second sanitizer would be a second policy to keep
343 + // in step with the first.
344 + body.innerHTML = message.body_html;
345 +
346 + li.append(author, this.#time(message.created_at), body);
347 + this.#decorate(li);
348 + return li;
349 + }
350 +
351 + /** The sender's own message, on screen before the server has confirmed it. */
352 + #renderPending(body: string, nonce: string): HTMLLIElement {
353 + const li = document.createElement("li");
354 + li.className = "chat-message is-pending";
355 + li.dataset["nonce"] = nonce;
356 + if (this.#viewer !== null) li.dataset["author"] = this.#viewer;
357 +
358 + const author = document.createElement("span");
359 + author.className = "chat-author";
360 + author.textContent = this.#selfName();
361 +
362 + const text = document.createElement("span");
363 + text.className = "chat-body";
364 + // The raw text the user typed, as text. What comes back from the server is
365 + // the rendered version, and this is replaced by it wholesale.
366 + text.textContent = body;
367 +
368 + li.append(author, this.#time(Math.floor(Date.now() / 1000)), text);
369 + return li;
370 + }
371 +
372 + /**
373 + * Fill in a timestamp.
374 + *
375 + * The server writes unix seconds into `datetime`, which is not a value the
376 + * attribute is allowed to hold, and leaves the text empty because only the
377 + * client knows the reader's timezone. Both are settled here: the attribute
378 + * becomes a real ISO instant and the text becomes a local clock time.
379 + */
380 + #time(seconds: number): HTMLTimeElement {
381 + const time = document.createElement("time");
382 + time.className = "chat-time";
383 + this.#fillTime(time, seconds);
384 + return time;
385 + }
386 +
387 + #fillTime(time: HTMLTimeElement, seconds: number): void {
388 + const at = new Date(seconds * 1000);
389 + time.dateTime = at.toISOString();
390 + time.textContent = at.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
391 + time.title = at.toLocaleString();
392 + }
393 +
394 + /**
395 + * Add what the server could not: a readable timestamp, and a delete control
396 + * for a message this viewer is allowed to remove.
397 + *
398 + * The button is an affordance, never the check. The handler re-derives
399 + * authorship and moderator status server-side, so hiding it is a courtesy to
400 + * the honest and nothing more.
401 + */
402 + #decorate(message: HTMLLIElement): void {
403 + const time = message.querySelector<HTMLTimeElement>(".chat-time");
404 + if (time) {
405 + const seconds = Number(time.dateTime);
406 + if (Number.isFinite(seconds) && seconds > 0) this.#fillTime(time, seconds);
407 + }
408 +
409 + const id = message.dataset["id"];
410 + if (id === undefined) return;
411 + if (!this.#moderator && message.dataset["author"] !== this.#viewer) return;
412 + if (message.querySelector(".chat-remove")) return;
413 +
414 + const remove = document.createElement("button");
415 + remove.type = "button";
416 + remove.className = "chat-remove";
417 + remove.title = "Delete this message";
418 + remove.setAttribute("aria-label", "Delete this message");
419 + remove.textContent = "x";
420 + remove.addEventListener("click", () => void this.#delete(id));
421 + message.append(remove);
422 + }
423 +
424 + /**
425 + * Ask the server to remove a message.
426 + *
427 + * Nothing happens to the DOM on success: the removal comes back as a `delete`
428 + * frame, which every reader in the room including this one acts on. Removing
429 + * it locally as well would race that frame for no benefit, and would show the
430 + * message gone in the one case where the server refused.
431 + */
432 + async #delete(id: string): Promise<void> {
433 + const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content;
434 + const body = new URLSearchParams(token ? { csrf_token: token } : {}).toString();
435 + try {
436 + const response = await fetch(
437 + `/p/${encodeURIComponent(this.#slug)}/chat/messages/${encodeURIComponent(id)}/delete`,
438 + {
439 + method: "POST",
440 + headers: {
441 + "Content-Type": "application/x-www-form-urlencoded",
442 + ...(token ? { "X-CSRF-Token": token } : {}),
443 + },
444 + body,
445 + },
446 + );
447 + if (!response.ok) this.#say("That message could not be deleted.");
448 + } catch {
449 + this.#say("That message could not be deleted.");
450 + }
451 + }
452 +
453 + // Small helpers over the log
454 +
455 + #append(message: HTMLLIElement): void {
456 + this.#log.append(message);
457 + if (this.#empty) {
458 + this.#empty.hidden = true;
459 + this.#empty = null;
460 + }
461 + }
462 +
463 + #remove(selector: string): void {
464 + for (const gone of this.#log.querySelectorAll(selector)) gone.remove();
465 + }
466 +
467 + #pending(nonce: string): HTMLLIElement | null {
468 + return this.#log.querySelector<HTMLLIElement>(`[data-nonce="${CSS.escape(nonce)}"]`);
469 + }
470 +
471 + #composer(): HTMLFormElement | null {
472 + return this.querySelector<HTMLFormElement>("#chat-composer");
473 + }
474 +
475 + /**
476 + * What to call the sender on their own optimistic message.
477 + *
478 + * Taken from the last message they sent in this room, falling back to "You".
479 + * The page does not carry a display name for the viewer, and the placeholder
480 + * is replaced by the server's own rendering within a round trip, so this is a
481 + * label with a short life and no consequences.
482 + */
483 + #selfName(): string {
484 + if (this.#viewer === null) return "You";
485 + const mine = this.#log.querySelectorAll<HTMLLIElement>(
486 + `.chat-message[data-author="${CSS.escape(this.#viewer)}"] .chat-author`,
487 + );
488 + return mine[mine.length - 1]?.textContent?.trim() || "You";
489 + }
490 +
491 + #atEnd(): boolean {
492 + const { scrollTop, scrollHeight, clientHeight } = this.#log;
493 + return scrollHeight - scrollTop - clientHeight <= FOLLOW_THRESHOLD_PX;
494 + }
495 +
496 + #scrollToEnd(): void {
497 + this.#log.scrollTop = this.#log.scrollHeight;
498 + }
499 +
500 + #say(message: string): void {
Lines truncated
@@ -1,0 +1,238 @@
1 + import assert from "node:assert/strict";
2 + import { test } from "node:test";
3 +
4 + import {
5 + ChatLedger,
6 + MAX_MESSAGE_LEN,
7 + MAX_NONCE_LEN,
8 + checkBody,
9 + newNonce,
10 + parseFrame,
11 + streamUrl,
12 + } from "./chat.logic.ts";
13 +
14 + // Body validation. The mirror of MessageBody::parse; the tests below are the
15 + // same cases livechat's message.rs asserts, so a divergence shows up on one
16 + // side or the other rather than as a message the composer accepts and the
17 + // server refuses.
18 +
19 + test("a body is trimmed before anything else happens to it", () => {
20 + const check = checkBody(" hi ");
21 + assert.equal(check.kind, "ok");
22 + assert.equal(check.kind === "ok" && check.body, "hi");
23 + });
24 +
25 + test("whitespace alone is empty", () => {
26 + assert.equal(checkBody("").kind, "empty");
27 + assert.equal(checkBody(" \n\t ").kind, "empty");
28 + });
29 +
30 + test("the length boundary is inclusive", () => {
31 + assert.equal(checkBody("x".repeat(MAX_MESSAGE_LEN)).kind, "ok");
32 +
33 + const over = checkBody("x".repeat(MAX_MESSAGE_LEN + 1));
34 + assert.equal(over.kind, "too-long");
35 + assert.equal(over.kind === "too-long" && over.length, MAX_MESSAGE_LEN + 1);
36 + });
37 +
38 + test("length is measured after trimming", () => {
39 + const padded = ` ${"x".repeat(MAX_MESSAGE_LEN)} `;
40 + assert.equal(checkBody(padded).kind, "ok");
41 + });
42 +
43 + test("length counts code points, not UTF-16 units", () => {
44 + // The whole reason checkBody spreads the string. An astral character is two
45 + // UTF-16 units and one Rust `char`, so a body of these at the limit is 1000
46 + // by String.length and 500 to the server. Counting the wrong one refuses a
47 + // message the server would have accepted.
48 + const astral = "\u{1F600}".repeat(MAX_MESSAGE_LEN);
49 + assert.equal(astral.length, MAX_MESSAGE_LEN * 2);
50 + assert.equal(checkBody(astral).kind, "ok");
51 +
52 + assert.equal(checkBody("\u{1F600}".repeat(MAX_MESSAGE_LEN + 1)).kind, "too-long");
53 + });
54 +
55 + // Nonces.
56 +
57 + test("a nonce fits inside the server's cap", () => {
58 + for (const r of [0, 0.5, 0.999999]) {
59 + const nonce = newNonce(() => r);
60 + assert.ok(nonce.length > 0, "empty nonces are dropped by the server");
61 + assert.ok(nonce.length <= MAX_NONCE_LEN, `${nonce} is ${nonce.length} chars`);
62 + }
63 + });
64 +
65 + test("nonces differ across sends", () => {
66 + const seen = new Set<string>();
67 + for (let i = 0; i < 200; i++) seen.add(newNonce());
68 + assert.equal(seen.size, 200);
69 + });
70 +
71 + // Frame parsing.
72 +
73 + const message = (id: number, extra: Record<string, unknown> = {}): string =>
74 + JSON.stringify({
75 + type: "message",
76 + id,
77 + room_id: "00000000-0000-0000-0000-000000000000",
78 + author_id: "11111111-1111-1111-1111-111111111111",
79 + body_html: "hi",
80 + created_at: 0,
81 + ...extra,
82 + });
83 +
84 + test("a message frame is flat, tag and all", () => {
85 + // serde's internally tagged enum writes `type` into the message object rather
86 + // than nesting it, so the parsed frame carries the fields directly.
87 + const frame = parseFrame(message(7));
88 + assert.equal(frame?.type, "message");
89 + assert.equal(frame?.type === "message" && frame.id, 7);
90 + assert.equal(frame?.type === "message" && frame.body_html, "hi");
91 + });
92 +
93 + test("the other three frames parse", () => {
94 + assert.deepEqual(parseFrame('{"type":"delete","id":3}'), { type: "delete", id: 3 });
95 + assert.deepEqual(parseFrame('{"type":"purge","author_id":"abc"}'), {
96 + type: "purge",
97 + author_id: "abc",
98 + });
99 + assert.deepEqual(parseFrame('{"type":"gap"}'), { type: "gap" });
100 + });
101 +
102 + test("an author rides along when the host resolved one", () => {
103 + const frame = parseFrame(message(1, { author: { display_name: "max", flair: "owner" } }));
104 + assert.equal(frame?.type === "message" && frame.author?.display_name, "max");
105 + });
106 +
107 + test("anything unparseable is null rather than a thrown error", () => {
108 + // One bad frame must not tear down a working stream.
109 + for (const bad of [
110 + "",
111 + "not json",
112 + "null",
113 + "[]",
114 + '"a string"',
115 + '{"type":"typing"}',
116 + '{"id":1}',
117 + '{"type":"delete"}',
118 + '{"type":"delete","id":"3"}',
119 + '{"type":"purge"}',
120 + '{"type":"message","id":"7","author_id":"a","body_html":"x","created_at":0}',
121 + '{"type":"message","id":7,"author_id":"a","created_at":0}',
122 + ]) {
123 + assert.equal(parseFrame(bad), null, `expected null for ${bad || "(empty)"}`);
124 + }
125 + });
126 +
127 + // The ledger: cursor tracking, overlap dedup, nonce reconciliation.
128 +
129 + test("a fresh room starts at the server-rendered cursor", () => {
130 + assert.equal(new ChatLedger(42).cursor, 42);
131 + });
132 +
133 + test("a cursor that is not a message id starts from the beginning", () => {
134 + // It arrives as a data attribute, so Number() can hand back 0 or NaN, and a
135 + // NaN in the query string would fail the server's `after` parse on every
136 + // single reconnect.
137 + for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
138 + assert.equal(new ChatLedger(bad).cursor, 0);
139 + }
140 + });
141 +
142 + test("new messages append and advance the cursor", () => {
143 + const ledger = new ChatLedger(10);
144 + assert.deepEqual(ledger.receive({ id: 11 }), { kind: "append" });
145 + assert.equal(ledger.cursor, 11);
146 + assert.deepEqual(ledger.receive({ id: 12 }), { kind: "append" });
147 + assert.equal(ledger.cursor, 12);
148 + });
149 +
150 + test("the replay overlap is deduplicated by the high-water mark", () => {
151 + // ChatStream::open subscribes before it fetches the backlog, so a reconnect
152 + // can be handed messages it already has. That overlap is the correct trade
153 + // (the alternative loses messages) and this is what pays for it.
154 + const ledger = new ChatLedger(10);
155 + ledger.receive({ id: 11 });
156 +
157 + assert.deepEqual(ledger.receive({ id: 11 }), { kind: "duplicate" });
158 + assert.deepEqual(ledger.receive({ id: 5 }), { kind: "duplicate" });
159 + assert.equal(ledger.cursor, 11, "a duplicate must not move the cursor");
160 + });
161 +
162 + test("our own echo reconciles instead of appending twice", () => {
163 + const ledger = new ChatLedger(10);
164 + ledger.track("n1");
165 + assert.equal(ledger.pending, 1);
166 +
167 + assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "reconcile", nonce: "n1" });
168 + assert.equal(ledger.pending, 0);
169 + assert.equal(ledger.cursor, 11);
170 + });
171 +
172 + test("someone else's nonce is not ours", () => {
173 + // Every recipient but the sender gets the frame without a nonce, but a client
174 + // must not reconcile against one it never sent even if it somehow sees it.
175 + const ledger = new ChatLedger(10);
176 + ledger.track("mine");
177 + assert.deepEqual(ledger.receive({ id: 11, nonce: "theirs" }), { kind: "append" });
178 + assert.equal(ledger.pending, 1, "our send is still in flight");
179 + });
180 +
181 + test("an echo below the cursor still reconciles", () => {
182 + // Two people sending at once can have their inserts ordered one way and their
183 + // broadcasts the other, so our own message can arrive after a higher id has
184 + // already been displayed. Reading the cursor first would call this a
185 + // duplicate and leave the sender's own message greyed out forever.
186 + const ledger = new ChatLedger(10);
187 + ledger.track("mine");
188 + ledger.receive({ id: 12 });
189 +
190 + assert.deepEqual(ledger.receive({ id: 11, nonce: "mine" }), { kind: "reconcile", nonce: "mine" });
191 + assert.equal(ledger.cursor, 12, "reconciling an older id must not rewind the cursor");
192 + });
193 +
194 + test("an echo delivered twice reconciles once", () => {
195 + const ledger = new ChatLedger(10);
196 + ledger.track("n1");
197 + ledger.receive({ id: 11, nonce: "n1" });
198 +
199 + assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "duplicate" });
200 + });
201 +
202 + test("a failed send stops waiting for an echo", () => {
203 + const ledger = new ChatLedger(10);
204 + ledger.track("n1");
205 + ledger.untrack("n1");
206 + assert.equal(ledger.pending, 0);
207 +
208 + // And if the send did land after all, the message is new, not a reconcile.
209 + assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "append" });
210 + });
211 +
212 + test("several sends are in flight at once", () => {
213 + const ledger = new ChatLedger(0);
214 + ledger.track("a");
215 + ledger.track("b");
216 + ledger.track("c");
217 +
218 + assert.deepEqual(ledger.receive({ id: 3, nonce: "c" }), { kind: "reconcile", nonce: "c" });
219 + assert.deepEqual(ledger.receive({ id: 1, nonce: "a" }), { kind: "reconcile", nonce: "a" });
220 + assert.equal(ledger.pending, 1);
221 + assert.equal(ledger.cursor, 3);
222 + });
223 +
224 + // Where the stream is opened.
225 +
226 + test("a first connection asks for no cursor", () => {
227 + // Absent means "the recent window"; after=0 would ask the server to replay
228 + // the entire retention window a page at a time.
229 + assert.equal(streamUrl("rust", 0), "/p/rust/chat/stream");
230 + });
231 +
232 + test("a reconnect resumes after the last message handled", () => {
233 + assert.equal(streamUrl("rust", 91), "/p/rust/chat/stream?after=91");
234 + });
235 +
236 + test("the slug is escaped into the path", () => {
237 + assert.equal(streamUrl("a b/c", 0), "/p/a%20b%2Fc/chat/stream");
238 + });
@@ -1,0 +1,259 @@
1 + /**
2 + * Chat client logic: everything the island decides, none of what it touches.
3 + *
4 + * No DOM, no network, no timers. The island calls into this to answer "is this
5 + * body sendable", "have I already seen this message", "is this frame the echo
6 + * of something I sent", and then does the rendering itself. Splitting it that
7 + * way is what lets `node --test` exercise the parts where the bugs actually
8 + * are without a browser.
9 + *
10 + * Backoff is not here. `reconnect.logic.ts` owns it and is imported by the
11 + * island directly; folding the two together would make one module that two
12 + * different concerns both edit.
13 + *
14 + * Three numbers and one wire format in this file are the server's, not ours.
15 + * They are restated rather than fetched, and every one carries the Rust it
16 + * mirrors, because a client that disagrees with the server about the length
17 + * limit shows a message being accepted and then rejected.
18 + */
19 +
20 + /**
21 + * Longest message a room accepts.
22 + *
23 + * Mirrors `MAX_MESSAGE_LEN` in `MNW/shared/livechat/src/message.rs`. The server
24 + * is the authority; this exists so the composer can say so before the round
25 + * trip, and the page also carries it as `data-max-length` so a change on the
26 + * server is visible to a client built before it.
27 + */
28 + export const MAX_MESSAGE_LEN = 500;
29 +
30 + /**
31 + * Longest nonce the server will accept.
32 + *
33 + * Mirrors `Nonce::MAX_LEN` in `MNW/shared/livechat/src/ids.rs`, where an
34 + * overlong nonce is dropped rather than rejected: the send would succeed and
35 + * the echo would come back without it, leaving the optimistic message grey
36 + * forever. Generating short ones is the only thing keeping us on the right
37 + * side of that.
38 + */
39 + export const MAX_NONCE_LEN = 64;
40 +
41 + /** What a body is, once trimmed. */
42 + export type BodyCheck =
43 + | { kind: "ok"; body: string; length: number }
44 + | { kind: "empty" }
45 + | { kind: "too-long"; length: number };
46 +
47 + /**
48 + * Trim, then classify, exactly as `MessageBody::parse` does server-side.
49 + *
50 + * Length is counted in code points rather than in `String.length`, which counts
51 + * UTF-16 units. Rust counts `chars()`, so anything outside the basic plane —
52 + * an emoji, most of the historic scripts — would otherwise read as two
53 + * characters here and one there, and the client would refuse a message the
54 + * server would have taken.
55 + */
56 + export function checkBody(raw: string): BodyCheck {
57 + const body = raw.trim();
58 + const length = [...body].length;
59 + if (length === 0) return { kind: "empty" };
60 + if (length > MAX_MESSAGE_LEN) return { kind: "too-long", length };
61 + return { kind: "ok", body, length };
62 + }
63 +
64 + /**
65 + * A nonce for one optimistic send.
66 + *
67 + * Only has to be unique among a single tab's in-flight sends, of which there
68 + * are a handful at most, so this is not a place that wants a CSPRNG. `random`
69 + * is injected so a test can force a collision and see what happens.
70 + */
71 + export function newNonce(random: () => number = Math.random): string {
72 + const part = (): string =>
73 + Math.floor(random() * 0xffff_ffff)
74 + .toString(36)
75 + .padStart(7, "0");
76 + return `${part()}${part()}`;
77 + }
78 +
79 + /** Who wrote a message, as the room should show them. */
80 + export interface Author {
81 + display_name: string;
82 + avatar_url?: string;
83 + flair?: string;
84 + }
85 +
86 + /**
87 + * A message as it arrives on the stream.
88 + *
89 + * `body_html` is already rendered and sanitized by the server through
90 + * docengine's chat preset. The client never renders markdown and never
91 + * sanitizes; if this string is not safe to insert, the bug is on the server and
92 + * escaping it here would only hide it.
93 + *
94 + * `author` is absent when the host could not resolve the account (a deletion,
95 + * mid-flight). A room stays readable in that case rather than emptying out.
96 + */
97 + export interface IncomingMessage {
98 + id: number;
99 + author_id: string;
100 + body_html: string;
101 + created_at: number;
102 + nonce?: string;
103 + author?: Author;
104 + }
105 +
106 + /**
107 + * One frame off the stream.
108 + *
109 + * The message variant is flat rather than nested because serde's internally
110 + * tagged enum writes the tag into the message object itself
111 + * (`ChatEvent` in `MNW/shared/livechat/src/event.rs`).
112 + */
113 + export type Frame =
114 + | ({ type: "message" } & IncomingMessage)
115 + | { type: "delete"; id: number }
116 + | { type: "purge"; author_id: string }
117 + | { type: "gap" };
118 +
119 + /**
120 + * Parse one SSE payload, or return null.
121 + *
122 + * Null covers malformed JSON, a frame type this client does not know, and a
123 + * known type missing a field it needs. All three are the same decision for the
124 + * caller: ignore the frame and keep the connection. A client that threw here
125 + * would tear down a working stream over one bad frame, and the SSE `event:`
126 + * name is not trusted to match the payload — the tag inside it is what
127 + * dispatch reads.
128 + */
129 + export function parseFrame(data: string): Frame | null {
130 + let raw: unknown;
131 + try {
132 + raw = JSON.parse(data);
133 + } catch {
134 + return null;
135 + }
136 + if (typeof raw !== "object" || raw === null) return null;
137 + const frame = raw as Record<string, unknown>;
138 +
139 + switch (frame["type"]) {
140 + case "message":
141 + return isIncomingMessage(frame) ? ({ ...frame, type: "message" } as Frame) : null;
142 + case "delete":
143 + return typeof frame["id"] === "number" ? { type: "delete", id: frame["id"] } : null;
144 + case "purge":
145 + return typeof frame["author_id"] === "string"
146 + ? { type: "purge", author_id: frame["author_id"] }
147 + : null;
148 + case "gap":
149 + return { type: "gap" };
150 + default:
151 + return null;
152 + }
153 + }
154 +
155 + function isIncomingMessage(frame: Record<string, unknown>): boolean {
156 + return (
157 + typeof frame["id"] === "number" &&
158 + typeof frame["author_id"] === "string" &&
159 + typeof frame["body_html"] === "string" &&
160 + typeof frame["created_at"] === "number"
161 + );
162 + }
163 +
164 + /** What the island should do with a message frame. */
165 + export type Outcome =
166 + | { kind: "append" }
167 + | { kind: "reconcile"; nonce: string }
168 + | { kind: "duplicate" };
169 +
170 + /**
171 + * The cursor and the in-flight sends, which are one object because they answer
172 + * one question between them: is this frame new, mine, or something I already
173 + * have on screen.
174 + *
175 + * The cursor is a high-water mark, not a set of seen ids. That is enough
176 + * because `ChatStream::open` subscribes before it fetches the backlog, so a
177 + * reconnect replays a window that may overlap the live feed but never skips
178 + * anything: every duplicate is an id at or below where the client already got
179 + * to, and the mark stays one number no matter how long the tab lives.
180 + */
181 + export class ChatLedger {
182 + #cursor: number;
183 + readonly #pending = new Set<string>();
184 +
185 + constructor(cursor: number) {
186 + this.#cursor = sanitizeCursor(cursor);
187 + }
188 +
189 + /** Highest message id handled. What a reconnect asks to resume after. */
190 + get cursor(): number {
191 + return this.#cursor;
192 + }
193 +
194 + /** How many optimistic sends are still waiting for their echo. */
195 + get pending(): number {
196 + return this.#pending.size;
197 + }
198 +
199 + /** Start waiting for the echo of a message rendered optimistically. */
200 + track(nonce: string): void {
201 + this.#pending.add(nonce);
202 + }
203 +
204 + /** Stop waiting: the POST failed, or the user gave up on it. */
205 + untrack(nonce: string): void {
206 + this.#pending.delete(nonce);
207 + }
208 +
209 + /**
210 + * Classify a message frame.
211 + *
212 + * The nonce is checked before the cursor, and the order is load-bearing. Two
213 + * people sending at once can have their inserts ordered one way and their
214 + * broadcasts the other, so our own echo can arrive with an id below a message
215 + * we have already displayed. Checking the cursor first would call that a
216 + * duplicate and leave the sender's own message greyed out permanently, which
217 + * is the one failure the whole nonce mechanism exists to prevent.
218 + */
219 + receive(message: { id: number; nonce?: string }): Outcome {
220 + const nonce = message.nonce;
221 + if (nonce !== undefined && this.#pending.delete(nonce)) {
222 + this.#advance(message.id);
223 + return { kind: "reconcile", nonce };
224 + }
225 + if (message.id <= this.#cursor) return { kind: "duplicate" };
226 + this.#advance(message.id);
227 + return { kind: "append" };
228 + }
229 +
230 + #advance(id: number): void {
231 + if (id > this.#cursor) this.#cursor = id;
232 + }
233 + }
234 +
235 + /**
236 + * A cursor that is not a usable message id reads as "start from the beginning".
237 + *
238 + * The value arrives as a data attribute, so it is a string the server wrote and
239 + * a string anything else could have written. `Number("")` is 0 and
240 + * `Number("x")` is NaN, and NaN in the query string would make the server's
241 + * `after` parse fail on every reconnect.
242 + */
243 + function sanitizeCursor(cursor: number): number {
244 + return Number.isFinite(cursor) && cursor > 0 ? Math.trunc(cursor) : 0;
245 + }
246 +
247 + /**
248 + * Where to open the stream.
249 + *
250 + * `after` is omitted rather than sent as 0 for a first connection: the server
251 + * distinguishes absent (send the recent window) from present (replay strictly
252 + * after this id), and 0 would ask it to replay the whole retention window a
253 + * page at a time.
254 + */
255 + export function streamUrl(slug: string, cursor: number): string {
256 + const base = `/p/${encodeURIComponent(slug)}/chat/stream`;
257 + const after = sanitizeCursor(cursor);
258 + return after > 0 ? `${base}?after=${after}` : base;
259 + }