feat(channels): ship M2 thread drawer + composer + shareable thread URL with cold-load relations repair

This commit is contained in:
heaven 2026-05-09 22:49:53 +03:00
parent 851f3d30a3
commit 4632be30f7
17 changed files with 1549 additions and 140 deletions

View file

@ -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 <bold>@{{creator}}</bold> on {{date}} {{time}}",
"invite_member": "Invite Member",

View file

@ -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": "Комната создана <bold>@{{creator}}</bold> {{date}} {{time}}",
"invite_member": "Пригласить",

View file

@ -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 `<Slate initialValue=…>`
// 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<HTMLDivElement, CustomEditorProps>(
},
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) => <RenderElement {...props} />,
[]

View file

@ -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 <Room>, 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 (
<PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes">
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
<Box grow="Yes" direction="Column">
<RoomViewProfilePanel header={<RoomViewHeader callView />}>
<Box grow="Yes">
<CallView />
</Box>
</RoomViewProfilePanel>
</Box>
)}
{!callView && (
<Box grow="Yes" direction="Column">
<RoomViewProfilePanel header={<RoomViewHeader />}>
<Box grow="Yes">{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}</Box>
</RoomViewProfilePanel>
</Box>
)}
<ThreadDrawerOpenProvider value={showThreadDrawer}>
<Box grow="Yes">
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
<Box grow="Yes" direction="Column">
<RoomViewProfilePanel header={<RoomViewHeader callView />}>
<Box grow="Yes">
<CallView />
</Box>
</RoomViewProfilePanel>
</Box>
)}
{!callView && !drawerHidesChat && (
<Box grow="Yes" direction="Column">
<RoomViewProfilePanel header={<RoomViewHeader />}>
<Box grow="Yes">
{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}
</Box>
</RoomViewProfilePanel>
</Box>
)}
{/* 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 && <RoomViewProfileSidePanel />}
`RoomViewProfilePanel`, so we don't mount the side pane
there. */}
{!isMobile && !showThreadDrawer && <RoomViewProfileSidePanel />}
{callView && chat && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
{callView && chat && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<CallChatView />
</>
)}
{/* 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 && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<MembersDrawer key={room.roomId} room={room} members={members} />
</>
)}
<CallChatView />
</>
)}
{!callView && !isOneOnOne && screenSize === ScreenSize.Desktop && isDrawer && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<MembersDrawer key={room.roomId} room={room} members={members} />
</>
)}
</Box>
{showThreadDrawer && decodedRootId && parentRoomPath && (
<ThreadDrawer
key={`${room.roomId}/${decodedRootId}`}
room={room}
rootId={decodedRootId}
parentRoomPath={parentRoomPath}
variant={isMobile ? 'mobile' : 'desktop'}
/>
)}
</Box>
</ThreadDrawerOpenProvider>
</PowerLevelsContextProvider>
);
}

View file

@ -71,6 +71,7 @@ import { useFileDropZone } from '../../hooks/useFileDrop';
import {
TUploadItem,
TUploadMetadata,
draftKey,
roomIdToMsgDraftAtomFamily,
roomIdToReplyDraftAtomFamily,
roomIdToUploadItemsAtomFamily,
@ -124,9 +125,14 @@ interface RoomInputProps {
fileDropContainerRef: RefObject<HTMLElement>;
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<HTMLDivElement, RoomInputProps>(
({ 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<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
});
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<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
await getImageUrlBlob(stickerUrl)
);
mx.sendEvent(roomId, EventType.Sticker, {
mx.sendEvent(roomId, threadId ?? null, EventType.Sticker, {
body: label,
url: mxc,
info,

View file

@ -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 /<space>/) 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 `<Editable>` 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<HTMLButtonElement> = 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<HTMLButtonElement> = 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() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -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 <RedactedContent />;
@ -1384,6 +1519,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
legacyUsernameColor={isOneOnOne}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
hideThreadReplyAffordance={hideThreadReplyAffordance}
hideMainReplyAffordance={hideMainReplyAffordance}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -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

View file

@ -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 `<Slate>` 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 }) {
/>
<RoomViewTyping room={room} />
</Box>
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)}
</div>
</Box>
{!threadDrawerOpen && (
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)}
</div>
</Box>
)}
</Page>
);
}

View file

@ -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 `<Scroll>` (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,
});

View file

@ -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 (
<div className={css.ThreadEventCard}>
<div className={css.ThreadEventAvatar}>
<Avatar size="200">
<UserAvatar
userId={senderId}
src={avatarUrl ?? undefined}
alt={senderDisplayName}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</div>
<div className={css.ThreadEventBody}>
<Box alignItems="Center" gap="200">
<Text size="T300">
<b>{senderDisplayName}</b>
</Text>
<Time ts={mEvent.getTs()} compact />
</Box>
{mEvent.isRedacted() ? (
<RedactedContent
reason={mEvent.getUnsigned().redacted_because?.content?.reason}
/>
) : (
<RenderMessageContent
displayName={senderDisplayName}
msgType={msgType || MsgType.Text}
ts={mEvent.getTs()}
edited={!!editedEvent}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment
/>
)}
{editedEvent && !mEvent.isRedacted() && (
<Text className={css.ThreadEventEdited} as="span" size="T200">
{t('Room.edited')}
</Text>
)}
</div>
</div>
);
}
// 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<HTMLDivElement>(null);
const closeBtnRef = useRef<HTMLButtonElement>(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<HTMLDivElement | null>(null);
const bottomObserverRef = useRef<IntersectionObserver | null>(null);
// Ref to the folds `<Scroll>` 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<HTMLDivElement | null>(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<LinkifyOpts>(
() => ({
...LINKIFY_OPTS,
render: factoryRenderLinkifyWithMention((href) =>
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
),
}),
[mx, room, mentionClickHandler]
);
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
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<Thread | null>(() => room.getThread(rootId));
const [rootEvent, setRootEvent] = useState<MatrixEvent | null>(
() => room.getThread(rootId)?.rootEvent ?? room.findEventById(rootId) ?? null
);
const [rootError, setRootError] = useState<Error | null>(null);
const [paginateError, setPaginateError] = useState<Error | null>(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<Error | null>(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<HTMLDivElement | null>(null);
const topObserverRef = useRef<IntersectionObserver | null>(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 (
<div className={css.ThreadErrorState}>
<Text size="T300" align="Center" priority="400">
{t('Room.thread_root_error')}
</Text>
<Button size="300" variant="Primary" onClick={loadRootEvent}>
<Text size="B300">{t('Room.thread_retry')}</Text>
</Button>
</div>
);
}
if (!rootEvent) {
return (
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ padding: config.space.S400 }}
>
<Spinner size="400" />
</Box>
);
}
return (
<div className={css.ThreadDrawerContent}>
<ThreadEventCard
room={room}
mEvent={rootEvent}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
mediaAutoLoad={mediaAutoLoad}
showUrlPreview={showUrlPreview}
/>
<div className={css.ThreadDivider} />
{coldLoadError && (
<div className={css.ThreadErrorState}>
<Text size="T300" align="Center" priority="400">
{t('Room.thread_paginate_error')}
</Text>
<Button size="300" variant="Primary" onClick={retryColdLoad}>
<Text size="B300">{t('Room.thread_retry')}</Text>
</Button>
</div>
)}
{!coldLoadError && (coldLoadFetching || !thread) && (
<Box alignItems="Center" justifyContent="Center" style={{ padding: config.space.S300 }}>
<Spinner size="300" />
</Box>
)}
{paginating && replies.length === 0 && (
<Box alignItems="Center" justifyContent="Center" style={{ padding: config.space.S300 }}>
<Spinner size="300" />
</Box>
)}
{paginateError && (
<div className={css.ThreadErrorState}>
<Text size="T300" align="Center" priority="400">
{t('Room.thread_paginate_error')}
</Text>
<Button size="300" variant="Primary" onClick={paginate}>
<Text size="B300">{t('Room.thread_retry')}</Text>
</Button>
</div>
)}
{thread && !coldLoadError && !coldLoadFetching && !paginating && !paginateError && replies.length === 0 && (
<div className={css.ThreadEmptyState}>
<Text size="T300" priority="400">
{t('Room.thread_no_replies')}
</Text>
</div>
)}
{/* 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 && (
<div ref={setTopSentinel} aria-hidden="true" />
)}
{paginating && replies.length > 0 && (
<Box alignItems="Center" justifyContent="Center" style={{ padding: config.space.S200 }}>
<Spinner size="200" />
</Box>
)}
{replies.map((reply) => (
<ThreadEventCard
key={reply.getId()}
room={room}
mEvent={reply}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
mediaAutoLoad={mediaAutoLoad}
showUrlPreview={showUrlPreview}
/>
))}
<div ref={setBottomSentinel} />
</div>
);
};
return (
<aside
ref={fileDropContainerRef}
className={variant === 'desktop' ? css.ThreadDrawer : css.ThreadDrawerMobile}
role="region"
aria-labelledby={headerId}
>
<Header className={css.ThreadDrawerHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" alignItems="Center" gap="200" id={headerId}>
<Icon src={Icons.Hash} size="100" />
<Text size="H5" truncate>
{t('Room.thread_in_channel', { channel: room.name ?? '' })}
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<IconButton
ref={closeBtnRef}
variant="Background"
onClick={close}
aria-label={t('Room.thread_close')}
>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</Header>
<div className={css.ThreadDrawerScroll}>
<Scroll
ref={scrollHostRef}
variant="Background"
direction="Vertical"
size="300"
hideTrack
visibility="Hover"
>
{renderBody()}
</Scroll>
</div>
<div className={css.ThreadComposer}>
{canMessage ? (
<RoomInput
room={room}
editor={editor}
roomId={room.roomId}
threadId={rootId}
fileDropContainerRef={fileDropContainerRef}
/>
) : (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">{t('Room.no_post_permission')}</Text>
</RoomInputPlaceholder>
)}
</div>
</aside>
);
}

View file

@ -673,6 +673,20 @@ export type MessageProps = {
legacyUsernameColor?: boolean;
streamRailStart?: boolean;
streamRailEnd?: boolean;
// M2: hide the «Reply in Thread» menu/quick action. Set by
// RoomTimeline outside channels-mode (where threads aren't surfaced
// anywhere) and inside bridged channels (where the bridge has no
// thread semantic). When true, the ThreadPlus button + the
// `reply_in_thread` menu item are skipped; the regular «Reply»
// affordance stays untouched.
hideThreadReplyAffordance?: boolean;
// M2: hide the regular «Reply» (m.in_reply_to) menu/quick action.
// Set by RoomTimeline when the thread drawer is open — the channel
// composer is unmounted in that state, so the reply chip would
// write to an unread atom and the focus call would no-op silently.
// ThreadPlus button stays usable for navigating to a different
// thread root.
hideMainReplyAffordance?: boolean;
// Snapshot of `mEvent.getContent().msgtype` from the caller. Threaded as
// a prop (not derived locally) so encrypted-then-decrypted events flip
// mediaMode reliably when EncryptedContent re-renders post-decrypt —
@ -707,6 +721,8 @@ export const Message = as<'div', MessageProps>(
legacyUsernameColor,
streamRailStart,
streamRailEnd,
hideThreadReplyAffordance,
hideMainReplyAffordance,
msgType,
children,
...props
@ -838,6 +854,13 @@ export const Message = as<'div', MessageProps>(
};
const isThreadedMessage = mEvent.threadRootId !== undefined;
// After a Thread is created on a root event, SDK sets
// `mEvent.threadRootId === mEvent.getId()` and `isThreadRoot ===
// true`. We want the «Reply in Thread» button to keep working on
// the root (re-open drawer) — only hide when the message is itself
// a thread reply (no thread can be started on a reply, and in
// channels-mode replies aren't visible in main timeline anyway).
const isThreadReply = isThreadedMessage && !mEvent.isThreadRoot;
return (
<MessageBase
@ -892,18 +915,20 @@ export const Message = as<'div', MessageProps>(
</IconButton>
</PopOut>
)}
<IconButton
onClick={onReplyClick}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
{!isThreadedMessage && (
{!hideMainReplyAffordance && (
<IconButton
onClick={(ev) => onReplyClick(ev, true)}
onClick={onReplyClick}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
)}
{!isThreadReply && !hideThreadReplyAffordance && (
<IconButton
onClick={(ev: Parameters<typeof onReplyClick>[0]) => onReplyClick(ev, true)}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
@ -972,26 +997,28 @@ export const Message = as<'div', MessageProps>(
onClose={closeMenu}
/>
)}
<MenuItem
size="300"
after={<Icon size="100" src={Icons.ReplyArrow} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onReplyClick(evt);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
{!hideMainReplyAffordance && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.ReplyArrow} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onReplyClick(evt);
closeMenu();
}}
>
{t('Room.reply')}
</Text>
</MenuItem>
{!isThreadedMessage && (
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
{t('Room.reply')}
</Text>
</MenuItem>
)}
{!isThreadReply && !hideThreadReplyAffordance && (
<MenuItem
size="300"
after={<Icon src={Icons.ThreadPlus} size="100" />}

View file

@ -13,3 +13,20 @@ export const ChannelsModeProvider = ChannelsModeContext.Provider;
export function useChannelsMode(): boolean {
return useContext(ChannelsModeContext);
}
// True while a `/channels/.../thread/:rootId/` URL is matched and the
// `<ThreadDrawer>` is mounted. RoomView reads this to suppress the
// channel composer (RoomInput) — Element-web pattern: only one composer
// surface mounts at a time so two `<Slate>` editors can't race against
// each other (slate#6016 + slate#4850 share-`initialValue` regression
// is otherwise possible at cold-load), and so the channel viewport
// doesn't visibly react to the user's own thread reply local-echo
// (the «message flashes in main timeline» blink). Provided by
// `Room.tsx` based on `useMatch(CHANNELS_THREAD_PATH)`.
const ThreadDrawerOpenContext = createContext<boolean>(false);
export const ThreadDrawerOpenProvider = ThreadDrawerOpenContext.Provider;
export function useThreadDrawerOpen(): boolean {
return useContext(ThreadDrawerOpenContext);
}

View file

@ -17,6 +17,7 @@ import {
CHANNELS_PATH,
CHANNELS_ROOM_PATH,
CHANNELS_SPACE_PATH,
CHANNELS_THREAD_PATH,
DIRECT_PATH,
EXPLORE_PATH,
HOME_PATH,
@ -316,7 +317,13 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Room />
</SpaceRouteRoomProvider>
}
/>
>
{/* Thread drawer URL same Room element renders, drawer
opens by reading `:rootId` via useParams. The
SpaceRouteRoomProvider lives on the parent route and
stays mounted across the roomthread URL flip. */}
<Route path={CHANNELS_THREAD_PATH.slice(CHANNELS_ROOM_PATH.length)} element={null} />
</Route>
</Route>
</Route>
<Route

