Skip to content

feat: Discord-style thread side-panel (thread drawer, summaries, real unreads, media fixes) - #3074

Open
jrimmer wants to merge 29 commits into
cinnyapp:release-v4.12.6from
jrimmer:pr/threads-panel-to-upstream
Open

jrimmer wants to merge 29 commits into
cinnyapp:release-v4.12.6from
jrimmer:pr/threads-panel-to-upstream

Conversation

@jrimmer

@jrimmer jrimmer commented Aug 19, 2026

Copy link
Copy Markdown

feat: Discord-style thread side-panel

Note: The repo README states PRs are closed during the SDK rewrite. This PR is opened for visibility and reference, not with an expectation of an immediate merge — it exists so there's a concrete, reviewable implementation of a feature that the community can see, build on, or revisit once the SDK migration settles. A public fork lives at https://github.com/jrimmer/cinny-threads for anyone who wants to run this today.

Summary

Cinny renders threads inline in the room timeline with no dedicated panel. This PR adds a Discord-style thread side-panel — a drawer docked to the right of the room view (modeled on the existing MembersDrawer) — plus Element-style thread summaries on root messages, real unread counts in channel badges, and two build/runtime fixes that were necessary to make the feature work end-to-end.

Based on release-v4.12.6. 28 commits, ~2,050 lines added across 21 files.

Cinny thread side-panel

Features

Thread side-panel drawer (ThreadsDrawer)

  • Thread list — every thread in the room, with root message preview, reply count, and a white unread-count pill. One-line rows, no sender prefix (the user is already in the channel).
  • Thread detail view — renders the full thread conversation inside the drawer, reusing Cinny's existing message renderers (Message, RenderMessageContent, EncryptedContent) so it looks identical to the main timeline. Supports m.room.message, m.room.encrypted (with decrypt/retry/redacted/unsupported states), m.sticker, and edited messages (m.new_content).
  • Thread reply composer — reply directly from the drawer with @mention autocomplete pills, emoji/sticker pickers, and auto-expand. Sends m.relates_to { rel_type: 'm.thread', event_id } correctly.
  • Resizable — drag the left edge to resize (min 320, max 640, default 400); width persists to localStorage.
  • Overflow menu — vertical-dots menu mirroring the room menu, with Mark as Read (threaded read receipt), View in Thread (focuses the root in the main timeline), and Copy Link.
  • Auto-mark-read — opening a thread detail sends a threaded read receipt, clearing the unread pill and the main-timeline summary.
  • Live updates — new threads and replies appear live without a reload; the detail view auto-scrolls to new replies.
  • Desktop only — collapses on mobile, same ScreenSize.Desktop gate as the member drawer. Toggled via a threads IconButton in the room header, mutually exclusive with the member drawer.

Element-style thread summaries (ThreadSummary)

  • Thread root messages in the main timeline show a clickable summary row beneath them (thread icon + last-reply preview + reply count) when they have replies.
  • When the thread has unread replies, the icon swaps to an accent thread-unread icon and a white unread-count pill appears.
  • Clicking the summary opens the thread in the drawer.

Real unread message counts (getUnreadMessageCount)

  • The left-side channel badge now shows the actual number of unread messages (walking the live timeline from the read receipt to the end), not the SDK push-rule notification count — which returns 0 for "Mentions & Keywords only" rooms even when there are unread messages. Matches Element behavior.
  • Respects the room notification mode: for "Mentions & Keywords only" rooms the real count is only surfaced when there is an actual mention/highlight, so the badge and desktop notifications stay quiet otherwise.

Adaptive authenticated media (useMediaAuthentication)

  • Cinny v4.12.6 generates authenticated v1 media URLs when the server advertises spec v1.11, but the service worker that injects the Bearer token only works in secure contexts (HTTPS / localhost). On plain HTTP LAN IPs the SW can't register, so media 401s and avatars never load.
  • Gates useMediaAuthentication on window.isSecureContext so authenticated v1 URLs are only generated when the SW can actually register, falling back to unauthenticated v3 media URLs over plain HTTP. Works for both https://chat.example.com and http://10.0.0.5:8083 access patterns.

