Skip to main content

max / makenotwork

19.1 KB · 553 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 mentionsViewer,
25 newNonce,
26 parseFrame,
27 streamUrl,
28 type IncomingMessage,
29 } from "./chat/chat.logic.ts";
30 import { ReconnectPolicy } from "./chat/reconnect.logic.ts";
31
32 /** The five frames the server sends. Dispatch reads the tag inside the payload. */
33 const FRAME_EVENTS = ["message", "delete", "purge", "wipe", "gap"] as const;
34
35 /**
36 * How close to the bottom counts as "following the room", in pixels.
37 *
38 * Someone reading back through the log must not be yanked to the bottom every
39 * time a message lands, and someone at the bottom must not have to chase it.
40 * The tolerance covers the fractional scroll heights a zoomed page produces.
41 */
42 const FOLLOW_THRESHOLD_PX = 40;
43
44 class ChatRoom extends MtElement {
45 #slug = "";
46 #viewer: string | null = null;
47 #viewerName: string | null = null;
48 #moderator = false;
49 #maxLength = MAX_MESSAGE_LEN;
50
51 #log!: HTMLOListElement;
52 #status!: HTMLParagraphElement;
53 #empty: HTMLElement | null = null;
54
55 #ledger!: ChatLedger;
56 readonly #policy = new ReconnectPolicy();
57 #source: EventSource | null = null;
58 #retry: ReturnType<typeof setTimeout> | null = null;
59
60 /**
61 * Cancels the listeners this element put on `window` and `document`.
62 *
63 * Those two outlive the element, so without this a room htmx swapped away
64 * would still be woken by a tab becoming visible, and would open a stream
65 * nothing is reading. That connection holds a slot against the per-user cap
66 * until the tab closes, so the room the user actually moved to is the one
67 * that gets refused.
68 */
69 readonly #listeners = new AbortController();
70
71 protected init(): void {
72 const log = this.querySelector<HTMLOListElement>("#chat-log");
73 if (!log) return; // not the chat page's markup; nothing to enhance
74 this.#log = log;
75
76 this.#slug = this.dataset["slug"] ?? "";
77 this.#viewer = this.dataset["viewer"] ?? null;
78 this.#viewerName = this.dataset["viewerName"] ?? null;
79 this.#moderator = this.dataset["moderator"] === "true";
80 this.#maxLength = Number(this.dataset["maxLength"]) || MAX_MESSAGE_LEN;
81 this.#ledger = new ChatLedger(Number(this.dataset["cursor"]));
82 this.#empty = this.querySelector<HTMLElement>("#chat-empty");
83
84 // Built here rather than in the template: with the script blocked there is
85 // no connection to have an opinion about, so an empty status line would be
86 // markup that only ever says nothing.
87 this.#status = document.createElement("p");
88 this.#status.className = "chat-status";
89 this.#status.setAttribute("role", "status");
90 this.#status.hidden = true;
91 this.#log.after(this.#status);
92
93 for (const message of this.#log.querySelectorAll<HTMLLIElement>(".chat-message")) {
94 this.#decorate(message);
95 }
96
97 this.#composer()?.addEventListener("submit", (event) => {
98 event.preventDefault();
99 void this.#send();
100 });
101
102 // Both events say something about this client, not about the server, so
103 // honoring an accumulated backoff after either would leave the room looking
104 // broken for up to a minute for no reason.
105 const signal = this.#listeners.signal;
106 addEventListener("online", () => this.#reconnectNow(), { signal });
107 document.addEventListener(
108 "visibilitychange",
109 () => {
110 if (!document.hidden) this.#reconnectNow();
111 },
112 { signal },
113 );
114
115 this.#connect();
116 this.#scrollToEnd();
117 }
118
119 /**
120 * htmx swaps the page body, and a swapped-away element keeps its connection
121 * open unless something closes it. A leaked SSE connection holds a slot
122 * against the per-user cap, so the room refuses to open in the new tab the
123 * user just moved to.
124 */
125 disconnectedCallback(): void {
126 this.#close();
127 this.#listeners.abort();
128 if (this.#retry !== null) clearTimeout(this.#retry);
129 }
130
131 // The stream
132
133 #connect(): void {
134 this.#close();
135 const source = new EventSource(streamUrl(this.#slug, this.#ledger.cursor));
136 this.#source = source;
137
138 source.addEventListener("open", () => {
139 this.#policy.succeed();
140 this.#clearStatus();
141 });
142
143 for (const name of FRAME_EVENTS) {
144 source.addEventListener(name, (event) => this.#frame(event as MessageEvent<string>));
145 }
146
147 // EventSource reconnects on its own schedule, which is a fixed few seconds
148 // with no jitter and no ceiling on attempts. Every open room drops at the
149 // same instant on a deploy, so that default is the thundering herd the
150 // backoff exists to prevent. Closing here takes the retry back.
151 source.addEventListener("error", () => this.#dropped());
152 }
153
154 #close(): void {
155 this.#source?.close();
156 this.#source = null;
157 }
158
159 #dropped(): void {
160 this.#close();
161 const action = this.#policy.fail();
162 if (action.kind === "give-up") {
163 this.#offerReconnect();
164 return;
165 }
166 this.#say("Reconnecting...");
167 this.#retry = setTimeout(() => this.#connect(), action.delayMs);
168 }
169
170 /** Drop the accumulated wait and try again immediately. */
171 #reconnectNow(): void {
172 if (this.#source !== null) return;
173 if (this.#retry !== null) clearTimeout(this.#retry);
174 this.#policy.resetForImmediateRetry();
175 this.#connect();
176 }
177
178 #frame(event: MessageEvent<string>): void {
179 const frame = parseFrame(event.data);
180 if (frame === null) return; // one malformed frame must not kill the stream
181
182 switch (frame.type) {
183 case "message":
184 this.#received(frame);
185 break;
186 case "delete":
187 this.#remove(`[data-id="${CSS.escape(String(frame.id))}"]`);
188 break;
189 case "purge":
190 this.#remove(`[data-author="${CSS.escape(frame.author_id)}"]`);
191 break;
192 case "wipe":
193 this.#wipe();
194 break;
195 case "gap":
196 // The connection fell behind and the hub dropped frames for it.
197 // Reopening at the cursor replays exactly the hole.
198 this.#connect();
199 break;
200 }
201 }
202
203 #received(message: IncomingMessage): void {
204 const outcome = this.#ledger.receive(message);
205 if (outcome.kind === "duplicate") return;
206
207 const following = this.#atEnd();
208
209 if (outcome.kind === "reconcile") {
210 const pending = this.#pending(outcome.nonce);
211 if (pending) {
212 pending.replaceWith(this.#render(message));
213 if (following) this.#scrollToEnd();
214 return;
215 }
216 // The placeholder is gone (the tab was swapped, the user dismissed a
217 // failure). The message is still real, so show it.
218 }
219
220 this.#append(this.#render(message));
221 if (following) this.#scrollToEnd();
222 }
223
224 // Sending
225
226 async #send(): Promise<void> {
227 const input = this.querySelector<HTMLInputElement>("#chat-input");
228 if (!input) return;
229
230 const check = checkBody(input.value);
231 if (check.kind === "empty") return;
232 if (check.kind === "too-long") {
233 this.#say(`Too long by ${check.length - this.#maxLength} characters.`);
234 return;
235 }
236
237 const nonce = newNonce();
238 const placeholder = this.#renderPending(check.body, nonce);
239 this.#ledger.track(nonce);
240 this.#append(placeholder);
241 this.#scrollToEnd();
242 input.value = "";
243 this.#clearStatus();
244
245 const outcome = await this.#post(check.body, nonce);
246 if (outcome.ok) {
247 // The echo does the un-greying, because it carries the rendered body and
248 // the author as the room should show them. All this needs to do is make
249 // the message addressable in case a delete frame arrives first.
250 if (placeholder.isConnected) placeholder.dataset["id"] = String(outcome.id);
251 return;
252 }
253
254 this.#ledger.untrack(nonce);
255 this.#fail(placeholder, check.body, outcome.message);
256 }
257
258 async #post(
259 body: string,
260 nonce: string,
261 ): Promise<{ ok: true; id: number } | { ok: false; message: string }> {
262 const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content;
263 try {
264 const response = await fetch(`/p/${encodeURIComponent(this.#slug)}/chat/send`, {
265 method: "POST",
266 headers: {
267 "Content-Type": "application/x-www-form-urlencoded",
268 ...(token ? { "X-CSRF-Token": token } : {}),
269 },
270 body: new URLSearchParams({ body, nonce }).toString(),
271 });
272
273 if (!response.ok) {
274 // Every refusal the send path can produce comes back as plain text
275 // written for the sender: rate limits, mutes, bans, a room that just
276 // went read-only. Showing it beats a generic failure.
277 const message = (await response.text().catch(() => "")).trim();
278 return { ok: false, message: message || "Message not sent." };
279 }
280
281 const sent = (await response.json()) as { id: number };
282 return { ok: true, id: sent.id };
283 } catch {
284 return { ok: false, message: "Message not sent. Check your connection." };
285 }
286 }
287
288 /** Mark a placeholder as failed, with the text preserved and one way back. */
289 #fail(placeholder: HTMLLIElement, body: string, message: string): void {
290 placeholder.classList.remove("is-pending");
291 placeholder.classList.add("is-failed");
292 delete placeholder.dataset["nonce"];
293
294 const retry = document.createElement("button");
295 retry.type = "button";
296 retry.className = "chat-retry";
297 retry.textContent = "Retry";
298 retry.addEventListener("click", () => {
299 const input = this.querySelector<HTMLInputElement>("#chat-input");
300 if (input) {
301 // Back into the composer rather than resent behind the user's back: by
302 // the time a send fails the reason is usually something they need to
303 // see, and a silent retry loop against a rate limit is how a client
304 // earns a ban.
305 input.value = body;
306 input.focus();
307 }
308 placeholder.remove();
309 });
310
311 const note = document.createElement("span");
312 note.className = "chat-error";
313 note.textContent = message;
314 placeholder.append(note, retry);
315 }
316
317 // Rendering
318 //
319 // The markup below is the same shape templates/pages/chat.html renders, so
320 // one set of CSS rules covers both and a message looks identical before and
321 // after a reload.
322
323 #render(message: IncomingMessage): HTMLLIElement {
324 const li = document.createElement("li");
325 li.className = "chat-message";
326 li.dataset["id"] = String(message.id);
327 li.dataset["author"] = message.author_id;
328
329 const avatar = message.author?.avatar_url;
330 if (avatar !== undefined) {
331 const img = document.createElement("img");
332 img.className = "chat-avatar";
333 img.src = avatar;
334 img.alt = "";
335 img.width = 24;
336 img.height = 24;
337 li.append(img);
338 }
339
340 const author = document.createElement("span");
341 author.className = "chat-author";
342 author.textContent = message.author?.display_name ?? "Unknown";
343
344 const body = document.createElement("span");
345 body.className = "chat-body";
346 // Rendered and sanitized server-side by docengine's chat preset, then
347 // carried verbatim through the transport. The client never renders markdown
348 // and never sanitizes: a second sanitizer would be a second policy to keep
349 // in step with the first.
350 body.innerHTML = message.body_html;
351
352 li.append(author, this.#time(message.created_at), body);
353 this.#decorate(li);
354 return li;
355 }
356
357 /** The sender's own message, on screen before the server has confirmed it. */
358 #renderPending(body: string, nonce: string): HTMLLIElement {
359 const li = document.createElement("li");
360 li.className = "chat-message is-pending";
361 li.dataset["nonce"] = nonce;
362 if (this.#viewer !== null) li.dataset["author"] = this.#viewer;
363
364 const author = document.createElement("span");
365 author.className = "chat-author";
366 author.textContent = this.#selfName();
367
368 const text = document.createElement("span");
369 text.className = "chat-body";
370 // The raw text the user typed, as text. What comes back from the server is
371 // the rendered version, and this is replaced by it wholesale.
372 text.textContent = body;
373
374 li.append(author, this.#time(Math.floor(Date.now() / 1000)), text);
375 return li;
376 }
377
378 /**
379 * Fill in a timestamp.
380 *
381 * The server writes unix seconds into `datetime`, which is not a value the
382 * attribute is allowed to hold, and leaves the text empty because only the
383 * client knows the reader's timezone. Both are settled here: the attribute
384 * becomes a real ISO instant and the text becomes a local clock time.
385 */
386 #time(seconds: number): HTMLTimeElement {
387 const time = document.createElement("time");
388 time.className = "chat-time";
389 this.#fillTime(time, seconds);
390 return time;
391 }
392
393 #fillTime(time: HTMLTimeElement, seconds: number): void {
394 const at = new Date(seconds * 1000);
395 time.dateTime = at.toISOString();
396 time.textContent = at.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
397 time.title = at.toLocaleString();
398 }
399
400 /**
401 * Add what the server could not: a readable timestamp, and a delete control
402 * for a message this viewer is allowed to remove.
403 *
404 * The button is an affordance, never the check. The handler re-derives
405 * authorship and moderator status server-side, so hiding it is a courtesy to
406 * the honest and nothing more.
407 */
408 #decorate(message: HTMLLIElement): void {
409 const time = message.querySelector<HTMLTimeElement>(".chat-time");
410 if (time) {
411 const seconds = Number(time.dateTime);
412 if (Number.isFinite(seconds) && seconds > 0) this.#fillTime(time, seconds);
413 }
414
415 // In-room highlight only, never a notification. Done here rather than at
416 // render time so it covers the server-rendered backlog as well: someone
417 // returning to the page should see they were named while they were away,
418 // and nothing else is going to tell them.
419 const body = message.querySelector<HTMLElement>(".chat-body");
420 if (body && mentionsViewer(body.innerHTML, this.#viewerName)) {
421 message.classList.add("is-mention");
422 }
423
424 const id = message.dataset["id"];
425 if (id === undefined) return;
426 if (!this.#moderator && message.dataset["author"] !== this.#viewer) return;
427 if (message.querySelector(".chat-remove")) return;
428
429 const remove = document.createElement("button");
430 remove.type = "button";
431 remove.className = "chat-remove";
432 remove.title = "Delete this message";
433 remove.setAttribute("aria-label", "Delete this message");
434 remove.textContent = "x";
435 remove.addEventListener("click", () => void this.#delete(id));
436 message.append(remove);
437 }
438
439 /**
440 * Ask the server to remove a message.
441 *
442 * Nothing happens to the DOM on success: the removal comes back as a `delete`
443 * frame, which every reader in the room including this one acts on. Removing
444 * it locally as well would race that frame for no benefit, and would show the
445 * message gone in the one case where the server refused.
446 */
447 async #delete(id: string): Promise<void> {
448 const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content;
449 const body = new URLSearchParams(token ? { csrf_token: token } : {}).toString();
450 try {
451 const response = await fetch(
452 `/p/${encodeURIComponent(this.#slug)}/chat/messages/${encodeURIComponent(id)}/delete`,
453 {
454 method: "POST",
455 headers: {
456 "Content-Type": "application/x-www-form-urlencoded",
457 ...(token ? { "X-CSRF-Token": token } : {}),
458 },
459 body,
460 },
461 );
462 if (!response.ok) this.#say("That message could not be deleted.");
463 } catch {
464 this.#say("That message could not be deleted.");
465 }
466 }
467
468 // Small helpers over the log
469
470 #append(message: HTMLLIElement): void {
471 this.#log.append(message);
472 if (this.#empty) this.#empty.hidden = true;
473 }
474
475 #remove(selector: string): void {
476 for (const gone of this.#log.querySelectorAll(selector)) gone.remove();
477 if (this.#empty && this.#log.childElementCount === 0) this.#empty.hidden = false;
478 }
479
480 /**
481 * The owner emptied the room.
482 *
483 * Everything goes, including a message still waiting for its echo: the send
484 * either landed before the wipe and was deleted with the rest, or lands after
485 * it and arrives on the stream like any other message. Either way the ledger
486 * is left alone — the cursor is a high-water mark over ids the server has
487 * issued, and a wipe does not un-issue them.
488 */
489 #wipe(): void {
490 this.#log.replaceChildren();
491 if (this.#empty) this.#empty.hidden = false;
492 this.#say("The owner cleared the chat history.");
493 }
494
495 #pending(nonce: string): HTMLLIElement | null {
496 return this.#log.querySelector<HTMLLIElement>(`[data-nonce="${CSS.escape(nonce)}"]`);
497 }
498
499 #composer(): HTMLFormElement | null {
500 return this.querySelector<HTMLFormElement>("#chat-composer");
501 }
502
503 /**
504 * What to call the sender on their own optimistic message.
505 *
506 * Taken from the last message they sent in this room, falling back to "You".
507 * The page does not carry a display name for the viewer, and the placeholder
508 * is replaced by the server's own rendering within a round trip, so this is a
509 * label with a short life and no consequences.
510 */
511 #selfName(): string {
512 if (this.#viewer === null) return "You";
513 const mine = this.#log.querySelectorAll<HTMLLIElement>(
514 `.chat-message[data-author="${CSS.escape(this.#viewer)}"] .chat-author`,
515 );
516 return mine[mine.length - 1]?.textContent?.trim() || "You";
517 }
518
519 #atEnd(): boolean {
520 const { scrollTop, scrollHeight, clientHeight } = this.#log;
521 return scrollHeight - scrollTop - clientHeight <= FOLLOW_THRESHOLD_PX;
522 }
523
524 #scrollToEnd(): void {
525 this.#log.scrollTop = this.#log.scrollHeight;
526 }
527
528 #say(message: string): void {
529 this.#status.textContent = message;
530 this.#status.hidden = false;
531 }
532
533 #clearStatus(): void {
534 this.#status.textContent = "";
535 this.#status.hidden = true;
536 }
537
538 /** Backoff exhausted: stop retrying into the void and hand it to the user. */
539 #offerReconnect(): void {
540 this.#clearStatus();
541 this.#status.textContent = "Disconnected. ";
542 const button = document.createElement("button");
543 button.type = "button";
544 button.className = "chat-retry";
545 button.textContent = "Reconnect";
546 button.addEventListener("click", () => this.#reconnectNow());
547 this.#status.append(button);
548 this.#status.hidden = false;
549 }
550 }
551
552 define("mt-chat-room", ChatRoom);
553