Column flex on keep-alive room hosts so the timeline fills the pane again, and route unicode jumbo through the plain emoji path with dedicated HTML parser options for custom emoticons.
795 lines
28 KiB
TypeScript
795 lines
28 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, IconButton, Line, Scroll, Text, Tooltip, TooltipProvider, config } from 'folds';
|
|
import { Icon, Icons } from '../../components/icons';
|
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
|
import { ReactEditor } from 'slate-react';
|
|
import { isKeyHotkey } from 'is-hotkey';
|
|
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
|
import { editableActiveElement } from '../../utils/dom';
|
|
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 { autoScrollToBottom, scrollElementIntoView } from '../../utils/dom';
|
|
|
|
type ThreadViewProps = {
|
|
room: Room;
|
|
threadRootId: string;
|
|
};
|
|
|
|
const FN_KEYS_REGEX = /^F\d+$/;
|
|
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
|
const { code } = evt;
|
|
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
|
|
return false;
|
|
}
|
|
|
|
if (FN_KEYS_REGEX.test(code)) return false;
|
|
|
|
if (
|
|
code.startsWith('OS') ||
|
|
code.startsWith('Meta') ||
|
|
code.startsWith('Shift') ||
|
|
code.startsWith('Alt') ||
|
|
code.startsWith('Control') ||
|
|
code.startsWith('Arrow') ||
|
|
code.startsWith('Page') ||
|
|
code.startsWith('End') ||
|
|
code.startsWith('Home') ||
|
|
code === 'Tab' ||
|
|
code === 'Space' ||
|
|
code === 'Enter' ||
|
|
code === 'NumLock' ||
|
|
code === 'ScrollLock'
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* 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 jumboHtmlReactParserOptions = useMemo<HTMLReactParserOptions>(
|
|
() =>
|
|
getReactCustomHtmlParser(mx, room.roomId, {
|
|
linkifyOpts,
|
|
useAuthentication,
|
|
handleSpoilerClick: spoilerClickHandler,
|
|
handleMentionClick: mentionClickHandler,
|
|
jumbo: true,
|
|
}),
|
|
[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();
|
|
|
|
useKeyDown(
|
|
window,
|
|
useCallback(
|
|
(evt) => {
|
|
if (editableActiveElement()) return;
|
|
const portalContainer = document.getElementById('portalContainer');
|
|
if (portalContainer && portalContainer.children.length > 0) {
|
|
return;
|
|
}
|
|
if (shouldFocusMessageField(evt)) {
|
|
evt.preventDefault();
|
|
ReactEditor.focus(editor);
|
|
if (evt.key.length === 1) {
|
|
editor.insertText(evt.key);
|
|
}
|
|
} else if (isKeyHotkey('mod+v', evt)) {
|
|
ReactEditor.focus(editor);
|
|
}
|
|
},
|
|
[editor]
|
|
)
|
|
);
|
|
|
|
// 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) autoScrollToBottom(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) scrollElementIntoView(el, { 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}
|
|
jumboHtmlReactParserOptions={jumboHtmlReactParserOptions}
|
|
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}
|
|
jumboHtmlReactParserOptions={jumboHtmlReactParserOptions}
|
|
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>
|
|
</>
|
|
);
|
|
}
|