feat: add thread functionality to room features

- Enhance RoomInput to support sending messages as replies in threads by adding `threadRootId` prop.
- Update RoomTimeline to manage active thread state and handle opening threads.
- Implement ThreadView component to display thread messages and input.
- Create HomeThreadsCategory to list threads user has participated in.
- Introduce activeThreadIdAtomFamily to manage active thread state across rooms.
This commit is contained in:
2026-03-13 00:44:33 +11:00
parent 37f4297972
commit ced29a6571
9 changed files with 1039 additions and 44 deletions

View File

@@ -0,0 +1,735 @@
import React, {
MouseEventHandler,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
MatrixEvent,
RelationType,
Room,
RoomEvent,
ThreadEvent,
} from 'matrix-js-sdk';
import { useAtomValue, useSetAtom } from 'jotai';
import {
Badge,
Box,
Icon,
IconButton,
Icons,
Line,
Scroll,
Text,
Tooltip,
TooltipProvider,
config,
} from 'folds';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { ReactEditor } from 'slate-react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import {
useEditor,
createMentionElement,
moveCursor,
} from '../../components/editor';
import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../state/settings';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import {
useAccessiblePowerTagColors,
useGetMemberPowerTag,
} from '../../hooks/useMemberPowerTag';
import {
combineEmbedFilters,
useRoomEmbedFilters,
useRoomWideEmbedFilters,
} from '../../hooks/useRoomEmbedFilters';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useIsDirectRoom } from '../../hooks/useRoom';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { RoomInput } from './RoomInput';
import { RoomViewTyping } from './RoomViewTyping';
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import { Message, Reactions, EncryptedContent } from './message';
import { Reply } from '../../components/message';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import {
LINKIFY_OPTS,
factoryRenderLinkifyWithMention,
getReactCustomHtmlParser,
makeMentionCustomProps,
renderMatrixMention,
} from '../../plugins/react-custom-html-parser';
import {
getEditedEvent,
getEventReactions,
getMemberDisplayName,
getReactionContent,
reactionOrEditEvent,
} from '../../utils/room';
import {
eventWithShortcode,
factoryEventSentBy,
getMxIdLocalPart,
} from '../../utils/matrix';
import { GetContentCallback, MessageEvent } from '../../../types/matrix/room';
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
import { scrollToBottom } from '../../utils/dom';
type ThreadViewProps = {
room: Room;
threadRootId: string;
};
/**
* Renders a thread inline, replacing the main room timeline.
* Structured as direct flex siblings of `Page` to match the RoomView layout contract.
*/
export function ThreadView({ room, threadRootId }: ThreadViewProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const setActiveThreadId = useSetAtom(activeThreadIdAtomFamily(room.roomId));
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
// eslint-disable-next-line no-console
console.log('[ThreadView] Component mount/render', {
roomId: room.roomId,
threadRootId,
allThreadsInRoom: room.getThreads().map(t => ({
id: t.id,
rootEventId: t.rootEvent?.getId(),
length: t.length,
hasCurrentUserParticipated: t.hasCurrentUserParticipated,
})),
});
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const direct = useIsDirectRoom();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const creatorsTag = useRoomCreatorsTag();
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const theme = useTheme();
const accessiblePowerTagColors = useAccessiblePowerTagColors(
theme.kind,
creatorsTag,
powerLevelTags
);
const permissions = useRoomPermissions(creators, powerLevels);
const canRedact = permissions.action('redact', mx.getSafeUserId());
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
const canPinEvent = permissions.stateEvent('m.room.pinned_events', mx.getSafeUserId());
const roomToParents = useAtomValue(roomToParentsAtom);
const imagePackRooms = useImagePackRooms(room.roomId, roomToParents);
const space = useSpaceOptionally();
const openUserRoomProfile = useOpenUserRoomProfile();
const ignoredUsersList = useIgnoredUsers();
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
const [personalEmbedFilters] = useRoomEmbedFilters(room);
const roomWideEmbedFilters = useRoomWideEmbedFilters(room);
const combinedEmbedFilters = useMemo(
() => combineEmbedFilters(personalEmbedFilters, roomWideEmbedFilters),
[personalEmbedFilters, roomWideEmbedFilters]
);
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
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 thread = room.getThread(threadRootId);
const roomTimelineSet = room.getUnfilteredTimelineSet();
// If thread doesn't exist, try to find it by checking if threadRootId is a root event
React.useEffect(() => {
if (!thread) {
// eslint-disable-next-line no-console
console.log('[ThreadView] Thread not found, checking if event exists in room timeline');
const rootEvent = room.findEventById(threadRootId);
// eslint-disable-next-line no-console
console.log('[ThreadView] Root event lookup', {
threadRootId,
rootEventFound: !!rootEvent,
rootEventType: rootEvent?.getType(),
rootEventThreadRootId: rootEvent?.threadRootId,
allEventIdsInTimeline: roomTimelineSet.getLiveTimeline().getEvents().slice(0, 20).map(e => e.getId()),
});
}
}, [thread, room, threadRootId, roomTimelineSet]);
const getThreadEvents = useCallback((): MatrixEvent[] => {
const t = room.getThread(threadRootId);
if (!t) {
// eslint-disable-next-line no-console
console.log('[ThreadView] No thread found for ID:', threadRootId);
// eslint-disable-next-line no-console
console.log('[ThreadView] Attempting to find replies manually from room timeline');
// Fallback: search the room timeline for events that are replies to this thread
const allEvents = roomTimelineSet.getLiveTimeline().getEvents();
const threadReplies = allEvents.filter(event => {
const relations = event.getRelation();
// Check if this event is a thread reply to our root event
return relations?.rel_type === 'm.thread' && relations?.event_id === threadRootId;
});
// eslint-disable-next-line no-console
console.log('[ThreadView] Manual search found', {
totalEventsInTimeline: allEvents.length,
repliesFound: threadReplies.length,
replies: threadReplies.map(e => ({
id: e.getId(),
sender: e.getSender(),
body: e.getContent()?.body?.substring(0, 50),
})),
});
return threadReplies;
}
// thread.events contains only replies, not the root event
const replies = [...t.events];
const liveTimelineEvents = t.liveTimeline?.getEvents() || [];
// eslint-disable-next-line no-console
console.log('[ThreadView] getThreadEvents', {
threadId: threadRootId,
repliesCount: replies.length,
threadLength: t.length,
hasRootEvent: !!t.rootEvent,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialEventsFetched: (t as any).initialEventsFetched,
liveTimelineEventsCount: liveTimelineEvents.length,
threadEventsMatch: replies.length === liveTimelineEvents.length,
allRepliesFromThreadEvents: replies.map(e => ({
id: e.getId(),
type: e.getType(),
sender: e.getSender(),
content: e.getContent()?.body?.substring(0, 50),
})),
allEventsFromLiveTimeline: liveTimelineEvents.map(e => ({
id: e.getId(),
type: e.getType(),
sender: e.getSender(),
content: e.getContent()?.body?.substring(0, 50),
})),
});
// Try using liveTimeline events if thread.events is empty but liveTimeline has events
if (replies.length === 0 && liveTimelineEvents.length > 0) {
// eslint-disable-next-line no-console
console.log('[ThreadView] Using liveTimeline events instead of thread.events');
return [...liveTimelineEvents];
}
return replies;
}, [room, threadRootId, roomTimelineSet]);
const [events, setEvents] = useState<MatrixEvent[]>(getThreadEvents);
const [editId, setEditId] = useState<string | undefined>(undefined);
const scrollRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLDivElement>(null);
const editor = useEditor();
// Subscribe to thread and room timeline events.
useEffect(() => {
// eslint-disable-next-line no-console
console.log('[ThreadView] Effect running', {
threadId: threadRootId,
threadExists: !!thread,
threadLength: thread?.length,
eventsCount: thread?.events?.length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialEventsFetched: (thread as any)?.initialEventsFetched,
});
const handleUpdate = () => {
const newEvents = getThreadEvents();
// eslint-disable-next-line no-console
console.log('[ThreadView] handleUpdate - new events count:', newEvents.length);
setEvents(newEvents);
};
// Immediately refresh in case events loaded between render and this effect.
handleUpdate();
if (thread) {
thread.on(ThreadEvent.NewReply, handleUpdate);
thread.on(ThreadEvent.Update, handleUpdate);
// Fires when initial events are loaded after the SDK paginates the thread.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(thread as any).on(RoomEvent.TimelineReset, handleUpdate);
// Also listen to Timeline events which fire when events are added
thread.on(RoomEvent.Timeline, handleUpdate);
}
const handleRoomTimeline = (mEvent: MatrixEvent, eventRoom: Room | undefined) => {
if (eventRoom?.roomId !== room.roomId) return;
// Check if this event is related to our thread (either it's a reply to it, or it IS the root event)
const relations = mEvent.getRelation();
const isThreadReply = relations?.rel_type === 'm.thread' && relations?.event_id === threadRootId;
const isRootEvent = mEvent.getId() === threadRootId;
if (isThreadReply || isRootEvent) {
handleUpdate();
}
};
room.on(RoomEvent.Timeline, handleRoomTimeline as never);
room.on(RoomEvent.Redaction, handleRoomTimeline as never);
return () => {
if (thread) {
thread.off(ThreadEvent.NewReply, handleUpdate);
thread.off(ThreadEvent.Update, handleUpdate);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(thread as any).off(RoomEvent.TimelineReset, handleUpdate);
thread.off(RoomEvent.Timeline, handleUpdate);
}
room.off(RoomEvent.Timeline, handleRoomTimeline as never);
room.off(RoomEvent.Redaction, handleRoomTimeline as never);
};
}, [thread, room, threadRootId, getThreadEvents]);
// Scroll to bottom on mount and when new events arrive.
useLayoutEffect(() => {
const el = scrollRef.current;
if (el) scrollToBottom(el);
}, [events.length]);
const handleClose = useCallback(() => {
setActiveThreadId(undefined);
setReplyDraft(undefined);
}, [setActiveThreadId, setReplyDraft]);
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
evt.preventDefault();
evt.stopPropagation();
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) return;
openUserRoomProfile(
room.roomId,
space?.roomId,
userId,
evt.currentTarget.getBoundingClientRect()
);
},
[room, space, openUserRoomProfile]
);
const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
evt.preventDefault();
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) return;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
editor.insertNode(
createMentionElement(
userId,
name.startsWith('@') ? name : `@${name}`,
userId === mx.getUserId()
)
);
ReactEditor.focus(editor);
moveCursor(editor);
},
[mx, room, editor]
);
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
const replyId = evt.currentTarget.getAttribute('data-event-id');
if (!replyId) return;
const replyEvt =
thread?.findEventById(replyId) ?? room.findEventById(replyId);
if (!replyEvt) return;
const editedReply = getEditedEvent(replyId, replyEvt, roomTimelineSet);
const content =
editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content;
const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') {
setReplyDraft({
userId: senderId,
eventId: replyId,
body,
formattedBody,
relation: { rel_type: RelationType.Thread, event_id: threadRootId },
});
setTimeout(() => ReactEditor.focus(editor), 100);
}
},
[room, thread, roomTimelineSet, threadRootId, setReplyDraft, editor]
);
const handleOpenReply: MouseEventHandler = useCallback((evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id');
if (!targetId) return;
const el = scrollRef.current?.querySelector(
`[data-message-id="${targetId}"]`
) as HTMLElement | null;
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, []);
const handleReactionToggle = useCallback(
(targetEventId: string, key: string, shortcode?: string) => {
const relations = getEventReactions(roomTimelineSet, targetEventId);
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
const reactions = reactionsSet ? Array.from(reactionsSet) : [];
const myReaction = reactions.find(factoryEventSentBy(mx.getSafeUserId()));
if (myReaction && !!myReaction?.isRelation()) {
const myReactionId = myReaction.getId();
if (myReactionId) mx.redactEvent(room.roomId, myReactionId);
return;
}
const rShortcode =
shortcode ||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
mx.sendEvent(
room.roomId,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MessageEvent.Reaction as any,
getReactionContent(targetEventId, key, rShortcode)
);
},
[mx, room, roomTimelineSet]
);
/** Renders a single thread event into JSX. Returns null for non-message events. */
const renderEvent = (mEvent: MatrixEvent, index: number, all: MatrixEvent[]) => {
const mEventId = mEvent.getId();
if (!mEventId) return null;
const eventSender = mEvent.getSender();
if (eventSender && ignoredUsersSet.has(eventSender)) return null;
if (mEvent.isRedacted()) return null;
if (reactionOrEditEvent(mEvent)) return null;
const type = mEvent.getType();
if (
type !== MessageEvent.RoomMessage &&
type !== MessageEvent.RoomMessageEncrypted
) {
return null;
}
const prevEvent = index > 0 ? all[index - 1] : undefined;
const dayDivider = prevEvent
? !inSameDay(prevEvent.getTs(), mEvent.getTs())
: false;
const collapse =
!dayDivider &&
prevEvent !== undefined &&
prevEvent.getSender() === eventSender &&
prevEvent.getType() === type &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
const reactionRelations = getEventReactions(roomTimelineSet, mEventId);
const hasReactions =
!!reactionRelations?.getSortedAnnotationsByKey()?.length;
const { replyEventId, threadRootId: evtThreadRootId } = mEvent;
const editedEvent = getEditedEvent(mEventId, mEvent, roomTimelineSet);
const getContent = (() =>
editedEvent?.getContent()['m.new_content'] ??
mEvent.getContent()) as GetContentCallback;
const senderId = mEvent.getSender() ?? '';
const senderDisplayName =
getMemberDisplayName(room, senderId) ??
getMxIdLocalPart(senderId) ??
senderId;
const dayDividerJSX = dayDivider ? (
<Box
key={`divider-${mEventId}`}
gap="100"
justifyContent="Center"
alignItems="Center"
style={{ padding: `${config.space.S200} 0` }}
>
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
<Text size="L400">
{(() => {
if (today(mEvent.getTs())) return 'Today';
if (yesterday(mEvent.getTs())) return 'Yesterday';
return timeDayMonthYear(mEvent.getTs());
})()}
</Text>
</Badge>
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
</Box>
) : null;
const messageJSX = (
<Message
key={mEventId}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={false}
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={setEditId}
hideReadReceipts={hideActivity}
showDeveloperTools={developerTools}
memberPowerTag={getMemberPowerTag(senderId)}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
reply={
replyEventId ? (
<Reply
room={room}
timelineSet={roomTimelineSet}
replyEventId={replyEventId}
threadRootId={evtThreadRootId}
onClick={handleOpenReply}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
/>
) : undefined
}
reactions={
reactionRelations ? (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={mEventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
) : undefined
}
>
{type === MessageEvent.RoomMessageEncrypted ? (
<EncryptedContent mEvent={mEvent}>
{() => (
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
/>
)}
</EncryptedContent>
) : (
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
/>
)}
</Message>
);
return (
<React.Fragment key={mEventId}>
{dayDividerJSX}
{messageJSX}
</React.Fragment>
);
};
const rootEvent = room.findEventById(threadRootId);
const rootBody =
getEditedEvent(threadRootId, rootEvent ?? new MatrixEvent(), roomTimelineSet)
?.getContent()['m.new_content']?.body ??
rootEvent?.getContent()?.body ??
'';
const rootSenderId = rootEvent?.getSender() ?? '';
const rootSenderName =
getMemberDisplayName(room, rootSenderId) ??
getMxIdLocalPart(rootSenderId) ??
rootSenderId;
// Use actual events count if thread doesn't exist but we found replies manually
const replyCount = thread?.length ?? events.length;
// Return as siblings rather than a wrapper so they slot into Page's flex
// column the same way the normal RoomTimeline + RoomInput children do.
return (
<>
{/* Thread sub-header */}
<Box
shrink="No"
alignItems="Center"
gap="300"
style={{
padding: `${config.space.S200} ${config.space.S400}`,
borderBottom: `1px solid var(--mx-surface-container-line, rgba(0,0,0,0.08))`,
minHeight: '3rem',
}}
>
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Back to Chat</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton ref={triggerRef} size="300" onClick={handleClose}>
<Icon src={Icons.ArrowLeft} size="200" />
</IconButton>
)}
</TooltipProvider>
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
<Box alignItems="Center" gap="200">
<Icon size="100" src={Icons.Thread} />
<Text size="H5" truncate>
Thread
</Text>
</Box>
{rootBody && (
<Text size="T200" priority="300" truncate>
{rootSenderName}: {rootBody}
</Text>
)}
</Box>
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap' }}>
{replyCount} {replyCount === 1 ? 'reply' : 'replies'}
</Text>
</Box>
{/* Thread messages — grows to fill remaining space */}
<Box grow="Yes" direction="Column">
<Box grow="Yes" style={{ position: 'relative' }} ref={containerRef}>
<Scroll ref={scrollRef} visibility="Hover">
<Box
direction="Column"
justifyContent="End"
style={{ minHeight: '100%', padding: `${config.space.S400} 0` }}
>
{/* Render the thread root event */}
{rootEvent && renderEvent(rootEvent, -1, [])}
{/* Show "no replies" message if no replies exist */}
{events.length === 0 && (
<Box
justifyContent="Center"
style={{ padding: config.space.S700 }}
>
<Text size="T300" priority="300">
No replies yet. Be the first!
</Text>
</Box>
)}
{/* Render all reply events */}
{events.map((evt, idx, arr) => renderEvent(evt, idx, arr))}
</Box>
</Scroll>
</Box>
<RoomViewTyping room={room} />
</Box>
{/* Thread input */}
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
<RoomInput
room={room}
editor={editor}
roomId={room.roomId}
fileDropContainerRef={containerRef}
threadRootId={threadRootId}
ref={inputRef}
/>
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
</Box>
</>
);
}