[deep dive] Threads: full UI/UX audit + "mark all threads read" #165

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

Jared: "we need an extreme deep dive into our implementation of threads as a whole, especially UI/UX."

Audit the whole thread surface: thread panel open/close and navigation, thread composer parity with the main composer (toolbar, drafts, paste, uploads, mentions), "N replies" chips + unread badges, per-thread read tracking, the Threads list panel (sorting, filters, empty states), notifications overrides, jumping between root and thread, replies-in-thread vs. replies-to-thread-root confusion, mobile layout, keyboard/a11y, and edge cases (edited/redacted roots, threads in encrypted rooms, threads across the search/bookmarks/forward features). Plus the small feature: Mark all threads read in the Threads list panel.

Deliverable: findings ranked by severity as a checklist here, then fixes in reviewed batches (a11y and privacy items first — see #140/#141).

Jared: "we need an extreme deep dive into our implementation of threads as a whole, especially UI/UX." Audit the whole thread surface: thread panel open/close and navigation, thread composer parity with the main composer (toolbar, drafts, paste, uploads, mentions), "N replies" chips + unread badges, per-thread read tracking, the Threads list panel (sorting, filters, empty states), notifications overrides, jumping between root and thread, replies-in-thread vs. replies-to-thread-root confusion, mobile layout, keyboard/a11y, and edge cases (edited/redacted roots, threads in encrypted rooms, threads across the search/bookmarks/forward features). Plus the small feature: **Mark all threads read** in the Threads list panel. Deliverable: findings ranked by severity as a checklist here, then fixes in reviewed batches (a11y and privacy items first — see #140/#141).
jared added this to the Features 2026-Q4 milestone 2026-09-17 23:06:35 -04:00
jared added the priority: mediumuxarea: threadsresearch labels 2026-09-17 23:06:35 -04:00
jared self-assigned this 2026-09-17 23:06:35 -04:00
Author
Owner

From #163: media posted inside threads never appears in the Media Gallery — thread replies are partitioned out of the room timeline by the SDK (room.partitionThreadedEvents), so neither the old live-timeline scan nor the new detached media timeline sees them. Include in this audit: decide whether the gallery should walk thread timelines (or the server-side filter should include thread relations) and whether the gallery tile should say which thread an item came from.

From #163: media posted **inside threads** never appears in the Media Gallery — thread replies are partitioned out of the room timeline by the SDK (`room.partitionThreadedEvents`), so neither the old live-timeline scan nor the new detached media timeline sees them. Include in this audit: decide whether the gallery should walk thread timelines (or the server-side filter should include thread relations) and whether the gallery tile should say which thread an item came from.
Author
Owner

From the #141 verification: each thread read receipt is POSTed twice for the same event (observed 4 /receipt/ requests for 2 events, identical bodies). ThreadPanel.tsx:150-176 runs markRead on both ThreadEvent.NewReply and RoomEvent.Timeline; lastReadEventIdRef should dedupe, so either both fire before the ref updates (it is set synchronously — unlikely) or two ThreadPanel instances are mounted. Low priority; fold into the audit.

From the #141 verification: each thread read receipt is POSTed **twice** for the same event (observed 4 `/receipt/` requests for 2 events, identical bodies). `ThreadPanel.tsx:150-176` runs `markRead` on both `ThreadEvent.NewReply` and `RoomEvent.Timeline`; `lastReadEventIdRef` should dedupe, so either both fire before the ref updates (it is set synchronously — unlikely) or two `ThreadPanel` instances are mounted. Low priority; fold into the audit.
Author
Owner

Threads audit — exercised end to end on the local dev homeserver (Playwright, desktop 1400px + Pixel 7)

Fixed on lotus (all pushed):

  • 58a716c7 — opening a thread on a pristine composer crashed the client (shared Slate initialValue; see #184). This alone explains most "threads feel broken" reports.
  • f2f49842 — every reply in the panel showed a Thread ↩ <root author> <root text…> quote because the spec's fallback reply relation (is_falling_back → root) was rendered like a real reply. Root is already pinned at the top, so that was noise on every row. Genuine reply-to-a-reply quotes stay. Before/after: the first screenshot on this issue vs the one below.
  • f2f49842 — no way to open the Threads list (or Widgets) on a phone: the header buttons were Desktop-only and the ⋮ menu had no entry. Both added next to Members / Media Gallery. (Closes #211.)
  • Thread receipts honour the privacy toggles (#141) and per-thread notifications behave Slack-style (#184) — verified separately.

Verified working: chips "N replies · time" on roots, replies never inline in the main timeline; panel = root + divider + reply timeline + own composer, opens scrolled to the latest reply; own reply updates the chip live; reply-to-a-reply produces a proper m.thread + non-fallback m.in_reply_to; Escape closes the panel; edit inside a thread keeps the thread relation and re-renders; reactions inside a thread work; deleting a reply drops the chip count; redacting the root while the panel is open leaves a stable "deleted" panel; Threads list with All/Unread/Participating + Recent/Oldest, participant avatars, "N replies · just now"; mobile panel is full-screen with Back.

Filed as follow-ups (need a decision or are bigger):

  • #217 [decide] visiting a room marks all its threads read, so thread unread badges never survive a glance at the room (Discord/Slack keep them until the thread is opened). Also causes the duplicate receipt seen in #141.
  • #218 thread panel + member drawer + timeline do not fit at 1400 px; composers collapse to "S…".
  • "Mark all threads read" (this issue's title) does not exist yet — keep this issue open for that, or fold it into #217 since the current behaviour already marks everything read on room visit.
  • Media posted inside threads is invisible to the Media Gallery (noted earlier).

panel after fix
list · mobile · chip

## Threads audit — exercised end to end on the local dev homeserver (Playwright, desktop 1400px + Pixel 7) **Fixed on `lotus`** (all pushed): - `58a716c7` — **opening a thread on a pristine composer crashed the client** (shared Slate `initialValue`; see #184). This alone explains most "threads feel broken" reports. - `f2f49842` — **every reply in the panel showed a `Thread ↩ <root author> <root text…>` quote** because the spec's fallback reply relation (`is_falling_back` → root) was rendered like a real reply. Root is already pinned at the top, so that was noise on every row. Genuine reply-to-a-reply quotes stay. Before/after: the first screenshot on this issue vs the one below. - `f2f49842` — **no way to open the Threads list (or Widgets) on a phone**: the header buttons were Desktop-only and the ⋮ menu had no entry. Both added next to Members / Media Gallery. (Closes #211.) - Thread receipts honour the privacy toggles (#141) and per-thread notifications behave Slack-style (#184) — verified separately. **Verified working:** chips "N replies · time" on roots, replies never inline in the main timeline; panel = root + divider + reply timeline + own composer, opens scrolled to the latest reply; own reply updates the chip live; reply-to-a-reply produces a proper `m.thread` + non-fallback `m.in_reply_to`; Escape closes the panel; edit inside a thread keeps the thread relation and re-renders; reactions inside a thread work; deleting a reply drops the chip count; redacting the root while the panel is open leaves a stable "deleted" panel; Threads list with All/Unread/Participating + Recent/Oldest, participant avatars, "N replies · just now"; mobile panel is full-screen with Back. **Filed as follow-ups (need a decision or are bigger):** - #217 **[decide]** visiting a room marks all its threads read, so thread unread badges never survive a glance at the room (Discord/Slack keep them until the thread is opened). Also causes the duplicate receipt seen in #141. - #218 thread panel + member drawer + timeline do not fit at 1400 px; composers collapse to "S…". - "Mark all threads read" (this issue's title) does not exist yet — keep this issue open for that, or fold it into #217 since the current behaviour already marks everything read on room visit. - Media posted inside threads is invisible to the Media Gallery (noted earlier). ![panel after fix](https://code.lotusguild.org/attachments/0af29802-339a-4bcf-85aa-03b1073b8cba) ![list · mobile · chip](https://code.lotusguild.org/attachments/b95b8788-2c5d-4a6a-bbe3-db429bc1fd82)
Author
Owner

Threads audit (2026-09-23) — driven headlessly, desktop 1300×850 + Pixel 7

Shipped from this audit

  • Mark all threads read — fdec3ed7. A chip in the Threads panel header, shown only when something is unread; one threaded receipt per unread thread (never the root, per the P6 rule). Verified: 2 unread threads → click → 0 unread rows, chip gone. mark all
  • Thread panel is a landmark — it is now <aside aria-label="Thread"> (6aa77552, from the a11y gate work) so screen readers can jump to it; the timeline inside is role="log" named "Thread timeline".

Verified working

  • Open from the "N replies · time" chip; Escape closes the panel; "Close thread" button present and labelled.
  • Per-thread drafts survive closing and reopening the panel (separate draft atom per threadRootId).
  • Threads list: All / Unread / Participating filters and Recent / Oldest sort, both persisted (cinny_threads_filter_v1 / _sort_v1); rows carry unread + highlight badges and a participants stack; rows announce "Open thread by X, unread, N replies".
  • Thread replies leave the main timeline (threadSupport) and the root keeps its summary chip; reactions on a thread root correctly go to the main timeline, replies to the thread (isRoot ? null : thread.id).
  • Threaded read receipts are sent from the panel; the room badge sums per-thread counts.
  • Encrypted rooms, search, forward and bookmarks all treat thread replies as ordinary events (no thread-specific breakage seen while testing #137/#151/#106).

Findings, ranked

  1. #217 — a reply in a thread you participate in is cleared by merely looking at the room. Reproduced: chip went from "1 reply" to "2 replies" with no unread marker at all while the room was open, and the Threads list showed 0 unread rows. This is markAsRead's per-thread half firing on every room visit. It also makes "Mark all read" mostly invisible unless you scroll up first. Highest-value fix, but it's the product call in #217 — my recommendation there is option (c): a room visit clears threads you have not participated in (they only lit the room dot), and leaves participating threads unread until the thread itself is opened. That matches Slack/Discord and keeps the room dot honest.
  2. Thread composer is permanently in compact mode. ThreadPanel passes compactLayout, so on a 360 px-wide desktop panel every secondary control (attach, formatting, sticker-vs-emoji split, location, poll, voice, schedule) hides behind "More actions" — the main composer shows them inline. Measured: main composer 12 inline controls, thread composer 3. At 360 px the collapse is defensible, but it should follow the panel's real width (the panel is already full-width under 750 px), not be hard-coded. Medium.
  3. No unread affordance on the chip itself. The "N replies" chip shows a count and a time but never an unread state, so even with #217 fixed there'd be nothing to see on the root message. Medium — pairs with #217.
  4. Threads list has no "jump to root" — a row opens the thread panel; there's no way to get to the root message in the main timeline from the list. Low.
  5. Panel header subtitle is the room name only; it doesn't say who started the thread or when, which is the first thing you want when you arrive from the list. Low.
  6. Two identical receipts per reply (panel + room-level) as noted in #217 — harmless, but it doubles receipt traffic in an active thread. Low.
  7. No keyboard shortcut to open/close the thread panel, and the chip is the only entry point from the timeline (the message menu has "Reply in thread" but nothing to view it). Low.

panel
list

## Threads audit (2026-09-23) — driven headlessly, desktop 1300×850 + Pixel 7 ### Shipped from this audit - [x] **Mark all threads read** — fdec3ed7. A chip in the Threads panel header, shown only when something is unread; one threaded receipt per unread thread (never the root, per the P6 rule). Verified: 2 unread threads → click → 0 unread rows, chip gone. ![mark all](https://code.lotusguild.org/attachments/5a0687c4-8c5d-477a-a33d-79cf84ac2f1d) - [x] **Thread panel is a landmark** — it is now `<aside aria-label="Thread">` (6aa77552, from the a11y gate work) so screen readers can jump to it; the timeline inside is `role="log"` named "Thread timeline". ### Verified working - [x] Open from the "N replies · time" chip; **Escape** closes the panel; "Close thread" button present and labelled. - [x] **Per-thread drafts survive** closing and reopening the panel (separate draft atom per `threadRootId`). - [x] Threads list: All / Unread / Participating filters and Recent / Oldest sort, both **persisted** (`cinny_threads_filter_v1` / `_sort_v1`); rows carry unread + highlight badges and a participants stack; rows announce "Open thread by X, unread, N replies". - [x] Thread replies leave the main timeline (threadSupport) and the root keeps its summary chip; reactions on a thread root correctly go to the **main** timeline, replies to the thread (`isRoot ? null : thread.id`). - [x] Threaded read receipts are sent from the panel; the room badge sums per-thread counts. - [x] Encrypted rooms, search, forward and bookmarks all treat thread replies as ordinary events (no thread-specific breakage seen while testing #137/#151/#106). ### Findings, ranked 1. **#217 — a reply in a thread you participate in is cleared by merely looking at the room.** Reproduced: chip went from "1 reply" to "2 replies" with **no unread marker at all** while the room was open, and the Threads list showed 0 unread rows. This is `markAsRead`'s per-thread half firing on every room visit. It also makes "Mark all read" mostly invisible unless you scroll up first. **Highest-value fix, but it's the product call in #217** — my recommendation there is option (c): a room visit clears threads you have *not* participated in (they only lit the room dot), and leaves participating threads unread until the thread itself is opened. That matches Slack/Discord and keeps the room dot honest. 2. **Thread composer is permanently in compact mode.** `ThreadPanel` passes `compactLayout`, so on a 360 px-wide desktop panel every secondary control (attach, formatting, sticker-vs-emoji split, location, poll, voice, schedule) hides behind "More actions" — the main composer shows them inline. Measured: main composer 12 inline controls, thread composer 3. At 360 px the collapse is defensible, but it should follow the panel's real width (the panel is already full-width under 750 px), not be hard-coded. Medium. 3. **No unread affordance on the chip itself.** The "N replies" chip shows a count and a time but never an unread state, so even with #217 fixed there'd be nothing to see on the root message. Medium — pairs with #217. 4. **Threads list has no "jump to root"** — a row opens the thread panel; there's no way to get to the root message in the main timeline from the list. Low. 5. **Panel header subtitle is the room name only**; it doesn't say who started the thread or when, which is the first thing you want when you arrive from the list. Low. 6. **Two identical receipts per reply** (panel + room-level) as noted in #217 — harmless, but it doubles receipt traffic in an active thread. Low. 7. **No keyboard shortcut** to open/close the thread panel, and the chip is the only entry point from the timeline (the message menu has "Reply in thread" but nothing to *view* it). Low. ![panel](https://code.lotusguild.org/attachments/a12fd28e-3241-4658-a546-896bfe7bf187) ![list](https://code.lotusguild.org/attachments/8c92ec15-67f8-4094-9a6a-cc123b38bfe9)
Author
Owner

More from the 2026-09-23 audit

  • 5. Header says who started the thread and when (64af736c). For example, "Thread Lab · started by alice, Fri 09:18 PM", using the user's clock and date settings. It fits the 360 px panel without clipping. The e2e aria snapshot was updated to match (3e69c715).
  • 4. "Go to message" from the Threads list (a932b199). Each row has a small button that takes the room timeline to the thread's first message. It sits beside the row button rather than inside it, so there are no nested buttons. On a phone it also closes the list, and the button is the larger touch size (34 px). Verified on desktop (the list stays open) and on a Pixel 7.
  • 2. Thread composer compact mode: no change needed. I made compact mode follow the composer's measured width and tested it. It made no difference, because under 750 px the whole app is already in phone layout, which makes every composer compact. Above that the panel is a fixed 360 px column, too narrow for the full set of controls. So hard-coding compactLayout is correct at every width, and I reverted the change.
  • Still open: 3. Unread state on the chip, which goes with the #217 decision; 6. Duplicate receipts (#217); 7. A keyboard shortcut and a "View thread" message action.
## More from the 2026-09-23 audit - [x] **5. Header says who started the thread and when (`64af736c`).** For example, "Thread Lab · started by alice, Fri 09:18 PM", using the user's clock and date settings. It fits the 360 px panel without clipping. The e2e aria snapshot was updated to match (`3e69c715`). - [x] **4. "Go to message" from the Threads list (`a932b199`).** Each row has a small button that takes the room timeline to the thread's first message. It sits beside the row button rather than inside it, so there are no nested buttons. On a phone it also closes the list, and the button is the larger touch size (34 px). Verified on desktop (the list stays open) and on a Pixel 7. - **2. Thread composer compact mode: no change needed.** I made compact mode follow the composer's measured width and tested it. It made no difference, because under 750 px the whole app is already in phone layout, which makes every composer compact. Above that the panel is a fixed 360 px column, too narrow for the full set of controls. So hard-coding `compactLayout` is correct at every width, and I reverted the change. - Still open: **3. Unread state on the chip**, which goes with the #217 decision; **6. Duplicate receipts** (#217); **7. A keyboard shortcut and a "View thread" message action.**
Author
Owner
  • 7 (part): a "View thread" action on the root (2b1f4ee5). The SDK gives a thread root its own id as threadRootId, so the !isThreadedMessage guard hid the thread action on the one message that actually has a thread. Roots now show View thread in the hover bar and View Thread (N replies) in the ⋮ menu, with the thread icon; other messages keep "Reply in Thread". Verified on a 14-reply root: both open the panel. A keyboard shortcut is still open.
- [x] **7 (part): a "View thread" action on the root (`2b1f4ee5`).** The SDK gives a thread root its own id as `threadRootId`, so the `!isThreadedMessage` guard hid the thread action on the one message that actually has a thread. Roots now show **View thread** in the hover bar and **View Thread (N replies)** in the ⋮ menu, with the thread icon; other messages keep "Reply in Thread". Verified on a 14-reply root: both open the panel. A keyboard shortcut is still open.
Author
Owner

Finishing the audit (2026-09-26)

  • 1 / #217: viewing a room cleared threads you follow. Fixed in a8db61f7; details in #217.

  • 3. Unread state on the chip. This already shipped with the a11y work in 206a3e93: a green dot on the chip, and "unread replies" in its accessible name. With #217 fixed the dot now survives while the room is open (screenshot in #217), and the new e2e asserts it.

  • 6. Duplicate receipts. One receipt per reply now, measured on the wire for both followed and unfollowed threads (see #217).

  • Media posted inside threads was missing from the Media Gallery (noted 2026-09-18). ba48e959: the SDK moves thread replies out of every room timeline set, including the gallery's, into the room's Thread objects. The gallery now merges in media from loaded threads, newest first, and refreshes when a thread reply arrives (after decryption in E2EE rooms).

    • Verified: a room with 1 image plus a thread with 2 images shows 3 tiles without ever opening the thread.
    • A thread image posted live appears as a 4th tile.
    • Clicking a thread tile opens the viewer on it.
    • Tiles don't say which thread they came from; Go to message in the viewer gets you there.
  • Related (#164, f5d3c43b): clicking an image inside the thread panel opened the viewer as 1 / 1. It now steps through the thread's own media.

  • 7. A dedicated keyboard shortcut: not adding one. The app's global shortcuts are deliberately just ? and Escape, and the thread panel is already fully keyboard-reachable:

    • Tab to the "N replies" chip or the View thread action (2b1f4ee5), then Enter to open it
    • Escape to close it.

    If you want a global hotkey anyway, say which key and I'll add it to the ? dialog.

That covers every finding in this audit, so I'm closing it.

gallery with thread media

## Finishing the audit (2026-09-26) - [x] **1 / #217: viewing a room cleared threads you follow.** Fixed in `a8db61f7`; details in #217. - [x] **3. Unread state on the chip.** This already shipped with the a11y work in `206a3e93`: a green dot on the chip, and "unread replies" in its accessible name. With #217 fixed the dot now survives while the room is open (screenshot in #217), and the new e2e asserts it. - [x] **6. Duplicate receipts.** One receipt per reply now, measured on the wire for both followed and unfollowed threads (see #217). - [x] **Media posted inside threads was missing from the Media Gallery** (noted 2026-09-18). `ba48e959`: the SDK moves thread replies out of every room timeline set, including the gallery's, into the room's Thread objects. The gallery now merges in media from loaded threads, newest first, and refreshes when a thread reply arrives (after decryption in E2EE rooms). - Verified: a room with 1 image plus a thread with 2 images shows **3 tiles** without ever opening the thread. - A thread image posted live appears as a 4th tile. - Clicking a thread tile opens the viewer on it. - Tiles don't say which thread they came from; **Go to message** in the viewer gets you there. - [x] **Related (#164, `f5d3c43b`):** clicking an image inside the thread panel opened the viewer as `1 / 1`. It now steps through the thread's own media. - **7. A dedicated keyboard shortcut: not adding one.** The app's global shortcuts are deliberately just `?` and Escape, and the thread panel is already fully keyboard-reachable: - Tab to the "N replies" chip or the **View thread** action (`2b1f4ee5`), then Enter to open it - Escape to close it. If you want a global hotkey anyway, say which key and I'll add it to the `?` dialog. That covers every finding in this audit, so I'm closing it. ![gallery with thread media](https://code.lotusguild.org/attachments/e2735cb8-0f39-4724-b958-ec69c29c834f)
jared closed this issue 2026-09-26 19:31:39 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#165