+ );
+}
+
+// Drawer body — handles all the loading / error states the plan calls
+// out (null Thread, fetchRoomEvent failure, paginate failure, post-load
+// empty). The component is intentionally light on chrome compared to
+// the full Message renderer in the timeline: M2 is MVP and rich
+// reactions / hover-menus / rail-dot inside the drawer is M9 territory.
+export function ThreadDrawer({
+ room,
+ rootId,
+ parentRoomPath,
+ variant,
+}: ThreadDrawerProps) {
+ const { t } = useTranslation();
+ const mx = useMatrixClient();
+ const navigate = useNavigate();
+ const editor = useEditor();
+ const fileDropContainerRef = useRef(null);
+ const closeBtnRef = useRef(null);
+ // Sentinel at the bottom of the replies list. The autoscroll effect
+ // below scrolls into view on (a) initial reveal, (b) reply count
+ // growth while at-bottom, (c) own send. Whether we're at-bottom is
+ // tracked via the IntersectionObserver attached by `setBottomSentinel`
+ // — using a callback-ref instead of a useEffect so the observer
+ // attaches at the moment the DOM node mounts (cold-load renders
+ // a Spinner first; the sentinel only appears in the content branch).
+ const bottomSentinelRef = useRef(null);
+ const bottomObserverRef = useRef(null);
+ // Ref to the folds `` host (the actual `overflow:auto`
+ // element). Used by the autoscroll layout-effect to set `scrollTop`
+ // synchronously before paint on first reveal — `scrollIntoView` from
+ // a passive `useEffect` runs AFTER paint, so the user briefly sees
+ // the drawer at top-of-content before it «jumps» to the bottom.
+ const scrollHostRef = useRef(null);
+ const headerId = `thread-drawer-header-${rootId}`;
+
+ const useAuthentication = useMediaAuthentication();
+ const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+ const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
+ const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
+ const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
+
+ const mentionClickHandler = useMentionClickHandler(room.roomId);
+ const spoilerClickHandler = useSpoilerClickHandler();
+
+ // Same parser pipeline RoomTimeline uses so links / mentions /
+ // spoilers behave identically in drawer and main.
+ const linkifyOpts = useMemo(
+ () => ({
+ ...LINKIFY_OPTS,
+ render: factoryRenderLinkifyWithMention((href) =>
+ renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
+ ),
+ }),
+ [mx, room, mentionClickHandler]
+ );
+ const htmlReactParserOptions = useMemo(
+ () =>
+ getReactCustomHtmlParser(mx, room.roomId, {
+ linkifyOpts,
+ useAuthentication,
+ handleSpoilerClick: spoilerClickHandler,
+ handleMentionClick: mentionClickHandler,
+ }),
+ [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication]
+ );
+
+ const powerLevels = usePowerLevelsContext();
+ const creators = useRoomCreators(room);
+ const permissions = useRoomPermissions(creators, powerLevels);
+ const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
+
+ const [thread, setThread] = useState(() => room.getThread(rootId));
+ const [rootEvent, setRootEvent] = useState(
+ () => room.getThread(rootId)?.rootEvent ?? room.findEventById(rootId) ?? null
+ );
+ const [rootError, setRootError] = useState(null);
+ const [paginateError, setPaginateError] = useState(null);
+ const [paginating, setPaginating] = useState(false);
+ // Cold-load relations fetch error state. Separate from `paginateError`
+ // because the failure is on initial Thread materialization (before
+ // any Thread exists), with a different retry pathway (re-runs the
+ // createThread effect via `coldLoadAttempt` increment instead of
+ // re-calling paginate).
+ const [coldLoadError, setColdLoadError] = useState(null);
+ const [coldLoadAttempt, setColdLoadAttempt] = useState(0);
+ // True while the cold-load `mx.relations` fetch is in-flight. We
+ // can't fall back to `replies.length === 0` to gate the «no replies»
+ // empty state because SDK may have pre-created a broken Thread
+ // (empty replyCount + null backward token) that we're about to
+ // repair via addEvents — until the fetch completes, an empty thread
+ // doesn't mean no replies, just «not loaded yet».
+ const [coldLoadFetching, setColdLoadFetching] = useState(false);
+ const retryColdLoad = useCallback(() => {
+ setColdLoadError(null);
+ setColdLoadAttempt((n) => n + 1);
+ }, []);
+
+ // Lazy-fetch the root event when it isn't already in the room store
+ // (e.g. cold-load via shareable thread URL — the room timeline hasn't
+ // back-paginated far enough to surface it). Without this we'd render
+ // the drawer with a permanent "loading root" placeholder.
+ //
+ // `getEventMapper()(raw)` wires the event into the room re-emit chain
+ // and kicks off decryption for encrypted events automatically (its
+ // `decrypt` opt defaults to true). The `MatrixEventEvent.Decrypted`
+ // listener attached below then triggers a re-render once decryption
+ // completes. `new MatrixEvent(raw)` would skip that wiring and leave
+ // an encrypted body permanently empty.
+ const loadRootEvent = useCallback(async () => {
+ setRootError(null);
+ try {
+ const raw = await mx.fetchRoomEvent(room.roomId, rootId);
+ const evt = mx.getEventMapper()(raw);
+ setRootEvent(evt);
+ } catch (err) {
+ setRootError(err as Error);
+ }
+ }, [mx, room.roomId, rootId]);
+
+ useEffect(() => {
+ if (!rootEvent) {
+ loadRootEvent();
+ }
+ // No cleanup needed: loadRootEvent is idempotent and setState is
+ // safely no-op on unmount under React 18 (no warning).
+ }, [rootEvent, loadRootEvent]);
+
+ // Subscribe to ThreadEvent.New on the *room* (not the thread — SDK
+ // excludes ThreadEvent.New from the thread's own emitter set). We
+ // need this for the cold-start case where the user opens the drawer
+ // before any reply has been posted: room.getThread() returns null,
+ // then the very first reply lands → SDK lazy-creates the Thread and
+ // fires ThreadEvent.New on the room.
+ //
+ // Listener-attach BEFORE the re-check (closes race-window completely
+ // — earlier «check then attach» order had a millisecond gap where a
+ // synchronous /sync handler could create+emit the Thread between
+ // check and attach).
+ useEffect(() => {
+ if (thread) return undefined;
+ const handler = (newThread: Thread) => {
+ if (newThread.id === rootId) setThread(newThread);
+ };
+ room.on(ThreadEvent.New, handler);
+ // Re-check now: a /sync delivered between the useState initializer
+ // and this effect AND between handler attach and now is now caught
+ // either by the handler (if New fires after attach) or by the
+ // re-check itself (if New fired before attach).
+ const existing = room.getThread(rootId);
+ if (existing) {
+ setThread(existing);
+ }
+ return () => {
+ room.off(ThreadEvent.New, handler);
+ };
+ }, [room, rootId, thread]);
+
+ // Cold-load deep-link materialization OR broken-thread recovery.
+ //
+ // Two paths leave the drawer empty when we deep-link into a thread
+ // whose replies aren't in /sync coverage:
+ //
+ // (a) `room.getThread(rootId) === null`: nothing has materialized
+ // the Thread yet. We need to fetch replies and create it.
+ // (b) SDK pre-emptively created an empty Thread via
+ // `client.processThreadRoots` when /sync or /messages pagination
+ // delivered the thread ROOT but no replies.
+ // `room.createThread(threadId, root, [], false)` runs with an
+ // empty events array. The resulting Thread runs
+ // `updateThreadMetadata`, which calls `resetLiveTimeline()` and
+ // then either branches on `replyCount === 0 && rootEvent` to add
+ // just the root with a null backward pagination token, or
+ // auto-paginates. If bundled `m.relations.m.thread.count` is
+ // missing/zero, the shortcut fires and the live timeline is
+ // left with only the root.
+ //
+ // Critical race: SDK's metadata pass is async and waits on an HTTP
+ // fetch (`Thread::fetchRootEvent`). If we add events BEFORE the
+ // pass reaches `resetLiveTimeline()`, our additions are wiped and
+ // our pagination-token write is overwritten by the shortcut's null
+ // token. The pass ends by setting `initialEventsFetched = true` and
+ // emitting `ThreadEvent.Update` — both observable, so we serialize
+ // after them.
+ //
+ // Element-web doesn't hit either path because their drawer always
+ // opens from a main-timeline click; the root is in cache with
+ // bundled aggregations, and `replyCount > 0` flows them through
+ // SDK's `paginateEventTimeline` branch which does the right thing.
+ //
+ // Repair flow:
+ // - thread === null: fetch via `mx.relations`, then `createThread`
+ // with replies pre-loaded. No SDK race because we own the
+ // creation; `addEvents` runs before any auto-fired metadata pass
+ // can reset.
+ // - thread !== null and `initialEventsFetched`: SDK done; safe to
+ // `addEvents` immediately.
+ // - thread !== null and not yet fetched: subscribe to
+ // `ThreadEvent.Update`; on first fire (= pass complete), check
+ // again and `addEvents` if still empty.
+ //
+ // Why we DON'T pass replies to `createThread([rootEvent], true)`:
+ // SDK's metadata pass calls `resetLiveTimeline()` unconditionally
+ // before deciding the branch — any events pre-loaded via the
+ // constructor get cleared in that step. Adding them afterwards
+ // (post-Update) is the only ordering that survives.
+ useEffect(() => {
+ if (!rootEvent) return undefined;
+ if (rootEvent.getId() !== rootId) return undefined;
+ // Use `liveTimelineHasReplies` (actual events in timeline) NOT
+ // `thread.length` (which is `replyCount + pendingReplyCount`).
+ // `replyCount` is set from bundled aggregations BEFORE events
+ // land in the timeline, so `length > 0` does not imply the
+ // renderer has anything to show.
+ if (thread && liveTimelineHasReplies(thread, rootId)) return undefined;
+
+ let cancelled = false;
+ let started = false;
+ setColdLoadError(null);
+
+ const performRepair = async (target: Thread | null) => {
+ if (cancelled || started) return;
+ // Last-chance check: SDK may have populated between subscribe
+ // and now (e.g. its own auto-paginate branch finished).
+ if (target && liveTimelineHasReplies(target, rootId)) return;
+ started = true;
+ setColdLoadFetching(true);
+ try {
+ const result = await mx.relations(room.roomId, rootId, RelationType.Thread);
+ if (cancelled) return;
+ const replies = [...result.events].reverse();
+ // Re-resolve the thread once more: SDK may have created one
+ // during the await (network slow, /sync arrived).
+ const current = target ?? room.getThread(rootId);
+ let written: Thread | null = current ?? null;
+ if (written) {
+ // Same lastchance — don't double-add if SDK already filled.
+ if (!liveTimelineHasReplies(written, rootId)) {
+ written.addEvents(replies, false);
+ }
+ } else {
+ written = room.createThread(rootId, rootEvent, replies, true);
+ }
+ if (written) {
+ // `result.nextBatch` is the `/relations` continuation
+ // marker — feeding it here lets SDK's pagination machinery
+ // resume back-pagination from where this fetch left off.
+ // null means «no more older events» (server reached the
+ // thread start), which correctly disables back-pagination.
+ written.liveTimeline.setPaginationToken(
+ result.nextBatch ?? null,
+ Direction.Backward
+ );
+ setThread(written);
+ }
+ } catch (err) {
+ if (cancelled) return;
+ started = false; // allow retry via coldLoadAttempt
+ setColdLoadError(err as Error);
+ } finally {
+ if (!cancelled) setColdLoadFetching(false);
+ }
+ };
+
+ // Case 1: no thread yet. We own creation — no SDK race.
+ if (!thread) {
+ performRepair(null);
+ return () => {
+ cancelled = true;
+ };
+ }
+
+ // Case 2: thread exists.
+ // 2a: SDK's initial metadata pass already done. Safe to repair.
+ if (thread.initialEventsFetched) {
+ performRepair(thread);
+ return () => {
+ cancelled = true;
+ };
+ }
+
+ // 2b: SDK's pass still in flight. Wait for ThreadEvent.Update
+ // (fired at the end of `updateThreadMetadata`) so our `addEvents`
+ // / token-write don't get reset out from under us. The handler
+ // also re-checks the events to skip if SDK populated via its own
+ // paginate branch.
+ const onUpdate = () => performRepair(thread);
+ thread.on(ThreadEvent.Update, onUpdate);
+ return () => {
+ cancelled = true;
+ thread.off(ThreadEvent.Update, onUpdate);
+ };
+ }, [thread, rootEvent, room, rootId, mx, coldLoadAttempt]);
+
+ // When a Thread already exists, listen for Update so the drawer
+ // re-renders on new replies / edits / redactions inside the thread.
+ // `ThreadEvent.Update` covers count + latest_event refresh and is
+ // fired both on aggregation changes and on every new reply
+ // (`updateThreadMetadata` fires it from `addEvent`). RoomEvent.Timeline
+ // re-emit was redundant — both ticked for the same reply, causing
+ // back-to-back re-renders + a wasted scrollIntoView frame.
+ const [, forceRender] = useState(0);
+ useEffect(() => {
+ if (!thread) return undefined;
+ const tick = () => forceRender((n) => n + 1);
+ thread.on(ThreadEvent.Update, tick);
+ return () => {
+ thread.off(ThreadEvent.Update, tick);
+ };
+ }, [thread]);
+
+ // Encrypted root cold-load: subscribe to MatrixEventEvent.Decrypted
+ // on the root event so the drawer re-renders when decryption finishes
+ // (the body is empty until then). Without this listener, an encrypted
+ // thread root deep-link shows a permanently empty card. Listener is
+ // event-scoped and detaches on unmount or when rootEvent changes.
+ useEffect(() => {
+ if (!rootEvent || !rootEvent.isEncrypted()) return undefined;
+ const tick = () => forceRender((n) => n + 1);
+ rootEvent.on(MatrixEventEvent.Decrypted, tick);
+ return () => {
+ rootEvent.off(MatrixEventEvent.Decrypted, tick);
+ };
+ }, [rootEvent]);
+
+ // Back-pagination: ONLY incremental on scroll-up via the top
+ // sentinel — initial fill is owned by the cold-load repair effect
+ // above, which uses `mx.relations` directly to avoid the SDK
+ // `Thread.updateThreadMetadata` race. Running an initial paginate
+ // here would fire BEFORE the SDK's metadata pass completes (which
+ // calls `resetLiveTimeline()` then either auto-paginates or takes
+ // the null-token shortcut), and the events we fetched would be
+ // wiped when the pass reaches its `resetLiveTimeline()` call.
+ //
+ // For incremental scroll-up paginate we still gate on
+ // `initialEventsFetched`. Once true, SDK won't reset the timeline
+ // again, so paginateEventTimeline writes are safe to keep.
+ const paginatingRef = useRef(false);
+ const canBackPaginateRef = useRef(true);
+ const paginate = useCallback(async () => {
+ if (!thread) return;
+ if (!thread.initialEventsFetched) return;
+ if (paginatingRef.current) return;
+ if (!canBackPaginateRef.current) return;
+ paginatingRef.current = true;
+ setPaginating(true);
+ setPaginateError(null);
+ try {
+ const live: EventTimeline = thread.liveTimeline;
+ const more = await mx.paginateEventTimeline(live, {
+ backwards: true,
+ limit: REPLY_PAGE_SIZE,
+ });
+ if (!more) {
+ canBackPaginateRef.current = false;
+ }
+ } catch (err) {
+ setPaginateError(err as Error);
+ } finally {
+ paginatingRef.current = false;
+ setPaginating(false);
+ }
+ }, [mx, thread]);
+
+ // Reset pagination state when thread identity changes (re-mount via
+ // key={`${roomId}/${rootId}`} normally handles this, but guard
+ // anyway for safety against future caller patterns).
+ useEffect(() => {
+ canBackPaginateRef.current = true;
+ paginatingRef.current = false;
+ }, [thread]);
+
+ // Incremental back-pagination via IntersectionObserver on a top
+ // sentinel — Element-web ScrollPanel `onFillRequest` pattern. The
+ // sentinel is conditionally rendered (only when `replies.length > 0`)
+ // so a useEffect with `[paginate]` deps would never re-run after the
+ // sentinel finally mounts: paginate identity is stable once `thread`
+ // is set, and the deps don't reflect sentinel-presence. Use a
+ // callback-ref so attach/detach happens atomically with node mount.
+ // Latest `paginate` is read via a ref so the callback-ref itself can
+ // stay stable identity across renders.
+ const topSentinelRef = useRef(null);
+ const topObserverRef = useRef(null);
+ const paginateRef = useRef(paginate);
+ paginateRef.current = paginate;
+ const setTopSentinel = useCallback((el: HTMLDivElement | null) => {
+ topObserverRef.current?.disconnect();
+ topObserverRef.current = null;
+ topSentinelRef.current = el;
+ if (!el) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (entry?.isIntersecting) paginateRef.current();
+ },
+ { threshold: TOP_SENTINEL_THRESHOLD }
+ );
+ observer.observe(el);
+ topObserverRef.current = observer;
+ }, []);
+
+ // Replies — strip aggregations (m.replace edits / m.annotation
+ // reactions / redactions) and the root itself so the UI shows just
+ // the user-visible reply cards.
+ //
+ // Compute inline (not via useMemo): the SDK mutates
+ // `thread.liveTimeline` in place — the Thread reference and its
+ // liveTimeline reference are stable across mutations. A useMemo
+ // keyed on `[thread, rootId]` would never invalidate even when
+ // forceRender bumps the counter, so newly-arrived replies (incl.
+ // own send local-echo) would never reach the rendered list.
+ const replies: MatrixEvent[] = thread
+ ? thread.liveTimeline.getEvents().filter((evt) => {
+ if (evt.getId() === rootId) return false;
+ if (reactionOrEditEvent(evt)) return false;
+ return true;
+ })
+ : [];
+
+ // Read receipts intentionally NOT fired here in M2. With
+ // threadSupport: true the SDK auto-attaches `thread_id: rootId` to
+ // any sendReadReceipt call from a thread event, but the existing
+ // receipt handler in `state/room/roomToUnread.ts` does a blind
+ // DELETE on any own receipt — so a thread read would wipe the
+ // whole channel's unread badge including main-timeline messages
+ // the user has not seen. M4 will refactor the handler to partition
+ // by thread_id; until then the drawer leaves unread state alone.
+
+ // Track whether the user is parked at the bottom of the replies
+ // list via IntersectionObserver on the sentinel. Element-web's
+ // ScrollPanel uses the same `stuckAtBottom` flag — gating autoscroll
+ // on this prevents pulling a user reading older replies down to the
+ // new arrival (matrix-react-sdk PR #9606 «Fix thread list jumping
+ // while scrolling»). See `AT_BOTTOM_THRESHOLD` for the «near
+ // bottom» tolerance (a few px of overscroll still counts as
+ // at-bottom).
+ //
+ // Callback-ref so the observer attaches when the sentinel actually
+ // mounts (cold-load renders Spinner first → sentinel absent on
+ // first effect pass; a `useEffect` with `[]` deps would never
+ // re-run after the sentinel appears in the content branch).
+ const isAtBottomRef = useRef(true);
+ const setBottomSentinel = useCallback((el: HTMLDivElement | null) => {
+ bottomObserverRef.current?.disconnect();
+ bottomObserverRef.current = null;
+ bottomSentinelRef.current = el;
+ if (!el) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (entry) isAtBottomRef.current = entry.isIntersecting;
+ },
+ { threshold: AT_BOTTOM_THRESHOLD }
+ );
+ observer.observe(el);
+ bottomObserverRef.current = observer;
+ }, []);
+
+ // Auto-scroll to the bottom on (a) initial mount with replies
+ // present, (b) reply-count growth when user is at-bottom, (c) own
+ // send (regardless of scroll position — own message intent is to
+ // follow the conversation, like Slack/Discord). Edits/redactions
+ // don't change `repliesCount` so don't trigger.
+ //
+ // First reveal uses `useLayoutEffect` + direct `scrollTop` write on
+ // the scroll host so the DOM is at-bottom BEFORE the browser paints.
+ // The previous `useEffect` + `scrollIntoView({behavior: 'auto'})`
+ // approach fired after paint, leaving a one-frame top-of-content
+ // flash + jump that the user could see. Element-web's ScrollPanel
+ // uses the same direct `scrollTop = scrollHeight` pattern
+ // (apps/web/src/components/structures/ScrollPanel.tsx:556).
+ //
+ // Subsequent growth keeps `scrollIntoView({behavior: 'smooth'})` in
+ // a regular `useEffect` — for replies arriving during the session
+ // an animated scroll is the right cue, and post-paint timing isn't
+ // visible because there's no top-of-content frame to flash.
+ const repliesCount = replies.length;
+ const lastReplyCountRef = useRef(0);
+ const myUserId = mx.getUserId();
+ useLayoutEffect(() => {
+ const host = scrollHostRef.current;
+ if (!host) return;
+ const isFirstReveal = lastReplyCountRef.current === 0 && repliesCount > 0;
+ if (!isFirstReveal) return;
+ host.scrollTop = host.scrollHeight;
+ isAtBottomRef.current = true;
+ lastReplyCountRef.current = repliesCount;
+ }, [repliesCount]);
+
+ useEffect(() => {
+ const host = scrollHostRef.current;
+ if (!host || !bottomSentinelRef.current) return;
+ // First reveal handled by useLayoutEffect above; skip here so we
+ // don't double-scroll (the layout-effect bumped the counter).
+ if (lastReplyCountRef.current === repliesCount) return;
+ const grew = repliesCount > lastReplyCountRef.current;
+ lastReplyCountRef.current = repliesCount;
+ if (!grew) return;
+ const last = replies[replies.length - 1];
+ const lastIsOwn = !!myUserId && last?.getSender() === myUserId;
+ if (!isAtBottomRef.current && !lastIsOwn) {
+ // User is reading older replies — don't yank them down on a
+ // new arrival from someone else.
+ return;
+ }
+ if (lastIsOwn) {
+ // Own send: jump to bottom instantly — the user just hit Enter,
+ // a smooth scroll animation feels like the input «kicks back»
+ // before settling. Slack/iMessage do the same. Direct scrollTop
+ // write is synchronous; no animation.
+ host.scrollTop = host.scrollHeight;
+ } else {
+ // Reply from someone else while at-bottom: smooth scroll keeps
+ // a visual cue («new reply landed»).
+ bottomSentinelRef.current.scrollIntoView({
+ behavior: 'smooth',
+ block: 'end',
+ });
+ }
+ isAtBottomRef.current = true;
+ // Intentional: replies array read inside is stable identity-wise
+ // because computed inline; lint disable for the false-positive.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [repliesCount, myUserId]);
+
+ // Autofocus: drop focus on the close button ONCE per drawer
+ // lifetime so screen-reader / keyboard users land somewhere
+ // predictable. Single-shot via `didFocusRef` — without it, any
+ // outer Room.tsx state change (callView/chat flip, screen-size
+ // breakpoint cross, useIsOneOnOne flip) would re-key parents and
+ // remount the drawer mid-typing, yanking focus from the composer
+ // back to the close button. The `key={`${roomId}/${rootId}`}` in
+ // Room.tsx already remounts the drawer for thread navigation, so
+ // a fresh ref per ThreadDrawer instance is the right granularity.
+ // Composer focus on open is intentional non-default — Element/Slack
+ // focus the composer; we focus close to avoid stealing focus from
+ // the user's current typing context (channel composer was unmounted
+ // by the same drawer-open). Trade-off: keyboard users hit Tab to
+ // reach the composer.
+ const didFocusRef = useRef(false);
+ useEffect(() => {
+ if (didFocusRef.current) return;
+ closeBtnRef.current?.focus({ preventScroll: true });
+ didFocusRef.current = true;
+ }, []);
+
+ const close = useCallback(() => {
+ // Use replace so a second open of the same thread doesn't grow the
+ // back-stack. useRoomNavigate uses the same trick for same-path
+ // collapses (see comment in hooks/useRoomNavigate.ts).
+ navigate(parentRoomPath, { replace: true });
+ }, [navigate, parentRoomPath]);
+
+ const renderBody = () => {
+ if (rootError) {
+ return (
+
+ )}
+ {/* Top sentinel — IntersectionObserver triggers paginate when
+ scrolled into view. Element-web onFillRequest pattern. The
+ sentinel only renders while back-pagination is possible:
+ once SDK reports no more events, we drop it so the observer
+ disconnects on next mount. */}
+ {replies.length > 0 && (
+
+ )}
+ {paginating && replies.length > 0 && (
+
+
+
+ )}
+ {replies.map((reply) => (
+
+ ))}
+
+
+ );
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
index 51ada6f7..545bad6d 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -673,6 +673,20 @@ export type MessageProps = {
legacyUsernameColor?: boolean;
streamRailStart?: boolean;
streamRailEnd?: boolean;
+ // M2: hide the «Reply in Thread» menu/quick action. Set by
+ // RoomTimeline outside channels-mode (where threads aren't surfaced
+ // anywhere) and inside bridged channels (where the bridge has no
+ // thread semantic). When true, the ThreadPlus button + the
+ // `reply_in_thread` menu item are skipped; the regular «Reply»
+ // affordance stays untouched.
+ hideThreadReplyAffordance?: boolean;
+ // M2: hide the regular «Reply» (m.in_reply_to) menu/quick action.
+ // Set by RoomTimeline when the thread drawer is open — the channel
+ // composer is unmounted in that state, so the reply chip would
+ // write to an unread atom and the focus call would no-op silently.
+ // ThreadPlus button stays usable for navigating to a different
+ // thread root.
+ hideMainReplyAffordance?: boolean;
// Snapshot of `mEvent.getContent().msgtype` from the caller. Threaded as
// a prop (not derived locally) so encrypted-then-decrypted events flip
// mediaMode reliably when EncryptedContent re-renders post-decrypt —
@@ -707,6 +721,8 @@ export const Message = as<'div', MessageProps>(
legacyUsernameColor,
streamRailStart,
streamRailEnd,
+ hideThreadReplyAffordance,
+ hideMainReplyAffordance,
msgType,
children,
...props
@@ -838,6 +854,13 @@ export const Message = as<'div', MessageProps>(
};
const isThreadedMessage = mEvent.threadRootId !== undefined;
+ // After a Thread is created on a root event, SDK sets
+ // `mEvent.threadRootId === mEvent.getId()` and `isThreadRoot ===
+ // true`. We want the «Reply in Thread» button to keep working on
+ // the root (re-open drawer) — only hide when the message is itself
+ // a thread reply (no thread can be started on a reply, and in
+ // channels-mode replies aren't visible in main timeline anyway).
+ const isThreadReply = isThreadedMessage && !mEvent.isThreadRoot;
return (
(
)}
-
-
-
- {!isThreadedMessage && (
+ {!hideMainReplyAffordance && (
onReplyClick(ev, true)}
+ onClick={onReplyClick}
+ data-event-id={mEvent.getId()}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ >
+
+
+ )}
+ {!isThreadReply && !hideThreadReplyAffordance && (
+ [0]) => onReplyClick(ev, true)}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
@@ -972,26 +997,28 @@ export const Message = as<'div', MessageProps>(
onClose={closeMenu}
/>
)}
- }
- radii="300"
- data-event-id={mEvent.getId()}
- onClick={(evt: any) => {
- onReplyClick(evt);
- closeMenu();
- }}
- >
- }
+ radii="300"
+ data-event-id={mEvent.getId()}
+ onClick={(evt: any) => {
+ onReplyClick(evt);
+ closeMenu();
+ }}
>
- {t('Room.reply')}
-
-
- {!isThreadedMessage && (
+
+ {t('Room.reply')}
+
+
+ )}
+ {!isThreadReply && !hideThreadReplyAffordance && (
}
diff --git a/src/app/hooks/useChannelsMode.ts b/src/app/hooks/useChannelsMode.ts
index ba47e343..e0d485bb 100644
--- a/src/app/hooks/useChannelsMode.ts
+++ b/src/app/hooks/useChannelsMode.ts
@@ -13,3 +13,20 @@ export const ChannelsModeProvider = ChannelsModeContext.Provider;
export function useChannelsMode(): boolean {
return useContext(ChannelsModeContext);
}
+
+// True while a `/channels/.../thread/:rootId/` URL is matched and the
+// `` is mounted. RoomView reads this to suppress the
+// channel composer (RoomInput) — Element-web pattern: only one composer
+// surface mounts at a time so two `` editors can't race against
+// each other (slate#6016 + slate#4850 share-`initialValue` regression
+// is otherwise possible at cold-load), and so the channel viewport
+// doesn't visibly react to the user's own thread reply local-echo
+// (the «message flashes in main timeline» blink). Provided by
+// `Room.tsx` based on `useMatch(CHANNELS_THREAD_PATH)`.
+const ThreadDrawerOpenContext = createContext(false);
+
+export const ThreadDrawerOpenProvider = ThreadDrawerOpenContext.Provider;
+
+export function useThreadDrawerOpen(): boolean {
+ return useContext(ThreadDrawerOpenContext);
+}
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index f8dba35a..ab9c7d1c 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -17,6 +17,7 @@ import {
CHANNELS_PATH,
CHANNELS_ROOM_PATH,
CHANNELS_SPACE_PATH,
+ CHANNELS_THREAD_PATH,
DIRECT_PATH,
EXPLORE_PATH,
HOME_PATH,
@@ -316,7 +317,13 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
- />
+ >
+ {/* Thread drawer URL — same Room element renders, drawer
+ opens by reading `:rootId` via useParams. The
+ SpaceRouteRoomProvider lives on the parent route and
+ stays mounted across the room↔thread URL flip. */}
+
+
{
+ const params = {
+ spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),
+ roomIdOrAlias: encodeURIComponent(roomIdOrAlias),
+ rootId: encodeURIComponent(rootId),
+ };
+ return generatePath(CHANNELS_THREAD_PATH, params);
+};
diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts
index 2caa6417..1c3d252c 100644
--- a/src/app/pages/paths.ts
+++ b/src/app/pages/paths.ts
@@ -91,6 +91,7 @@ export const BOTS_BOT_PATH = '/bots/:botId/';
export const CHANNELS_PATH = '/channels/';
export const CHANNELS_SPACE_PATH = '/channels/:spaceIdOrAlias/';
export const CHANNELS_ROOM_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/';
+export const CHANNELS_THREAD_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/thread/:rootId/';
export const SPACE_SETTINGS_PATH = '/space-settings/';
diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts
index f3bf9e1c..93c9f523 100644
--- a/src/app/state/room/roomInputDrafts.ts
+++ b/src/app/state/room/roomInputDrafts.ts
@@ -20,7 +20,26 @@ export type TUploadItem = {
export type TUploadListAtom = ReturnType>;
-export const roomIdToUploadItemsAtomFamily = atomFamily(createListAtom);
+// M2/M3b draft key: (roomId, threadKey). `threadKey === 'main'` for the
+// channel/DM/legacy composer (preserves pre-M2 semantics), or `rootId`
+// for the per-thread composer in `ThreadDrawer`. Tuple key requires a
+// custom equality function — without it, atomFamily uses
+// reference-equality on the array and every render gets a fresh atom.
+//
+// The plan slated this extension as M3b, but pulling the change forward
+// into M2 is necessary: drawer composer and channel composer share the
+// same `roomId`, so a single-key family clobbers each other's drafts on
+// open/close (typing in the thread drawer leaks back into the channel
+// composer when the drawer unmounts).
+export type DraftKey = readonly [roomId: string, threadKey: string];
+export const draftKey = (roomId: string, threadId?: string): DraftKey =>
+ [roomId, threadId ?? 'main'] as const;
+const draftKeyEqual = (a: DraftKey, b: DraftKey) => a[0] === b[0] && a[1] === b[1];
+
+export const roomIdToUploadItemsAtomFamily = atomFamily(
+ createListAtom,
+ draftKeyEqual
+);
export const roomUploadAtomFamily = createUploadAtomFamily();
@@ -37,8 +56,9 @@ export type RoomIdToMsgAction =
const createMsgDraftAtom = () => atom([]);
export type TMsgDraftAtom = ReturnType;
-export const roomIdToMsgDraftAtomFamily = atomFamily(() =>
- createMsgDraftAtom()
+export const roomIdToMsgDraftAtomFamily = atomFamily(
+ () => createMsgDraftAtom(),
+ draftKeyEqual
);
export type IReplyDraft = {
@@ -50,6 +70,7 @@ export type IReplyDraft = {
};
const createReplyDraftAtom = () => atom(undefined);
export type TReplyDraftAtom = ReturnType;
-export const roomIdToReplyDraftAtomFamily = atomFamily(() =>
- createReplyDraftAtom()
+export const roomIdToReplyDraftAtomFamily = atomFamily(
+ () => createReplyDraftAtom(),
+ draftKeyEqual
);
diff --git a/src/app/utils/routeParent.ts b/src/app/utils/routeParent.ts
index f50194a1..c727614f 100644
--- a/src/app/utils/routeParent.ts
+++ b/src/app/utils/routeParent.ts
@@ -4,6 +4,7 @@ import {
CHANNELS_PATH,
CHANNELS_ROOM_PATH,
CHANNELS_SPACE_PATH,
+ CHANNELS_THREAD_PATH,
DIRECT_PATH,
EXPLORE_PATH,
HOME_PATH,
@@ -12,6 +13,7 @@ import {
import {
getBotsPath,
getChannelsPath,
+ getChannelsRoomPath,
getChannelsSpacePath,
getDirectPath,
getExplorePath,
@@ -36,6 +38,19 @@ export const getRouteSectionParent = (pathname: string): string | null => {
if (under(BOTS_PATH)) return atRoot(BOTS_PATH) ? null : getBotsPath();
if (under(CHANNELS_PATH)) {
+ // Thread URL collapses to its parent room view (drawer closed) — must
+ // match before the room-level matcher because thread is a strict
+ // subpath of room (`/channels/:s/:r/thread/:rootId/`).
+ const threadMatch = matchPath(
+ { path: CHANNELS_THREAD_PATH, caseSensitive: true, end: false },
+ pathname
+ );
+ if (threadMatch?.params.spaceIdOrAlias && threadMatch?.params.roomIdOrAlias) {
+ return getChannelsRoomPath(
+ decodeURIComponent(threadMatch.params.spaceIdOrAlias),
+ decodeURIComponent(threadMatch.params.roomIdOrAlias)
+ );
+ }
const roomMatch = matchPath(
{ path: CHANNELS_ROOM_PATH, caseSensitive: true, end: false },
pathname
diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts
index 912fb7dd..73f621b7 100644
--- a/src/client/initMatrix.ts
+++ b/src/client/initMatrix.ts
@@ -44,8 +44,18 @@ export const initClient = async (session: Session): Promise => {
};
export const startClient = async (mx: MatrixClient) => {
+ // threadSupport partitions m.thread relations into Thread objects so
+ // /channels thread drawer can read room.getThread(rootId), receive
+ // ThreadEvent.New / Update from the room emitter, and let
+ // sendReadReceipt auto-route thread_id. SDK 38.2+ default is false:
+ // without this flag drawer reads silently NO-OP. Flag is global —
+ // also affects DM/Bots receipt shape (SDK now adds thread_id: 'main'
+ // to main-timeline receipts and per-thread unread shape changes
+ // arrive in `unread_thread_notifications`). M4 will consume the
+ // shape; M2 just enables it so the drawer surface works.
await mx.startClient({
lazyLoadMembers: true,
+ threadSupport: true,
});
};