Skip to main content

max / makenotwork

17.9 KB · 525 lines History Blame Raw
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 {
501 this.#status.textContent = message;
502 this.#status.hidden = false;
503 }
504
505 #clearStatus(): void {
506 this.#status.textContent = "";
507 this.#status.hidden = true;
508 }
509
510 /** Backoff exhausted: stop retrying into the void and hand it to the user. */
511 #offerReconnect(): void {
512 this.#clearStatus();
513 this.#status.textContent = "Disconnected. ";
514 const button = document.createElement("button");
515 button.type = "button";
516 button.className = "chat-retry";
517 button.textContent = "Reconnect";
518 button.addEventListener("click", () => this.#reconnectNow());
519 this.#status.append(button);
520 this.#status.hidden = false;
521 }
522 }
523
524 define("mt-chat-room", ChatRoom);
525