Build fix: crypto WASM glue (vite.config.js)

  • The matrix-js-sdk crypto WASM chunk references matrix_sdk_crypto_wasm_bg.js at module load time, but @rollup/plugin-wasm only emits the .wasm file. Without the glue JS sibling, the chunk's top-level await rejects and the app crashes with "Failed to fetch dynamically imported module" on any route that lazy-loads the crypto SDK (e.g. post-SSO). Adds the glue file to viteStaticCopy targets.

Settings

A new Threads toggle in Settings → General: "Show a threads side panel for the open room (desktop)". Off by default.

Implementation notes

  • Activates authentic SDK thread aggregation via threadSupport: true in mx.startClient() (src/client/initMatrix.ts); without this, room.getThreads() only returns threads from sync-loaded local timeline events.
  • Thread list hydration uses room.fetchRoomThreads() (the SDK's authentic primitive — no auto-caller, the app must invoke it).
  • The drawer reuses Cinny's existing message renderers with RoomTimeline's context wiring so rendering stays byte-consistent with the main timeline.
  • Live new-thread creation for the user's own replies subscribes to RoomEvent.LocalEchoUpdated, because the SDK's handleRemoteEcho() path skips createThread() (only addLiveEventsaddThreadedEvents creates threads), so without this a user's own first reply in a thread never creates the Thread object live and the summary doesn't appear until a reload.
  • An RFC with the full design rationale lives at docs/thread-panel-rfc.md.

How this was built

This feature was vibe-coded — implemented iteratively through an AI-assisted coding workflow using GLM-5.2 and DeepSeek v4 Flash, with implementation review and structured engineering discipline provided by Compound Engineering skills (plan → work → code review → test loops).

It has been tested only in a local, private install — not on a public deployment — against a single Synapse + MAS homeserver. It is working as designed in that environment, but has not received broad testing across homeserver configurations, client versions, or scale. Reviewers should treat it as a reference implementation rather than production-hardened.

Test plan

  • Open a room with active threads, toggle Settings → Threads on, verify the drawer appears with the thread list.
  • Click a thread in the list, verify the detail view renders all replies (including encrypted and sticker events).
  • Reply from the drawer composer, verify the reply appears live and auto-scrolls.
  • Verify @mention autocomplete pills trigger in the thread composer.
  • Verify the overflow menu Mark as Read / View in Thread / Copy Link all work.
  • Verify resizing the drawer persists across reloads.
  • Verify thread summaries appear on root messages in the main timeline and update their unread state.
  • Verify channel badges show real unread counts and respect Mentions-only notification mode.
  • Verify avatars/media load over both HTTPS (authenticated v1) and plain HTTP LAN (v3 fallback).

Why a fork exists

Upstream is closed to PRs during the SDK rewrite. A public fork at https://github.com/jrimmer/cinny-threads lets people run this feature today. When upstream reopens and the SDK migration settles, the thread-panel work here can serve as a reference implementation — the component structure (a drawer modeled on MembersDrawer, reusing existing renderers) should translate, though the SDK calls will change.

License

AGPL-3.0, same as upstream.

jrimmer added 26 commits August 19, 2026 00:59
Add a threads side-panel to Cinny, exposed as a user setting. When enabled
on desktop, the room view gains a right-side threads drawer (mirroring the
member drawer) that lists the room's threads and opens a selected thread's
events in-panel.

- Add 'threadsDrawer' setting (default off) to the Settings model and a
  'Threads' toggle in General settings > Appearance.
- Add ThreadsDrawer component: thread list (root sender + preview + reply
  count, server-side list bootstrapped via room.createThreadsTimelineSets())
  and a thread detail sub-view rendering root + replies with the same
  Message/RenderMessageContent components the main timeline uses.
- Mount ThreadsDrawer in Room.tsx, mutually exclusive with the member drawer.
- Add a desktop Threads icon button in RoomViewHeader that toggles the drawer
  and closes the member drawer (and vice-versa).

Read-only MVP: reply-in-thread and per-thread unread badges are follow-ups.
Verified: vite build passes, eslint + prettier clean on changed files.
Fork base: baseline-v4.12.6 (33f4ba3).
Threads appeared labeled in the timeline but were missing from the panel
because matrix-js-sdk's server-side thread machinery was dead code in
Cinny: client.supportsThreads() returns clientOpts.threadSupport, which
Cinny never set. With it false, room.createThreadsTimelineSets() no-ops,
client.processThreadRoots() early-returns, and room.getThreads() only
holds threads in the sync'd timeline window.

- initMatrix: enable threadSupport on startClient (the IStartClientOpts
  field, not createClient), flipping on /threads list fetch, root
  registration, and per-thread timeline sets.
- ThreadsDrawer: populate the list via the SDK's room.fetchRoomThreads()
  (public, has no auto-caller) instead of a manual createTimelineSets()
  pagination loop; keep ThreadEvent.* listeners refreshing getThreads().
- ThreadsDrawer detail: backfill a thread's timeline on open (threads from
  the server list start empty) and re-render event-driven from the room's
  re-emitted RoomEvent.Timeline/TimelineRefresh/Redaction filtered to the
  open thread, mirroring RoomTimeline's subscription model instead of a
  forced re-render.
