Files
cinny/src/app/features/room/thread/ThreadTimeline.tsx
T

962 lines
32 KiB
TypeScript
Raw Normal View History

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, EncryptedContent } 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 }));
// 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;
};
thread.on(RoomEvent.Timeline, handleTimeline);
thread.on(ThreadEvent.Update, handleUpdate);
thread.on(RoomEvent.TimelineReset, handleReset);
return () => {
thread.removeListener(RoomEvent.Timeline, handleTimeline);
thread.removeListener(ThreadEvent.Update, handleUpdate);
thread.removeListener(RoomEvent.TimelineReset, handleReset);
};
}, [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 => {
// 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;
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}
/>
);
};
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) {
return <EncryptedContent mEvent={mEvent}>{renderByType}</EncryptedContent>;
}
return renderByType();
},
[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>
);
}