Conversation
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.
|
All contributors have signed the CLA ✍️ ✅ |
…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.
|
I have read the CLA Document and I hereby sign the CLA |
|
I have read the CLA Document and I hereby sign the CLA |
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.
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:
What's still always-on (not user-configurable):
Net effect:
Commit: 36fa325 |
|
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. |
|
I wish the AI read the discussion on thread issue before doing this much work. |
|
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. |
|
It's deployed at https://fastidious-maamoul-c21df9.netlify.app (for playing only xD) |
feat: Discord-style thread side-panel
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.Features
Thread side-panel drawer (
ThreadsDrawer)Message,RenderMessageContent,EncryptedContent) so it looks identical to the main timeline. Supportsm.room.message,m.room.encrypted(with decrypt/retry/redacted/unsupported states),m.sticker, and edited messages (m.new_content).@mentionautocomplete pills, emoji/sticker pickers, and auto-expand. Sendsm.relates_to { rel_type: 'm.thread', event_id }correctly.localStorage.ScreenSize.Desktopgate as the member drawer. Toggled via a threadsIconButtonin the room header, mutually exclusive with the member drawer.Element-style thread summaries (
ThreadSummary)Real unread message counts (
getUnreadMessageCount)Adaptive authenticated media (
useMediaAuthentication)useMediaAuthenticationonwindow.isSecureContextso authenticated v1 URLs are only generated when the SW can actually register, falling back to unauthenticated v3 media URLs over plain HTTP. Works for bothhttps://chat.example.comandhttp://10.0.0.5:8083access patterns.Build fix: crypto WASM glue (
vite.config.js)matrix_sdk_crypto_wasm_bg.jsat module load time, but@rollup/plugin-wasmonly emits the.wasmfile. 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 toviteStaticCopytargets.Settings
A new Threads toggle in Settings → General: "Show a threads side panel for the open room (desktop)". Off by default.
Implementation notes
threadSupport: trueinmx.startClient()(src/client/initMatrix.ts); without this,room.getThreads()only returns threads from sync-loaded local timeline events.room.fetchRoomThreads()(the SDK's authentic primitive — no auto-caller, the app must invoke it).RoomTimeline's context wiring so rendering stays byte-consistent with the main timeline.RoomEvent.LocalEchoUpdated, because the SDK'shandleRemoteEcho()path skipscreateThread()(onlyaddLiveEvents→addThreadedEventscreates threads), so without this a user's own first reply in a thread never creates theThreadobject live and the summary doesn't appear until a reload.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
@mentionautocomplete pills trigger in the thread composer.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.