oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

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