Open
Conversation
- Extract useMessageEdit hook (shared by RoomTimeline + ThreadDrawer) - Update useTimelineActions to accept handleEdit callback directly - Add skipThreadFilter option to useProcessedTimeline - Delete ThreadMessage component; ThreadDrawer now uses useProcessedTimeline + useTimelineEventRenderer, inheriting all future RoomTimeline improvements
…ical threads in ThreadBrowser
ThreadDrawer: when no Thread object exists for a thread root (e.g. the
root was loaded via jump/backward-pagination but the SDK has not yet
created a Thread shell for it), call room.createThread() so that the SDK
constructor triggers updateThreadMetadata() which handles
resetLiveTimeline() + paginateEventTimeline() internally. Previously we
returned early with no thread, leaving the drawer empty forever. We no
longer call resetLiveTimeline()/paginateEventTimeline() ourselves, which
eliminates the race that caused "Ignoring event does not belong in
timeline" console warnings.
ThreadBrowser: call room.fetchRoomThreads() on mount so the SDK fetches
all thread roots from /rooms/{id}/threads rather than only showing threads
whose root events happened to fall inside the current timeline window.
fetchRoomThreads() is idempotent (room.threadsReady guard) so calling it
on every browser open is safe. Each newly-discovered thread emits
ThreadEvent.New which triggers a re-render so the list populates
progressively.
When a thread is discovered via fetchRoomThreads() its root event is stored in thread.rootEvent but is not indexed in the main room timeline, so room.findEventById() returns undefined and the live-event scan yields nothing. The old Case C fell through to forceUpdate() which re-rendered to the same empty state. Fix: when liveEvents is empty, check thread.length (public getter for replyCount + pendingReplyCount, populated from bundled server relations). If the server says replies exist, reset the thread timeline (safe: SDK init is complete because initialEventsFetched=true) and paginate backwards to load them from the server. The existing RoomEvent.Timeline listener triggers a re-render as events arrive.
…out-of-window threads When a thread's root event is outside the sliding-sync window, use thread.rootEvent as a fallback (populated by ThreadBrowser's createThread call from the /threads API response). When root is not in the local timeline at all, fetch it safely via mx.fetchRoomEvent() — unlike mx.getEventTimeline(), this has no side effects on the main timeline. Add a 'Load older replies' chip that appears when getPaginationToken(Direction.Backward) is non-null, and calls paginateEventTimeline to load additional reply pages. ThreadBrowser: prefer local counted replies; fall back to server-reported thread.length when local events haven't been loaded yet. useTimelineEventRenderer: add hideThreadChip option to suppress the thread chip inside the thread When a thread's root event is q
…hide thread chip in drawer
- ThreadBrowser: merge two mount effects into a single sequential async flow
(createThreadsTimelineSets -> fetchRoomThreads -> paginateEventTimeline) so
the SDK's early-return guard in fetchRoomThreadList does not silently no-op
when threadsTimelineSets is still empty from a parallel run
- ThreadDrawer: replace the !currThread.length guard with serverFetchAttemptedRef
to fix blank drawer with sliding sync (server omits bundled aggregations so
thread.replyCount stays 0 even when replies exist); add forceUpdate() after
paginateEventTimeline so replies appear immediately
- ThreadDrawer: capture hasMore from paginateEventTimeline and clear the
'Load older replies' button when the last page is reached
- useTimelineEventRenderer: pass threadRootId={hideThreadChip ? undefined : threadRootId}
to Reply at all three call sites so the thread chip is suppressed inside the
thread drawer (which sets hideThreadChip: true)
- DevelopTools: expose room.threadsReady flag in thread diagnostics panel and
reset it before fetchRoomThreads() in the dev-tools fetch action
The effect that handles thread initialisation (Cases A/B/C) was missing forceUpdateCounter from its dependency array. The consequence: - On mount the effect runs and hits Case B (initialEventsFetched=false while the SDK's updateThreadMetadata() is still running) and returns. - ThreadEvent.Update fires when the SDK finishes, calling forceUpdate((n) => n+1) which increments forceUpdateCounter. - Because the effect deps were [mx, room, threadRootId, forceUpdate] and forceUpdate is the stable useState setter (not the counter), React did not re-run the effect. - The drawer stayed empty forever with sliding sync, where replyCount starts at 0 (no bundled aggregations), so Case B always ran on the first pass. Fix: add forceUpdateCounter to the effect deps so it re-runs after the SDK finishes initialising the thread. The serverFetchAttemptedRef guard already prevents infinite re-fetch loops once the server fetch is done.
…e SS window
fetchRoomThreads() internally calls room.createThread() using
room.findEventById() to get the rootEvent -- but for thread roots that
aren't in the sliding-sync timeline cache, findEventById() returns
undefined, leaving Thread.rootEvent=undefined. ThreadPreview bails early
with "if (!rootEvent) return null", so those threads are invisible.
The loop after paginateEventTimeline already had the correct events from
the threads endpoint in allThreadsSet.getLiveTimeline(). Extend it to
also handle the case where the thread already exists but has no rootEvent:
- Set existingThread.rootEvent = event (the one we have from /threads)
- Call setEventMetadata(event) to initialise room state on the event
- Seed replyCount from the bundled m.thread aggregation in the event's
unsigned so the preview shows the correct count immediately (before the
SDK's async fetchRootEvent updates it)
Same fix applied in handleLoadMore for consistency.
Two bugs fixed: 1. Threads in sliding-sync window showed as empty (0 replies): SS delivers thread root events without bundled aggregations, so room.createThread() sets replyCount=0 and the SDK fast-path fires (initialEventsFetched=true, no server fetch). Later, fetchRoomThreads returns events WITH bundled counts, but createThread() is idempotent and returns the stale thread unchanged — replyCount stays 0 forever. Fix: in the ThreadBrowser backfill loop, update replyCount from the bundled aggregations for ANY thread with replyCount===0, not just threads with undefined rootEvent. 2. Clicking a thread caused the app to hang/crash (classic and SS): Case C in ThreadDrawer called resetLiveTimeline() before paginating. Resetting a thread timeline emits RoomEvent.TimelineReset, which Thread's onTimelineReset handler handles by awaiting processRootEventPromise — a live fetchRootEvent() network call. If that promise is still in flight this deadlocks and eventually crashes the app. Fix: remove resetLiveTimeline() entirely. paginateEventTimeline with a null back-token passes from=undefined to fetchRelations, which correctly fetches the latest replies.
mx.supportsThreads() returns clientOpts.threadSupport || false. Without threadSupport:true, ALL of the following are no-ops or bail: - createThreadsTimelineSets() returns null (kills ThreadBrowser loadThreads) - fetchRoomThreads() returns early (threadsReady stays false) - processThreadRoots() returns early (threads outside SS window never created) Threads whose root events arrive via sliding sync happen to get Thread objects created through a direct createThread() call, but any thread whose root is outside the current SS window is invisible entirely. Adding threadSupport:true means: - partitionThreadedEvents routes thread replies to thread timelines - processThreadRoots actually creates Thread objects for all 4 threads - updateThreadMetadata properly fetches replies (initialEventsFetched transitions correctly) - ThreadBrowser shows all threads from the server /threads endpoint
…aders, auto-load all replies
29282cb to
740e69c
Compare
Contributor
There was a problem hiding this comment.
Pull request overview
Adds end-to-end Matrix thread support across the room timeline: a thread drawer for reading/replying in-thread, a thread browser listing room threads (with pagination), and live reply/unread indicators, backed by Matrix SDK threadSupport.
Changes:
- Enable SDK thread tracking by passing
threadSupport: truetostartClient(classic + sliding sync). - Introduce shared edit-mode hook (
useMessageEdit) and wire it throughRoomTimeline/timeline actions andThreadDrawer. - Implement thread UI primitives:
ThreadDrawerported to the shared timeline rendering pipeline,ThreadBrowserbacked by/threads+ pagination/backfill, and live reply/unread badges on timeline thread chips.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/client/initMatrix.ts | Enables Matrix SDK thread support in both classic and sliding sync start paths. |
| src/app/hooks/useMessageEdit.ts | New hook to centralize edit-mode state/reset/focus behavior for composers. |
| src/app/hooks/timeline/useTimelineEventRenderer.tsx | Adds live thread reply/unread chip updates; supports suppressing thread chip/reply headers inside drawer. |
| src/app/hooks/timeline/useTimelineActions.ts | Refactors timeline actions to accept shared handleEdit instead of owning edit state. |
| src/app/hooks/timeline/useProcessedTimeline.ts | Adds skipThreadFilter to allow processing thread timelines without filtering out replies. |
| src/app/features/room/ThreadDrawer.tsx | Reworks thread drawer to use shared timeline processing/rendering; adds sequential loading/backfill and edit-last-message support. |
| src/app/features/room/ThreadBrowser.tsx | Adds server-backed thread list with load-more pagination, root-event backfill, and unread badges. |
| src/app/features/room/RoomTimeline.tsx | Adopts useMessageEdit and passes handleEdit into timeline actions. |
| src/app/features/common-settings/developer-tools/DevelopTools.tsx | Adds thread diagnostics + “fetch from server” action for debugging thread state. |
| .changeset/feat-thread-enhancements.md | Declares a minor release note for thread support features. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- useTimelineEventRenderer.tsx: Use Math.max(thread.length, replyEvents.length) to avoid undercounting replies when only a subset of events is loaded locally. - ThreadBrowser.tsx: Use Math.max(thread.length, localReplyCount) to show correct total even for threads whose timeline hasn't been fully paginated. - ThreadDrawer.tsx: Align showClientUrlPreview logic with RoomTimeline: require both clientUrlPreview AND encClientUrlPreview in encrypted rooms. - useMessageEdit.ts: Minor wording: 'never stales' → 'never goes stale'.
Just-Insane
added a commit
to Just-Insane/Sable
that referenced
this pull request
Mar 28, 2026
- useTimelineEventRenderer.tsx: Use Math.max(thread.length, replyEvents.length) to avoid undercounting replies when only a subset of events is loaded locally. - ThreadBrowser.tsx: Use Math.max(thread.length, localReplyCount) to show correct total even for threads whose timeline hasn't been fully paginated. - ThreadDrawer.tsx: Align showClientUrlPreview logic with RoomTimeline: require both clientUrlPreview AND encClientUrlPreview in encrypted rooms. - useMessageEdit.ts: Minor wording: 'never stales' → 'never goes stale'.
Replace Math.max approach with server-authoritative bundledCount logic to prefer the accurate server total over locally-loaded events. This aligns feat/thread-enhancements with the bundledCount approach used in integration branch.
Updated thread features with various fixes and improvements.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR dependency (stacked)
This PR currently includes shared infra/plumbing commits that are introduced in #572.
Please review/merge #572 first, then review this PR for thread-specific behavior.
Description
Updates Matrix thread support: a slide-in drawer for reading/replying in threads, a thread browser, and live thread indicators.
Fixes #
Type of change
Checklist:
AI disclosure:
ThreadDrawer/ThreadBrowser/thread-chip iterations were partially AI assisted; I reviewed and adjusted logic for timeline processing, thread loading, and event rendering behavior.