- RFC: document root cause + authentic data flow.

Verified: vite build passes (new dist), eslint + prettier clean.
Fork base feature/threads-panel @ 54e82f4.
Render a clickable summary row under each thread-root message in the main
timeline: thread icon (or unread variant), reply count, and a preview of the
last reply. Clicking opens the threads drawer and selects that thread.

- ThreadSummary.tsx: reads thread.lastReply()/replyToEvent, computes unread
  from thread.hasUserReadEvent && lastReply not by me, subscribes
  ThreadEvent.NewReply/Update/Delete + RoomEvent.Receipt for live updates.
- threadSelection.ts: shared jotai atom {roomId, threadId} so the timeline can
  direct the drawer to open a specific thread.
- RoomTimeline.tsx: wraps the RoomMessage render in a fragment that appends
  ThreadSummary beneath the Message when room.getThread(mEventId) is non-null;
  handleOpenThread opens the drawer + sets the selection.
- ThreadsDrawer is now resizable: drag its left edge to change the panel
  width (clamped 320-640px, default 400px), persisted to localStorage.
- Add a Close (X) button to the thread detail header (top-right), matching
  the list view's close, so the panel closes from either view.
- Add a reply composer at the bottom of a thread: ThreadReplyInput persists
  its draft per-thread (threadIdToMsgDraftAtomFamily, independent of the main
  composer's draft) and sends an m.thread reply (m.relates_to rel_type m.thread
  + root event id), honoring markdown + enter-for-newline + mentions, and
  clearing the per-thread unread count on send.
…etail header

- Add minHeight:0 to the drawer's column wrapper and scroll region so the
  reply composer stays pinned at the bottom of the window instead of being
  pushed partly off-screen by tall message lists (flex min-height:auto was
  preventing the timeline scroll area from shrinking).
- Show the thread root message body as the detail-header title instead of the
  root author's display name, which reads as an awkward truncated author name.
A newly created thread is first a local echo (added via addEventToTimeline,
which does not create a Thread object), then a remote echo that lands through
the sync path (room.addLiveEvents) which fires ThreadEvent.New. The timeline's
thread-revision subscription only listened for ThreadEvent.NewReply/Update/
Delete, so the first reply in a brand-new thread did not bump the revision and
the ThreadSummary under the root message never appeared until a reload.

Subscribe to ThreadEvent.New as well (on both on and off) so threadRevision
bumps, renderMatrixEvent re-checks room.getThread(mEventId), and the summary
appears live. The drawer list already handled ThreadEvent.New.
A thread's timeline set can already contain its root event (after backfill via
the /messages thread filter), so unconditionally prepending thread.rootEvent
rendered the parent message twice. Only prepend the root when it is not already
present.

