diff --git a/public/locales/en.json b/public/locales/en.json index 6eae0d87..fce5c55c 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -509,6 +509,14 @@ "empty_message": "Empty message", "edited": " (edited)", + "thread_in_channel": "Thread in #{{channel}}", + "thread_close": "Close thread", + "thread_no_replies": "No one has replied yet", + "thread_root_error": "Could not load the original message", + "thread_paginate_error": "Could not load replies", + "thread_retry": "Retry", + "no_post_permission": "You do not have permission to post in this room", + "conversation_beginning": "This is the beginning of conversation.", "created_by": "Created by @{{creator}} on {{date}} {{time}}", "invite_member": "Invite Member", diff --git a/public/locales/ru.json b/public/locales/ru.json index 5c40fa2a..134b26d6 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -513,6 +513,14 @@ "empty_message": "Пустое сообщение", "edited": " (изменено)", + "thread_in_channel": "Тред в #{{channel}}", + "thread_close": "Закрыть тред", + "thread_no_replies": "Никто пока не ответил", + "thread_root_error": "Не удалось загрузить исходное сообщение", + "thread_paginate_error": "Не удалось загрузить ответы", + "thread_retry": "Повторить", + "no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате", + "conversation_beginning": "Начало переписки.", "created_by": "Комната создана @{{creator}} {{date}} {{time}}", "invite_member": "Пригласить", diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index bd848dd5..b0f1be9f 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -24,7 +24,15 @@ import { CustomElement } from './slate'; import * as css from './Editor.css'; import { toggleKeyboardShortcut } from './keyboard'; -const initialValue: CustomElement[] = [ +// Factory — NOT a module-level constant. Slate's slate-react keeps its +// internal NODE_TO_INDEX / NODE_TO_PARENT WeakMaps keyed by object +// identity (slate#6016, slate#4850). When two `` +// instances share the same array+leaf reference (e.g. channel composer +// and thread drawer composer mounted at the same time), the second +// mount overwrites the first's WeakMap entry → `findPath` returns the +// wrong index → "Unable to find the path for Slate node: {text:''}" +// crash on cold-load. Fresh array per call sidesteps the collision. +const makeInitialValue = (): CustomElement[] => [ { type: BlockType.Paragraph, children: [{ text: '' }], @@ -90,6 +98,12 @@ export const CustomEditor = forwardRef( }, ref ) => { + // Fresh value per CustomEditor instance — see comment on + // `makeInitialValue` for why we cannot share a module-level const. + // useState ensures we don't recreate the value across renders of + // the same editor (Slate would not accept a new array reference + // anyway after the editor has mounted). + const [initialValue] = useState(makeInitialValue); const renderElement = useCallback( (props: RenderElementProps) => , [] diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 5a2472e1..67a813f6 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -1,10 +1,11 @@ import React, { useCallback } from 'react'; import { Box, Line } from 'folds'; -import { useParams } from 'react-router-dom'; +import { useMatch, useParams } from 'react-router-dom'; import { isKeyHotkey } from 'is-hotkey'; import { useAtomValue } from 'jotai'; import { RoomView } from './RoomView'; import { MembersDrawer } from './MembersDrawer'; +import { ThreadDrawer } from './ThreadDrawer'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; @@ -20,6 +21,10 @@ import { callChatAtom } from '../../state/callEmbed'; import { CallChatView } from './CallChatView'; import { RoomViewProfilePanel } from './RoomViewProfilePanel'; import { RoomViewProfileSidePanel } from './RoomViewProfileSidePanel'; +import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChannelsMode'; +import { CHANNELS_THREAD_PATH } from '../../pages/paths'; +import { getChannelsRoomPath } from '../../pages/pathUtils'; +import { isBridgedRoom } from '../../utils/room'; type RoomProps = { renderRoomView?: (props: { eventId?: string }) => React.ReactNode; @@ -29,10 +34,50 @@ export function Room({ renderRoomView }: RoomProps) { const { eventId } = useParams(); const room = useRoom(); const mx = useMatrixClient(); - + const channelsMode = useChannelsMode(); + // Match the thread URL pattern via useMatch rather than reading + // useParams: rootId lives in a CHILD route relative to , so + // useParams here returns only the room-level segments. useMatch + // walks the absolute URL and gives us all four segments back. + const threadMatch = useMatch({ path: CHANNELS_THREAD_PATH, end: true }); + const matchedSpaceParam = threadMatch?.params.spaceIdOrAlias; + const matchedRoomParam = threadMatch?.params.roomIdOrAlias; + const matchedRootParam = threadMatch?.params.rootId; + // Suppress the drawer in bridged rooms. Telegram puppets have no + // m.thread on the bridge side; M7 will replace this gate with an + // inline-quote indicator. Until then, keeping the drawer hidden + // matches the «Reply in Thread» button gate in RoomTimeline. + const showThreadDrawer = + channelsMode && + !!matchedRootParam && + !!matchedSpaceParam && + !!matchedRoomParam && + !isBridgedRoom(room); + let parentRoomPath: string | undefined; + let decodedRootId: string | undefined; + if (showThreadDrawer && matchedSpaceParam && matchedRoomParam && matchedRootParam) { + try { + parentRoomPath = getChannelsRoomPath( + decodeURIComponent(matchedSpaceParam), + decodeURIComponent(matchedRoomParam) + ); + } catch { + parentRoomPath = getChannelsRoomPath(matchedSpaceParam, matchedRoomParam); + } + try { + decodedRootId = decodeURIComponent(matchedRootParam); + } catch { + decodedRootId = matchedRootParam; + } + } const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + // Mobile drawer takes over the chat column instead of sitting beside + // it — the timeline+composer stay mounted but visually hidden, so the + // M5 nav-stack work picks up where M2 left off without re-routing. + const drawerHidesChat = showThreadDrawer && isMobile; const powerLevels = usePowerLevels(room); const members = useRoomMembers(mx, room.roomId); const chat = useAtomValue(callChatAtom); @@ -47,58 +92,85 @@ export function Room({ renderRoomView }: RoomProps) { window, useCallback( (evt) => { - if (isKeyHotkey('escape', evt)) { + // Skip Escape-markAsRead when the thread drawer is open. The + // existing handler in `state/room/roomToUnread.ts` blind-deletes + // ALL room unread on any own receipt — so firing markAsRead + // here while the drawer is the active surface would also wipe + // unrelated channel-main unread that the user hasn't seen. + // M4 will refactor the receipt handler to partition by + // thread_id; until then, gate the Escape shortcut. + if (isKeyHotkey('escape', evt) && !showThreadDrawer) { markAsRead(mx, room.roomId, hideActivity); } }, - [mx, room.roomId, hideActivity] + [mx, room.roomId, hideActivity, showThreadDrawer] ) ); const callView = room.isCallRoom(); - const isMobile = screenSize === ScreenSize.Mobile; return ( - - {callView && (screenSize === ScreenSize.Desktop || !chat) && ( - - }> - - - - - - )} - {!callView && ( - - }> - {renderRoomView?.({ eventId }) ?? } - - - )} + + + {callView && (screenSize === ScreenSize.Desktop || !chat) && ( + + }> + + + + + + )} + {!callView && !drawerHidesChat && ( + + }> + + {renderRoomView?.({ eventId }) ?? } + + + + )} {/* Tablet / Desktop: profile renders as a third pane to the right of the chat. Mobile uses the top horseshoe inside - `RoomViewProfilePanel`, so we don't mount the side pane - there. */} - {!isMobile && } + `RoomViewProfilePanel`, so we don't mount the side pane + there. */} + {!isMobile && !showThreadDrawer && } - {callView && chat && ( - <> - {screenSize === ScreenSize.Desktop && ( - + {callView && chat && ( + <> + {screenSize === ScreenSize.Desktop && ( + + )} + + + )} + {/* Members drawer hidden when thread drawer is open — three + simultaneous side panes don't fit the chat column on + anything narrower than ultrawide. The thread is the more + recent intent so it wins. */} + {!callView && + !isOneOnOne && + !showThreadDrawer && + screenSize === ScreenSize.Desktop && + isDrawer && ( + <> + + + )} - - - )} - {!callView && !isOneOnOne && screenSize === ScreenSize.Desktop && isDrawer && ( - <> - - - - )} - + {showThreadDrawer && decodedRootId && parentRoomPath && ( + + )} + + ); } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index ccf995b7..d1055d33 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -71,6 +71,7 @@ import { useFileDropZone } from '../../hooks/useFileDrop'; import { TUploadItem, TUploadMetadata, + draftKey, roomIdToMsgDraftAtomFamily, roomIdToReplyDraftAtomFamily, roomIdToUploadItemsAtomFamily, @@ -124,9 +125,14 @@ interface RoomInputProps { fileDropContainerRef: RefObject; roomId: string; room: Room; + // M2: when set, all sendMessage / sendEvent paths emit with this + // threadId so the SDK attaches the m.thread relation. Channel and DM + // composers leave this undefined so messages land in the main timeline + // unchanged. The drawer composer passes threadId={rootId}. + threadId?: string; } export const RoomInput = forwardRef( - ({ editor, fileDropContainerRef, roomId, room }, ref) => { + ({ editor, fileDropContainerRef, roomId, room, threadId }, ref) => { const { t } = useTranslation(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -140,8 +146,12 @@ export const RoomInput = forwardRef( const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); - const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); - const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId)); + // Per-(room, thread) draft scope: drawer composer (threadId={rootId}) + // and channel/DM composer (threadId=undefined → 'main') no longer + // clobber each other's drafts. See `roomInputDrafts.ts::DraftKey`. + const inputDraftKey = draftKey(roomId, threadId); + const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(inputDraftKey)); + const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(inputDraftKey)); const replyUserID = replyDraft?.userId; const powerLevelTags = usePowerLevelTags(room, powerLevels); @@ -161,7 +171,9 @@ export const RoomInput = forwardRef( const replyUsernameColor = isOneOnOne ? colorMXID(replyUserID ?? '') : replyPowerColor; const [uploadBoard, setUploadBoard] = useState(true); - const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId)); + const [selectedFiles, setSelectedFiles] = useAtom( + roomIdToUploadItemsAtomFamily(inputDraftKey) + ); const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( roomUploadAtomFamily, selectedFiles.map((f) => f.file) @@ -240,7 +252,12 @@ export const RoomInput = forwardRef( resetEditor(editor); resetEditorHistory(editor); }, - [roomId, editor, setMsgDraft] + // `threadId` included so a future caller passing dynamic + // threadId without remount still routes the cleanup-on-unmount + // setter to the correct atom. Today the drawer is fully + // remounted on rootId change via Room.tsx key, but the dep + // pins the invariant explicitly. + [roomId, threadId, editor, setMsgDraft] ); const handleFileMetadata = useCallback( @@ -293,7 +310,7 @@ export const RoomInput = forwardRef( }); handleCancelUpload(uploads); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); - contents.forEach((content) => mx.sendMessage(roomId, content as any)); + contents.forEach((content) => mx.sendMessage(roomId, threadId ?? null, content as any)); }; const submit = useCallback(() => { @@ -372,12 +389,22 @@ export const RoomInput = forwardRef( content['m.relates_to'].is_falling_back = false; } } - mx.sendMessage(roomId, content as any); + mx.sendMessage(roomId, threadId ?? null, content as any); resetEditor(editor); resetEditorHistory(editor); setReplyDraft(undefined); sendTypingStatus(false); - }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]); + }, [ + mx, + roomId, + threadId, + editor, + replyDraft, + sendTypingStatus, + setReplyDraft, + isMarkdown, + commands, + ]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { @@ -439,7 +466,7 @@ export const RoomInput = forwardRef( await getImageUrlBlob(stickerUrl) ); - mx.sendEvent(roomId, EventType.Sticker, { + mx.sendEvent(roomId, threadId ?? null, EventType.Sticker, { body: label, url: mxc, info, diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 7e595ca8..3d420884 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -44,6 +44,7 @@ import { import { isKeyHotkey } from 'is-hotkey'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { Trans, useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator'; @@ -99,7 +100,7 @@ import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResize import * as css from './RoomTimeline.css'; import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time'; import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor'; -import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; +import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; import { useKeyDown } from '../../hooks/useKeyDown'; @@ -117,7 +118,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useIsOneOnOne } from '../../hooks/useRoom'; -import { useChannelsMode } from '../../hooks/useChannelsMode'; +import { useChannelsMode, useThreadDrawerOpen } from '../../hooks/useChannelsMode'; +import { getChannelsThreadPath } from '../../pages/pathUtils'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { useRoomCreators } from '../../hooks/useRoomCreators'; @@ -423,18 +425,30 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => { // Channels-mode visibility filter. Single source of truth for the // /channels/* surface — called from BOTH the rail-endpoint scan // (`isRenderableTimelineEvent`) AND the renderer null-gate so the two never -// disagree on which events count as visible. Diverging the two would silently -// break rail-start mid-conversation and miscount unread anchors. +// disagree on which events count as visible. // -// Filters: thread / reference relations, `m.call.*`, RTC member churn (stable -// + msc4143 unstable), RTC notifications, polls (no MVP renderer per plan -// §53-56). Bridged-room exception passes `m.thread`/`io.element.thread` -// through as roots so federated-via-bridge replies stay visible until M7. -const isChannelsModeHidden = (event: MatrixEvent, isBridged: boolean): boolean => { - const relType = event.getRelation()?.rel_type; - if (relType === 'm.thread' || relType === 'io.element.thread') { - if (!isBridged) return true; - } else if (relType === 'm.reference') { +// Thread-relation hiding delegates to SDK's `room.eventShouldLiveIn` +// (matrix-js-sdk room.ts:891) — same predicate Element-web uses in +// `MessagePanel.filterEventsThatShouldLiveInThisTimeline`. Canonical +// because: (a) handles thread-root visible-in-both-timelines correctly; +// (b) handles orphan thread relations whose root never landed; (c) +// classifies redactions via target's thread; (d) classifies pending +// local-echoes the same way the SDK does — kills the «message flashes +// in main timeline before drawer» blink that hand-rolled relType check +// missed because the SDK adds the thread relation to the local-echo +// content asynchronously vs the predicate's `getRelation()` read. +// +// Bridged-room exception: SDK still partitions thread events into the +// thread, but on bridged rooms (Telegram puppets etc.) we want the +// thread events visible in main timeline because the bridge has no +// thread surface. Override returns false (don't hide). +// +// Service events (call.*, rtc.*, poll.*) stay hand-filtered — these +// are not thread-related and the SDK predicate has no opinion on them. +const isChannelsModeHidden = (room: Room, event: MatrixEvent, isBridged: boolean): boolean => { + const partition = room.eventShouldLiveIn(event); + if (!partition.shouldLiveInRoom) { + if (isBridged) return false; return true; } const eventType = event.getType(); @@ -448,6 +462,10 @@ const isChannelsModeHidden = (event: MatrixEvent, isBridged: boolean): boolean = if (eventType === 'org.matrix.msc3381.poll.start') return true; if (eventType === 'org.matrix.msc3381.poll.response') return true; if (eventType === 'org.matrix.msc3381.poll.end') return true; + // m.reference relations (e.g. polls' votes) — partition reports + // shouldLiveInRoom: true for these but we never render them in the + // channel center column. SDK has no special predicate; explicit drop. + if (event.getRelation()?.rel_type === 'm.reference') return true; return false; }; @@ -464,6 +482,32 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // each render is fine — `isBridgedRoom` walks the room state-event index, // not the timeline. const isBridged = channelsMode && isBridgedRoom(room); + // M2: «Reply in Thread» affordance is channels-mode-only. Bridged + // channels (Telegram puppets) get nothing because the bridge has no + // thread semantic — clicking would create a draft the bridge drops + // on send. Outside channels (DM/Bots/legacy //) the surface + // has no drawer and no thread UI per plan §6.7, so the button has + // nowhere to lead. Pre-M2 the button existed in those surfaces but + // never produced a visible thread. + const hideThreadReplyAffordance = !channelsMode || isBridged; + // M2: when the thread drawer is open, the channel composer is + // unmounted by RoomView (single-Slate-at-a-time pattern). Clicking + // the channel timeline's «Reply» menu would write a reply chip into + // an atom no surface reads, then fire `ReactEditor.focus(editor)` + // on a Slate editor that's no longer mounted (silent no-op) — dead + // click from the user's perspective. Hide the regular Reply + // affordance in this state. ThreadPlus stays gated separately by + // `hideThreadReplyAffordance` and remains active for re-opening the + // drawer to a different thread root. + const threadDrawerOpen = useThreadDrawerOpen(); + const hideMainReplyAffordance = threadDrawerOpen; + // Captured for the channels-mode «Reply in Thread» button — the + // handler turns a click on a row into a navigate(thread URL) instead + // of writing a thread reply-draft into the channel composer. Decoded + // params are pre-encoded by react-router; getChannelsThreadPath + // re-encodes via generatePath so passing the raw URL value is fine. + const navigate = useNavigate(); + const { spaceIdOrAlias, roomIdOrAlias } = useParams(); const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); @@ -476,7 +520,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const ignoredUsersList = useIgnoredUsers(); const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]); - const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); + // Reply-draft setter targets the MAIN composer of this room (channel + // / DM / legacy timeline). Drawer composer manages its own per-thread + // reply draft via DraftKey([roomId, rootId]) inside RoomInput. + const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId))); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); @@ -626,10 +673,43 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli }, [alive, room]) ); + // M2 channels-mode: hidden events (thread replies, RTC, polls, etc.) + // are appended to the SDK timelineSet and bump `getTimelinesEventsCount`, + // but the live handler early-returns without mutating state. The + // virtual `range` is indexed against the FULL SDK event-pool (see + // `getInitialTimeline` — `range.end = evLength`), so a +1 advance on + // the next visible event would lag behind by N hidden events and + // miss the visible one in the rendered window. Track skipped count + // here; the next visible event consumes `skipped + 1` as its delta. + const skippedHiddenLiveEventsRef = useRef(0); + // Counter is meaningful only against the CURRENT linkedTimelines — + // after a full rebase (loadEventTimeline / refresh / jump-to-latest / + // jump-to-unread), range is recomputed against the fresh evLength, + // so any pre-rebase skipped count would over-shoot range on the next + // visible event. Track `linkedTimelines` reference identity (replaced + // on every rebase) and zero the counter alongside. + useEffect(() => { + skippedHiddenLiveEventsRef.current = 0; + }, [timeline.linkedTimelines]); useLiveEventArrive( room, useCallback( (mEvt: MatrixEvent) => { + // Hidden events: skip render-state mutation (no blink), but + // record the count so the next visible event can advance the + // range by the correct delta. + if (channelsMode && isChannelsModeHidden(room, mEvt, isBridged)) { + skippedHiddenLiveEventsRef.current += 1; + return; + } + + // Visible event arrival — snapshot + reset the skipped counter. + // `liveDelta` covers all SDK-appended events since the last + // visible one (skipped hidden + this one) so the virtual range + // stays aligned with `getTimelinesEventsCount(linkedTimelines)`. + const liveDelta = skippedHiddenLiveEventsRef.current + 1; + skippedHiddenLiveEventsRef.current = 0; + // «Sending while scrolled up jumps the timeline to live» — Telegram / // WhatsApp / Slack pattern. After P3c every room renders Stream, so // the own-message follow-to-bottom guard is universal. @@ -674,8 +754,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli return { ...ct, range: { - start: ct.range.start + 1, - end: ct.range.end + 1, + start: ct.range.start + liveDelta, + end: ct.range.end + liveDelta, }, }; }); @@ -686,7 +766,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, unreadInfo, hideActivity] + [mx, room, unreadInfo, hideActivity, channelsMode, isBridged] ) ); @@ -843,6 +923,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli window, useCallback( (evt) => { + // Drawer-active: the focused `` may be the drawer + // composer (also tagged `data-editable-name="RoomInput"`), so + // this selector alone can't distinguish main vs thread. Without + // this guard, ArrowUp in an empty drawer composer would put the + // CHANNEL timeline's latest event into edit mode — visible on + // desktop, where main timeline stays mounted next to the drawer. + if (threadDrawerOpen) return; if ( isKeyHotkey('arrowup', evt) && editableActiveElement() && @@ -858,7 +945,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli evt.preventDefault(); } }, - [mx, room, editor] + [mx, room, editor, threadDrawerOpen] ) ); @@ -998,6 +1085,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const handleUsernameClick: MouseEventHandler = useCallback( (evt) => { evt.preventDefault(); + // Drawer-active: this handler targets the MAIN composer's editor, + // which is unmounted while the drawer is open. Inserting a mention + // would silently write into a hidden Slate instance the user can't + // see, then `ReactEditor.focus` no-ops on the unmounted editor. + // Mention-from-username inside an open thread drawer is M9 polish + // (drawer composer's own click handler); for M2 the click is a + // no-op so the user doesn't lose the input invisibly. + if (threadDrawerOpen) return; const userId = evt.currentTarget.getAttribute('data-user-id'); if (!userId) { console.warn('Button should have "data-user-id" attribute!'); @@ -1014,7 +1109,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ReactEditor.focus(editor); moveCursor(editor); }, - [mx, room, editor] + [mx, room, editor, threadDrawerOpen] ); const handleReplyClick: MouseEventHandler = useCallback( @@ -1026,6 +1121,33 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli } const replyEvt = room.findEventById(replyId); if (!replyEvt) return; + // Channels: «Reply in Thread» opens the right-side drawer instead + // of stuffing a thread relation into the channel composer's reply + // draft. Bridged rooms (Telegram puppets etc.) have no thread + // semantic on the bridge side — fall back to the legacy + // m.in_reply_to draft path so messages still go through. + if ( + startThread && + channelsMode && + !isBridged && + spaceIdOrAlias && + roomIdOrAlias + ) { + // useParams returns the encoded URL value; getChannelsThreadPath + // re-encodes via generatePath so we decode once first to avoid + // double-encoding (matches `routeParent.ts` decode pattern). + let decodedSpace: string; + let decodedRoom: string; + try { + decodedSpace = decodeURIComponent(spaceIdOrAlias); + decodedRoom = decodeURIComponent(roomIdOrAlias); + } catch { + decodedSpace = spaceIdOrAlias; + decodedRoom = roomIdOrAlias; + } + navigate(getChannelsThreadPath(decodedSpace, decodedRoom, replyId)); + return; + } const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); const { body, formatted_body: formattedBody } = content; @@ -1044,7 +1166,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli setTimeout(() => ReactEditor.focus(editor), 100); } }, - [room, setReplyDraft, editor] + [ + room, + setReplyDraft, + editor, + channelsMode, + isBridged, + navigate, + spaceIdOrAlias, + roomIdOrAlias, + ] ); const handleReactionToggle = useCallback( @@ -1171,6 +1302,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli streamRailStart={streamRailStart} streamRailEnd={streamRailEnd} msgType={mEvent.getContent().msgtype ?? ''} + hideThreadReplyAffordance={hideThreadReplyAffordance} + hideMainReplyAffordance={hideMainReplyAffordance} > {mEvent.isRedacted() ? ( @@ -1272,6 +1405,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli streamRailStart={streamRailStart} streamRailEnd={streamRailEnd} msgType={mEvent.getContent().msgtype ?? ''} + hideThreadReplyAffordance={hideThreadReplyAffordance} + hideMainReplyAffordance={hideMainReplyAffordance} > {(() => { if (mEvent.isRedacted()) return ; @@ -1384,6 +1519,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli legacyUsernameColor={isOneOnOne} streamRailStart={streamRailStart} streamRailEnd={streamRailEnd} + hideThreadReplyAffordance={hideThreadReplyAffordance} + hideMainReplyAffordance={hideMainReplyAffordance} > {mEvent.isRedacted() ? ( @@ -1794,7 +1931,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // Channels-mode filter — see `isChannelsModeHidden` above for the rule // set and rationale. Same helper used by the renderer null-gate so the // rail-endpoint scan and the actual render always agree on visibility. - if (channelsMode && isChannelsModeHidden(event, isBridged)) return false; + if (channelsMode && isChannelsModeHidden(room, event, isBridged)) return false; if (eventType === StateEvent.RoomMember) { // Mirror the membership-sysline gate from the renderer above so the @@ -1923,7 +2060,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // Channels-mode renderer gate — same helper as the predicate above so // the rail-endpoint scan and the renderer never disagree on visibility. - const channelsModeHidden = channelsMode && isChannelsModeHidden(mEvent, isBridged); + const channelsModeHidden = channelsMode && isChannelsModeHidden(room, mEvent, isBridged); const eventJSX = channelsModeHidden || reactionOrEditEvent(mEvent) ? null diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 7a4209bc..db32715a 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -19,6 +19,7 @@ import { editableActiveElement } from '../../utils/dom'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoom } from '../../hooks/useRoom'; +import { useThreadDrawerOpen } from '../../hooks/useChannelsMode'; const FN_KEYS_REGEX = /^F\d+$/; const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { @@ -68,6 +69,16 @@ export function RoomView({ eventId }: { eventId?: string }) { const permissions = useRoomPermissions(creators, powerLevels); const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId()); + // M2: when the thread drawer is open, the drawer hosts its own + // composer (RoomInput) for thread replies. We must NOT mount a + // second RoomInput here because (a) two `` editors crash on + // cold-load with the shared module-level initial value (slate#6016) + // and (b) the channel composer's local-echo would still subscribe to + // room.timeline events and visibly react to the user's thread reply. + // Element-web uses the same single-composer-at-a-time pattern via + // `TimelineRenderingType.Thread` context. + const threadDrawerOpen = useThreadDrawerOpen(); + useKeyDown( window, useCallback( @@ -77,11 +88,17 @@ export function RoomView({ eventId }: { eventId?: string }) { if (portalContainer && portalContainer.children.length > 0) { return; } + // Drawer-active: this RoomInput is unmounted; ReactEditor.focus + // on it silently no-ops, but the user's keystroke would bypass + // the drawer composer instead of routing there. Skip the auto- + // focus so the keystroke falls through to whatever has focus + // (typically the drawer composer's CustomEditor when open). + if (threadDrawerOpen) return; if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) { ReactEditor.focus(editor); } }, - [editor] + [editor, threadDrawerOpen] ) ); @@ -97,38 +114,40 @@ export function RoomView({ eventId }: { eventId?: string }) { /> - -
- {tombstoneEvent ? ( - - ) : ( - <> - {canMessage && ( - - )} - {!canMessage && ( - - You do not have permission to post in this room - - )} - - )} -
-
+ {!threadDrawerOpen && ( + +
+ {tombstoneEvent ? ( + + ) : ( + <> + {canMessage && ( + + )} + {!canMessage && ( + + You do not have permission to post in this room + + )} + + )} +
+
+ )} ); } diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts new file mode 100644 index 00000000..97c7aa52 --- /dev/null +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -0,0 +1,102 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +// Layout copies element-web `_ThreadPanel.pcss:73-84`: +// `max-height: 100%` clamps the column to the parent's viewport-bound +// height; `min-height: 0` on the inner Scroll wrapper lets it shrink +// under content (otherwise flex-children grow with content and push +// the composer off-screen — bug observed on cold-load with many +// replies). Comment in element-web: «don't displace the composer». +export const ThreadDrawer = style({ + flexShrink: 0, + width: `clamp(${toRem(320)}, 28%, ${toRem(420)})`, + maxHeight: '100%', + display: 'flex', + flexDirection: 'column', + backgroundColor: color.Surface.Container, + borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + minHeight: 0, +}); + +// Mobile push: drawer occupies the whole content column, not a side pane. +export const ThreadDrawerMobile = style({ + flexGrow: 1, + width: '100%', + maxHeight: '100%', + display: 'flex', + flexDirection: 'column', + backgroundColor: color.Surface.Container, + minHeight: 0, +}); + +export const ThreadDrawerHeader = style({ + flexShrink: 0, + padding: `0 ${config.space.S200} 0 ${config.space.S400}`, + borderBottomWidth: config.borderWidth.B300, +}); + +// Critical pair: `flexGrow: 1` lets the scroll wrapper take remaining +// space, `minHeight: 0` is what allows it to actually SHRINK under +// content (without it, flex-children grow to natural content size → +// composer pushed off viewport). `overflow: hidden` clips so the +// inner folds `` (which is `overflow: auto`) is the only +// scroller. Same trio that element-web uses on the timeline wrapper. +export const ThreadDrawerScroll = style({ + flexGrow: 1, + minHeight: 0, + overflow: 'hidden', + position: 'relative', +}); + +export const ThreadDrawerContent = style({ + display: 'flex', + flexDirection: 'column', + gap: config.space.S400, + padding: `${config.space.S400} ${config.space.S400}`, +}); + +export const ThreadEventCard = style({ + display: 'flex', + flexDirection: 'row', + gap: config.space.S300, +}); + +export const ThreadEventAvatar = style({ + flexShrink: 0, +}); + +export const ThreadEventBody = style({ + flexGrow: 1, + minWidth: 0, +}); + +export const ThreadEventEdited = style({ + marginLeft: config.space.S100, + color: color.SurfaceVariant.OnContainer, + opacity: 0.6, +}); + +export const ThreadDivider = style({ + height: 1, + backgroundColor: color.Surface.ContainerLine, + margin: `0 ${config.space.S400}`, + flexShrink: 0, +}); + +export const ThreadComposer = style({ + flexShrink: 0, + padding: `0 ${config.space.S400} ${config.space.S400}`, +}); + +export const ThreadEmptyState = style({ + padding: `${config.space.S500} ${config.space.S300}`, + textAlign: 'center', +}); + +export const ThreadErrorState = style({ + padding: config.space.S400, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: config.space.S300, +}); diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx new file mode 100644 index 00000000..93204799 --- /dev/null +++ b/src/app/features/room/ThreadDrawer.tsx @@ -0,0 +1,911 @@ +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + Avatar, + Box, + Button, + Header, + Icon, + IconButton, + Icons, + Scroll, + Spinner, + Text, + config, +} from 'folds'; +import { + Direction, + EventTimeline, + EventType, + MatrixEvent, + MatrixEventEvent, + MsgType, + 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 { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useEditor } from '../../components/editor'; +import { + getEditedEvent, + getMemberAvatarMxc, + getMemberDisplayName, + reactionOrEditEvent, +} from '../../utils/room'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { UserAvatar } from '../../components/user-avatar'; +import { RedactedContent, Time } from '../../components/message'; +import { RenderMessageContent } from '../../components/RenderMessageContent'; +import { RoomInput } from './RoomInput'; +import { RoomInputPlaceholder } from './RoomInputPlaceholder'; +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 { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; +import { + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + LINKIFY_OPTS, + makeMentionCustomProps, + renderMatrixMention, +} from '../../plugins/react-custom-html-parser'; +import { GetContentCallback } 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'; +}; + +type ThreadEventCardProps = { + room: Room; + mEvent: MatrixEvent; + htmlReactParserOptions: HTMLReactParserOptions; + linkifyOpts: LinkifyOpts; + mediaAutoLoad: boolean; + showUrlPreview: boolean; +}; +// Avatar + name + Time + content. Content rendering delegates to +// `RenderMessageContent` — the same pipeline RoomTimeline uses for the +// main column. That gives image / video / sticker / file / location / +// notice / emote / unable-to-decrypt rendering for free, and edit +// aggregation flows through `getEditedEvent` against the room's +// unfiltered timelineSet (which is where `m.replace` events live). +function ThreadEventCard({ + room, + mEvent, + htmlReactParserOptions, + linkifyOpts, + mediaAutoLoad, + showUrlPreview, +}: ThreadEventCardProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const { t } = useTranslation(); + + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; + const avatarMxc = getMemberAvatarMxc(room, senderId); + const avatarUrl = avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 100, 100, 'crop') + : undefined; + + const eventId = mEvent.getId(); + const editedEvent = + eventId && !mEvent.isRedacted() + ? getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet()) + : undefined; + const getContent = ((): GetContentCallback => + editedEvent + ? () => editedEvent.getContent()['m.new_content'] + : () => mEvent.getContent())(); + + const msgType = (mEvent.getContent().msgtype as string | undefined) ?? ''; + + return ( +
+
+ + } + /> + +
+
+ + + {senderDisplayName} + + + {mEvent.isRedacted() ? ( + + ) : ( + + )} + {editedEvent && !mEvent.isRedacted() && ( + + {t('Room.edited')} + + )} +
+
+ ); +} + +// 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 ( +
+ + {t('Room.thread_root_error')} + + +
+ ); + } + + if (!rootEvent) { + return ( + + + + ); + } + + return ( +
+ +
+ {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 && ( +