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

@@ -58,6 +58,8 @@ type ReplyProps = {
replyEventId: string;
threadRootId?: string | undefined;
onClick?: MouseEventHandler | undefined;
/** Called when the thread indicator badge is clicked. Receives the thread root event ID. */
onOpenThread?: (threadRootId: string) => void;
getMemberPowerTag?: GetMemberPowerTag;
accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean;
@@ -71,6 +73,7 @@ export const Reply = as<'div', ReplyProps>(
replyEventId,
threadRootId,
onClick,
onOpenThread,
getMemberPowerTag,
accessibleTagColors,
legacyUsernameColor,
@@ -107,7 +110,11 @@ export const Reply = as<'div', ReplyProps>(
return (
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
{threadRootId && (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
<ThreadIndicator
as="button"
data-event-id={threadRootId}
onClick={onOpenThread ? () => onOpenThread(threadRootId) : onClick}
/>
)}
<ReplyLayout
as="button"

View File

@@ -126,9 +126,11 @@ interface RoomInputProps {
fileDropContainerRef: RefObject<HTMLElement>;
roomId: string;
room: Room;
/** When provided, all messages are sent as replies in this thread. */
threadRootId?: string;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ editor, fileDropContainerRef, roomId, room }, ref) => {
({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
@@ -388,13 +390,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content['m.relates_to'].rel_type = RelationType.Thread;
content['m.relates_to'].is_falling_back = false;
}
} else if (threadRootId) {
content['m.relates_to'] = {
rel_type: RelationType.Thread,
event_id: threadRootId,
is_falling_back: true,
'm.in_reply_to': { event_id: threadRootId },
};
}
mx.sendMessage(roomId, content as any);
resetEditor(editor);
resetEditorHistory(editor);
setReplyDraft(undefined);
sendTypingStatus(false);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]);
}, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]);
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {

View File

@@ -101,6 +101,7 @@ import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import { useKeyDown } from '../../hooks/useKeyDown';
@@ -480,6 +481,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const setActiveThreadId = useSetAtom(activeThreadIdAtomFamily(room.roomId));
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
@@ -1025,8 +1027,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
});
setTimeout(() => ReactEditor.focus(editor), 100);
}
if (startThread) {
setActiveThreadId(replyId);
}
},
[room, setReplyDraft, editor]
[room, setReplyDraft, setActiveThreadId, editor]
);
/** Opens the thread view for the given thread root event ID. */
const handleOpenThread = useCallback(
(threadRootId: string) => {
setActiveThreadId(threadRootId);
},
[setActiveThreadId]
);
const handleReactionToggle = useCallback(
@@ -1114,6 +1127,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
onOpenThread={handleOpenThread}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
@@ -1197,6 +1211,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
onOpenThread={handleOpenThread}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}

View File

@@ -3,6 +3,7 @@ import { Box, Text, config } from 'folds';
import { EventType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { isKeyHotkey } from 'is-hotkey';
import { useAtomValue } from 'jotai';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
@@ -22,6 +23,8 @@ import { settingsAtom } from '../../state/settings';
import { useSetting } from '../../state/hooks/settings';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { ThreadView } from './ThreadView';
const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
@@ -74,6 +77,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const permissions = useRoomPermissions(creators, powerLevels);
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId));
useKeyDown(
window,
useCallback(
@@ -94,49 +99,55 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
return (
<Page ref={roomViewRef}>
<RoomViewHeader />
<Box grow="Yes" direction="Column">
<RoomTimeline
key={roomId}
room={room}
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
/>
<RoomViewTyping room={room} />
</Box>
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
{activeThreadId ? (
<ThreadView room={room} threadRootId={activeThreadId} />
) : (
<>
<Box grow="Yes" direction="Column">
<RoomTimeline
key={roomId}
room={room}
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
/>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
<RoomViewTyping room={room} />
</Box>
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)}
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
</Box>
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
</Box>
</>
)}
</Page>
);
}

View File

