feat(channels): ship M2 thread drawer + composer + shareable thread URL with cold-load relations repair
This commit is contained in:
parent
851f3d30a3
commit
4632be30f7
17 changed files with 1549 additions and 140 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Пригласить",
|
||||
|
|
|
|||
|
|
@ -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} />,
|
||||
[]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
102
src/app/features/room/ThreadDrawer.css.ts
Normal file
102
src/app/features/room/ThreadDrawer.css.ts
Normal 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,
|
||||
});
|
||||
911
src/app/features/room/ThreadDrawer.tsx
Normal file
911
src/app/features/room/ThreadDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 room↔thread URL flip. */}
|
||||
<Route path={CHANNELS_THREAD_PATH.slice(CHANNELS_ROOM_PATH.length)} element={null} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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/';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue