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;
|
replyEventId: string;
|
||||||
threadRootId?: string | undefined;
|
threadRootId?: string | undefined;
|
||||||
onClick?: MouseEventHandler | undefined;
|
onClick?: MouseEventHandler | undefined;
|
||||||
|
/** Called when the thread indicator badge is clicked. Receives the thread root event ID. */
|
||||||
|
onOpenThread?: (threadRootId: string) => void;
|
||||||
getMemberPowerTag?: GetMemberPowerTag;
|
getMemberPowerTag?: GetMemberPowerTag;
|
||||||
accessibleTagColors?: Map<string, string>;
|
accessibleTagColors?: Map<string, string>;
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
@@ -71,6 +73,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
replyEventId,
|
replyEventId,
|
||||||
threadRootId,
|
threadRootId,
|
||||||
onClick,
|
onClick,
|
||||||
|
onOpenThread,
|
||||||
getMemberPowerTag,
|
getMemberPowerTag,
|
||||||
accessibleTagColors,
|
accessibleTagColors,
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
@@ -107,7 +110,11 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
return (
|
return (
|
||||||
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
||||||
{threadRootId && (
|
{threadRootId && (
|
||||||
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
<ThreadIndicator
|
||||||
|
as="button"
|
||||||
|
data-event-id={threadRootId}
|
||||||
|
onClick={onOpenThread ? () => onOpenThread(threadRootId) : onClick}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<ReplyLayout
|
<ReplyLayout
|
||||||
as="button"
|
as="button"
|
||||||
|
|||||||
@@ -126,9 +126,11 @@ interface RoomInputProps {
|
|||||||
fileDropContainerRef: RefObject<HTMLElement>;
|
fileDropContainerRef: RefObject<HTMLElement>;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
room: Room;
|
room: Room;
|
||||||
|
/** When provided, all messages are sent as replies in this thread. */
|
||||||
|
threadRootId?: string;
|
||||||
}
|
}
|
||||||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
({ editor, fileDropContainerRef, roomId, room }, ref) => {
|
({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
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'].rel_type = RelationType.Thread;
|
||||||
content['m.relates_to'].is_falling_back = false;
|
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);
|
mx.sendMessage(roomId, content as any);
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
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(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ import * as css from './RoomTimeline.css';
|
|||||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||||
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
|
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
|
||||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
|
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
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 ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
||||||
|
|
||||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||||
|
const setActiveThreadId = useSetAtom(activeThreadIdAtomFamily(room.roomId));
|
||||||
const powerLevels = usePowerLevelsContext();
|
const powerLevels = usePowerLevelsContext();
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
@@ -1025,8 +1027,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
});
|
});
|
||||||
setTimeout(() => ReactEditor.focus(editor), 100);
|
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(
|
const handleReactionToggle = useCallback(
|
||||||
@@ -1114,6 +1127,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
replyEventId={replyEventId}
|
replyEventId={replyEventId}
|
||||||
threadRootId={threadRootId}
|
threadRootId={threadRootId}
|
||||||
onClick={handleOpenReply}
|
onClick={handleOpenReply}
|
||||||
|
onOpenThread={handleOpenThread}
|
||||||
getMemberPowerTag={getMemberPowerTag}
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
@@ -1197,6 +1211,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
replyEventId={replyEventId}
|
replyEventId={replyEventId}
|
||||||
threadRootId={threadRootId}
|
threadRootId={threadRootId}
|
||||||
onClick={handleOpenReply}
|
onClick={handleOpenReply}
|
||||||
|
onOpenThread={handleOpenThread}
|
||||||
getMemberPowerTag={getMemberPowerTag}
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Box, Text, config } from 'folds';
|
|||||||
import { EventType, Room } from 'matrix-js-sdk';
|
import { EventType, Room } from 'matrix-js-sdk';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
@@ -22,6 +23,8 @@ import { settingsAtom } from '../../state/settings';
|
|||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
|
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||||
|
import { ThreadView } from './ThreadView';
|
||||||
|
|
||||||
const FN_KEYS_REGEX = /^F\d+$/;
|
const FN_KEYS_REGEX = /^F\d+$/;
|
||||||
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||||
@@ -74,6 +77,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
|
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId));
|
||||||
|
|
||||||
useKeyDown(
|
useKeyDown(
|
||||||
window,
|
window,
|
||||||
useCallback(
|
useCallback(
|
||||||
@@ -94,6 +99,10 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
return (
|
return (
|
||||||
<Page ref={roomViewRef}>
|
<Page ref={roomViewRef}>
|
||||||
<RoomViewHeader />
|
<RoomViewHeader />
|
||||||
|
{activeThreadId ? (
|
||||||
|
<ThreadView room={room} threadRootId={activeThreadId} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<RoomTimeline
|
<RoomTimeline
|
||||||
key={roomId}
|
key={roomId}
|
||||||
@@ -137,6 +146,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import {
|
|||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { JoinRule, Room } from 'matrix-js-sdk';
|
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 { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { PageHeader } from '../../components/page';
|
import { PageHeader } from '../../components/page';
|
||||||
@@ -499,6 +500,7 @@ export function RoomViewHeader() {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
|
||||||
|
|
||||||
const handleSearchClick = () => {
|
const handleSearchClick = () => {
|
||||||
const searchParams: _SearchPathSearchParams = {
|
const searchParams: _SearchPathSearchParams = {
|
||||||
@@ -685,6 +687,25 @@ export function RoomViewHeader() {
|
|||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</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
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
align="End"
|
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 { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
||||||
import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt';
|
import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt';
|
||||||
import { _RoomSearchParams } from '../../paths';
|
import { _RoomSearchParams } from '../../paths';
|
||||||
|
import { HomeThreadsCategory } from './HomeThreadsCategory';
|
||||||
|
|
||||||
type HomeMenuProps = {
|
type HomeMenuProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -395,6 +396,7 @@ export function Home() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
|
<HomeThreadsCategory rooms={rooms} />
|
||||||
</Box>
|
</Box>
|
||||||
</PageNavContent>
|
</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