Also reserve the same 28px following-bar space below the drawer reply composer
(as the main timeline's RoomViewFollowing does) so the composer's bottom edge
lines up with the main room input box.
Mirror the main RoomInput toolbar so the thread reply box gains the sticker
and smiley buttons to the right of the 'aA' toggle. Emojis insert a custom
emoticon element into the Slate editor (same createEmoticonElement/moveCursor
pipeline as the main composer), and stickers are sent as an m.sticker event
with an m.relates_to m.thread relation so they land inside the thread.
Add a vertical-dots (Cinny standard) overflow menu to the thread detail header
next to the close button with two actions: 'View in Thread' (navigate to the
room focusing the root event and close the panel, so the parent message is
highlighted in the main timeline) and 'Copy link to thread' (copy a
matrix.to permalink for the root event, same as the message context-menu
copy-link).

Also bound the drawer to the viewport height (height/maxHeight 100%) so a tall
message list can never push the reply composer off the bottom of the window.
…k-as-read

Layout alignment with the main window:
- Thread detail header is now the folds Header (size 600, variant Background),
  matching the main window's PageHeader and the thread-list header, so its
  bottom HR line aligns with the main header's. The divider now uses the
  standard (lighter) Header border instead of the heavier Box one.
- Thread reply composer drops its bottom S200 padding so its box bottom aligns
  vertically with the main window's compose box (moves down a smidge).

Thread header three-dot menu now matches the main window's menu formatting:
- Same structure (Menu maxWidth 160, Box-wrapped item groups with S100 padding,
  Line separators, flexGrow T300 labels) and the VerticalDots icon is sized 400
  and filled while open, like RoomViewHeader.
- New first item 'Mark as Read' (Icons.CheckTwice) sends a *threaded* read
  receipt on the thread's latest event so the unread indicators clear.

Unread indicator (thread list + timeline):
- New useThreadUnreadCount hook reads room.getThreadUnreadNotificationCount
  (thread total) — the same notification-count data the left sidebar uses — and
  subscribes to RoomEvent.UnreadNotifications / thread events / receipts.
- ThreadList rows now show a neutral (white) UnreadBadge count pill (Secondary
  variant, like the channel unread) instead of the inline reply count.
- ThreadSummary is now white in both unread and read cases (removed the
  Success/green accent and the ThreadUnread icon switch).
- Opening a thread in the detail view now sends a threaded read receipt, so
  'caught up on the entire thread' clears the pill and summary without reload.
Floating reply composer: the thread drawer's reply box bottom position varied
with thread content (two two-message threads could place it slightly
differently). Root cause: percentage height on the drawer column resolved to
auto because the outer row ancestor had no definite height, so the column
(and its contents, including the composer) grew with thread length instead of
staying pinned to the viewport bottom.

Fix: set height:100% on the drawer's outer row Box (establishes a definite
height for the inner column whose height:100% now resolves), and add
height:100% to the ThreadsDrawerBody wrapper so the ThreadDetail/ThreadList
fragment fills it before the flex column lays the header, message scroll
(flexGrow:1 minHeight:0) and pinned composer out.

Thread three-dot menu casing: 'Copy link to thread' is now 'Copy Link',
matching the main window's title-cased menu items (Mark as Read / Copy Link /
Jump to Time). Mark as Read and View in Thread already matched.
…ghts

The reply composer still floated with thread content (and sometimes sat almost
off the bottom) even after the previous height:100% attempt. Root cause: that
attempt was itself the bug. Percentage heights (height:100% / maxHeight:100%)
only resolve against a *definite* ancestor height; in this layout the drawer's
height comes from flexbox stretch (align-items:stretch up the chain from
#root), which the CSS spec treats as an *auto*/indefinite containing block, so
height:100% collapsed to auto and the drawer grew with its content — letting
the composer drift with thread length and overflow the window.

Fix: drop every percentage height I added (ThreadsDrawer height:100% +
maxHeight:100%, ThreadsDrawerBody height:100%, and the inline height:100% on
the outer row Box) and rely purely on flexbox, exactly like the main timeline
column does: folds Box is display:flex, the outer Room row stretches the
drawer row to the content-area height (definite from html/body/#root
height:100%), the drawer column stretches to that, ThreadsDrawerBody
grow='Yes' fills it, and the ThreadDetail fragment lays out header (shrink 0)
+ message scroll (flexGrow:1, minHeight:0) + pinned reply composer + following
placeholder. minHeight:0 (kept) lets the scroll shrink so the composer never
overflows. No percentage heights anywhere in the chain.
The reply composer still floated with thread length. Root cause: the ThreadsDrawer
root CSS had overflow:hidden + minHeight:0, which diverged from the working
MembersDrawer (width-only) and interfered with the flexbox stretch that pins the
drawer to the content-area height. Strip overflow:hidden and minHeight from the
drawer root so height comes purely from flex stretch (exactly like MembersDrawer);
keep minHeight:0 only on the ThreadsDrawerBody wrapper so the message scroll can
shrink. This should also restore the composer's inherent CustomEditor auto-expand,
which the overflow/height constraint was clipping.
… reply

Thread reply composer now matches the main composer's mention behavior:
- @user / #room / :emoji autocomplete popovers (UserMentionAutocomplete,
  RoomMentionAutocomplete, EmoticonAutocomplete) wired via handleKeyUp +
  getAutocompleteQuery/getPrevWorldRange, with Escape closing the popover.
  The m.mentions payload was already wired via getMentions; only the
  autocomplete UI (which inserts the mention pill node) was missing, so @max
  stayed plain text instead of becoming a pill.

