| 1 | // chat.js: the panel half of the suite's first chat (spec §6, §10). |
| 2 | // |
| 3 | // One file, no library, no framework, and — deliberately — no knowledge |
| 4 | // of Calls. It does not read a sqid, does not touch #room, does not know |
| 5 | // room.js exists, and never talks to a server: the host hands it a |
| 6 | // `send` function and feeds it arriving messages. A second app adopts |
| 7 | // this file, templates/partials/chat.html and static/chat.css unchanged |
| 8 | // and writes only its own send function. |
| 9 | // |
| 10 | // const chat = window.Chat.mount(document.querySelector('[data-chat]'), { |
| 11 | // name: 'Ann', // whose the local echo is |
| 12 | // send: (text) => Promise<boolean>, // true when it was accepted |
| 13 | // }); |
| 14 | // chat.append({ from, name, text, at, self }); |
| 15 | // chat.setEnabled(false); // no send while not admitted |
| 16 | // chat.clear(); |
| 17 | // |
| 18 | // House rules, the same ones room.js is written under: no inline script |
| 19 | // (the page's CSP is `default-src 'self'`), nothing built with |
| 20 | // innerHTML, and every string that came from another person written |
| 21 | // with textContent. Chat text arrives from a peer through a server that |
| 22 | // reads nothing inside it (§4.4), so this file is the last thing |
| 23 | // standing between that text and the DOM: it sets textContent and it |
| 24 | // does not linkify (§6, version one). |
| 25 | // |
| 26 | // Nothing here is stored. There is no localStorage, no history and no |
| 27 | // replay: the log starts empty and goes with the tab (§6). |
| 28 | (() => { |
| 29 | 'use strict'; |
| 30 | |
| 31 | // §4.4's limit, in bytes of the text as the wire carries it — not |
| 32 | // characters. The control's maxlength caps characters, which is the |
| 33 | // courtesy the browser can offer for free; this is the one the server |
| 34 | // actually measures, and for anything outside ASCII, and for anything |
| 35 | // JSON has to escape, the two disagree. |
| 36 | const MAX_BYTES = 2048; |
| 37 | |
| 38 | // The log is a window, not a record. Five hundred lines is far past |
| 39 | // anything a call produces and small enough that a wedged sender |
| 40 | // cannot grow the document without bound. |
| 41 | const MAX_LINES = 500; |
| 42 | |
| 43 | // How close to the bottom still counts as "reading the newest line". |
| 44 | // Anyone scrolled further up than this is reading something older and |
| 45 | // must not be yanked away from it by an arrival. |
| 46 | const PINNED_PX = 24; |
| 47 | |
| 48 | const SEND_FAILED = 'Could not send. Try again.'; |
| 49 | const TOO_LONG = 'Message is too long.'; |
| 50 | |
| 51 | // chatBytes is the byte counter: the length of the text as the wire |
| 52 | // will carry it — its JSON-escaped UTF-8 form, without the quotes |
| 53 | // around it — which is what §4.4's 2 KB counts and what the server |
| 54 | // refuses above. Lifted by name in test/browser/chat.test.mjs. |
| 55 | // |
| 56 | // The escaped form, not the raw one: the server measures the canonical |
| 57 | // JSON it re-encodes and fans out, where a quote, a backslash or a |
| 58 | // newline is two bytes rather than one. Counting raw UTF-8 here let a |
| 59 | // draft the panel called 2048 bytes come back 413 from a rule the |
| 60 | // panel had no way to apply (whole-branch review, W4-1). Angle |
| 61 | // brackets and ampersands are not escaped on either side — SSE data is |
| 62 | // not HTML — so they cost one byte each here too. |
| 63 | // U+2028 and U+2029 are the one place the two languages disagree |
| 64 | // about what "the JSON escaping of this string" is: Go's encoder |
| 65 | // escapes them unconditionally — SetEscapeHTML(false) included — |
| 66 | // and JavaScript leaves them literal. Three bytes here, six there, |
| 67 | // and a draft of 2045 a's and one U+2028 counted 2048 in the composer |
| 68 | // and 2051 at the server, so the panel accepted a message that came |
| 69 | // back 413 with nothing to say about it (Codex S4-7). The counter |
| 70 | // measures what the server will measure, so it escapes them too. |
| 71 | // |
| 72 | // Written as escapes rather than as themselves: both characters are |
| 73 | // line terminators in JavaScript source, and a literal one inside a |
| 74 | // regular expression is a syntax error. |
| 75 | const GO_ESCAPES = /[\u2028\u2029]/g; |
| 76 | |
| 77 | function chatBytes(text) { |
| 78 | const s = JSON.stringify(String(text == null ? '' : text)) |
| 79 | .replace(GO_ESCAPES, (c) => (c === '\u2028' ? '\\u2028' : '\\u2029')); |
| 80 | return new TextEncoder().encode(s).length - 2; |
| 81 | } |
| 82 | |
| 83 | // chatLine builds one line of the log and nothing else: no document |
| 84 | // lookups, no state, the document passed in so it can be driven |
| 85 | // without a browser. Every string goes in through textContent, so an |
| 86 | // angle bracket in a message is an angle bracket on the screen and |
| 87 | // never markup. Lifted by name in test/browser/chat.test.mjs. |
| 88 | // |
| 89 | // The timestamp is local and the machine-readable instant goes in the |
| 90 | // datetime attribute, which is what a reader's own tooling wants; an |
| 91 | // `at` this file cannot make sense of becomes "now" rather than an |
| 92 | // Invalid Date on the screen. |
| 93 | function chatLine(doc, entry) { |
| 94 | const e = entry || {}; |
| 95 | const li = doc.createElement('li'); |
| 96 | li.className = e.self ? 'chat-line chat-line-self' : 'chat-line'; |
| 97 | |
| 98 | const name = doc.createElement('span'); |
| 99 | name.className = 'chat-name'; |
| 100 | name.textContent = e.name == null ? '' : String(e.name); |
| 101 | |
| 102 | let at = e.at instanceof Date ? e.at : (e.at == null ? new Date() : new Date(e.at)); |
| 103 | if (isNaN(at.getTime())) at = new Date(); |
| 104 | const time = doc.createElement('time'); |
| 105 | time.className = 'chat-time'; |
| 106 | time.setAttribute('datetime', at.toISOString()); |
| 107 | // The clock is the short form a call wants; the whole date and time |
| 108 | // sits in title for anyone who needs to know which day this was — |
| 109 | // AGENTS.md's dates convention, and the reason the visible text can |
| 110 | // stay four digits wide. |
| 111 | time.setAttribute('title', at.toLocaleString()); |
| 112 | const hh = String(at.getHours()).padStart(2, '0'); |
| 113 | const mm = String(at.getMinutes()).padStart(2, '0'); |
| 114 | time.textContent = hh + ':' + mm; |
| 115 | |
| 116 | const text = doc.createElement('span'); |
| 117 | text.className = 'chat-text'; |
| 118 | text.textContent = e.text == null ? '' : String(e.text); |
| 119 | |
| 120 | li.appendChild(name); |
| 121 | li.appendChild(doc.createTextNode(' ')); |
| 122 | li.appendChild(time); |
| 123 | li.appendChild(doc.createTextNode(' ')); |
| 124 | li.appendChild(text); |
| 125 | return li; |
| 126 | } |
| 127 | |
| 128 | // trimLog drops the oldest lines until the log is back inside max. |
| 129 | // Oldest first, because the newest line is the one being read. |
| 130 | // Lifted by name in test/browser/chat.test.mjs. |
| 131 | function trimLog(log, max) { |
| 132 | while (log.childElementCount > max) { |
| 133 | log.removeChild(log.firstElementChild); |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // mount wires one panel and hands back the three things a host needs. |
| 138 | // A root that is not there gets a working no-op back rather than a |
| 139 | // thrown error: a host that renders the panel conditionally should |
| 140 | // not have to guard every call site. |
| 141 | function mount(root, opts) { |
| 142 | const options = opts || {}; |
| 143 | if (!root) { |
| 144 | return { append() {}, setEnabled() {}, clear() {} }; |
| 145 | } |
| 146 | |
| 147 | const doc = root.ownerDocument || document; |
| 148 | const log = root.querySelector('[data-chat-log]'); |
| 149 | const form = root.querySelector('[data-chat-form]'); |
| 150 | const input = root.querySelector('[data-chat-text]'); |
| 151 | const button = root.querySelector('[data-chat-send]'); |
| 152 | const status = root.querySelector('[data-chat-status]'); |
| 153 | if (!log || !form || !input) { |
| 154 | return { append() {}, setEnabled() {}, clear() {} }; |
| 155 | } |
| 156 | |
| 157 | // Enabled is the host's word (admitted, still connected); tooLong |
| 158 | // is this panel's own. The button is off when either says so, and |
| 159 | // the two are tracked apart so re-enabling after a lost connection |
| 160 | // does not re-enable a button over an over-long draft. |
| 161 | let enabled = true; |
| 162 | let sending = false; |
| 163 | // The draft's own revision, bumped by every edit. The box stays |
| 164 | // editable while a message is on its way — a chat you cannot type |
| 165 | // in for the length of a round trip is a chat that feels broken — |
| 166 | // which makes the acceptance path a race: `input.value = ''` clears |
| 167 | // whatever is in the box *then*, and that is not necessarily the |
| 168 | // text that was sent. A send captures the revision it left on, and |
| 169 | // clears the box only if nothing has been typed since (Codex S4-3). |
| 170 | // Clearing it here is not an edit and does not bump this. |
| 171 | let revision = 0; |
| 172 | // What the status line is currently saying, tracked here rather |
| 173 | // than read back off the element: the element is the host's to |
| 174 | // restyle or replace, and a panel that decides what to clear by |
| 175 | // string-matching its own DOM is one stray space from never |
| 176 | // clearing anything again. |
| 177 | let notice = ''; |
| 178 | |
| 179 | function say(sentence) { |
| 180 | notice = sentence; |
| 181 | if (status) status.textContent = sentence; |
| 182 | } |
| 183 | |
| 184 | function tooLong() { |
| 185 | return chatBytes(input.value) > MAX_BYTES; |
| 186 | } |
| 187 | |
| 188 | function refresh() { |
| 189 | const over = tooLong(); |
| 190 | input.disabled = !enabled; |
| 191 | if (button) button.disabled = !enabled || sending || over; |
| 192 | if (over) say(TOO_LONG); |
| 193 | else if (notice === TOO_LONG) say(''); |
| 194 | } |
| 195 | |
| 196 | // Editing the draft answers both sentences the panel can be saying: |
| 197 | // what is in the box now is under the limit, or it is not the thing |
| 198 | // that was refused. Either way the old sentence is about text that |
| 199 | // no longer exists. |
| 200 | function onInput() { |
| 201 | revision += 1; |
| 202 | if (notice === SEND_FAILED) say(''); |
| 203 | refresh(); |
| 204 | } |
| 205 | |
| 206 | function submit() { |
| 207 | if (!enabled || sending) return; |
| 208 | // An empty box is not an error, it is nothing to send: both paths |
| 209 | // answer it in silence. See the note about `required` in |
| 210 | // partials/chat.html — the control deliberately does not carry it, |
| 211 | // so a click on Send and a press of Enter behave identically here |
| 212 | // instead of one of them raising a browser validation bubble. |
| 213 | const text = input.value.trim(); |
| 214 | if (!text) return; |
| 215 | if (chatBytes(text) > MAX_BYTES) { |
| 216 | say(TOO_LONG); |
| 217 | return; |
| 218 | } |
| 219 | // Clicking Send disables the button under the pointer, which |
| 220 | // drops focus to the body; anyone who was inside the panel when |
| 221 | // they sent gets the caret back in the box afterwards. |
| 222 | const inPanel = root.contains(doc.activeElement); |
| 223 | // The revision this message leaves on. Anything typed while it is |
| 224 | // in flight moves it, and then the box is not this send's to |
| 225 | // empty — neither on acceptance nor on refusal, which never |
| 226 | // wrote to the box anyway and must not start. |
| 227 | const sentAt = revision; |
| 228 | sending = true; |
| 229 | refresh(); |
| 230 | // The host's send may return a promise, a plain boolean, or throw; |
| 231 | // all three end in the same two outcomes. A refusal keeps the |
| 232 | // draft in the box — the person can press Send again — and only |
| 233 | // an accepted message is echoed locally, because a message the |
| 234 | // server never took is one nobody else will ever see. |
| 235 | // |
| 236 | // `ok !== true` and not `!ok` on purpose: the obvious wrong |
| 237 | // implementation of send is `fetch(...)`, whose Response is |
| 238 | // truthy whatever the status, and a truthy answer here would echo |
| 239 | // a message the server refused. Only the documented `true` counts. |
| 240 | Promise.resolve() |
| 241 | .then(() => (options.send ? options.send(text) : false)) |
| 242 | .then((ok) => { |
| 243 | if (ok !== true) { |
| 244 | say(SEND_FAILED); |
| 245 | return; |
| 246 | } |
| 247 | say(''); |
| 248 | if (revision === sentAt) input.value = ''; |
| 249 | api.append({ name: options.name, text: text, self: true }); |
| 250 | }) |
| 251 | .catch(() => say(SEND_FAILED)) |
| 252 | .then(() => { |
| 253 | sending = false; |
| 254 | refresh(); |
| 255 | if (inPanel && enabled) input.focus(); |
| 256 | }); |
| 257 | } |
| 258 | |
| 259 | const api = { |
| 260 | // append renders one arrived (or locally echoed) message. It |
| 261 | // keeps the newest line in view for a reader who is at the |
| 262 | // bottom, and leaves the scroll alone for one who is not. |
| 263 | append(entry) { |
| 264 | const pinned = |
| 265 | log.scrollHeight - log.scrollTop - log.clientHeight <= PINNED_PX; |
| 266 | log.appendChild(chatLine(doc, entry)); |
| 267 | const before = log.scrollHeight; |
| 268 | trimLog(log, MAX_LINES); |
| 269 | if (pinned) { |
| 270 | log.scrollTop = log.scrollHeight; |
| 271 | return; |
| 272 | } |
| 273 | // Not pinned: someone is reading something older, and the trim |
| 274 | // just cut that many pixels off the top of the document under |
| 275 | // them. Leaving scrollTop alone would slide the text they are |
| 276 | // reading up and out of the box on every arrival once the log |
| 277 | // is at its cap. Subtracting what was removed keeps the same |
| 278 | // lines under the same eyes. |
| 279 | const removed = before - log.scrollHeight; |
| 280 | if (removed > 0) log.scrollTop = Math.max(0, log.scrollTop - removed); |
| 281 | }, |
| 282 | // setEnabled is the host's gate: a participant who is not |
| 283 | // admitted, or whose stream has gone, can read what is there and |
| 284 | // cannot send. |
| 285 | setEnabled(value) { |
| 286 | enabled = !!value; |
| 287 | refresh(); |
| 288 | }, |
| 289 | clear() { |
| 290 | while (log.firstChild) log.removeChild(log.firstChild); |
| 291 | }, |
| 292 | }; |
| 293 | |
| 294 | form.addEventListener('submit', (ev) => { |
| 295 | ev.preventDefault(); |
| 296 | submit(); |
| 297 | }); |
| 298 | |
| 299 | // Enter sends and Shift+Enter is a newline, which is what a chat |
| 300 | // box is expected to do — but never mid-composition, where Enter |
| 301 | // is how an IME commits the characters being typed. |
| 302 | input.addEventListener('keydown', (ev) => { |
| 303 | if (ev.key !== 'Enter' || ev.shiftKey || ev.isComposing || ev.keyCode === 229) return; |
| 304 | ev.preventDefault(); |
| 305 | submit(); |
| 306 | }); |
| 307 | |
| 308 | input.addEventListener('input', onInput); |
| 309 | refresh(); |
| 310 | return api; |
| 311 | } |
| 312 | |
| 313 | window.Chat = { mount: mount }; |
| 314 | })(); |
| 315 | |