vojo/src/app/features/room/ThreadDrawer.tsx

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>
);
}