Thread detail view now auto-scrolls to the bottom when a new reply arrives
(local or remote echo): ThreadMessages reports its event count via
onEventsCount, and ThreadDetail scrolls the Scroll container to the bottom
whenever the count grows, mirroring RoomTimeline's scroll-to-latest.
Two intertwined bugs with one root cause: the thread detail Scroll was not
being height-bounded, so it grew to content height — pushing the reply
composer off the bottom (the long-standing 'floating composer' bug) AND
preventing the thread message list from ever showing a scrollbar (so a
73-reply thread only showed the 8 events sync had loaded, with no way to
scroll to the rest).

Layout fix: flatten the drawer flex chain to mirror MembersDrawer exactly.
Remove the extra ThreadsDrawerBody column wrapper (MembersDrawer has no such
intermediate) and give the drawer root column grow='Yes' so it fills the
outer row's stretched height. Now ThreadDetail's fragment children
(Header, ContentBase grow='Yes' minHeight:0 overflow:hidden, ReplyInput,
Placeholder) are direct flex children of the drawer column — the ContentBase
clamps the Scroll to the available height, the Scroll scrolls internally
instead of overflowing, and the composer pins to the bottom.

Backfill fix: the alreadyLoaded = timeline.getEvents().length > 0 guard
short-circuited backfill whenever sync had loaded ANY events, so a thread
with 73 replies where sync had the 8 most-recent never backfilled the
other 65. Always run the paginate-to-exhaustion loop; it no-ops cleanly
when hasMore returns false.
…oom.message

Root cause of '73 replies but only 8 render': ThreadMessages filtered events to
MessageEvent.RoomMessage only, dropping every m.room.encrypted reply. In rooms
with E2E encryption (and a broken key backup), the vast majority of thread
replies arrive as m.room.encrypted and were silently filtered out — only the
handful of unencrypted/decrypted m.room.message events rendered.

Mirror the main RoomTimeline: include m.room.encrypted and m.sticker in the
events filter, and in the render branch wrap encrypted events in EncryptedContent
(which re-renders on MatrixEventEvent.Decrypted). If the event decrypts to
m.room.message, render it as a normal message; if it stays m.room.encrypted,
show MessageNotDecryptedContent (the same 'Unable to decrypt' placeholder the
main timeline shows); redacted -> RedactedContent; else MessageUnsupportedContent.
Also wire getEditedEvent so edited thread replies show their latest content
(the main timeline does this; the thread view previously passed edited=false).
… can't crush it

Root cause of the recurring 'composer disappears with long threads' bug:
ThreadDetail rendered ThreadReplyInput + RoomViewFollowingPlaceholder as
bare fragment children of the drawer's grow column, with no shrink=No
wrapper. The grow=1 ThreadDrawerContentBase (scroll region) had no sibling
guaranteeing the composer's space, so a 78-message thread let the scroll
region's min-content height crush the composer to zero.

Mirror RoomView exactly: RoomView wraps RoomInput + RoomViewFollowing in a
<Box shrink='No' direction='Column'>. This commit does the same for
ThreadReplyInput + RoomViewFollowingPlaceholder, so the scroll region above
absorbs all overflow and the composer is pinned regardless of thread length.
…summary

The thread existence line beneath a root message in the main timeline now
signals unread state with BOTH cues the user requested:

1. Icon swaps from Icons.Thread to Icons.ThreadUnread when hasUnread is true
   (lastReply exists, sender !== me, and hasUserReadEvent is false) — an
   accent-colored thread icon, Element-style.