@@ -25,7 +25,8 @@ import {
} from 'folds';
import { useNavigate } from 'react-router-dom';
import { JoinRule, Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { useStateEvent } from '../../hooks/useStateEvent';
import { PageHeader } from '../../components/page';
@@ -499,6 +500,7 @@ export function RoomViewHeader() {
: undefined;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
const handleSearchClick = () => {
const searchParams: _SearchPathSearchParams = {
@@ -685,6 +687,25 @@ export function RoomViewHeader() {
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{activeThreadId ? 'Back to Chat' : 'Threads'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={() => setActiveThreadId(undefined)}
aria-pressed={!!activeThreadId}
>
<Icon size="400" src={activeThreadId ? Icons.ArrowLeft : Icons.Thread} filled={!!activeThreadId} />
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Bottom"
align="End"

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

View File

@@ -67,6 +67,7 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt';
import { _RoomSearchParams } from '../../paths';
import { HomeThreadsCategory } from './HomeThreadsCategory';
type HomeMenuProps = {
requestClose: () => void;
@@ -395,6 +396,7 @@ export function Home() {
})}
</div>
</NavCategory>
<HomeThreadsCategory rooms={rooms} />
</Box>
</PageNavContent>
)}

View File

@@ -0,0 +1,184 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Avatar,
Box,
Icon,
Icons,
Text,
Badge,
} from 'folds';
import { useAtom, useSetAtom } from 'jotai';
import { Room, Thread, ThreadEvent } from 'matrix-js-sdk';
import { NavCategory, NavCategoryHeader, NavItem, NavItemContent, NavButton } from '../../../components/nav';
import { RoomNavCategoryButton } from '../../../features/room-nav';
import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { activeThreadIdAtomFamily } from '../../../state/activeThread';
import { getCanonicalAliasOrRoomId, getMxIdLocalPart } from '../../../utils/matrix';
import { getHomeRoomPath } from '../../pathUtils';
import { getMemberDisplayName } from '../../../utils/room';
const THREADS_CATEGORY_ID = makeNavCategoryId('home', 'threads');
type ThreadEntry = {
room: Room;
thread: Thread;
};
type ThreadNavItemProps = {
room: Room;
thread: Thread;
selected: boolean;
};
/**
* A single nav item representing a thread the user has participated in.
* Navigates to the room and opens the thread when clicked.
*/
function ThreadNavItem({ room, thread, selected }: ThreadNavItemProps) {
const navigate = useNavigate();
const setActiveThreadId = useSetAtom(activeThreadIdAtomFamily(room.roomId));
const mx = useMatrixClient();
const { rootEvent } = thread;
const senderId = rootEvent?.getSender() ?? '';
const senderName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const rootBody = rootEvent?.getContent()?.body ?? '';
const replyCount = thread.length;
const handleClick = useCallback(() => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
navigate(getHomeRoomPath(roomIdOrAlias));
// Defer slightly so RoomView has time to render before we set the atom.
requestAnimationFrame(() => {
// eslint-disable-next-line no-console
console.log('[HomeThreadsCategory] Opening thread', {
threadId: thread.id,
rootEventId: thread.rootEvent?.getId(),
roomId: room.roomId,
});
// Use rootEvent ID if available, fallback to thread.id
const threadIdToUse = thread.rootEvent?.getId() || thread.id;
setActiveThreadId(threadIdToUse);
});
}, [navigate, mx, room.roomId, setActiveThreadId, thread.id, thread.rootEvent]);
return (
<NavItem variant="Background" radii="400" aria-selected={selected}>
<NavButton onClick={handleClick}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Thread} size="100" />
</Avatar>
<Box as="span" grow="Yes" direction="Column" style={{ overflow: 'hidden', minWidth: 0 }}>
<Text as="span" size="Inherit" truncate>
{senderName}
</Text>
{rootBody && (
<Text as="span" size="T200" priority="300" truncate>
{rootBody}
</Text>
)}
</Box>
{replyCount > 0 && (
<Badge variant="Secondary" size="400" fill="Soft" radii="Pill">
<Text as="span" size="L400">
{replyCount}
</Text>
</Badge>
)}
</Box>
</NavItemContent>
</NavButton>
</NavItem>
);
}
type HomeThreadsCategoryProps = {
rooms: string[];
};
/**
* Nav category for the left sidebar showing threads in Home rooms where the
* current user has participated.
*/
export function HomeThreadsCategory({ rooms }: HomeThreadsCategoryProps) {
const mx = useMatrixClient();
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const getParticipatedThreads = useCallback((): ThreadEntry[] =>
rooms.reduce<ThreadEntry[]>((acc, roomId) => {
const room = mx.getRoom(roomId);
if (!room) return acc;
return acc.concat(
room
.getThreads()
.filter((t) => t.hasCurrentUserParticipated)
.map((t) => ({ room, thread: t }))
);
}, []),
[mx, rooms]);
const [threads, setThreads] = useState<ThreadEntry[]>(getParticipatedThreads);
// Re-compute when thread events fire in any home room.
useEffect(() => {
const handleUpdate = () => setThreads(getParticipatedThreads());
const roomObjects = rooms.reduce<Room[]>((acc, roomId) => {
const room = mx.getRoom(roomId);
if (room) acc.push(room);
return acc;
}, []);
roomObjects.forEach((room) => {
room.getThreads().forEach((thread) => {
thread.on(ThreadEvent.Update, handleUpdate);
thread.on(ThreadEvent.NewReply, handleUpdate);
});
});
return () => {
roomObjects.forEach((room) => {
room.getThreads().forEach((thread) => {
thread.off(ThreadEvent.Update, handleUpdate);
thread.off(ThreadEvent.NewReply, handleUpdate);
});
});
};
}, [mx, rooms, getParticipatedThreads]);
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
if (threads.length === 0) return null;
return (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(THREADS_CATEGORY_ID)}
data-category-id={THREADS_CATEGORY_ID}
onClick={handleCategoryClick}
>
My Threads
</RoomNavCategoryButton>
</NavCategoryHeader>
{!closedCategories.has(THREADS_CATEGORY_ID) &&
threads.map(({ room, thread }) => (
<ThreadNavItem
key={`${room.roomId}:${thread.id}`}
room={room}
thread={thread}
selected={false}
/>
))}
</NavCategory>
);
}

View File

@@ -0,0 +1,11 @@
import { atom } from 'jotai';
import { atomFamily } from 'jotai/utils';
/** Stores the active thread root event ID for a given room. `undefined` means no thread is open. */
const createActiveThreadAtom = () => atom<string | undefined>(undefined);
export type TActiveThreadAtom = ReturnType<typeof createActiveThreadAtom>;
export const activeThreadIdAtomFamily = atomFamily<string, TActiveThreadAtom>(
createActiveThreadAtom
);