[deep dive] Full-screen image/video viewer UI/UX audit #164

Closed
opened 2026-09-17 23:06:35 -04:00 by jared · 5 comments
Owner

The viewer already steps ← → between the room's images. Audit it end-to-end on desktop and phone: keyboard (arrows, Esc, +/−, 0), swipe/pinch on touch, zoom persistence between items, video controls vs. image controls, captions display, download/copy/open-in-new-tab, "jump to message", loading states + blurhash, very tall/wide images, GIFs vs. videos, E2EE media, focus trap + screen reader labels, and what happens when a new image arrives while open. Deliver findings as a checklist here, then fix in one reviewed batch.

The viewer already steps ← → between the room's images. Audit it end-to-end on desktop and phone: keyboard (arrows, Esc, +/−, 0), swipe/pinch on touch, zoom persistence between items, video controls vs. image controls, captions display, download/copy/open-in-new-tab, "jump to message", loading states + blurhash, very tall/wide images, GIFs vs. videos, E2EE media, focus trap + screen reader labels, and what happens when a new image arrives while open. Deliver findings as a checklist here, then fix in one reviewed batch.
jared added this to the Features 2026-Q4 milestone 2026-09-17 23:06:35 -04:00
jared added the priority: mediumuxarea: mediaresearch labels 2026-09-17 23:06:35 -04:00
jared self-assigned this 2026-09-17 23:06:35 -04:00
Author
Owner

Image viewer audit — exercised on the local dev homeserver (Playwright, desktop + Pixel 7)

There are two viewers and they behave differently:

timeline ImageViewer gallery lightbox
backdrop light grey card dark full-screen
header file name only file name, sender · date, 1 / N
prev / next none ←/→ across the room's media
zoom buttons, wheel; no keys (fixed) buttons, wheel, + - 0, double-click
keyboard on open focus on the <img> (fixed) focus stayed on the tile behind — all keys dead until a click (fixed)
a11y no role / label (fixed) role=dialog aria-modal "Media viewer"
download ✓ ✓
jump to message n/a ✓
touch buttons only, no pinch buttons only, no pinch

Fixed (53823f54): lightbox now focuses itself on open so ←/→/Esc/+/- work immediately; the timeline viewer gained + / = / - / 0, double-click 1×↔2×, role=dialog aria-modal labelled with the file name, and focuses on open. Escape closes both.

Filed: #219 — unify: a timeline image should open the gallery lightbox at that item (dark backdrop, sender/time, prev/next, consistent keys), and add pinch-zoom; the small viewer then only remains for avatars.

