feat(threads): Thread Panel — full side drawer (P3-8)
Right-side thread drawer (MembersDrawer pattern; mobile fullscreen): - ThreadPanel: header + close/Escape, ThreadTimeline, its own RoomInput (threadRootId prop; drafts/replies/uploads isolated per roomId::threadId; schedule + slash-commands off in threads v1) and threaded mark-as-read. - ThreadTimeline: lean reimplementation over thread.liveTimeline — copied useTimelinePagination pattern (/relations back-pagination + decryption), virtualized, root event emphasized + "N replies" divider, reactions/edits/ redactions, and a pending strip (chronological local echo never enters the thread timelineSet — rendered from LocalEchoUpdated instead). - ThreadSummary chips on root messages (server-aggregated bundle or live Thread; unread badge via getThreadUnreadNotificationCount) keep threads discoverable now that replies leave the main timeline. - Reply-in-Thread menu + thread indicators open the panel; deep links to thread events redirect into it. - State: roomIdToActiveThreadIdAtomFamily + getThreadDraftKey (+18 tests). Gates: tsc clean, eslint 0 errors, build OK, 616/617 tests (1 IDB skip). Awaiting live QA; release note: threaded replies no longer render inline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,940 @@
|
||||
import React, {
|
||||
Dispatch,
|
||||
MouseEventHandler,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Direction,
|
||||
EventStatus,
|
||||
EventTimeline,
|
||||
EventTimelineSet,
|
||||
EventTimelineSetHandlerMap,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
Thread,
|
||||
ThreadEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { Editor } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import to from 'await-to-js';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { Badge, Box, Line, Scroll, Spinner, Text, color, config } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
import { scrollToBottom } from '../../../utils/dom';
|
||||
import {
|
||||
DefaultPlaceholder,
|
||||
MessageBase,
|
||||
Reply,
|
||||
RedactedContent,
|
||||
MSticker,
|
||||
MessageUnsupportedContent,
|
||||
MessageNotDecryptedContent,
|
||||
ImageContent,
|
||||
} from '../../../components/message';
|
||||
import {
|
||||
factoryRenderLinkifyWithMention,
|
||||
getReactCustomHtmlParser,
|
||||
LINKIFY_OPTS,
|
||||
makeMentionCustomProps,
|
||||
renderMatrixMention,
|
||||
} from '../../../plugins/react-custom-html-parser';
|
||||
import {
|
||||
decryptAllTimelineEvent,
|
||||
getEditedEvent,
|
||||
getEventReactions,
|
||||
getMemberDisplayName,
|
||||
getReactionContent,
|
||||
reactionOrEditEvent,
|
||||
} from '../../../utils/room';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { MessageLayout, settingsAtom } from '../../../state/settings';
|
||||
import { Message, Reactions } from '../message';
|
||||
import { RenderMessageContent } from '../../../components/RenderMessageContent';
|
||||
import { Image } from '../../../components/media';
|
||||
import { ImageViewer } from '../../../components/image-viewer';
|
||||
import * as css from './ThreadTimeline.css';
|
||||
import {
|
||||
inSameDay,
|
||||
minuteDifference,
|
||||
timeDayMonthYear,
|
||||
today,
|
||||
yesterday,
|
||||
} from '../../../utils/time';
|
||||
import { createMentionElement, moveCursor } from '../../../components/editor';
|
||||
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
|
||||
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
||||
import {
|
||||
getIntersectionObserverEntry,
|
||||
useIntersectionObserver,
|
||||
} from '../../../hooks/useIntersectionObserver';
|
||||
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
|
||||
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
|
||||
import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
|
||||
import { useIsDirectRoom } from '../../../hooks/useRoom';
|
||||
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import {
|
||||
useAccessiblePowerTagColors,
|
||||
useGetMemberPowerTag,
|
||||
} from '../../../hooks/useMemberPowerTag';
|
||||
import { useTheme } from '../../../hooks/useTheme';
|
||||
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
||||
import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
|
||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { EditHistoryModal } from '../message/EditHistoryModal';
|
||||
import {
|
||||
getLinkedTimelines,
|
||||
getTimelineAndBaseIndex,
|
||||
getTimelineEvent,
|
||||
getTimelineRelativeIndex,
|
||||
getTimelinesEventsCount,
|
||||
timelineToEventsCount,
|
||||
} from '../RoomTimeline';
|
||||
import { getThreadDraftKey } from '../../../state/room/thread';
|
||||
import { useThreadLinkedTimelines, useThreadPendingEvents } from './useThread';
|
||||
|
||||
// Virtual window size (how many items render around the viewport).
|
||||
const PAGINATION_LIMIT = 50;
|
||||
// Network page size for backward /relations pagination of the thread timeline.
|
||||
const THREAD_PAGE_LIMIT = 30;
|
||||
|
||||
type Timeline = {
|
||||
linkedTimelines: EventTimeline[];
|
||||
range: ItemRange;
|
||||
};
|
||||
|
||||
const getEmptyTimeline = (): Timeline => ({
|
||||
linkedTimelines: [],
|
||||
range: { start: 0, end: 0 },
|
||||
});
|
||||
|
||||
const getInitialThreadTimeline = (thread: Thread, timelines?: EventTimeline[]): Timeline => {
|
||||
const linkedTimelines =
|
||||
timelines && timelines.length > 0 ? timelines : getLinkedTimelines(thread.liveTimeline);
|
||||
const evLength = getTimelinesEventsCount(linkedTimelines);
|
||||
return {
|
||||
linkedTimelines,
|
||||
range: {
|
||||
start: Math.max(evLength - PAGINATION_LIMIT, 0),
|
||||
end: evLength,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy of RoomTimeline's `useTimelinePagination` pattern (not exported from RoomTimeline
|
||||
* as its ~35 hooks are hardwired to the room live timeline). Works transparently against
|
||||
* the thread timeline's /relations pagination.
|
||||
*/
|
||||
const useThreadTimelinePagination = (
|
||||
mx: MatrixClient,
|
||||
timeline: Timeline,
|
||||
setTimeline: Dispatch<SetStateAction<Timeline>>,
|
||||
limit: number,
|
||||
) => {
|
||||
const timelineRef = useRef(timeline);
|
||||
timelineRef.current = timeline;
|
||||
const alive = useAlive();
|
||||
|
||||
const handleTimelinePagination = useMemo(() => {
|
||||
let fetching = false;
|
||||
|
||||
const recalibratePagination = (
|
||||
linkedTimelines: EventTimeline[],
|
||||
timelinesEventsCount: number[],
|
||||
backwards: boolean,
|
||||
) => {
|
||||
const topTimeline = linkedTimelines[0];
|
||||
const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;
|
||||
|
||||
const newLTimelines = getLinkedTimelines(topTimeline);
|
||||
const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
|
||||
const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);
|
||||
|
||||
const topTmAddedEvt =
|
||||
timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
|
||||
const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);
|
||||
|
||||
setTimeline((currentTimeline) => ({
|
||||
linkedTimelines: newLTimelines,
|
||||
range:
|
||||
offsetRange > 0
|
||||
? {
|
||||
start: currentTimeline.range.start + offsetRange,
|
||||
end: currentTimeline.range.end + offsetRange,
|
||||
}
|
||||
: { ...currentTimeline.range },
|
||||
}));
|
||||
};
|
||||
|
||||
return async (backwards: boolean) => {
|
||||
if (fetching) return;
|
||||
const { linkedTimelines: lTimelines } = timelineRef.current;
|
||||
const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
|
||||
|
||||
const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
|
||||
if (!timelineToPaginate) return;
|
||||
|
||||
const paginationToken = timelineToPaginate.getPaginationToken(
|
||||
backwards ? Direction.Backward : Direction.Forward,
|
||||
);
|
||||
if (
|
||||
!paginationToken &&
|
||||
getTimelinesEventsCount(lTimelines) !==
|
||||
getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
|
||||
) {
|
||||
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
|
||||
return;
|
||||
}
|
||||
|
||||
fetching = true;
|
||||
const [err] = await to(
|
||||
mx.paginateEventTimeline(timelineToPaginate, {
|
||||
backwards,
|
||||
limit,
|
||||
}),
|
||||
);
|
||||
if (err) {
|
||||
fetching = false;
|
||||
return;
|
||||
}
|
||||
const fetchedTimeline =
|
||||
timelineToPaginate.getNeighbouringTimeline(
|
||||
backwards ? Direction.Backward : Direction.Forward,
|
||||
) ?? timelineToPaginate;
|
||||
// Decrypt all event ahead of render cycle
|
||||
const roomId = fetchedTimeline.getRoomId();
|
||||
const room = roomId ? mx.getRoom(roomId) : null;
|
||||
|
||||
if (room?.hasEncryptionStateEvent()) {
|
||||
await to(decryptAllTimelineEvent(mx, fetchedTimeline));
|
||||
}
|
||||
|
||||
fetching = false;
|
||||
if (alive()) {
|
||||
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
|
||||
}
|
||||
};
|
||||
}, [mx, alive, setTimeline, limit]);
|
||||
return handleTimelinePagination;
|
||||
};
|
||||
|
||||
export type ThreadTimelineProps = {
|
||||
room: Room;
|
||||
thread: Thread;
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
const mx = useMatrixClient();
|
||||
const alive = useAlive();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||
const [perMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
|
||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const direct = useIsDirectRoom();
|
||||
const ignoredUsersList = useIgnoredUsers();
|
||||
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
||||
|
||||
const setReplyDraft = useSetAtom(
|
||||
roomIdToReplyDraftAtomFamily(getThreadDraftKey(room.roomId, thread.id)),
|
||||
);
|
||||
|
||||
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 canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
|
||||
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
||||
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
||||
|
||||
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
||||
const spoilerClickHandler = useSpoilerClickHandler();
|
||||
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||
const space = useSpaceOptionally();
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
||||
|
||||
const [editId, setEditId] = useState<string>();
|
||||
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
|
||||
|
||||
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 { timelines, ready } = useThreadLinkedTimelines(mx, thread);
|
||||
const pendingEvents = useThreadPendingEvents(room, thread.id, thread);
|
||||
|
||||
const [timeline, setTimeline] = useState<Timeline>(() =>
|
||||
ready ? getInitialThreadTimeline(thread, timelines) : getEmptyTimeline(),
|
||||
);
|
||||
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
|
||||
|
||||
const canPaginateBack =
|
||||
typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
|
||||
const rangeAtStart = timeline.range.start === 0;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const atBottomAnchorRef = useRef<HTMLElement>(null);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const atBottomRef = useRef(atBottom);
|
||||
atBottomRef.current = atBottom;
|
||||
const scrollToBottomRef = useRef({ count: 0, smooth: true });
|
||||
|
||||
const handleTimelinePagination = useThreadTimelinePagination(
|
||||
mx,
|
||||
timeline,
|
||||
setTimeline,
|
||||
THREAD_PAGE_LIMIT,
|
||||
);
|
||||
|
||||
const getScrollElement = useCallback(() => scrollRef.current, []);
|
||||
|
||||
const { getItems, scrollToItem, observeBackAnchor } = useVirtualPaginator({
|
||||
count: eventsLength,
|
||||
limit: PAGINATION_LIMIT,
|
||||
range: timeline.range,
|
||||
onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
|
||||
getScrollElement,
|
||||
getItemElement: useCallback(
|
||||
(index: number) =>
|
||||
(scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
|
||||
undefined,
|
||||
[],
|
||||
),
|
||||
onEnd: handleTimelinePagination,
|
||||
});
|
||||
|
||||
// Seed local timeline once the thread has fetched its initial events.
|
||||
const seededRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!ready || seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
setTimeline(getInitialThreadTimeline(thread, timelines));
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = false;
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
to(decryptAllTimelineEvent(mx, thread.liveTimeline)).then(() => {
|
||||
if (alive()) setTimeline((ct) => ({ ...ct }));
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once when ready flips
|
||||
}, [ready, thread]);
|
||||
|
||||
// Re-render / stick-to-bottom on live thread activity.
|
||||
useEffect(() => {
|
||||
const handleTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
||||
mEvent,
|
||||
eventRoom,
|
||||
toStartOfTimeline,
|
||||
removed,
|
||||
data,
|
||||
) => {
|
||||
if (!data?.liveEvent) return;
|
||||
if (atBottomRef.current) {
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = true;
|
||||
setTimeline((ct) => ({
|
||||
...ct,
|
||||
range: {
|
||||
start: ct.range.start + 1,
|
||||
end: ct.range.end + 1,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setTimeline((ct) => ({ ...ct }));
|
||||
};
|
||||
const handleUpdate = () => setTimeline((ct) => ({ ...ct }));
|
||||
|
||||
thread.on(RoomEvent.Timeline, handleTimeline);
|
||||
thread.on(ThreadEvent.Update, handleUpdate);
|
||||
return () => {
|
||||
thread.removeListener(RoomEvent.Timeline, handleTimeline);
|
||||
thread.removeListener(ThreadEvent.Update, handleUpdate);
|
||||
};
|
||||
}, [thread]);
|
||||
|
||||
// atBottom detection
|
||||
useIntersectionObserver(
|
||||
useCallback((entries) => {
|
||||
const target = atBottomAnchorRef.current;
|
||||
if (!target) return;
|
||||
const entry = getIntersectionObserverEntry(target, entries);
|
||||
if (entry) setAtBottom(entry.isIntersecting);
|
||||
}, []),
|
||||
useCallback(
|
||||
() => ({
|
||||
root: getScrollElement(),
|
||||
rootMargin: '100px',
|
||||
}),
|
||||
[getScrollElement],
|
||||
),
|
||||
useCallback(() => atBottomAnchorRef.current, []),
|
||||
);
|
||||
|
||||
// Initial scroll to bottom on mount.
|
||||
useLayoutEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl) scrollToBottom(scrollEl);
|
||||
}, []);
|
||||
|
||||
// Scroll to bottom when requested.
|
||||
const scrollToBottomCount = scrollToBottomRef.current.count;
|
||||
useLayoutEffect(() => {
|
||||
if (scrollToBottomCount > 0) {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl)
|
||||
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
|
||||
}
|
||||
}, [scrollToBottomCount]);
|
||||
|
||||
// Scroll in-place editor into view.
|
||||
useEffect(() => {
|
||||
if (editId) {
|
||||
const editMsgElement =
|
||||
(scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
|
||||
undefined;
|
||||
editMsgElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
}, [editId]);
|
||||
|
||||
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, thread.getUnfilteredTimelineSet());
|
||||
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: thread.id },
|
||||
});
|
||||
setTimeout(() => ReactEditor.focus(editor), 100);
|
||||
}
|
||||
},
|
||||
[room, thread, setReplyDraft, editor],
|
||||
);
|
||||
|
||||
const handleReactionToggle = useCallback(
|
||||
(targetEventId: string, key: string, shortcode?: string) => {
|
||||
const timelineSet = thread.getUnfilteredTimelineSet();
|
||||
const relations = getEventReactions(timelineSet, targetEventId);
|
||||
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
|
||||
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
|
||||
const reactions = reactionsSet ? Array.from(reactionsSet) : [];
|
||||
const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
|
||||
|
||||
if (myReaction && !!myReaction.isRelation()) {
|
||||
mx.redactEvent(room.roomId, myReaction.getId()!);
|
||||
return;
|
||||
}
|
||||
const rShortcode =
|
||||
shortcode ||
|
||||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
|
||||
mx.sendEvent(
|
||||
room.roomId,
|
||||
thread.id,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
MessageEvent.Reaction as any,
|
||||
getReactionContent(targetEventId, key, rShortcode),
|
||||
);
|
||||
},
|
||||
[mx, room, thread],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(editEvtId?: string) => {
|
||||
if (editEvtId) {
|
||||
setEditId(editEvtId);
|
||||
return;
|
||||
}
|
||||
setEditId(undefined);
|
||||
ReactEditor.focus(editor);
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const handleOpenReply: MouseEventHandler = useCallback(
|
||||
(evt) => {
|
||||
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
||||
if (!targetId) return;
|
||||
// best-effort: scroll to referenced event if it is inside the loaded thread window
|
||||
let absIndex = -1;
|
||||
let acc = 0;
|
||||
timeline.linkedTimelines.some((tl) => {
|
||||
const idx = tl.getEvents().findIndex((e) => e.getId() === targetId);
|
||||
if (idx !== -1) {
|
||||
absIndex = acc + idx;
|
||||
return true;
|
||||
}
|
||||
acc += tl.getEvents().length;
|
||||
return false;
|
||||
});
|
||||
if (absIndex >= 0) {
|
||||
scrollToItem(absIndex, {
|
||||
behavior: 'smooth',
|
||||
align: 'center',
|
||||
stopInView: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
[timeline.linkedTimelines, scrollToItem],
|
||||
);
|
||||
|
||||
const renderMessageContent = useCallback(
|
||||
(mEvent: MatrixEvent, mEventId: string, timelineSet: EventTimelineSet): ReactNode => {
|
||||
if (mEvent.isRedacted()) {
|
||||
return <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />;
|
||||
}
|
||||
const type = mEvent.getType();
|
||||
if (type === MessageEvent.Sticker) {
|
||||
return (
|
||||
<MSticker
|
||||
content={mEvent.getContent()}
|
||||
renderImageContent={(props) => (
|
||||
<ImageContent
|
||||
{...props}
|
||||
autoPlay={mediaAutoLoad}
|
||||
renderImage={(p) => <Image {...p} loading="lazy" />}
|
||||
renderViewer={(p) => <ImageViewer {...p} />}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (type === MessageEvent.RoomMessageEncrypted) {
|
||||
return (
|
||||
<Text>
|
||||
<MessageNotDecryptedContent />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (type !== MessageEvent.RoomMessage) {
|
||||
return (
|
||||
<Text>
|
||||
<MessageUnsupportedContent />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
|
||||
const getContent = (() =>
|
||||
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderDisplayName =
|
||||
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||
return (
|
||||
<RenderMessageContent
|
||||
displayName={senderDisplayName}
|
||||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
ts={mEvent.getTs()}
|
||||
edited={!!editedEvent}
|
||||
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
|
||||
getContent={getContent}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
eventId={mEventId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[room, mediaAutoLoad, showUrlPreview, htmlReactParserOptions, linkifyOpts, messageLayout],
|
||||
);
|
||||
|
||||
const renderMessage = useCallback(
|
||||
(
|
||||
mEvent: MatrixEvent,
|
||||
opts: { item?: number; collapse: boolean; highlight: boolean; editable: boolean },
|
||||
): ReactNode => {
|
||||
const mEventId = mEvent.getId();
|
||||
if (!mEventId) return null;
|
||||
const timelineSet = thread.getUnfilteredTimelineSet();
|
||||
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||
const reactions = reactionRelations?.getSortedAnnotationsByKey();
|
||||
const hasReactions = !!reactions && reactions.length > 0;
|
||||
const { replyEventId, threadRootId } = mEvent;
|
||||
|
||||
return (
|
||||
<Message
|
||||
key={mEventId}
|
||||
data-message-item={opts.item}
|
||||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
messageSpacing={messageSpacing}
|
||||
messageLayout={messageLayout}
|
||||
collapse={opts.collapse}
|
||||
highlight={opts.highlight}
|
||||
edit={opts.editable && editId === mEventId}
|
||||
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||
canSendReaction={canSendReaction}
|
||||
canPinEvent={canPinEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
relations={hasReactions ? reactionRelations : undefined}
|
||||
onUserClick={handleUserClick}
|
||||
onUsernameClick={handleUsernameClick}
|
||||
onReplyClick={handleReplyClick}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
onEditId={opts.editable ? handleEdit : undefined}
|
||||
reply={
|
||||
replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
timelineSet={timelineSet}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenReply}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
/>
|
||||
)
|
||||
}
|
||||
reactions={
|
||||
reactionRelations && (
|
||||
<Reactions
|
||||
style={{ marginTop: config.space.S200 }}
|
||||
room={room}
|
||||
relations={reactionRelations}
|
||||
mEventId={mEventId}
|
||||
canSendReaction={canSendReaction}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
hideReadReceipts={hideActivity}
|
||||
showDeveloperTools={showDeveloperTools}
|
||||
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
lotusTerminal={!!lotusTerminal}
|
||||
>
|
||||
{renderMessageContent(mEvent, mEventId, timelineSet)}
|
||||
</Message>
|
||||
);
|
||||
},
|
||||
[
|
||||
thread,
|
||||
room,
|
||||
messageSpacing,
|
||||
messageLayout,
|
||||
editId,
|
||||
canRedact,
|
||||
canDeleteOwn,
|
||||
canSendReaction,
|
||||
canPinEvent,
|
||||
imagePackRooms,
|
||||
handleUserClick,
|
||||
handleUsernameClick,
|
||||
handleReplyClick,
|
||||
handleReactionToggle,
|
||||
handleEdit,
|
||||
handleOpenReply,
|
||||
getMemberPowerTag,
|
||||
accessiblePowerTagColors,
|
||||
legacyUsernameColor,
|
||||
direct,
|
||||
hideActivity,
|
||||
showDeveloperTools,
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
lotusTerminal,
|
||||
mx,
|
||||
renderMessageContent,
|
||||
],
|
||||
);
|
||||
|
||||
let prevEvent: MatrixEvent | undefined;
|
||||
let isPrevRendered = false;
|
||||
let dayDivider = false;
|
||||
const eventRenderer = (item: number) => {
|
||||
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
|
||||
if (!eventTimeline) return null;
|
||||
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
|
||||
const mEventId = mEvent?.getId();
|
||||
if (!mEvent || !mEventId) return null;
|
||||
|
||||
// Skip annotations, edits, and any state/membership events (they can't be threaded).
|
||||
if (reactionOrEditEvent(mEvent) || typeof mEvent.getStateKey() === 'string') {
|
||||
prevEvent = mEvent;
|
||||
return null;
|
||||
}
|
||||
const eventSender = mEvent.getSender();
|
||||
if (eventSender && ignoredUsersSet.has(eventSender)) {
|
||||
prevEvent = mEvent;
|
||||
return null;
|
||||
}
|
||||
|
||||
const isRoot = mEventId === thread.id;
|
||||
|
||||
if (!dayDivider) {
|
||||
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
|
||||
}
|
||||
|
||||
const collapsed =
|
||||
!isRoot &&
|
||||
!perMessageProfiles &&
|
||||
isPrevRendered &&
|
||||
!dayDivider &&
|
||||
prevEvent !== undefined &&
|
||||
prevEvent.getId() !== thread.id &&
|
||||
prevEvent.getSender() === eventSender &&
|
||||
prevEvent.getType() === mEvent.getType() &&
|
||||
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 5;
|
||||
|
||||
const eventJSX = renderMessage(mEvent, {
|
||||
item,
|
||||
collapse: collapsed,
|
||||
highlight: false,
|
||||
editable: true,
|
||||
});
|
||||
|
||||
const dayDividerJSX =
|
||||
dayDivider && eventJSX && !isRoot ? (
|
||||
<MessageBase space={messageSpacing}>
|
||||
<Box gap="100" justifyContent="Center" alignItems="Center">
|
||||
<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>
|
||||
</MessageBase>
|
||||
) : null;
|
||||
|
||||
prevEvent = mEvent;
|
||||
isPrevRendered = !!eventJSX;
|
||||
if (dayDividerJSX) dayDivider = false;
|
||||
|
||||
// Root gets an emphasized container + a "N replies" divider under it.
|
||||
if (isRoot && eventJSX) {
|
||||
const replyCount = thread.length;
|
||||
return (
|
||||
<React.Fragment key={mEventId}>
|
||||
<div className={css.RootMessage}>{eventJSX}</div>
|
||||
{replyCount > 0 && (
|
||||
<Box
|
||||
className={css.RepliesDivider}
|
||||
gap="100"
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
>
|
||||
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||
<Text size="L400" priority="300">
|
||||
{replyCount === 1 ? '1 reply' : `${replyCount} replies`}
|
||||
</Text>
|
||||
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||
</Box>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventJSX && dayDividerJSX) {
|
||||
return (
|
||||
<React.Fragment key={mEventId}>
|
||||
{dayDividerJSX}
|
||||
{eventJSX}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return eventJSX;
|
||||
};
|
||||
|
||||
const items = getItems();
|
||||
const showEmptyReplies = ready && thread.length === 0;
|
||||
|
||||
const renderPendingEvent = (mEvent: MatrixEvent) => {
|
||||
const failed =
|
||||
mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED;
|
||||
return (
|
||||
<div
|
||||
key={mEvent.getId() ?? mEvent.getTxnId()}
|
||||
className={classNames(failed ? css.PendingFailed : css.PendingMessage)}
|
||||
>
|
||||
{renderMessage(mEvent, { collapse: false, highlight: false, editable: false })}
|
||||
{failed && (
|
||||
<Box style={{ padding: `0 ${config.space.S400}` }}>
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Failed to send
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<Box
|
||||
className={css.ThreadCentered}
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
>
|
||||
<Spinner variant="Secondary" size="600" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={css.ThreadTimeline} grow="Yes">
|
||||
<Scroll ref={scrollRef} visibility="Hover">
|
||||
<Box
|
||||
className={css.ThreadTimelineContent}
|
||||
direction="Column"
|
||||
justifyContent="End"
|
||||
role="log"
|
||||
aria-label="Thread timeline"
|
||||
aria-live="polite"
|
||||
>
|
||||
{(canPaginateBack || !rangeAtStart) && (
|
||||
<>
|
||||
<MessageBase>
|
||||
<DefaultPlaceholder />
|
||||
</MessageBase>
|
||||
<MessageBase ref={observeBackAnchor}>
|
||||
<DefaultPlaceholder />
|
||||
</MessageBase>
|
||||
</>
|
||||
)}
|
||||
|
||||
{items.map(eventRenderer)}
|
||||
|
||||
{showEmptyReplies && (
|
||||
<Box className={css.NoReplies} justifyContent="Center">
|
||||
<Text size="T300" priority="300">
|
||||
No replies yet — say something
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{pendingEvents.map(renderPendingEvent)}
|
||||
|
||||
<span ref={atBottomAnchorRef} />
|
||||
</Box>
|
||||
</Scroll>
|
||||
{editHistoryEvent && (
|
||||
<EditHistoryModal
|
||||
room={room}
|
||||
mEvent={editHistoryEvent}
|
||||
onClose={() => setEditHistoryEvent(undefined)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user