eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// The "Media, links and docs" overlay (spec: docs/superpowers/specs/
2// 2026-08-05-media-links-docs-design.md): a full-pane surface over the open
3// thread with Media | Links | Docs tabs, fed by threadmedia.js over the local
4// store, completed by an auto-run resumable backfill (marks shared with
5// search — either feature finishing the walk finishes it for both).
6import { ThreadMediaIndex, bucketByMonth, filterEntries } from "/static/threadmedia.js";
7import { allMessages as storeAllMessages, getBackfillMark as storeBackfillMark, runBackfill as storeRunBackfill } from "/static/store.js";
8import { decrypt } from "/static/crypto.js";
9import { threadKeys, threadName } from "/static/shellstate.js";
10import { mutedPeople, blockedPeople } from "/static/moderation.js";
11import { formatBytes, relTime } from "/static/format.js";
12import { openLightbox } from "/static/lightbox.js";
13import { decodeBlurhash } from "/static/blurhash.js";
14import { jumpToMessage, fetchMessagePage } from "/static/searchui.js";
15
16// Shell behaviours injected at boot (Task 4): the blob loader (decrypt +
17// object-URL cache) and the unfurl fetcher — the same functions the chat
18// app's host bridge hands out.
19let deps = { loadBlob: async () => null, unfurl: async () => null };
20export function wireMediaView(d) {
21 deps = { ...deps, ...d };
22}
23
24export const mediaIdx = new ThreadMediaIndex(async (row, tid) => {
25 try {
26 return await decrypt(threadKeys[tid], row.blob);
27 } catch {
28 return null;
29 }
30});
31
32// Live edit/delete frames re-extract / drop (Task 4 calls this beside the
33// store's own applyEditRow/deleteMessageRow mirrors). Repaint an open overlay
34// on the same thread so the entry drops live (spec: "drops the entries
35// live"), not just on next open.
36export function mediaInvalidate(tid, sid) {
37 mediaIdx.invalidate(tid, sid);
38 if (openIs(tid)) open.repaint();
39}
40
41// A mute/block change can hide or reveal entries (hiddenUids). shell.js's
42// single onModerationChange sink already does its own repaint (see
43// moderation.js — onModerationChange(fn) REPLACES the sink, so mediaview.js
44// must not call it directly); shell.js calls this from inside that same
45// callback instead. Repaint if the overlay is open, on whichever thread.
46export function mediaModerationChanged() {
47 if (open) open.repaint();
48}
49
50const hiddenUids = () => new Set([...mutedPeople, ...blockedPeople]);
51
52// Tabs are DATA so a future Pinned tab is one array entry (spec § Non-goals).
53const TABS = [
54 { id: "media", label: "Media", render: renderMediaTab },
55 { id: "links", label: "Links", render: renderLinksTab },
56 { id: "docs", label: "Docs", render: renderDocsTab },
57];
58
59let open = null; // { tid, root, walk: AbortController|null, tab, query } — one overlay at a time
60
61// Unfurl titles the Links tab has already fetched, so search can match a
62// link by its title too (a title only exists once its row has scrolled into
63// view — search never triggers fetches of its own). Session-lifetime memo,
64// like the shell's own unfurl cache.
65const linkTitles = new Map();
66
67export async function openMediaView(tid) {
68 closeMediaView();
69 const host = document.getElementById("thread-view");
70 if (!host || !threadKeys[tid]) return;
71 const root = document.createElement("div");
72 root.className = "media-overlay";
73 root.setAttribute("role", "dialog");
74 root.setAttribute("aria-modal", "false"); // the chat stays beneath, inert to pointer only
75 root.setAttribute("aria-label", `Media, links and docs — ${threadName[tid] || "Chat"}`);
76 open = { tid, root, walk: null, tab: "media", query: "", returnFocus: document.activeElement };
77
78 const head = document.createElement("div");
79 head.className = "media-head";
80 const back = document.createElement("button");
81 back.className = "icon-btn media-back";
82 back.setAttribute("aria-label", "Back to chat");
83 back.textContent = "‹";
84 back.onclick = () => closeMediaView();
85 const title = document.createElement("div");
86 title.className = "media-title";
87 title.textContent = "Media, links and docs";
88 head.append(back, title);
89
90 const seg = document.createElement("div");
91 seg.className = "media-tabs";
92 seg.setAttribute("role", "tablist");
93 seg.setAttribute("aria-label", "Kind");
94 // Search within the open tab — sender, URL/title, filename (filterEntries).
95 // Filters as you type; the field survives repaints (it's built once, here).
96 const search = document.createElement("div");
97 search.className = "media-search";
98 const searchInput = document.createElement("input");
99 searchInput.type = "search";
100 searchInput.placeholder = "Search";
101 searchInput.setAttribute("aria-label", "Search media, links and docs");
102 searchInput.autocomplete = "off";
103 searchInput.oninput = () => {
104 open.query = searchInput.value;
105 paintBody();
106 };
107 search.appendChild(searchInput);
108 const body = document.createElement("div");
109 body.className = "media-body";
110 // ONE polite live region for the walk's narration; sits in layout (never a
111 // toast), holds the Stop control beside the text.
112 const progress = document.createElement("div");
113 progress.className = "media-progress hidden";
114 const progressText = document.createElement("span");
115 progressText.setAttribute("aria-live", "polite");
116 const stop = document.createElement("button");
117 stop.type = "button";
118 stop.className = "media-stop";
119 stop.textContent = "Stop";
120 stop.onclick = () => open && open.walk && open.walk.abort();
121 progress.append(progressText, stop);
122
123 root.append(head, seg, search, progress, body);
124 host.appendChild(root);
125 document.addEventListener("keydown", onOverlayKey);
126 back.focus(); // focus lands in the overlay; Escape/back returns it
127
128 const paintTabs = () => {
129 const counts = mediaIdx.counts(tid, { hiddenUids: hiddenUids() });
130 seg.replaceChildren();
131 for (const t of TABS) {
132 const b = document.createElement("button");
133 b.type = "button";
134 b.className = "media-tab" + (open.tab === t.id ? " on" : "");
135 b.setAttribute("role", "tab");
136 b.setAttribute("aria-selected", String(open.tab === t.id));
137 b.id = `media-tab-${t.id}`;
138 const n = counts[t.id];
139 b.textContent = n ? `${t.label} · ${n.toLocaleString()}` : t.label;
140 b.onclick = () => {
141 open.tab = t.id;
142 paintTabs();
143 paintBody();
144 };
145 seg.appendChild(b);
146 }
147 };
148 const paintBody = () => {
149 const t = TABS.find((x) => x.id === open.tab);
150 // Every full repaint rebuilds tiles from scratch (tab switch, live
151 // invalidation, each backfill page) — drop the old observer first so it
152 // doesn't keep watching nodes that are about to be detached (leak).
153 disconnectTileObserver();
154 body.replaceChildren();
155 body.setAttribute("role", "tabpanel");
156 body.setAttribute("aria-labelledby", `media-tab-${open.tab}`);
157 const all = mediaIdx.tab(tid, open.tab, { hiddenUids: hiddenUids() });
158 const entries = filterEntries(all, open.query, (u) => linkTitles.get(u));
159 if (!entries.length) {
160 const empty = document.createElement("div");
161 empty.className = "media-empty";
162 empty.textContent = all.length
163 ? "Nothing matches."
164 : { media: "No photos or videos shared yet.", links: "No links shared yet.", docs: "No files shared yet." }[open.tab];
165 body.appendChild(empty);
166 return;
167 }
168 t.render(body, entries, tid);
169 };
170 open.repaint = () => {
171 // The auto-walk repaints the body on every backfill page. Each repaint
172 // tears down and rebuilds the DOM, which would otherwise punt keyboard
173 // focus to <body> mid-walk — capture by a stable key and restore the
174 // equivalent element afterwards.
175 const focusKey = captureFocusKey(root);
176 paintTabs();
177 paintBody();
178 restoreFocus(root, focusKey);
179 };
180
181 // Instant: what the store already holds. Then the one-time walk (below).
182 await mediaIdx.ensureThread(tid, await storeAllMessages(tid));
183 if (!openIs(tid)) return;
184 open.repaint();
185 autoBackfill(tid, progress, progressText);
186}
187
188function openIs(tid) {
189 return open && open.tid === tid;
190}
191
192// A stable key for whatever's focused inside the overlay, so a repaint
193// (which tears down and rebuilds the DOM) can find the equivalent element
194// afterwards. Tabs/back/stop key off fixed classes/ids; grid/list items key
195// off the source message id (`data-sid`, set by each tab renderer) plus
196// whether the focused control was the item's primary control or its
197// sibling "Show in chat" jump button.
198function captureFocusKey(root) {
199 const active = document.activeElement;
200 if (!active || !root.contains(active)) return null;
201 if (active.id && active.id.startsWith("media-tab-")) return { type: "tab", id: active.id };
202 if (active.classList.contains("media-back")) return { type: "back" };
203 if (active.classList.contains("media-stop")) return { type: "stop" };
204 const itemEl = active.closest(".media-item[data-sid]");
205 if (itemEl) return { type: "item", sid: itemEl.dataset.sid, jump: active.classList.contains("media-jump") };
206 return null;
207}
208
209function restoreFocus(root, key) {
210 if (!key) return;
211 let target = null;
212 if (key.type === "tab") target = document.getElementById(key.id);
213 else if (key.type === "back") target = root.querySelector(".media-back");
214 else if (key.type === "stop") target = root.querySelector(".media-stop");
215 else if (key.type === "item") {
216 const itemEl = root.querySelector(`.media-item[data-sid="${CSS.escape(key.sid)}"]`);
217 if (itemEl) target = key.jump ? itemEl.querySelector(".media-jump") : itemEl.querySelector(".media-tile, .media-link-row, .media-doc-row");
218 }
219 if (target) {
220 target.focus();
221 return;
222 }
223 // The previously-focused element is gone (its entry dropped out — deleted
224 // or muted mid-walk). Only steal focus back if the browser actually lost
225 // it to <body>; if something else already has focus, leave it alone.
226 if (document.activeElement === document.body) root.querySelector(".media-back")?.focus();
227}
228
229export function closeMediaView() {
230 if (!open) return;
231 if (open.walk) open.walk.abort();
232 document.removeEventListener("keydown", onOverlayKey);
233 disconnectTileObserver();
234 const focus = open.returnFocus;
235 open.root.remove();
236 open = null;
237 if (focus && focus.isConnected) focus.focus();
238}
239
240function onOverlayKey(e) {
241 // A lightbox or message action sheet stacked on top owns Escape first —
242 // same guard chat.js uses (see its keydown handlers) — otherwise closing
243 // the top layer also closes the media overlay underneath it.
244 if (e.key === "Escape" && open && !document.querySelector(".lightbox, .msg-sheet")) {
245 e.preventDefault();
246 // Escape in a non-empty search clears the search first (the field's own
247 // convention); a second Escape closes the overlay.
248 const input = open.root.querySelector(".media-search input");
249 if (input && document.activeElement === input && input.value) {
250 input.value = "";
251 open.query = "";
252 open.repaint();
253 return;
254 }
255 closeMediaView();
256 }
257}
258
259// Auto-run the resumable per-thread walk so the tabs become COMPLETE, not
260// just cached (spec § Completeness). Quiet when already complete.
261async function autoBackfill(tid, progress, progressText) {
262 const mark = await storeBackfillMark(tid);
263 if (!openIs(tid) || (mark && mark.complete)) return;
264 const controller = new AbortController();
265 open.walk = controller;
266 progress.classList.remove("hidden");
267 progressText.textContent = "Loading earlier messages…";
268 try {
269 await storeRunBackfill(tid, (before) => fetchMessagePage(tid, before), {
270 signal: controller.signal,
271 onPage: async ({ oldestAt, rows }) => {
272 await mediaIdx.ensureThread(tid, rows || []);
273 if (!openIs(tid)) return;
274 const d = oldestAt ? new Date(oldestAt * 1000) : null;
275 progressText.textContent = d
276 ? `Loading earlier messages… back to ${d.toLocaleDateString([], { month: "long", year: "numeric" })}`
277 : "Loading earlier messages…";
278 open.repaint();
279 },
280 });
281 } finally {
282 if (openIs(tid)) {
283 open.walk = null;
284 progress.classList.add("hidden");
285 open.repaint();
286 }
287 }
288}
289
290// ---- the three tab renderers ----------------------------------------------
291
292// Month sections wrap each tab the same way; the item painter differs.
293function monthSections(body, entries, paintItem, containerClass) {
294 for (const bucket of bucketByMonth(entries)) {
295 const h = document.createElement("div");
296 h.className = "media-month";
297 h.textContent = bucket.label;
298 body.appendChild(h);
299 const wrap = document.createElement("div");
300 wrap.className = containerClass;
301 for (const e of bucket.items) wrap.appendChild(paintItem(e));
302 body.appendChild(wrap);
303 }
304}
305
306// Lazy blob loading: tiles carry their blurhash/aspect immediately; the blob
307// decrypts only when scrolled into view. One observer per repaint — see
308// disconnectTileObserver, called before every rebuild so detached tiles from
309// the previous paint (tab switch, live invalidation, each backfill page)
310// aren't retained by a still-watching observer.
311let tileObserver = null;
312function observeTile(el, onVisible) {
313 if (!tileObserver) {
314 tileObserver = new IntersectionObserver(
315 (ents) => {
316 for (const en of ents) {
317 if (!en.isIntersecting) continue;
318 tileObserver.unobserve(en.target);
319 en.target._onVisible && en.target._onVisible();
320 }
321 },
322 { rootMargin: "200px" },
323 );
324 }
325 el._onVisible = onVisible;
326 tileObserver.observe(el);
327}
328function disconnectTileObserver() {
329 if (!tileObserver) return;
330 tileObserver.disconnect();
331 tileObserver = null;
332}
333
334function renderMediaTab(body, entries, tid) {
335 monthSections(
336 body,
337 entries,
338 (e) => {
339 const a = e.att;
340 const tile = document.createElement("button");
341 tile.type = "button";
342 tile.className = "media-tile";
343 const isVideo = (a.type || "").startsWith("video/");
344 tile.setAttribute("aria-label", `${isVideo ? "Video" : "Photo"} from ${e.name || "someone"}, ${relTime(Math.floor(e.ts / 1000))}`);
345 const img = document.createElement("img");
346 img.alt = "";
347 img.draggable = false;
348 if (a.w && a.h) img.style.aspectRatio = `${a.w} / ${a.h}`;
349 // BlurHash ghost until the blob decrypts — same recipe as the feed's
350 // photo bubbles (chat.js), painted as the img's background.
351 if (a.bh) {
352 try {
353 const pw = 32, ph = a.w && a.h ? Math.max(1, Math.round((32 * a.h) / a.w)) : 32;
354 const px = decodeBlurhash(a.bh, pw, ph);
355 if (px) {
356 const c = document.createElement("canvas");
357 c.width = pw;
358 c.height = ph;
359 c.getContext("2d").putImageData(new ImageData(px, pw, ph), 0, 0);
360 img.style.backgroundImage = `url(${c.toDataURL()})`;
361 img.style.backgroundSize = "cover";
362 }
363 } catch { /* a bad hash keeps the flat placeholder */ }
364 }
365 tile.appendChild(img);
366 if (isVideo && a.dur) {
367 const badge = document.createElement("span");
368 badge.className = "media-dur";
369 const s = Math.max(0, Math.round(a.dur));
370 badge.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
371 tile.appendChild(badge);
372 }
373 observeTile(tile, async () => {
374 // Videos show their (much smaller) poster frame; images their blob.
375 const att = isVideo && a.poster ? { id: a.poster, type: "image/jpeg" } : a;
376 const url = await deps.loadBlob(tid, att);
377 if (url) img.src = url;
378 });
379 tile.dataset.sid = e.sid;
380 tile.dataset.att = a.id;
381 tile.onclick = async () => {
382 // The whole tab's current media list becomes the swipe collection —
383 // videos ride their poster still, same as the single-tap path below.
384 const all = mediaIdx.tab(tid, "media", { hiddenUids: hiddenUids() });
385 const items = all.map((x) => {
386 const xa = x.att;
387 const still = (xa.type || "").startsWith("video/") && xa.poster ? { id: xa.poster, type: "image/jpeg" } : xa;
388 return {
389 load: () => deps.loadBlob(tid, still),
390 source: () => document.querySelector(`.media-tile[data-sid="${x.sid}"][data-att="${xa.id}"] img`),
391 };
392 });
393 const index = all.findIndex((x) => x.sid === e.sid && x.att.id === a.id);
394 // A tile can outlive its index entry — e.g. the message got deleted
395 // or its sender muted between paint and this tap resolving.
396 if (index < 0) return;
397 const url = await items[index].load();
398 if (url) openLightbox(url, img, { collection: { items, index } });
399 };
400 // The jump button is a SIBLING of the tile, never a descendant — a
401 // <button> inside a <button> is invalid HTML (nested-interactive) and
402 // axe/VoiceOver flatten or hide the inner control.
403 const item = document.createElement("div");
404 item.className = "media-item";
405 item.dataset.sid = String(e.sid);
406 item.appendChild(tile);
407 addShowInChat(item, tid, e.sid);
408 return item;
409 },
410 "media-grid",
411 );
412}
413
414function renderLinksTab(body, entries, tid) {
415 monthSections(
416 body,
417 entries,
418 (e) => {
419 const row = document.createElement("a");
420 row.className = "media-link-row";
421 row.href = e.url;
422 row.target = "_blank";
423 row.rel = "noopener noreferrer";
424 const urlEl = document.createElement("span");
425 urlEl.className = "media-link-url";
426 urlEl.textContent = e.url;
427 const who = document.createElement("span");
428 who.className = "media-item-who";
429 who.textContent = `${e.name || "Someone"} · ${relTime(Math.floor(e.ts / 1000))}`;
430 row.append(urlEl, who);
431 // The unfurl card fills in lazily; a failed unfurl leaves the plain row.
432 observeTile(row, async () => {
433 const card = await deps.unfurl(e.url);
434 if (!card || !card.ok || !card.title) return;
435 linkTitles.set(e.url, card.title);
436 const t = document.createElement("span");
437 t.className = "media-link-title";
438 t.textContent = card.title;
439 row.prepend(t);
440 });
441 // Sibling, not descendant — see the note in renderMediaTab.
442 const item = document.createElement("div");
443 item.className = "media-item";
444 item.dataset.sid = String(e.sid);
445 item.appendChild(row);
446 addShowInChat(item, tid, e.sid);
447 return item;
448 },
449 "media-list",
450 );
451}
452
453function renderDocsTab(body, entries, tid) {
454 monthSections(
455 body,
456 entries,
457 (e) => {
458 const a = e.att;
459 const row = document.createElement("a");
460 row.className = "media-doc-row";
461 row.href = "#";
462 row.download = a.name || "file";
463 row.addEventListener("click", (ev) => {
464 if (row.getAttribute("href") === "#") ev.preventDefault();
465 });
466 const nm = document.createElement("span");
467 nm.className = "media-doc-name";
468 nm.textContent = a.name || "file";
469 const sub = document.createElement("span");
470 sub.className = "media-item-who";
471 sub.textContent = [a.size ? formatBytes(a.size) : "", `${e.name || "Someone"} · ${relTime(Math.floor(e.ts / 1000))}`]
472 .filter(Boolean)
473 .join(" · ");
474 row.append(nm, sub);
475 // The blob decrypts on approach so the tap is an instant save.
476 observeTile(row, async () => {
477 const url = await deps.loadBlob(tid, a);
478 if (url) row.href = url;
479 });
480 // Sibling, not descendant — see the note in renderMediaTab.
481 const item = document.createElement("div");
482 item.className = "media-item";
483 item.dataset.sid = String(e.sid);
484 item.appendChild(row);
485 addShowInChat(item, tid, e.sid);
486 return item;
487 },
488 "media-list",
489 );
490}
491
492// Every item's second affordance: land on the source message. A visible
493// button (≥24px target) that's a SIBLING of the tile/row (never nested
494// inside it — a button-in-a-button or button-in-an-anchor is invalid HTML
495// and axe/screen readers flatten or hide the inner control), so it's
496// reachable by keyboard and screen reader alike. `el` is the non-interactive
497// `.media-item` wrapper each renderer builds around its primary control.
498function addShowInChat(el, tid, sid) {
499 const b = document.createElement("button");
500 b.type = "button";
501 b.className = "media-jump";
502 b.setAttribute("aria-label", "Show in chat");
503 b.textContent = "↩";
504 b.onclick = (e) => {
505 e.preventDefault();
506 e.stopPropagation();
507 closeMediaView();
508 jumpToMessage(tid, sid);
509 };
510 el.appendChild(b);
511}
512