import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { Box, Button, Header, Icon, IconButton, Icons, Scroll, Spinner, Text, config, toRem, } from 'folds'; import { Direction, EventTimeline, EventType, MatrixEvent, MatrixEventEvent, RelationType, Room, Thread, ThreadEvent, } from 'matrix-js-sdk'; import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts as LinkifyOpts } from 'linkifyjs'; import * as css from './ThreadDrawer.css'; import { ChatComposer } from './RoomView.css'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useResizeObserver } from '../../hooks/useResizeObserver'; import { scrollToBottom } from '../../utils/dom'; import { useUnreadThreadingEnabled } from '../../hooks/useClientConfig'; import { markAsThreadRead } from '../../utils/notifications'; import { useEditor } from '../../components/editor'; import { getEditedEvent, getEventReactions, getMemberDisplayName, isBridgedRoom, reactionOrEditEvent, } from '../../utils/room'; import { getMxIdLocalPart } from '../../utils/matrix'; import { ImageContent, MessageNotDecryptedContent, MessageUnsupportedContent, MSticker, RedactedContent, Reply, } from '../../components/message'; import { Image } from '../../components/media'; import { ImageViewer } from '../../components/image-viewer'; import { RenderMessageContent } from '../../components/RenderMessageContent'; import { RoomInput } from './RoomInput'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { EncryptedContent, Message, Reactions, useMessageInteractionHandlers, } from './message'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useChannelsMode } from '../../hooks/useChannelsMode'; import { useIsOneOnOne } from '../../hooks/useRoom'; import { useTheme } from '../../hooks/useTheme'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useAccessiblePowerTagColors, useGetMemberPowerTag, } from '../../hooks/useMemberPowerTag'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { THREAD_DRAWER_WIDTH_MIN, clampThreadDrawerWidth, threadDrawerWidthAtom, } from '../../state/threadDrawerWidth'; import { factoryRenderLinkifyWithMention, getReactCustomHtmlParser, LINKIFY_OPTS, makeMentionCustomProps, renderMatrixMention, } from '../../plugins/react-custom-html-parser'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; // IntersectionObserver ratio at which the bottom sentinel still counts // as «at the bottom of the replies list». 0.9 lets a few pixels of // overscroll still register as at-bottom (matrix-react-sdk PR #9606 // pattern: don't toggle stuckAtBottom on tiny scroll movements). const AT_BOTTOM_THRESHOLD = 0.9; // Top sentinel triggers on minimal intersection so back-pagination // fires while the user is still scrolling toward the top, not after // the sentinel is fully on-screen — avoids a perceptible pause at the // edge before the next page loads. const TOP_SENTINEL_THRESHOLD = 0.1; // Reply page size for both the cold-load relations fetch and the // incremental back-pagination via paginateEventTimeline. 50 matches // element-web's default thread page; large enough to fill a typical // drawer viewport in one shot. const REPLY_PAGE_SIZE = 50; // Live event check — returns true if the thread's liveTimeline holds // any user-visible reply event (not the root, not an aggregation like // edit / reaction / redaction). Module-level so it doesn't get // re-allocated inside repair / render passes; pure given inputs. const liveTimelineHasReplies = (thread: Thread, rootId: string): boolean => thread.liveTimeline .getEvents() .some((evt) => evt.getId() !== rootId && !reactionOrEditEvent(evt)); type ThreadDrawerProps = { room: Room; rootId: string; // The /channels parent path that the close button navigates back to. // Pre-resolved by `Room.tsx` so the drawer doesn't reimplement the // routing rules; on close we replace history (not push) so the back // stack collapses naturally on Android hardware-back. parentRoomPath: string; // On mobile the drawer takes over the content column instead of // sitting beside the timeline. Layout is otherwise identical so the // M5 mobile-stack work doesn't need to fork the component. variant: 'desktop' | 'mobile'; }; // M4a: thread events render through the shared `` component // — same hover/menu/edit/reactions/reply chrome as the main timeline, // `'channel'` layout (avatar + name + body without bubble). Root and // replies share the render path so editing/reactions/redact work // identically for both, matching Slack/Element-web and removing the // pre-M4a fork between `ThreadEventCard` and ``. // 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); // Desktop-only resizable side pane. Mirrors `ResizablePageNav` in // `components/page/Page.tsx`: width persisted to localStorage via // `threadDrawerWidthAtom`, clamped [MIN, viewport/3]. Mobile variant // doesn't render the wrapper at all so these hooks are effectively // no-ops there — they always run (rules-of-hooks) but the ref / DOM // is never wired up. const isDesktopVariant = variant === 'desktop'; const resizableRef = useRef(null); const resizeHandleRef = useRef(null); const [savedWidth, setSavedWidth] = useAtom(threadDrawerWidthAtom); const [vw, setVw] = useState( typeof window !== 'undefined' ? window.innerWidth : 1280 ); const [dragging, setDragging] = useState(false); // Live width during a drag — kept in component state so we don't // flush localStorage on every pointermove (hundreds of sync disk // writes per drag). Atom is committed once on pointerup. const [liveWidth, setLiveWidth] = useState(null); useEffect(() => { if (!isDesktopVariant) return undefined; const onResize = () => setVw(window.innerWidth); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, [isDesktopVariant]); const drawerMaxW = Math.max(THREAD_DRAWER_WIDTH_MIN, Math.floor(vw / 3)); const drawerBaseWidth = dragging && liveWidth !== null ? liveWidth : savedWidth; const drawerWidth = clampThreadDrawerWidth(drawerBaseWidth, vw); // Viewports too narrow for any meaningful range (max == min) hide the // handle entirely — leaving a non-draggable handle reads as broken UI. const canResize = isDesktopVariant && drawerMaxW > THREAD_DRAWER_WIDTH_MIN; const atMin = dragging && liveWidth !== null && liveWidth <= THREAD_DRAWER_WIDTH_MIN; const atMax = dragging && liveWidth !== null && liveWidth >= drawerMaxW; // Body-style cleanup if the drag terminates without `pointerup` // (component unmount mid-drag — route change, mobile breakpoint flip, // Alt-Tab). Without this the page can get stuck with col-resize cursor // + userSelect: none. useEffect(() => { if (!dragging) return undefined; return () => { document.body.style.cursor = ''; document.body.style.userSelect = ''; }; }, [dragging]); const beginDrag = useCallback((pointerId: number) => { setDragging(true); setLiveWidth(null); try { resizeHandleRef.current?.setPointerCapture(pointerId); } catch { /* setPointerCapture is best-effort */ } document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }, []); const endDrag = useCallback(() => { setDragging(false); document.body.style.cursor = ''; document.body.style.userSelect = ''; setLiveWidth((current) => { if (current !== null) { setSavedWidth(clampThreadDrawerWidth(current, window.innerWidth)); } return null; }); }, [setSavedWidth]); const onResizePointerDown = useCallback( (e: React.PointerEvent) => { if (e.button !== 0 || !canResize) return; e.preventDefault(); beginDrag(e.pointerId); }, [beginDrag, canResize] ); const onResizePointerMove = useCallback( (e: React.PointerEvent) => { if (!dragging || !resizableRef.current) return; // Right edge of the wrapper is anchored at the row's right edge // (drawer is the last item in Room.tsx's flex row). Width grows // as the pointer moves LEFT, shrinks as it moves right — inverse // of the page-nav handle. const rect = resizableRef.current.getBoundingClientRect(); setLiveWidth(clampThreadDrawerWidth(rect.right - e.clientX, window.innerWidth)); }, [dragging] ); const onResizeStopPointer = useCallback( (e: React.PointerEvent) => { try { resizeHandleRef.current?.releasePointerCapture(e.pointerId); } catch { /* releasePointerCapture is best-effort */ } endDrag(); }, [endDrag] ); const onResizeKeyDown = useCallback( (e: React.KeyboardEvent) => { if (!canResize) return; const step = e.shiftKey ? 64 : 16; let next: number | null = null; // Arrow direction is inverted vs the page-nav handle: ArrowLeft // grows the drawer (its left edge moves left → width increases). if (e.key === 'ArrowLeft') next = savedWidth + step; else if (e.key === 'ArrowRight') next = savedWidth - step; else if (e.key === 'Home') next = drawerMaxW; else if (e.key === 'End') next = THREAD_DRAWER_WIDTH_MIN; if (next === null) return; e.preventDefault(); setSavedWidth(clampThreadDrawerWidth(next, vw)); }, [savedWidth, vw, drawerMaxW, canResize, setSavedWidth] ); // 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 [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const unreadThreadingEnabled = useUnreadThreadingEnabled(); 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 canRedact = permissions.action('redact', mx.getSafeUserId()); const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId()); const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId()); // Power-tag colour wiring — drawer mirrors RoomTimeline so coloured // username chips (mods / room creators / power-levelled users) render // identically in main timeline and drawer. Bundling these into a // shared hook is M9 polish; for now duplicating the call sites keeps // the dependency graph explicit. const theme = useTheme(); const creatorsTag = useRoomCreatorsTag(); const powerLevelTags = usePowerLevelTags(room, powerLevels); const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); const accessiblePowerTagColors = useAccessiblePowerTagColors( theme.kind, creatorsTag, powerLevelTags ); const isOneOnOne = useIsOneOnOne(); const channelsMode = useChannelsMode(); const isBridged = channelsMode && isBridgedRoom(room); const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const roomToParents = useAtomValue(roomToParentsAtom); const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); const { spaceIdOrAlias, roomIdOrAlias } = useParams(); // Reply-draft scope: tuple key `[roomId, rootId]` so the chip writes // into the drawer composer's draft slot, not the channel composer's. // The `RoomInput` instance below subscribes to the same atom via // `draftKey(roomId, threadId)` (see `roomInputDrafts.ts:34-37`) so // the chip surfaces inside the drawer composer. const setReplyDraft = useSetAtom( roomIdToReplyDraftAtomFamily(draftKey(room.roomId, rootId)) ); // Reply-chip click scrolls within the drawer to the matching // `data-message-id` row. If the target lives in the main timeline // (e.g. a chip on the root that points to a quoted main-column // message) the scroll is a silent no-op — that's an accepted M4a // limitation; cross-pane navigation is M9 polish. const handleScrollToDrawerEvent = useCallback((evtId: string) => { const host = scrollHostRef.current; if (!host) return; const target = host.querySelector( `[data-message-id="${evtId}"]` ); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, []); const { editId, handleEdit, handleOpenReply, handleUserClick, handleUsernameClick, handleReplyClick, handleReactionToggle, } = useMessageInteractionHandlers({ room, editor, // Drawer composer is mounted alongside the drawer, EXCEPT in the // read-only branch where `canMessage === false` and the body // renders `` instead of `` (see // bottom of this component). In that branch the Slate `` // is never registered in slate-react's `EDITOR_TO_ELEMENT` map, so // `ReactEditor.focus(editor)` from the username-click handler // would throw. Treating !canMessage as suspended makes the hook // bail before touching the unmounted editor. composerSuspended: !canMessage, setReplyDraft, onOpenEvent: handleScrollToDrawerEvent, channelsMode, isBridged, spaceIdOrAlias, roomIdOrAlias, }); 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; }) : []; // M4a: mount-transition trigger for `` and edited-body // refresh. `getEventReactions` and `getEditedEvent` return undefined // until the SDK creates a Relations container — at which point the // target event emits `MatrixEventEvent.RelationsCreated` // (matrix-js-sdk relations.js:375). Without this listener, the FIRST // reaction or edit on a thread row never surfaces in the drawer: // ThreadEvent.Update doesn't fire for annotations/replaces (Thread // returns early at thread.js:332-334 before updateThreadMetadata), // and `` only self-subscribes via `useRelations` AFTER // it's mounted. Once mounted, subsequent relation deltas flow // through useRelations and `getEditedEvent` re-evaluates each // render. This effect handles only the empty-→-first transition. // // Stable string signature (`repliesIds`) keeps the effect from // re-running on every `replies` re-allocation; the array reference // changes each render by design (see comment above) but content is // stable across non-paginate renders. const repliesIds = replies.map((r) => r.getId() ?? '').join('|'); useEffect(() => { const handler = () => forceRender((n) => n + 1); const subscribed: MatrixEvent[] = []; if (rootEvent) { rootEvent.on(MatrixEventEvent.RelationsCreated, handler); subscribed.push(rootEvent); } replies.forEach((evt) => { evt.on(MatrixEventEvent.RelationsCreated, handler); subscribed.push(evt); }); return () => { subscribed.forEach((evt) => evt.off(MatrixEventEvent.RelationsCreated, handler) ); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [rootEvent, repliesIds]); // Read receipts (M4): fired on first reveal at-bottom, on reply // growth while at-bottom, and on own send via the layout/effect // pair below. The receipt handler in `state/room/roomToUnread.ts` // partitions by thread_id (PUT-with-fresh instead of blind DELETE) // so a thread receipt no longer wipes main-timeline unread. SDK // auto-attaches `thread_id: rootId` via `threadIdForReceipt(event)`. // Gated on `unreadThreadingEnabled` so the kill switch falls back // to pre-M4 silence (no thread receipts → channel-list badge // semantics match the pre-M4 blind-DELETE handler). // 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(); // Fire a thread-scoped read receipt when the user lands at-bottom on // a thread with replies. SDK auto-routes `thread_id: rootId` via // `threadIdForReceipt(event)` (client.js:2655). Skipped when the // kill switch (`channels.unreadThreading: false`) is set — the // receipt handler in roomToUnread.ts then runs in pre-M4 // blind-DELETE mode, so a thread receipt would wipe channel-main // unread (the M2 reason this was deferred). Also skipped when the // tab is backgrounded, mirroring RoomTimeline's `document.hasFocus` // gate so push notifications keep firing for unfocused tabs. const tryMarkThreadRead = useCallback(() => { if (!unreadThreadingEnabled) return; if (!thread) return; if (!document.hasFocus()) return; markAsThreadRead(mx, thread, hideActivity); }, [mx, thread, hideActivity, unreadThreadingEnabled]); 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; tryMarkThreadRead(); }, [repliesCount, tryMarkThreadRead]); 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; } // Always instant — never smooth. Cold-load arrives in 1-2 waves // (SDK cache + mx.relations resolve), so the first growth pass // after initial reveal would otherwise smooth-scroll a few rows // and leak a visible animation on drawer-open. Even for // steady-state live replies a snap-to-bottom matches Discord / // Telegram and beats the «kicks back before settling» feel of a // smooth scrollIntoView. Direct scrollTop write is synchronous — // browser repaints with the new position in the same frame. host.scrollTop = host.scrollHeight; isAtBottomRef.current = true; tryMarkThreadRead(); // 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, tryMarkThreadRead]); // Stay at-bottom when the drawer scroll container resizes — Android // soft-keyboard open/close shrinks the viewport, and Capacitor's // default `windowSoftInputMode=adjustResize` shrinks the WebView // so our scroll host's clientHeight changes. Without this, the // composer pushes up to roughly mid-replies because `scrollHeight` // grows past the new shrunk viewport while `scrollTop` stays put. // Mirrors the keyboard-open fix in `RoomTimeline.tsx::useResizeObserver` // (line 858+). Skip the first observer callback (initial mount fires // synthetic resize) so we don't fight the first-reveal layout-effect. useResizeObserver( useMemo(() => { let mounted = false; return (_entries: ResizeObserverEntry[]) => { if (!mounted) { mounted = true; return; } if (isAtBottomRef.current) { requestAnimationFrame(() => { const el = scrollHostRef.current; if (el) scrollToBottom(el); }); } }; }, []), useCallback(() => scrollHostRef.current, []) ); // 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 renderThreadEvent = (mEvent: MatrixEvent) => { const eventId = mEvent.getId(); if (!eventId) return null; // Reactions and edits aggregate into different timelineSets depending // on the event's thread partition. Root reactions live on the room's // unfiltered timelineSet (root has `shouldLiveInRoom: true`, see // matrix-js-sdk room.js:eventShouldLiveIn). Thread-reply reactions // live on `thread.timelineSet.relations` — // `Thread.addRelatedThreadEvent` (thread.js:367 / 332-334) calls // `aggregateChildEvent(event, this.timelineSet)` and returns BEFORE // updateThreadMetadata, so they never touch the room's relation // cache. Reading from the wrong source means `getEventReactions` // and `getEditedEvent` return undefined for thread replies even // when the SDK has fully ingested the relation. const eventThread = mEvent.getThread(); const isThreadReply = mEvent.threadRootId !== undefined && mEvent.threadRootId !== eventId; const timelineSet = isThreadReply && eventThread ? eventThread.timelineSet : room.getUnfilteredTimelineSet(); const reactionRelations = getEventReactions(timelineSet, eventId); const reactionList = reactionRelations?.getSortedAnnotationsByKey(); const hasReactions = reactionList && reactionList.length > 0; const isEncrypted = mEvent.getType() === MessageEvent.RoomMessageEncrypted; const renderMessage = () => { const eventType = mEvent.getType(); const senderId = mEvent.getSender() ?? ''; const senderDisplayName = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; const { replyEventId, threadRootId } = mEvent; // matrix-js-sdk auto-injects a fallback `m.in_reply_to` into every // thread reply for non-thread-aware clients (client.js:1819 sets // `is_falling_back: !isReply` — true when user just typed in the // thread without explicitly quoting). MSC3440 says thread-aware // clients SHOULD ignore the fallback chip. Without this gate the // drawer renders «↩ test6 » on every reply, making it // look like the user quoted the previous message every time. // Cinny's `RoomInput.tsx:393` flips the flag to `false` only for // explicit replies (the chip the user added with the Reply menu), // so checking `is_falling_back === false` cleanly distinguishes // intentional quotes from auto-fallback chains. const wireRelatesTo = mEvent.getWireContent()['m.relates_to']; const isFallbackInReplyTo = wireRelatesTo?.rel_type === RelationType.Thread && wireRelatesTo?.is_falling_back !== false; const showReplyChip = !!replyEventId && !isFallbackInReplyTo; return ( ) } reactions={ reactionRelations && ( ) } hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools} memberPowerTag={getMemberPowerTag(senderId)} accessibleTagColors={accessiblePowerTagColors} legacyUsernameColor={isOneOnOne} msgType={mEvent.getContent().msgtype ?? ''} // We're inside a thread — nested threads aren't a thing in // Matrix, hide ThreadPlus on the row (and its menu twin). hideThreadReplyAffordance // Drawer composer is mounted alongside the row — regular // m.in_reply_to reply works in-drawer; setReplyDraft routes // to `[roomId, rootId]` so the chip surfaces there. hideMainReplyAffordance={false} layout="channel" channelHeaderInBubble > {(() => { if (mEvent.isRedacted()) { return ( ); } if (eventType === MessageEvent.Sticker) { return ( ( } renderViewer={(p) => } /> )} /> ); } if (eventType === MessageEvent.RoomMessage) { const editedEvent = getEditedEvent(eventId, mEvent, timelineSet); const getContent = (() => editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; return ( ); } if (eventType === MessageEvent.RoomMessageEncrypted) { return ( ); } return ( ); })()} ); }; if (isEncrypted) { return ( {renderMessage} ); } return renderMessage(); }; const renderBody = () => { if (rootError) { return (
{t('Room.thread_root_error')}
); } if (!rootEvent) { return ( ); } return (
{renderThreadEvent(rootEvent)} {(() => { // Counter prefers the larger of materialized `thread.length` // (replyCount + pending) and loaded `replies.length` — covers // the cold-load window where the SDK can synthesize an empty // Thread (length=0) shortly before the relations fetch lands // a populated `replies` list (`??` would have collapsed to 0 // and silently shown the divider despite present replies). const counterCount = Math.max(thread?.length ?? 0, replies.length); if (counterCount === 0) return
; return (
{t('Room.thread_summary_count', { count: counterCount })}
); })()} {coldLoadError && (
{t('Room.thread_paginate_error')}
)} {!coldLoadError && (coldLoadFetching || !thread) && ( )} {paginating && replies.length === 0 && ( )} {paginateError && (
{t('Room.thread_paginate_error')}
)} {thread && !coldLoadError && !coldLoadFetching && !paginating && !paginateError && replies.length === 0 && (
{t('Room.thread_no_replies')}
)} {/* 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 && (