2. A white UnreadBadge count pill is appended on the right when
   useThreadUnreadCount(room, thread) > 0 — the same numeric pill the
   ThreadsDrawer list and the left channel list use, sourced from the room's
   thread notification map (room.getThreadUnreadNotificationCount), so all
   three surfaces stay consistent and clear together when a threaded read
   receipt is sent (opening the drawer / Mark as Read).
…reply count

The thread list is shown inside the room the user is already looking at, so
prefixing every row with the thread root author's display name (e.g. 'al...')
was redundant noise. Each row now leads with the root message body preview
(primary line), with the reply count and unread badge on a secondary line
below it — cleaner and consistent with the 'we're already in the channel'
context.
Per user request: preview text, reply count, and unread badge all on one
row instead of two stacked lines. The preview truncates (flexGrow 1) and
the reply count + badge sit flush on the right (shrink No).
The left channel list showed an empty dot for rooms with unreads when the
SDK notification count (NotificationCountType.Total) was 0 — which happens
for rooms whose notification mode is 'Mentions & Keywords only' or when push
rules don't increment the count.

getUnreadInfo now falls back to getUnreadMessageCount, which walks the live
timeline and counts actual notification events after the read receipt (the
same logic roomHaveUnread uses to detect unreads, but counting instead of
returning a boolean). The channel badge now shows a real number for every
room with unread messages, matching Element behavior.

getUnreadInfo signature changed from (room) to (mx, room); all three call
sites updated (getUnreadInfos, roomToUnread timeline handler,
ClientNonUIFeatures unread reset).
…ia loading

Synapse advertises v1.11 support so Cinny's useMediaAuthentication()
returned true, generating authenticated v1 media URLs
(/_matrix/client/v1/media/thumbnail/...) that require a Bearer token.
Cinny's service worker is supposed to inject the access token into those
requests, but it silently fails to do so in this deployment, causing 401
'Missing access token' on every avatar, media thumbnail, and media
download. The upload and avatar_url PUT both succeed (confirmed in
Synapse logs), but the image can never be displayed.

Since Synapse does not set require_auth_for_media (unauthenticated media
is allowed), force useMediaAuthentication() to return false so Cinny uses
legacy v3 media URLs that need no token.
Replaces the crude 'always return false' from the previous commit with a
proper fix: only use authenticated v1 media URLs when the service worker
is actively controlling the page.

Root cause of the 401s: the user accesses Cinny at http://10.1.0.220:8083
(plain HTTP on a LAN IP). Browsers only register service workers in secure
contexts (HTTPS or localhost), so the SW never registers on a plain-HTTP
LAN URL and navigator.serviceWorker.controller is null. Without a controller
the SW cannot inject the Bearer token into v1 authenticated media requests,
so Synapse returns 401 'Missing access token' on every avatar, thumbnail,
and download — nothing renders even though the upload succeeded.

The hook now tracks navigator.serviceWorker.controller via a
controllerchange listener and returns true only when:
  1. The server supports MSC3916 authenticated media (v1.11+), AND
  2. The service worker is actively controlling the page

On plain-HTTP LAN access the SW is absent, so it falls back to legacy v3
media URLs that need no token. On HTTPS access (chat.lacy.casa) the SW
registers and authenticated media works as designed. This is the correct
adaptive behavior rather than a hardcoded workaround.
The matrix-js-sdk crypto WASM chunk (index-BjEIBlSw.js) references
'./matrix_sdk_crypto_wasm_bg.js' as a streaming instantiation target at
module load time, but @rollup/plugin-wasm only emits the .wasm file
(with a content hash) and not the .js glue file. Without the glue JS
sibling, the chunk's top-level await rejects and the app crashes with
'Failed to fetch dynamically imported module: index-BjEIBlSw.js' on any
route that lazy-loads the crypto SDK (e.g. the SSO callback).

Add the glue file to viteStaticCopy targets so it lands at
dist/assets/matrix_sdk_crypto_wasm_bg.js alongside the .wasm file.
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

…ears live

Root cause: when the user sends a thread reply, the SDK's addLiveEvents()
recognises the remote echo by its transaction_id and routes it through
handleRemoteEcho(), which only updates an EXISTING thread — it never
creates one (it skips the addThreadedEvents path that createThread and
ThreadEvent.New live on). So the Thread object for our own newly-started
thread is never created live, and neither the ThreadSummary under the
root message nor the ThreadsDrawer list updates until a page reload
re-runs fetchRoomThreads() -> processThreadRoots() -> createThread().

