Skip to content

feat: Update Matrix threads#564

Open
Just-Insane wants to merge 32 commits intoSableClient:devfrom
Just-Insane:feat/thread-enhancements
Open

feat: Update Matrix threads#564
Just-Insane wants to merge 32 commits intoSableClient:devfrom
Just-Insane:feat/thread-enhancements

Conversation

@Just-Insane
Copy link
Copy Markdown
Contributor

@Just-Insane Just-Insane commented Mar 28, 2026

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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings

AI disclosure:

  • Partially AI assisted (clarify which code was AI assisted and briefly explain what it does).
  • Fully AI generated (explain what all the generated code does in moderate detail).
    ThreadDrawer/ThreadBrowser/thread-chip iterations were partially AI assisted; I reviewed and adjusted logic for timeline processing, thread loading, and event rendering behavior.

- 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
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
@Just-Insane Just-Insane force-pushed the feat/thread-enhancements branch from 29282cb to 740e69c Compare March 28, 2026 02:33
@Just-Insane Just-Insane changed the title feat(threads): add full thread support with ThreadDrawer and ThreadBrowser feat: add Matrix thread support Mar 28, 2026
@Just-Insane Just-Insane marked this pull request as ready for review March 28, 2026 02:48
@Just-Insane Just-Insane requested review from 7w1 and hazre as code owners March 28, 2026 02:48
Copilot AI review requested due to automatic review settings March 28, 2026 02:48
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: true to startClient (classic + sliding sync).
  • Introduce shared edit-mode hook (useMessageEdit) and wire it through RoomTimeline/timeline actions and ThreadDrawer.
  • Implement thread UI primitives: ThreadDrawer ported to the shared timeline rendering pipeline, ThreadBrowser backed 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.
@Just-Insane Just-Insane changed the title feat: add Matrix thread support feat: Update Matrix threads Mar 28, 2026
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.

2 participants