Cosmetic seen along the way: the seasonal floating button sits on top of both viewers (#215).

timeline viewer desktop · mobile
lightbox

## Image viewer audit — exercised on the local dev homeserver (Playwright, desktop + Pixel 7) There are **two** viewers and they behave differently: | | timeline `ImageViewer` | gallery lightbox | |---|---|---| | backdrop | light grey card | dark full-screen | | header | file name only | file name, **sender · date**, 1 / N | | prev / next | none | ←/→ across the room's media | | zoom | buttons, wheel; **no keys** (fixed) | buttons, wheel, + - 0, double-click | | keyboard on open | focus on the `<img>` (fixed) | **focus stayed on the tile behind — all keys dead until a click** (fixed) | | a11y | no role / label (fixed) | `role=dialog aria-modal` "Media viewer" | | download | ✓ | ✓ | | jump to message | n/a | ✓ | | touch | buttons only, no pinch | buttons only, no pinch | **Fixed (`53823f54`):** lightbox now focuses itself on open so ←/→/Esc/+/- work immediately; the timeline viewer gained + / = / - / 0, double-click 1×↔2×, `role=dialog aria-modal` labelled with the file name, and focuses on open. Escape closes both. **Filed:** #219 — unify: a timeline image should open the gallery lightbox at that item (dark backdrop, sender/time, prev/next, consistent keys), and add pinch-zoom; the small viewer then only remains for avatars. Cosmetic seen along the way: the seasonal floating button sits on top of both viewers (#215). ![timeline viewer desktop · mobile](https://code.lotusguild.org/attachments/988080ff-bf58-4066-b092-39c15dd73486) ![lightbox](https://code.lotusguild.org/attachments/ac48677e-cc0b-4548-b0a3-d05afdae233f)
Author
Owner

Audit (2026-09-23) — driven headlessly on desktop 1300×850 and Pixel 7

Works

  • Keyboard: ←/→ step, Esc closes, +/=/- zoom, 0 resets — and focus lands on the dialog on open (initialFocus: () => dialogRef.current), so the keys work without clicking first.
  • Zoom: wheel zooms, double-click toggles 1×↔2×, zoom resets on navigation (verified 140% at 2/31 → 100% at 3/31).
  • Pan while zoomed (mouse drag moves the transform; usePan resets offset when zoom returns to 1).
  • Video vs image controls: videos render <video controls autoPlay>; zoom/pan keys are image-only by design.
  • Loading / E2EE: spinner with "Decrypting…" vs "Loading…", explicit "Failed to load" state; encrypted media decrypts through the same hook as the gallery.
  • Header: filename/caption, sender · date (now via the unified formatter, #139), N / M counter, zoom %.
  • Actions: zoom out / reset / in, Download file, Go to message, Close — all labelled; role="dialog" aria-modal aria-label="Media viewer" with a focus trap.
  • A new message arriving while the viewer is open does not close or re-index it.
  • Very tall images (200×2400) fit the viewport (61×730 in an 850 px window); no overflow.

Fixed now

  • Swipe on touch did nothing — the only way between items on a phone was the small arrow buttons. cf80729a adds a one-finger horizontal swipe (inert while zoomed, where the gesture pans), pure classifier + unit tests. Verified on Pixel 7: swipe left 1/31 → 2/31.

Findings left (ranked)

  1. No pinch-zoom on touch. Zoom on a phone is only the +/− buttons; two-finger pinch does nothing. Medium — the buttons are reachable but it's the gesture people try first.
  2. Opening a just-sent image can show 1 / 1. RoomMediaLightbox pages the detached media timeline back up to 6 times to find the clicked event and meanwhile shows it alone; for a very recent upload the paging sometimes doesn't pick it up and the counter stays 1/1 (older images opened right after showed 1/31 correctly). Medium — the ←/→ affordance silently disappears.
  3. No "Copy image" / "Open in new tab". Download exists. Copy needs ClipboardItem (Chromium-only for PNG) and an open-in-new-tab on an E2EE blob URL breaks as soon as the viewer unmounts and revokes it — worth doing only with a deliberate design (re-fetch on demand). Low.
  4. Focus return on close goes to whatever had focus before (the composer when opened from the timeline, since a timeline <img> isn't focusable). From the gallery the tiles are buttons and focus returns correctly. Low — would need the timeline image to become a real button.
  5. Arrow buttons are 32 px and sit at the screen edges on a phone; with swipe landing they matter less, but they're under the 44 px touch target the rest of the mobile work uses. Low.
  6. No caption line under the media — the caption is the header title, which truncates on one line; a long caption is unreadable in the viewer. Low.

desktop
phone

## Audit (2026-09-23) — driven headlessly on desktop 1300×850 and Pixel 7 ### Works - [x] **Keyboard**: ←/→ step, Esc closes, `+`/`=`/`-` zoom, `0` resets — and focus lands on the dialog on open (`initialFocus: () => dialogRef.current`), so the keys work without clicking first. - [x] **Zoom**: wheel zooms, double-click toggles 1×↔2×, zoom **resets on navigation** (verified 140% at 2/31 → 100% at 3/31). - [x] **Pan** while zoomed (mouse drag moves the transform; `usePan` resets offset when zoom returns to 1). - [x] **Video vs image controls**: videos render `<video controls autoPlay>`; zoom/pan keys are image-only by design. - [x] **Loading / E2EE**: spinner with "Decrypting…" vs "Loading…", explicit "Failed to load" state; encrypted media decrypts through the same hook as the gallery. - [x] **Header**: filename/caption, sender · date (now via the unified formatter, #139), `N / M` counter, zoom %. - [x] **Actions**: zoom out / reset / in, Download file, Go to message, Close — all labelled; `role="dialog" aria-modal aria-label="Media viewer"` with a focus trap. - [x] **A new message arriving while the viewer is open** does not close or re-index it. - [x] **Very tall images** (200×2400) fit the viewport (61×730 in an 850 px window); no overflow. ### Fixed now - [x] **Swipe on touch did nothing** — the only way between items on a phone was the small arrow buttons. cf80729a adds a one-finger horizontal swipe (inert while zoomed, where the gesture pans), pure classifier + unit tests. Verified on Pixel 7: swipe left 1/31 → 2/31. ### Findings left (ranked) 1. **No pinch-zoom on touch.** Zoom on a phone is only the +/− buttons; two-finger pinch does nothing. Medium — the buttons are reachable but it's the gesture people try first. 2. **Opening a just-sent image can show `1 / 1`.** `RoomMediaLightbox` pages the detached media timeline back up to 6 times to find the clicked event and meanwhile shows it alone; for a very recent upload the paging sometimes doesn't pick it up and the counter stays 1/1 (older images opened right after showed 1/31 correctly). Medium — the ←/→ affordance silently disappears. 3. **No "Copy image" / "Open in new tab".** Download exists. Copy needs `ClipboardItem` (Chromium-only for PNG) and an open-in-new-tab on an E2EE blob URL breaks as soon as the viewer unmounts and revokes it — worth doing only with a deliberate design (re-fetch on demand). Low. 4. **Focus return on close** goes to whatever had focus before (the composer when opened from the timeline, since a timeline `<img>` isn't focusable). From the gallery the tiles are buttons and focus returns correctly. Low — would need the timeline image to become a real button. 5. **Arrow buttons are 32 px** and sit at the screen edges on a phone; with swipe landing they matter less, but they're under the 44 px touch target the rest of the mobile work uses. Low. 6. **No caption line under the media** — the caption is the header title, which truncates on one line; a long caption is unreadable in the viewer. Low. ![desktop](https://code.lotusguild.org/attachments/eb9ec136-d76f-4ad2-9cb2-7b3e3a27de14) ![phone](https://code.lotusguild.org/attachments/2411dc5e-1a4e-4b86-89ce-c232c652ad95)
Author
Owner

Findings from the 2026-09-23 audit: progress

  • 1. Pinch-zoom on touch: shipped (39589b40). A two-finger pinch on the viewer scales from the zoom it started at, clamped to 1×–5×. Releasing near 1× snaps back to exactly 1×, so one-finger swipe works again. The media area uses touch-action: none, so the browser no longer zooms the page instead. Verified on an emulated Pixel 7 with a real two-point CDP touch: spreading 80→200 px gives 250 %, closing to 120 px gives 150 %, closing fully gives 100 %, and a one-finger swipe afterwards still moves 2/2 → 1/2.

  • 2. Just-sent image shows 1 / 1: not reproduced. I tried four ways, and each opened as 2 / 2:

    • unencrypted room, image sent from the composer after the viewer had already been opened once
    • the same, but clicked while the send was held back 2.5 s
    • encrypted room, both images sent from the composer
    • fresh viewer on the new image

    Leaving it here in case someone sees it again. If you do, note whether the room was encrypted and how long after sending you clicked.

  • 3. Copy image / open in new tab: unchanged. It needs a deliberate design, see the audit.

  • 4. Focus return from a timeline image: unchanged.

  • 5. Arrow buttons on touch: shipped (ea07fb50). On a coarse pointer they use the next IconButton size, 48 px measured on a Pixel 7. Desktop is unchanged.

  • 6. Caption: shipped (c088b4c0). For MSC2530 captions (filename set and body differs), the header now shows the file name, and the caption appears in full under the media (wrapped, scrolls past 25vh). The alt text uses the caption, and Download now saves under the real file name instead of the caption. Verified with a 150-character caption.

## Findings from the 2026-09-23 audit: progress - [x] **1. Pinch-zoom on touch: shipped (`39589b40`).** A two-finger pinch on the viewer scales from the zoom it started at, clamped to 1×–5×. Releasing near 1× snaps back to exactly 1×, so one-finger swipe works again. The media area uses `touch-action: none`, so the browser no longer zooms the page instead. Verified on an emulated Pixel 7 with a real two-point CDP touch: spreading 80→200 px gives 250 %, closing to 120 px gives 150 %, closing fully gives 100 %, and a one-finger swipe afterwards still moves 2/2 → 1/2. - [ ] **2. Just-sent image shows `1 / 1`: not reproduced.** I tried four ways, and each opened as `2 / 2`: - unencrypted room, image sent from the composer after the viewer had already been opened once - the same, but clicked while the send was held back 2.5 s - encrypted room, both images sent from the composer - fresh viewer on the new image Leaving it here in case someone sees it again. If you do, note whether the room was encrypted and how long after sending you clicked. - [ ] 3. Copy image / open in new tab: unchanged. It needs a deliberate design, see the audit. - [ ] 4. Focus return from a timeline image: unchanged. - [x] **5. Arrow buttons on touch: shipped (`ea07fb50`).** On a coarse pointer they use the next IconButton size, 48 px measured on a Pixel 7. Desktop is unchanged. - [x] **6. Caption: shipped (`c088b4c0`).** For MSC2530 captions (`filename` set and `body` differs), the header now shows the file name, and the caption appears in full under the media (wrapped, scrolls past 25vh). The alt text uses the caption, and **Download now saves under the real file name** instead of the caption. Verified with a 150-character caption.
Author
Owner
  • 4. Focus return / keyboard (1ab75147). Focus already went back to the timeline image when the viewer closed. The real gap was that keyboard users could Tab to an image but not open it. Images are now role="button", labelled "View image: ", and open on Enter or Space. Verified: Tab → Enter opens the viewer, Escape returns focus to the image, Space opens it again.
- [x] **4. Focus return / keyboard (`1ab75147`).** Focus already went back to the timeline image when the viewer closed. The real gap was that keyboard users could Tab to an image but not open it. Images are now `role="button"`, labelled "View image: <name>", and open on Enter or Space. Verified: Tab → Enter opens the viewer, Escape returns focus to the image, Space opens it again.
Author
Owner

Last pass (2026-09-26): f5d3c43b

  • Images inside a thread always opened as 1 / 1. New finding, and a likely explanation for finding 2 (the unreproduced "just-sent image shows 1 / 1"). The thread panel opened the room lightbox, which looks the image up in the room's media timeline. The SDK keeps thread replies out of that timeline, so:

    • the image could never be found there, and the viewer showed it on its own with no ←/→
    • it also paged the room's media history up to six times for nothing.

    The thread panel now builds the viewer from the thread itself (the root plus its loaded replies), and Go to message scrolls the thread panel instead of jumping to the room.

    Verified on a thread with 3 images:

    • opening the second image shows 2 / 3
    • → goes to 3 / 3, and ← ← back to 1 / 3
    • no /messages requests while the viewer is open (2 before the fix)
    • Go to message closes the viewer and leaves the thread panel open.
  • 3. Copy image: shipped. A new header button, images only. It re-encodes non-PNG images to PNG, because the clipboard API only guarantees image/png. The button shows a check for 2 s, or a warning icon with "Could not copy image" if the copy fails. It is hidden on browsers without ClipboardItem.

    Verified by reading the clipboard back in Chromium:

    • unencrypted PNG: image/png, 320×200, correct pixel colour
    • E2EE JPEG: image/png, 300×180, correct pixel colour.

    Unit tests: utils/copyImage.test.ts.

  • 3. Open in new tab: not doing, by design.

    • An E2EE image is a blob URL that is revoked when the viewer closes.
    • Authenticated media returns 401 in a bare tab.

    Download and Copy cover both uses.

Nothing on the original checklist is left open, so I'm closing this. Reopen it if a room image (not a thread image) ever shows 1 / 1 again.

thread viewer 2/3
copy image

## Last pass (2026-09-26): `f5d3c43b` - [x] **Images inside a thread always opened as `1 / 1`.** New finding, and a likely explanation for finding 2 (the unreproduced "just-sent image shows 1 / 1"). The thread panel opened the *room* lightbox, which looks the image up in the room's media timeline. The SDK keeps thread replies out of that timeline, so: - the image could never be found there, and the viewer showed it on its own with no ←/→ - it also paged the room's media history up to six times for nothing. The thread panel now builds the viewer from the thread itself (the root plus its loaded replies), and **Go to message** scrolls the thread panel instead of jumping to the room. Verified on a thread with 3 images: - opening the second image shows **2 / 3** - → goes to 3 / 3, and ← ← back to 1 / 3 - no `/messages` requests while the viewer is open (2 before the fix) - Go to message closes the viewer and leaves the thread panel open. - [x] **3. Copy image: shipped.** A new header button, images only. It re-encodes non-PNG images to PNG, because the clipboard API only guarantees `image/png`. The button shows a check for 2 s, or a warning icon with "Could not copy image" if the copy fails. It is hidden on browsers without `ClipboardItem`. Verified by reading the clipboard back in Chromium: - unencrypted PNG: `image/png`, 320×200, correct pixel colour - **E2EE JPEG**: `image/png`, 300×180, correct pixel colour. Unit tests: `utils/copyImage.test.ts`. - **3. Open in new tab: not doing, by design.** - An E2EE image is a blob URL that is revoked when the viewer closes. - Authenticated media returns 401 in a bare tab. Download and Copy cover both uses. Nothing on the original checklist is left open, so I'm closing this. Reopen it if a room image (not a thread image) ever shows `1 / 1` again. ![thread viewer 2/3](https://code.lotusguild.org/attachments/85e8cede-f7e9-4be1-a229-1e1b92e5f61d) ![copy image](https://code.lotusguild.org/attachments/26357676-2c50-458c-b2c4-0134fe69e9af)
jared closed this issue 2026-09-26 19:03:17 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#164