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