View file

@ -5,6 +5,7 @@ import {
CHANNELS_PATH,
CHANNELS_ROOM_PATH,
CHANNELS_SPACE_PATH,
CHANNELS_THREAD_PATH,
DIRECT_CREATE_PATH,
DIRECT_PATH,
DIRECT_ROOM_PATH,
@ -178,3 +179,15 @@ export const getChannelsRoomPath = (
};
return generatePath(CHANNELS_ROOM_PATH, params);
};
export const getChannelsThreadPath = (
spaceIdOrAlias: string,
roomIdOrAlias: string,
rootId: string
): string => {
const params = {
spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),
roomIdOrAlias: encodeURIComponent(roomIdOrAlias),
rootId: encodeURIComponent(rootId),
};
return generatePath(CHANNELS_THREAD_PATH, params);
};

View file

@ -91,6 +91,7 @@ export const BOTS_BOT_PATH = '/bots/:botId/';
export const CHANNELS_PATH = '/channels/';
export const CHANNELS_SPACE_PATH = '/channels/:spaceIdOrAlias/';
export const CHANNELS_ROOM_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/';
export const CHANNELS_THREAD_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/thread/:rootId/';
export const SPACE_SETTINGS_PATH = '/space-settings/';

View file

@ -20,7 +20,26 @@ export type TUploadItem = {
export type TUploadListAtom = ReturnType<typeof createListAtom<TUploadItem>>;
export const roomIdToUploadItemsAtomFamily = atomFamily<string, TUploadListAtom>(createListAtom);
// M2/M3b draft key: (roomId, threadKey). `threadKey === 'main'` for the
// channel/DM/legacy composer (preserves pre-M2 semantics), or `rootId`
// for the per-thread composer in `ThreadDrawer`. Tuple key requires a
// custom equality function — without it, atomFamily uses
// reference-equality on the array and every render gets a fresh atom.
//
// The plan slated this extension as M3b, but pulling the change forward
// into M2 is necessary: drawer composer and channel composer share the
// same `roomId`, so a single-key family clobbers each other's drafts on
// open/close (typing in the thread drawer leaks back into the channel
// composer when the drawer unmounts).
export type DraftKey = readonly [roomId: string, threadKey: string];
export const draftKey = (roomId: string, threadId?: string): DraftKey =>
[roomId, threadId ?? 'main'] as const;
const draftKeyEqual = (a: DraftKey, b: DraftKey) => a[0] === b[0] && a[1] === b[1];
export const roomIdToUploadItemsAtomFamily = atomFamily<DraftKey, TUploadListAtom>(
createListAtom,
draftKeyEqual
);
export const roomUploadAtomFamily = createUploadAtomFamily();
@ -37,8 +56,9 @@ export type RoomIdToMsgAction =
const createMsgDraftAtom = () => atom<Descendant[]>([]);
export type TMsgDraftAtom = ReturnType<typeof createMsgDraftAtom>;
export const roomIdToMsgDraftAtomFamily = atomFamily<string, TMsgDraftAtom>(() =>
createMsgDraftAtom()
export const roomIdToMsgDraftAtomFamily = atomFamily<DraftKey, TMsgDraftAtom>(
() => createMsgDraftAtom(),
draftKeyEqual
);
export type IReplyDraft = {
@ -50,6 +70,7 @@ export type IReplyDraft = {
};
const createReplyDraftAtom = () => atom<IReplyDraft | undefined>(undefined);
export type TReplyDraftAtom = ReturnType<typeof createReplyDraftAtom>;
export const roomIdToReplyDraftAtomFamily = atomFamily<string, TReplyDraftAtom>(() =>
createReplyDraftAtom()
export const roomIdToReplyDraftAtomFamily = atomFamily<DraftKey, TReplyDraftAtom>(
() => createReplyDraftAtom(),
draftKeyEqual
);

View file

@ -4,6 +4,7 @@ import {
CHANNELS_PATH,
CHANNELS_ROOM_PATH,
CHANNELS_SPACE_PATH,
CHANNELS_THREAD_PATH,
DIRECT_PATH,
EXPLORE_PATH,
HOME_PATH,
@ -12,6 +13,7 @@ import {
import {
getBotsPath,
getChannelsPath,
getChannelsRoomPath,
getChannelsSpacePath,
getDirectPath,
getExplorePath,
@ -36,6 +38,19 @@ export const getRouteSectionParent = (pathname: string): string | null => {
if (under(BOTS_PATH)) return atRoot(BOTS_PATH) ? null : getBotsPath();
if (under(CHANNELS_PATH)) {
// Thread URL collapses to its parent room view (drawer closed) — must
// match before the room-level matcher because thread is a strict
// subpath of room (`/channels/:s/:r/thread/:rootId/`).
const threadMatch = matchPath(
{ path: CHANNELS_THREAD_PATH, caseSensitive: true, end: false },
pathname
);
if (threadMatch?.params.spaceIdOrAlias && threadMatch?.params.roomIdOrAlias) {
return getChannelsRoomPath(
decodeURIComponent(threadMatch.params.spaceIdOrAlias),
decodeURIComponent(threadMatch.params.roomIdOrAlias)
);
}
const roomMatch = matchPath(
{ path: CHANNELS_ROOM_PATH, caseSensitive: true, end: false },
pathname

View file

@ -44,8 +44,18 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
};
export const startClient = async (mx: MatrixClient) => {
// threadSupport partitions m.thread relations into Thread objects so
// /channels thread drawer can read room.getThread(rootId), receive
// ThreadEvent.New / Update from the room emitter, and let
// sendReadReceipt auto-route thread_id. SDK 38.2+ default is false:
// without this flag drawer reads silently NO-OP. Flag is global —
// also affects DM/Bots receipt shape (SDK now adds thread_id: 'main'
// to main-timeline receipts and per-thread unread shape changes
// arrive in `unread_thread_notifications`). M4 will consume the
// shape; M2 just enables it so the drawer surface works.
await mx.startClient({
lazyLoadMembers: true,
threadSupport: true,
});
};