736 lines
26 KiB
TypeScript
736 lines
26 KiB
TypeScript
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 && replyEventId !== threadRootId ? (
|
|
<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>
|
|
</>
|
|
);
|
|
}
|