2026-07-01 21:45:20 -04:00
|
|
|
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';
|
2026-07-01 21:58:42 -04:00
|
|
|
import { Message, Reactions, EncryptedContent } from '../message';
|
2026-07-01 21:45:20 -04:00
|
|
|
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 }));
|
2026-07-01 21:58:42 -04:00
|
|
|
// A gappy sync / updateThreadMetadata resets the thread's live timeline —
|
|
|
|
|
// the stored linkedTimelines would then point at a detached timeline, so
|
|
|
|
|
// reseed the window from the fresh liveTimeline.
|
|
|
|
|
const handleReset = () => {
|
|
|
|
|
setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
|
|
|
|
|
scrollToBottomRef.current.count += 1;
|
|
|
|
|
scrollToBottomRef.current.smooth = false;
|
|
|
|
|
};
|
2026-07-01 21:45:20 -04:00
|
|
|
|
|
|
|
|
thread.on(RoomEvent.Timeline, handleTimeline);
|
|
|
|
|
thread.on(ThreadEvent.Update, handleUpdate);
|
2026-07-01 21:58:42 -04:00
|
|
|
thread.on(RoomEvent.TimelineReset, handleReset);
|
2026-07-01 21:45:20 -04:00
|
|
|
return () => {
|
|
|
|
|
thread.removeListener(RoomEvent.Timeline, handleTimeline);
|
|
|
|
|
thread.removeListener(ThreadEvent.Update, handleUpdate);
|
2026-07-01 21:58:42 -04:00
|
|
|
thread.removeListener(RoomEvent.TimelineReset, handleReset);
|
2026-07-01 21:45:20 -04:00
|
|
|
};
|
|
|
|
|
}, [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 => {
|
2026-07-01 21:58:42 -04:00
|
|
|
// Evaluated lazily so EncryptedContent can re-run it (re-reading getType())
|
|
|
|
|
// after MatrixEventEvent.Decrypted fires — decryption re-emits NEITHER
|
|
|
|
|
// RoomEvent.Timeline nor ThreadEvent.Update, so without this wrapper a
|
|
|
|
|
// live-arriving encrypted reply would show "Unable to decrypt" forever.
|
|
|
|
|
const renderByType = (): 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;
|
2026-07-01 21:45:20 -04:00
|
|
|
return (
|
2026-07-01 21:58:42 -04:00
|
|
|
<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}
|
2026-07-01 21:45:20 -04:00
|
|
|
/>
|
|
|
|
|
);
|
2026-07-01 21:58:42 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) {
|
|
|
|
|
return <EncryptedContent mEvent={mEvent}>{renderByType}</EncryptedContent>;
|
2026-07-01 21:45:20 -04:00
|
|
|
}
|
2026-07-01 21:58:42 -04:00
|
|
|
return renderByType();
|
2026-07-01 21:45:20 -04:00
|
|
|
},
|
|
|
|
|
[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>
|
|
|
|
|
);
|
|
|
|
|
}
|