// chat.js: the panel half of the suite's first chat (spec §6, §10). // // One file, no library, no framework, and — deliberately — no knowledge // of Calls. It does not read a sqid, does not touch #room, does not know // room.js exists, and never talks to a server: the host hands it a // `send` function and feeds it arriving messages. A second app adopts // this file, templates/partials/chat.html and static/chat.css unchanged // and writes only its own send function. // // const chat = window.Chat.mount(document.querySelector('[data-chat]'), { // name: 'Ann', // whose the local echo is // send: (text) => Promise, // true when it was accepted // }); // chat.append({ from, name, text, at, self }); // chat.setEnabled(false); // no send while not admitted // chat.clear(); // // House rules, the same ones room.js is written under: no inline script // (the page's CSP is `default-src 'self'`), nothing built with // innerHTML, and every string that came from another person written // with textContent. Chat text arrives from a peer through a server that // reads nothing inside it (§4.4), so this file is the last thing // standing between that text and the DOM: it sets textContent and it // does not linkify (§6, version one). // // Nothing here is stored. There is no localStorage, no history and no // replay: the log starts empty and goes with the tab (§6). (() => { 'use strict'; // §4.4's limit, in bytes of the text as the wire carries it — not // characters. The control's maxlength caps characters, which is the // courtesy the browser can offer for free; this is the one the server // actually measures, and for anything outside ASCII, and for anything // JSON has to escape, the two disagree. const MAX_BYTES = 2048; // The log is a window, not a record. Five hundred lines is far past // anything a call produces and small enough that a wedged sender // cannot grow the document without bound. const MAX_LINES = 500; // How close to the bottom still counts as "reading the newest line". // Anyone scrolled further up than this is reading something older and // must not be yanked away from it by an arrival. const PINNED_PX = 24; const SEND_FAILED = 'Could not send. Try again.'; const TOO_LONG = 'Message is too long.'; // chatBytes is the byte counter: the length of the text as the wire // will carry it — its JSON-escaped UTF-8 form, without the quotes // around it — which is what §4.4's 2 KB counts and what the server // refuses above. Lifted by name in test/browser/chat.test.mjs. // // The escaped form, not the raw one: the server measures the canonical // JSON it re-encodes and fans out, where a quote, a backslash or a // newline is two bytes rather than one. Counting raw UTF-8 here let a // draft the panel called 2048 bytes come back 413 from a rule the // panel had no way to apply (whole-branch review, W4-1). Angle // brackets and ampersands are not escaped on either side — SSE data is // not HTML — so they cost one byte each here too. // U+2028 and U+2029 are the one place the two languages disagree // about what "the JSON escaping of this string" is: Go's encoder // escapes them unconditionally — SetEscapeHTML(false) included — // and JavaScript leaves them literal. Three bytes here, six there, // and a draft of 2045 a's and one U+2028 counted 2048 in the composer // and 2051 at the server, so the panel accepted a message that came // back 413 with nothing to say about it (Codex S4-7). The counter // measures what the server will measure, so it escapes them too. // // Written as escapes rather than as themselves: both characters are // line terminators in JavaScript source, and a literal one inside a // regular expression is a syntax error. const GO_ESCAPES = /[\u2028\u2029]/g; function chatBytes(text) { const s = JSON.stringify(String(text == null ? '' : text)) .replace(GO_ESCAPES, (c) => (c === '\u2028' ? '\\u2028' : '\\u2029')); return new TextEncoder().encode(s).length - 2; } // chatLine builds one line of the log and nothing else: no document // lookups, no state, the document passed in so it can be driven // without a browser. Every string goes in through textContent, so an // angle bracket in a message is an angle bracket on the screen and // never markup. Lifted by name in test/browser/chat.test.mjs. // // The timestamp is local and the machine-readable instant goes in the // datetime attribute, which is what a reader's own tooling wants; an // `at` this file cannot make sense of becomes "now" rather than an // Invalid Date on the screen. function chatLine(doc, entry) { const e = entry || {}; const li = doc.createElement('li'); li.className = e.self ? 'chat-line chat-line-self' : 'chat-line'; const name = doc.createElement('span'); name.className = 'chat-name'; name.textContent = e.name == null ? '' : String(e.name); let at = e.at instanceof Date ? e.at : (e.at == null ? new Date() : new Date(e.at)); if (isNaN(at.getTime())) at = new Date(); const time = doc.createElement('time'); time.className = 'chat-time'; time.setAttribute('datetime', at.toISOString()); // The clock is the short form a call wants; the whole date and time // sits in title for anyone who needs to know which day this was — // AGENTS.md's dates convention, and the reason the visible text can // stay four digits wide. time.setAttribute('title', at.toLocaleString()); const hh = String(at.getHours()).padStart(2, '0'); const mm = String(at.getMinutes()).padStart(2, '0'); time.textContent = hh + ':' + mm; const text = doc.createElement('span'); text.className = 'chat-text'; text.textContent = e.text == null ? '' : String(e.text); li.appendChild(name); li.appendChild(doc.createTextNode(' ')); li.appendChild(time); li.appendChild(doc.createTextNode(' ')); li.appendChild(text); return li; } // trimLog drops the oldest lines until the log is back inside max. // Oldest first, because the newest line is the one being read. // Lifted by name in test/browser/chat.test.mjs. function trimLog(log, max) { while (log.childElementCount > max) { log.removeChild(log.firstElementChild); } } // mount wires one panel and hands back the three things a host needs. // A root that is not there gets a working no-op back rather than a // thrown error: a host that renders the panel conditionally should // not have to guard every call site. function mount(root, opts) { const options = opts || {}; if (!root) { return { append() {}, setEnabled() {}, clear() {} }; } const doc = root.ownerDocument || document; const log = root.querySelector('[data-chat-log]'); const form = root.querySelector('[data-chat-form]'); const input = root.querySelector('[data-chat-text]'); const button = root.querySelector('[data-chat-send]'); const status = root.querySelector('[data-chat-status]'); if (!log || !form || !input) { return { append() {}, setEnabled() {}, clear() {} }; } // Enabled is the host's word (admitted, still connected); tooLong // is this panel's own. The button is off when either says so, and // the two are tracked apart so re-enabling after a lost connection // does not re-enable a button over an over-long draft. let enabled = true; let sending = false; // The draft's own revision, bumped by every edit. The box stays // editable while a message is on its way — a chat you cannot type // in for the length of a round trip is a chat that feels broken — // which makes the acceptance path a race: `input.value = ''` clears // whatever is in the box *then*, and that is not necessarily the // text that was sent. A send captures the revision it left on, and // clears the box only if nothing has been typed since (Codex S4-3). // Clearing it here is not an edit and does not bump this. let revision = 0; // What the status line is currently saying, tracked here rather // than read back off the element: the element is the host's to // restyle or replace, and a panel that decides what to clear by // string-matching its own DOM is one stray space from never // clearing anything again. let notice = ''; function say(sentence) { notice = sentence; if (status) status.textContent = sentence; } function tooLong() { return chatBytes(input.value) > MAX_BYTES; } function refresh() { const over = tooLong(); input.disabled = !enabled; if (button) button.disabled = !enabled || sending || over; if (over) say(TOO_LONG); else if (notice === TOO_LONG) say(''); } // Editing the draft answers both sentences the panel can be saying: // what is in the box now is under the limit, or it is not the thing // that was refused. Either way the old sentence is about text that // no longer exists. function onInput() { revision += 1; if (notice === SEND_FAILED) say(''); refresh(); } function submit() { if (!enabled || sending) return; // An empty box is not an error, it is nothing to send: both paths // answer it in silence. See the note about `required` in // partials/chat.html — the control deliberately does not carry it, // so a click on Send and a press of Enter behave identically here // instead of one of them raising a browser validation bubble. const text = input.value.trim(); if (!text) return; if (chatBytes(text) > MAX_BYTES) { say(TOO_LONG); return; } // Clicking Send disables the button under the pointer, which // drops focus to the body; anyone who was inside the panel when // they sent gets the caret back in the box afterwards. const inPanel = root.contains(doc.activeElement); // The revision this message leaves on. Anything typed while it is // in flight moves it, and then the box is not this send's to // empty — neither on acceptance nor on refusal, which never // wrote to the box anyway and must not start. const sentAt = revision; sending = true; refresh(); // The host's send may return a promise, a plain boolean, or throw; // all three end in the same two outcomes. A refusal keeps the // draft in the box — the person can press Send again — and only // an accepted message is echoed locally, because a message the // server never took is one nobody else will ever see. // // `ok !== true` and not `!ok` on purpose: the obvious wrong // implementation of send is `fetch(...)`, whose Response is // truthy whatever the status, and a truthy answer here would echo // a message the server refused. Only the documented `true` counts. Promise.resolve() .then(() => (options.send ? options.send(text) : false)) .then((ok) => { if (ok !== true) { say(SEND_FAILED); return; } say(''); if (revision === sentAt) input.value = ''; api.append({ name: options.name, text: text, self: true }); }) .catch(() => say(SEND_FAILED)) .then(() => { sending = false; refresh(); if (inPanel && enabled) input.focus(); }); } const api = { // append renders one arrived (or locally echoed) message. It // keeps the newest line in view for a reader who is at the // bottom, and leaves the scroll alone for one who is not. append(entry) { const pinned = log.scrollHeight - log.scrollTop - log.clientHeight <= PINNED_PX; log.appendChild(chatLine(doc, entry)); const before = log.scrollHeight; trimLog(log, MAX_LINES); if (pinned) { log.scrollTop = log.scrollHeight; return; } // Not pinned: someone is reading something older, and the trim // just cut that many pixels off the top of the document under // them. Leaving scrollTop alone would slide the text they are // reading up and out of the box on every arrival once the log // is at its cap. Subtracting what was removed keeps the same // lines under the same eyes. const removed = before - log.scrollHeight; if (removed > 0) log.scrollTop = Math.max(0, log.scrollTop - removed); }, // setEnabled is the host's gate: a participant who is not // admitted, or whose stream has gone, can read what is there and // cannot send. setEnabled(value) { enabled = !!value; refresh(); }, clear() { while (log.firstChild) log.removeChild(log.firstChild); }, }; form.addEventListener('submit', (ev) => { ev.preventDefault(); submit(); }); // Enter sends and Shift+Enter is a newline, which is what a chat // box is expected to do — but never mid-composition, where Enter // is how an IME commits the characters being typed. input.addEventListener('keydown', (ev) => { if (ev.key !== 'Enter' || ev.shiftKey || ev.isComposing || ev.keyCode === 229) return; ev.preventDefault(); submit(); }); input.addEventListener('input', onInput); refresh(); return api; } window.Chat = { mount: mount }; })();