max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | + | } |