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:
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
735
src/app/features/room/ThreadView.tsx
Normal file
735
src/app/features/room/ThreadView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
184
src/app/pages/client/home/HomeThreadsCategory.tsx
Normal file
184
src/app/pages/client/home/HomeThreadsCategory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/app/state/activeThread.ts
Normal file
11
src/app/state/activeThread.ts
Normal 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
|
||||
);
|
||||
Reference in New Issue
Block a user