eleven / ocho Public

web: lightbox collection mode — swipe/arrows between photos (PR 2 of 3) (#1426)

* docs: Media, Links & Docs design spec

Approved design for the per-thread media/links/docs view: full-pane
overlay off the thread kebab, client-side extraction over decrypted
payloads (zero server changes), auto-backfill on open, lightbox
collection swipe, and the iOS/Mac parity plan. Pinned messages named
as a follow-up with an extensible tab bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: Media/Links/Docs web implementation plan (PRs 1-2)

Task-by-task plan for the extractor + overlay PR and the lightbox
collection-swipe PR, grounded in the real seams (store.js backfill,
searchui decrypt/jump, shell host bridge, kebab wiring). Native plan
follows once web lands, using the extractor tests as parity fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: threadmedia.js — pure media/links/docs extractor + index

* web: export jumpToMessage + fetchMessagePage for the media overlay

* web: mediaview.js — the Media/Links/Docs overlay

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: mediaview.js — fix nested-interactive Show-in-chat button

The overlay's per-item jump button was appended INSIDE each tile <button>
and link/doc <a> — invalid HTML (interactive content can't contain
interactive descendants), flagged nested-interactive by axe/Lighthouse,
and flattened or hidden by screen readers. Wrap each item in a
non-interactive .media-item div and append the jump button as a sibling
of the primary control instead; adjust .media-jump positioning/hover-
reveal CSS to key off the wrapper rather than the (no longer ancestor)
tile/row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: Media/Links/Docs kebab entry + shell wiring + changelog

* web: lightbox collection nav decision helpers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: lightbox collection mode — swipe/arrows between photos

* web: lightbox navTo — generation guard against close/nav races

* web: media grid opens the lightbox in collection mode

* web: media overlay fix wave — escape guard, observer lifecycle, live repaint, focus (final review)

- onOverlayKey: ignore Escape when a stacked .lightbox/.msg-sheet owns it,
  same guard chat.js uses, so closing the top layer doesn't also close the
  media overlay underneath.
- tileObserver: disconnect+null before every full repaint (tab switch,
  invalidation, each backfill page) and on closeMediaView, so detached
  tiles aren't retained across pages.
- mediaInvalidate repaints an open overlay on the same thread so deletes/
  edits drop live, matching the spec. Added mediaModerationChanged(),
  called from shell.js's existing onModerationChange sink (mediaview.js
  can't register its own — that sink is single-slot and would replace
  shell's) so mutes/blocks repaint too.
- open.repaint now captures/restores focus by a stable key (tab id, back/
  stop button, or an item's data-sid) across the DOM rebuild each backfill
  page does, falling back to the back button only when the focused element
  is gone and focus fell to <body>.
- style.css: .media-stop gets min-height: 24px (borderline target size).
- spec: record v1 web reality for video tiles — poster still in the
  lightbox, not the in-grid player; playing rides Show in chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: media grid collection tap + lightbox navTo — final review fixes

- mediaview.js grid tap handler: guard items[index] with index < 0 → return
  before awaiting .load() — a tile can outlive its index entry when the
  message is deleted or its sender muted between paint and the tap
  resolving.
- lightbox.js navTo: add closing to the entry guard
  (!coll || navBusy || closing) so a nav that starts mid-close doesn't
  race the fly-out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 966e49c4be833bf5fd8f2448a1fc95f462c574c2 parent 2d0e154
5 files changed, +211 −11
  • package.json +1 −1
  • test/lightbox.test.mjs +30 −0
  • web/static/lightbox.js +129 −8
  • web/static/mediaview.js +19 −2
  • web/static/style.css +32 −0
diff --git a/package.json b/package.json
index df634d7..598a3ae 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/appcatalog.test.mjs && node test/appcatalog-seed.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/prejoin.test.mjs && node --test test/invitehint.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/ledgerchain.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/threadmedia.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs && node home/test/sessionreuse.test.mjs && node home/test/joinreturn.test.mjs"
+ "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/appcatalog.test.mjs && node test/appcatalog-seed.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/prejoin.test.mjs && node --test test/invitehint.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/ledgerchain.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/threadmedia.test.mjs && node test/lightbox.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs && node home/test/sessionreuse.test.mjs && node home/test/joinreturn.test.mjs"
}
}
diff --git a/test/lightbox.test.mjs b/test/lightbox.test.mjs
new file mode 100644
index 0000000..04eaf1f
--- /dev/null
+++ b/test/lightbox.test.mjs
@@ -0,0 +1,30 @@
+// Collection-swipe decision logic for the lightbox (web/static/lightbox.js).
+import assert from "node:assert/strict";
+import { stepIndex, dragIntent } from "../web/static/lightbox.js";
+
+let failures = 0;
+function test(name, fn) {
+ try { fn(); console.log("PASS " + name); } catch (e) { console.log("FAIL " + name + " — " + e.message); failures++; }
+}
+
+test("stepIndex clamps at both ends, no wrap", () => {
+ assert.equal(stepIndex(0, -1, 5), 0);
+ assert.equal(stepIndex(4, 1, 5), 4);
+ assert.equal(stepIndex(2, 1, 5), 3);
+});
+
+test("a mostly-horizontal committed drag navigates against the drag direction", () => {
+ assert.equal(dragIntent(-120, 10, -0.8, 0), "nav-next"); // dragged left → next
+ assert.equal(dragIntent(120, -10, 0.8, 0), "nav-prev");
+});
+
+test("a fast horizontal flick navigates even at small distance", () => {
+ assert.equal(dragIntent(-40, 5, -0.7, 0), "nav-next");
+});
+
+test("a mostly-vertical drag still dismisses; a small slow drag settles", () => {
+ assert.equal(dragIntent(20, 200, 0, 0.9), "dismiss");
+ assert.equal(dragIntent(10, 8, 0.01, 0.01), "settle");
+});
+
+process.exit(failures ? 1 : 0);
diff --git a/web/static/lightbox.js b/web/static/lightbox.js
index fa8bdd9..9ed25d7 100644
--- a/web/static/lightbox.js
+++ b/web/static/lightbox.js
@@ -4,7 +4,22 @@
// avatar's full photo). Self-contained: everything it needs rides in the call
// (the image URL + the source element whose corner radius the fly-out must
// land on). Extracted from the twin verbatim copies both files carried.
-export function openLightbox(url, source) {
+
+// Pure decision helpers for collection mode, exported for Node tests.
+// A horizontal gesture navigates; vertical keeps the flick-dismiss. Thresholds
+// mirror the dismiss fling's own (speed 0.55 px/ms, reach 130px).
+export function stepIndex(i, delta, len) {
+ return Math.max(0, Math.min(len - 1, i + delta));
+}
+
+export function dragIntent(tx, ty, vx, vy) {
+ const horizontal = Math.abs(tx) > Math.abs(ty) * 1.5;
+ if (horizontal && (Math.abs(tx) > 80 || Math.abs(vx) > 0.5)) return tx < 0 ? "nav-next" : "nav-prev";
+ if (Math.hypot(vx, vy) >= 0.55 || Math.hypot(tx, ty) >= 130) return "dismiss";
+ return "settle";
+}
+
+export function openLightbox(url, source, opts = {}) {
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const back = document.createElement("div");
back.className = "lightbox";
@@ -20,6 +35,31 @@ export function openLightbox(url, source) {
back.appendChild(backdrop);
back.appendChild(img);
+ // ---- collection controls: prev/next buttons, only when opts.collection ----
+ // Built here (gated on the raw opts.collection, not the `coll` binding below,
+ // which doesn't exist yet) but wired to `navTo`/`paintNav` by closure — those
+ // are defined further down and only ever called after a user interaction, by
+ // which point the bindings are live (same forward-reference pattern `onKey`
+ // already uses to call `animateClose`).
+ let navPrev = null;
+ let navNext = null;
+ if (opts.collection) {
+ navPrev = document.createElement("button");
+ navPrev.type = "button";
+ navPrev.className = "lightbox-nav lightbox-nav-prev";
+ navPrev.setAttribute("aria-label", "Previous photo");
+ navPrev.textContent = "‹"; // ‹
+ navPrev.onclick = () => navTo(-1);
+ back.appendChild(navPrev);
+ navNext = document.createElement("button");
+ navNext.type = "button";
+ navNext.className = "lightbox-nav lightbox-nav-next";
+ navNext.setAttribute("aria-label", "Next photo");
+ navNext.textContent = "›"; // ›
+ navNext.onclick = () => navTo(1);
+ back.appendChild(navNext);
+ }
+
let tapTimer = 0; // pending single-tap-to-close, cancelled by a 2nd tap or a drag/pinch
let closing = false;
const close = () => {
@@ -29,6 +69,8 @@ export function openLightbox(url, source) {
};
const onKey = (e) => {
if (e.key === "Escape") animateClose();
+ else if (coll && e.key === "ArrowLeft") navTo(-1);
+ else if (coll && e.key === "ArrowRight") navTo(1);
};
// Tapping empty space (not the photo) closes — unless a gesture just ran, whose
// synthesized click we swallow.
@@ -51,9 +93,11 @@ export function openLightbox(url, source) {
// transform scales the element, the CSS radius that *renders* as `r` at scale `s`
// is `r / s`.
const restRadius = parseFloat(getComputedStyle(img).borderTopLeftRadius) || 8;
- const sourceRadius = (rect) => {
- if (!source) return restRadius;
- const raw = getComputedStyle(source).borderTopLeftRadius;
+ // Takes the element explicitly (rather than closing over `source`) so both the
+ // two-arg path and collection mode's per-item `liveSource()` can share it.
+ const sourceRadius = (rect, el) => {
+ if (!el) return restRadius;
+ const raw = getComputedStyle(el).borderTopLeftRadius;
const r = raw.endsWith("%") ? (parseFloat(raw) / 100) * Math.min(rect.width, rect.height) : parseFloat(raw);
return r || 0;
};
@@ -113,6 +157,22 @@ export function openLightbox(url, source) {
let lastTapX = 0;
let lastTapY = 0;
+ // ---- collection mode (opts.collection): swipe/arrow between items -------
+ const coll = opts.collection || null;
+ let collIndex = coll ? coll.index : 0;
+ const liveSource = () => {
+ // The fly-out targets the CURRENT item's tile when the grid still shows
+ // it; falls back to the original tap's element (or none — plain fade).
+ if (!coll) return source;
+ const el = coll.items[collIndex].source && coll.items[collIndex].source();
+ return el && el.isConnected ? el : source;
+ };
+ const paintNav = () => {
+ if (!coll) return;
+ navPrev.classList.toggle("hidden", collIndex === 0);
+ navNext.classList.toggle("hidden", collIndex === coll.items.length - 1);
+ };
+
const render = () => {
raf = 0;
let t = `translate3d(${tx}px, ${ty}px, 0)`;
@@ -135,7 +195,8 @@ export function openLightbox(url, source) {
openStarted = true;
img.style.visibility = "";
setBase();
- const S = source && source.getBoundingClientRect();
+ const src = liveSource();
+ const S = src && src.getBoundingClientRect();
if (!haveBase || !img.clientWidth || !S || !S.width || reduceMotion) {
backdrop.style.transition = reduceMotion ? "" : "opacity 0.2s";
backdrop.style.opacity = "1"; // no thumbnail to fly from — just appear
@@ -146,7 +207,7 @@ export function openLightbox(url, source) {
const fromY = S.top + S.height / 2 - baseCY;
img.style.transition = "none";
img.style.transform = `translate3d(${fromX}px, ${fromY}px, 0) scale(${s})`;
- img.style.borderRadius = sourceRadius(S) / s + "px"; // match the thumbnail's rounding to start
+ img.style.borderRadius = sourceRadius(S, src) / s + "px"; // match the thumbnail's rounding to start
void img.offsetWidth; // reflow so the changes below animate from here
img.style.transition = "transform 0.3s cubic-bezier(0.2,0.8,0.2,1), border-radius 0.3s ease";
img.style.transform = "translate3d(0,0,0)";
@@ -159,6 +220,15 @@ export function openLightbox(url, source) {
img.addEventListener("load", runOpenAnim, { once: true });
img.addEventListener("error", runOpenAnim, { once: true });
}
+ if (coll) {
+ paintNav();
+ // Preload both neighbours once up front so a swipe right after open usually
+ // lands on an already-decrypted URL instead of waiting on `.load()`.
+ const prev = stepIndex(collIndex, -1, coll.items.length);
+ if (prev !== collIndex) coll.items[prev].load();
+ const next = stepIndex(collIndex, 1, coll.items.length);
+ if (next !== collIndex) coll.items[next].load();
+ }
// Reverse of the fly-in: shrink the photo back onto its thumbnail, then remove.
// Used by taps / Escape / backdrop closes; a fling instead hurls it off-screen.
@@ -170,14 +240,15 @@ export function openLightbox(url, source) {
cancelAnimationFrame(raf);
raf = 0;
}
- const S = source && source.getBoundingClientRect();
+ const src = liveSource();
+ const S = src && src.getBoundingClientRect();
if (!haveBase || !S || !S.width || reduceMotion) return close();
const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight);
const toX = S.left + S.width / 2 - baseCX;
const toY = S.top + S.height / 2 - baseCY;
img.style.transition = "transform 0.28s cubic-bezier(0.4,0,0.2,1), border-radius 0.28s ease";
img.style.transform = `translate3d(${toX}px, ${toY}px, 0) scale(${s})`;
- img.style.borderRadius = sourceRadius(S) / s + "px"; // shrink to the thumbnail's rounding, no snap
+ img.style.borderRadius = sourceRadius(S, src) / s + "px"; // shrink to the thumbnail's rounding, no snap
backdrop.style.transition = "opacity 0.28s";
backdrop.style.opacity = "0";
setTimeout(close, 300);
@@ -229,6 +300,45 @@ export function openLightbox(url, source) {
backdrop.style.opacity = "1";
};
+ // Move to a neighbour: quick fade + src swap on the ONE img element (the
+ // FLIP fly-in stays an open/close-only effect), neighbours preloading on
+ // approach so a swipe usually lands on a ready decrypted URL.
+ //
+ // navBusy only blocks a second nav while the first is still awaiting
+ // .load() — it clears before the 150ms fade/swap phase, so a nav that
+ // starts in that window (arrow-key repeat, a fast double-swipe) can still
+ // race its own deferred swap against an earlier one, and a dismiss
+ // (Escape/backdrop/tap) can land mid-fly-out on a still-scheduled swap.
+ // A monotonic generation counter is the single guard for both: each
+ // accepted call claims the current generation, and the deferred callback
+ // only applies if no later nav (and no close) has superseded it since.
+ let navBusy = false;
+ let navGen = 0;
+ const navTo = async (delta) => {
+ if (!coll || navBusy || closing) return;
+ const next = stepIndex(collIndex, delta, coll.items.length);
+ if (next === collIndex) return springBack();
+ navBusy = true;
+ const gen = ++navGen;
+ const url2 = await coll.items[next].load();
+ navBusy = false;
+ if (!url2 || closing || gen !== navGen) return springBack();
+ collIndex = next;
+ scale = 1; tx = 0; ty = 0; rot = 0;
+ img.style.transition = reduceMotion ? "none" : "opacity 0.15s";
+ img.style.opacity = "0";
+ setTimeout(() => {
+ if (closing || gen !== navGen) return; // superseded by a close or a newer nav
+ img.src = url2;
+ img.style.transform = "translate3d(0,0,0)";
+ img.style.opacity = "1";
+ setBase();
+ paintNav();
+ const n = stepIndex(collIndex, delta, coll.items.length);
+ if (n !== collIndex) coll.items[n].load(); // preload the next neighbour
+ }, reduceMotion ? 0 : 150);
+ };
+
// Settle a still-zoomed image: keep the zoom, clamp into view, straighten any
// rotation the pinch left behind.
const settleZoom = () => {
@@ -358,6 +468,17 @@ export function openLightbox(url, source) {
armSuppress();
return;
}
+ // In a collection, a strongly horizontal committed drag navigates instead of
+ // dismissing (mirrors the dismiss fling's own speed/reach thresholds — see
+ // dragIntent). Reaching here already means !pinched && scale === 1, but the
+ // guard is spelled out so this can't fire outside that state if the checks
+ // above it ever change.
+ const intent = coll && !pinched && scale === 1 ? dragIntent(tx, ty, vx, vy) : null;
+ if (intent === "nav-next" || intent === "nav-prev") {
+ armSuppress();
+ navTo(intent === "nav-next" ? 1 : -1);
+ return;
+ }
// Clean 1× drag: spring back, or fling off with momentum.
const speed = Math.hypot(vx, vy); // px/ms
if (speed < 0.55 && Math.hypot(tx, ty) < 130) {
diff --git a/web/static/mediaview.js b/web/static/mediaview.js
index f97b956..f19ddf0 100644
--- a/web/static/mediaview.js
+++ b/web/static/mediaview.js
@@ -344,9 +344,26 @@ function renderMediaTab(body, entries, tid) {
const url = await deps.loadBlob(tid, att);
if (url) img.src = url;
});
+ tile.dataset.sid = e.sid;
+ tile.dataset.att = a.id;
tile.onclick = async () => {
- const url = await deps.loadBlob(tid, isVideo && a.poster ? { id: a.poster, type: "image/jpeg" } : a);
- if (url) openLightbox(url, img); // PR 2 upgrades this to collection swipe
+ // The whole tab's current media list becomes the swipe collection —
+ // videos ride their poster still, same as the single-tap path below.
+ const all = mediaIdx.tab(tid, "media", { hiddenUids: hiddenUids() });
+ const items = all.map((x) => {
+ const xa = x.att;
+ const still = (xa.type || "").startsWith("video/") && xa.poster ? { id: xa.poster, type: "image/jpeg" } : xa;
+ return {
+ load: () => deps.loadBlob(tid, still),
+ source: () => document.querySelector(`.media-tile[data-sid="${x.sid}"][data-att="${xa.id}"] img`),
+ };
+ });
+ const index = all.findIndex((x) => x.sid === e.sid && x.att.id === a.id);
+ // A tile can outlive its index entry — e.g. the message got deleted
+ // or its sender muted between paint and this tap resolving.
+ if (index < 0) return;
+ const url = await items[index].load();
+ if (url) openLightbox(url, img, { collection: { items, index } });
};
// The jump button is a SIBLING of the tile, never a descendant — a
// <button> inside a <button> is invalid HTML (nested-interactive) and
diff --git a/web/static/style.css b/web/static/style.css
index ecc265b..14ced3a 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -709,6 +709,38 @@ select:disabled { opacity: 0.5; cursor: default; }
-webkit-user-drag: none;
will-change: transform;
}
+/* collection-mode prev/next controls (openLightbox opts.collection). The
+ backdrop is always dark regardless of theme (see .lightbox-backdrop above),
+ so these match it directly rather than the light/dark panel variables. */
+.lightbox-nav {
+ position: fixed;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 2; /* above the image */
+ width: 44px;
+ height: 44px;
+ min-width: 44px;
+ min-height: 44px;
+ display: grid;
+ place-items: center;
+ border: none;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.4);
+ color: #fff;
+ font-size: 28px;
+ line-height: 1;
+ cursor: pointer;
+ transition: background 0.15s ease;
+}
+.lightbox-nav:hover { background: rgba(0, 0, 0, 0.6); }
+.lightbox-nav:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.lightbox-nav-prev { left: 12px; }
+.lightbox-nav-next { right: 12px; }
+/* touch already navigates via swipe (dragIntent) — the buttons would only
+ duplicate the gesture and eat thumb space at the edges, so hide them */
+@media (pointer: coarse) {
+ .lightbox-nav { display: none; }
+}
/* the lightbox open/close is driven in JS (a FLIP fly-in/out from the thumbnail) */
@keyframes fade-in { from { opacity: 0; } }