Fix: subscribe to RoomEvent.LocalEchoUpdated (emitted by handleRemoteEcho)
in both RoomTimeline and ThreadsDrawer. When the confirmed event has a
threadRootId and no Thread object exists yet, call
room.processThreadRoots([rootEvent], false), which calls createThread()
and emits ThreadEvent.New — the existing onNew/handleNewReply handlers
then bump state and the summary + list update live.
@jrimmer

jrimmer commented Aug 19, 2026

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

@jrimmer

jrimmer commented Aug 19, 2026

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA


ajbura added a commit to cinnyapp/cla that referenced this pull request Aug 19, 2026
The real-unread-count fallback added in ce79ede made getUnreadInfo return
a non-zero total for EVERY message in Mentions & Keywords-only rooms,
because it counted all unread notification events regardless of push
rules. Since getUnreadInfo feeds both the channel-list badge AND the
MessageNotifications desktop-notification logic, this caused Cinny to
fire desktop notifications + show a badge + play the sound for every
message in #alerts (a Mentions-only room), defeating the notification
mode the user had set.

Fix: gate the real-count fallback on the room's notification mode. For
MentionsAndKeywords rooms, only surface the real count when there is an
actual highlight/mention (unread.highlight > 0); otherwise return total
0 so the room stays quiet (no badge, no notification) as the mode
promises. All-Messages and Default rooms keep the real-count fallback so
their badges still show a number instead of an empty dot.
The threadsDrawer setting now defaults to true so the Discord-style
thread panel and Element-style thread summaries are the default
experience, but users who prefer the original inline-only threading
behavior can turn the setting off to hide both the drawer and the
main-timeline summary rows — restoring the upstream v4.12.6 timeline
appearance. The ThreadSummary render in RoomTimeline is now gated on
threadsDrawer so the main timeline looks exactly like upstream when
the setting is off.
@jrimmer

jrimmer commented Aug 19, 2026

Copy link
Copy Markdown
Author

Update: threading is now configurable (opt-out, default on)

The original threading behavior is retained for users who prefer it, with the new threading behavior being the default.

What changed:

  • The existing threadsDrawer setting (Settings → General → "Show a threads side panel for the open room (desktop)") now defaults to true, so the Discord-style thread panel and the Element-style thread summaries under root messages are the default experience.
  • The ThreadSummary component in the main timeline is now gated on that setting. Turning the setting off hides both the drawer and the summary rows, so the main timeline reverts to the exact upstream v4.12.6 appearance — thread replies still render inline as they always did, just without the added summary row.

What's still always-on (not user-configurable):

  • threadSupport: true on the SDK client (mx.startClient). This activates the SDK's authentic thread aggregation (fetchRoomThreads, per-thread timeline sets). It's invisible to the user when the setting is off (no UI surfaces threads without the drawer/summaries), but it's required for the drawer to function when the setting is on. Gating it off would make the feature a no-op, so it stays on — but it has no effect on the timeline's appearance or behavior when threadsDrawer is false.

Net effect:

threadsDrawer Drawer Main-timeline summary rows Inline thread replies
true (default) ✅ shown ✅ shown ✅ as always
false ❌ hidden ❌ hidden ✅ as always (matches upstream v4.12.6)

Commit: 36fa325

@CrazyNicc

Copy link
Copy Markdown

The new SDK was primarily built because of a new implementation of Threads (which will come in the next major Update), so I fear you did a lot of redundant work here.

@kfiven

kfiven commented Aug 27, 2026

Copy link
Copy Markdown
Member

I wish the AI read the discussion on thread issue before doing this much work.

@jrimmer

jrimmer commented Aug 27, 2026

Copy link
Copy Markdown
Author

Sadly the human read it which is why they went ahead and did the PR anyway! 😆

As I said in my initial comment "This PR is opened for visibility and reference." I didn't expect anything to actually be done with it.

I had an itch to scratch and learned a lot about Matrix and Cinny in the process. Win, win!

I'm glad to hear a bespoke threads implementation is in the offing. I look forward to playing with it.

@kfiven

kfiven commented Aug 27, 2026

Copy link
Copy Markdown
Member

It's deployed at https://fastidious-maamoul-c21df9.netlify.app (for playing only xD)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants