Files
cinny/src/app/features/room/RoomTimeline.tsx

2710 lines
91 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable react/destructuring-assignment */
import React, {
Dispatch,
MouseEventHandler,
RefObject,
SetStateAction,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
Direction,
EventTimeline,
EventTimelineSet,
EventTimelineSetHandlerMap,
IContent,
MatrixClient,
MatrixEvent,
MsgType,
Room,
RoomEvent,
RoomEventHandlerMap,
} from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import classNames from 'classnames';
import { ReactEditor } from 'slate-react';
import { Editor } from 'slate';
import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai';
import { Badge, Box, Chip, ContainerColor, IconButton, Line, Scroll, Text, as, color, config, toRem } from 'folds';
import { Icon, Icons } from '../../components/icons';
import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive';
import { autoScrollToBottom, editableActiveElement, isScrolledToBottom } from '../../utils/dom';
import {
DefaultPlaceholder,
CompactPlaceholder,
Reply,
MessageBase,
MessageUnsupportedContent,
Time,
MessageNotDecryptedContent,
RedactedContent,
MImage,
MSticker,
ImageContent,
EventContent,
} from '../../components/message';
import {
factoryRenderLinkifyWithMention,
getReactCustomHtmlParser,
LINKIFY_OPTS,
makeMentionCustomProps,
renderMatrixMention,
} from '../../plugins/react-custom-html-parser';
import {
canEditEvent,
decryptAllTimelineEvent,
getEditedEvent,
getEventReactions,
getLatestEditableEvt,
getMemberDisplayName,
getReactionContent,
isMembershipChanged,
reactionOrEditEvent,
} from '../../utils/room';
import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, ScrollToLatestBehavior, settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { Reactions, Message, Event, EncryptedContent } from './message';
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
import * as customHtmlCss from '../../styles/CustomHtml.css';
import { RoomIntro } from '../../components/room-intro';
import {
getIntersectionObserverEntry,
useIntersectionObserver,
} from '../../hooks/useIntersectionObserver';
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { Image } from '../../components/media';
import { ImageViewer } from '../../components/image-viewer';
import { useInertialHorizontalScroll } from '../../hooks/useInertialHorizontalScroll';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import {
clearRoomReturnAnchor,
clearRoomScrollState,
getRoomReturnAnchor,
getRoomScrollState,
saveRoomScrollState,
setRoomReturnAnchor,
} from '../../state/roomScrollCache';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsDirectRoom } from '../../hooks/useRoom';
import { setupCopyHandler } from '../../utils/copyHandler';
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 {
useRoomEmbedFilters,
useRoomWideEmbedFilters,
combineEmbedFilters,
} from '../../hooks/useRoomEmbedFilters';
import {
IImageContent,
PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
} from '../../../types/matrix/common';
/** Information about a call member event for grouping */
interface CallEventInfo {
senderId: string;
senderName: string;
isJoining: boolean;
ts: number;
item: number;
mEventId: string;
mEvent: MatrixEvent;
}
interface CarouselEventMetadata {
uuid: string;
index: number;
total: number;
content: IImageContent;
}
interface CarouselRenderableEvent {
mEvent: MatrixEvent;
mEventId: string;
item: number;
content: IImageContent;
index: number;
}
const getCarouselEventMetadata = (mEvent: MatrixEvent): CarouselEventMetadata | undefined => {
if (mEvent.getType() !== MessageEvent.RoomMessage || mEvent.isRedacted()) {
return undefined;
}
const content = mEvent.getContent() as IImageContent;
const uuid = content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME];
const index = content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME];
const total = content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME];
if (content.msgtype !== MsgType.Image) return undefined;
if (typeof uuid !== 'string') return undefined;
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) return undefined;
if (typeof total !== 'number' || !Number.isInteger(total) || total < 1) return undefined;
return { uuid, index, total, content };
};
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
<Box
className={classNames(css.TimelineFloat({ position }), className)}
justifyContent="Center"
alignItems="Center"
gap="200"
{...props}
ref={ref}
/>
)
);
const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
({ variant, children, ...props }, ref) => (
<Box gap="100" justifyContent="Center" alignItems="Center" {...props} ref={ref}>
<Line style={{ flexGrow: 1 }} variant={variant} size="300" />
{children}
<Line style={{ flexGrow: 1 }} variant={variant} size="300" />
</Box>
)
);
type CarouselScrollerProps = {
children: React.ReactNode;
};
function CarouselScroller({ children }: CarouselScrollerProps) {
const {
scrollRef,
isDragging,
canScrollBack,
canScrollForward,
scrollBack,
scrollForward,
scrollProps,
} = useInertialHorizontalScroll();
return (
<Box style={{ position: 'relative' }}>
<Box
ref={scrollRef}
className={classNames(css.CarouselScroller, isDragging && css.CarouselScrollerDragging)}
{...scrollProps}
>
{children}
</Box>
{canScrollBack && (
<>
<div className={css.CarouselEdgeGradient({ position: 'Left' })} />
<IconButton
className={css.CarouselScrollBtn({ position: 'Left' })}
variant="Secondary"
radii="Pill"
size="300"
outlined
onClick={scrollBack}
>
<Icon size="300" src={Icons.ArrowLeft} />
</IconButton>
</>
)}
{canScrollForward && (
<>
<div className={css.CarouselEdgeGradient({ position: 'Right' })} />
<IconButton
className={css.CarouselScrollBtn({ position: 'Right' })}
variant="Primary"
radii="Pill"
size="300"
outlined
onClick={scrollForward}
>
<Icon size="300" src={Icons.ArrowRight} />
</IconButton>
</>
)}
</Box>
);
}
const getCarouselItemWidth = (content: IImageContent): string => {
const width = content.info?.w;
const height = content.info?.h;
if (!width || !height || height <= 0) {
return 'min(24rem, 72vw)';
}
const aspectRatio = width / height;
const baseHeightRem = 22;
const computedWidthRem = Math.max(11, Math.min(24, baseHeightRem * aspectRatio));
return `min(${computedWidthRem}rem, 72vw)`;
};
export const getLiveTimeline = (room: Room): EventTimeline =>
room.getUnfilteredTimelineSet().getLiveTimeline();
export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
const timelineSet = room.getUnfilteredTimelineSet();
return timelineSet.getTimelineForEvent(eventId) ?? undefined;
};
export const getFirstLinkedTimeline = (
timeline: EventTimeline,
direction: Direction
): EventTimeline => {
const linkedTm = timeline.getNeighbouringTimeline(direction);
if (!linkedTm) return timeline;
return getFirstLinkedTimeline(linkedTm, direction);
};
export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
const timelines: EventTimeline[] = [];
for (
let nextTimeline: EventTimeline | null = firstTimeline;
nextTimeline;
nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
) {
timelines.push(nextTimeline);
}
return timelines;
};
export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
count + timelineToEventsCount(tm);
return timelines.reduce(timelineEventCountReducer, 0);
};
export const getTimelineAndBaseIndex = (
timelines: EventTimeline[],
index: number
): [EventTimeline | undefined, number] => {
let uptoTimelineLen = 0;
const timeline = timelines.find((t) => {
uptoTimelineLen += t.getEvents().length;
if (index < uptoTimelineLen) return true;
return false;
});
if (!timeline) return [undefined, 0];
return [timeline, uptoTimelineLen - timeline.getEvents().length];
};
export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
absoluteIndex - timelineBaseIndex;
export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
timeline.getEvents()[index];
export const getEventIdAbsoluteIndex = (
timelines: EventTimeline[],
eventTimeline: EventTimeline,
eventId: string
): number | undefined => {
const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
if (timelineIndex === -1) return undefined;
const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
if (eventIndex === -1) return undefined;
const baseIndex = timelines
.slice(0, timelineIndex)
.reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
return baseIndex + eventIndex;
};
type RoomTimelineProps = {
room: Room;
eventId?: string;
roomInputRef: RefObject<HTMLElement>;
editor: Editor;
};
const PAGINATION_LIMIT = 80;
type Timeline = {
linkedTimelines: EventTimeline[];
range: ItemRange;
};
const useEventTimelineLoader = (
mx: MatrixClient,
room: Room,
onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
onError: (err: Error | null) => void
) => {
const loadEventTimeline = useCallback(
async (eventId: string) => {
const [err, replyEvtTimeline] = await to(
mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId)
);
if (!replyEvtTimeline) {
onError(err ?? null);
return;
}
const linkedTimelines = getLinkedTimelines(replyEvtTimeline);
const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
if (absIndex === undefined) {
onError(err ?? null);
return;
}
onLoad(eventId, linkedTimelines, absIndex);
},
[mx, room, onLoad, onError]
);
return loadEventTimeline;
};
const useTimelinePagination = (
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) {
// TODO: handle pagination error.
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;
};
const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => {
useEffect(() => {
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
mEvent,
eventRoom,
toStartOfTimeline,
removed,
data
) => {
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
onArrive(mEvent);
};
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
if (eventRoom?.roomId !== room.roomId) return;
onArrive(mEvent);
};
room.on(RoomEvent.Timeline, handleTimelineEvent);
room.on(RoomEvent.Redaction, handleRedaction);
return () => {
room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
room.removeListener(RoomEvent.Redaction, handleRedaction);
};
}, [room, onArrive]);
};
const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
useEffect(() => {
const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
if (r.roomId !== room.roomId) return;
onRefresh();
};
room.on(RoomEvent.TimelineRefresh, handleTimelineRefresh);
return () => {
room.removeListener(RoomEvent.TimelineRefresh, handleTimelineRefresh);
};
}, [room, onRefresh]);
};
const getInitialTimeline = (room: Room) => {
const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
const evLength = getTimelinesEventsCount(linkedTimelines);
return {
linkedTimelines,
range: {
start: Math.max(evLength - PAGINATION_LIMIT, 0),
end: evLength,
},
};
};
const getEmptyTimeline = () => ({
range: { start: 0, end: 0 },
linkedTimelines: [],
});
const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? '');
if (!readUptoEventId) return undefined;
const evtTimeline = getEventTimeline(room, readUptoEventId);
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
return {
readUptoEventId,
inLiveTimeline: latestTimeline === room.getLiveTimeline(),
scrollTo,
};
};
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const direct = useIsDirectRoom();
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const [personalEmbedFilters] = useRoomEmbedFilters(room);
const roomWideEmbedFilters = useRoomWideEmbedFilters(room);
const combinedEmbedFilters = useMemo(
() => combineEmbedFilters(personalEmbedFilters, roomWideEmbedFilters),
[personalEmbedFilters, roomWideEmbedFilters]
);
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const [scrollToLatestBehavior] = useSetting(settingsAtom, 'scrollToLatestBehavior');
// Track consecutive call member events for collapsing
const pendingCallEventsRef = useRef<CallEventInfo[]>([]);
const ignoredUsersList = useIgnoredUsers();
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const setActiveThreadId = useSetAtom(activeThreadIdAtomFamily(room.roomId));
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const creatorsTag = useRoomCreatorsTag();
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const theme = useTheme();
const accessiblePowerTagColors = useAccessiblePowerTagColors(
theme.kind,
creatorsTag,
powerLevelTags
);
const permissions = useRoomPermissions(creators, powerLevels);
const canRedact = permissions.action('redact', mx.getSafeUserId());
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
const [editId, setEditId] = useState<string>();
const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const { navigateRoom } = useRoomNavigate();
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
const openUserRoomProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const markAsRead = useMarkAsRead(mx);
const location = useLocation();
const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;
const savedScrollRef = useRef(
eventId || scrollToLatest ? undefined : getRoomScrollState(room.roomId)
);
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
const [unreadInfo, setUnreadInfo] = useState(() =>
getRoomUnreadInfo(room, scrollToLatestBehavior !== ScrollToLatestBehavior.Always)
);
const readUptoEventIdRef = useRef<string>();
if (unreadInfo) {
readUptoEventIdRef.current = unreadInfo.readUptoEventId;
}
const atBottomAnchorRef = useRef<HTMLElement>(null);
const [atBottom, setAtBottom] = useState<boolean>(
() => savedScrollRef.current?.atBottom ?? true
);
const atBottomRef = useRef(atBottom);
atBottomRef.current = atBottom;
const [latestUnreadMessage, setLatestUnreadMessage] = useState<{
sender: string;
content: string;
} | null>(null);
const [returnToEventId, setReturnToEventIdState] = useState<string | null>(
() => getRoomReturnAnchor(room.roomId) ?? null
);
const setReturnToEventId = useCallback(
(eventId: string | null) => {
if (eventId) setRoomReturnAnchor(room.roomId, eventId);
else clearRoomReturnAnchor(room.roomId);
setReturnToEventIdState(eventId);
},
[room.roomId]
);
const scrollRef = useRef<HTMLDivElement>(null);
const timelineContentRef = useRef<HTMLDivElement>(null);
const timelineContentHeightRef = useRef(0);
const restoringScrollRef = useRef(false);
const scrollToBottomRef = useRef({
count: 0,
smooth: true,
force: false,
});
const getViewportAnchorEventId = useCallback((): string | undefined => {
const scrollEl = scrollRef.current;
if (!scrollEl) return undefined;
const scrollRect = scrollEl.getBoundingClientRect();
const centerY = scrollRect.top + scrollRect.height / 2;
const items = scrollEl.querySelectorAll<HTMLElement>('[data-message-id]');
let bestId: string | undefined;
let bestDist = Infinity;
items.forEach((el) => {
const rect = el.getBoundingClientRect();
if (rect.bottom < scrollRect.top || rect.top > scrollRect.bottom) return;
const mid = (rect.top + rect.bottom) / 2;
const dist = Math.abs(mid - centerY);
if (dist < bestDist) {
bestDist = dist;
bestId = el.getAttribute('data-message-id') ?? undefined;
}
});
return bestId;
}, []);
const [focusItem, setFocusItem] = useState<
| {
index: number;
scrollTo: boolean;
highlight: boolean;
}
| undefined
>();
const alive = useAlive();
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 parseMemberEvent = useMemberEventParser();
const [timeline, setTimeline] = useState<Timeline>(() => {
if (eventId) return getEmptyTimeline();
const initial = getInitialTimeline(room);
const saved = savedScrollRef.current;
if (!saved?.range) return initial;
const evLength = getTimelinesEventsCount(initial.linkedTimelines);
if (evLength === 0) return initial;
const start = Math.min(saved.range.start, Math.max(0, evLength - 1));
const end = Math.min(Math.max(start + 1, saved.range.end), evLength);
return { ...initial, range: { start, end } };
});
const timelineRef = useRef(timeline);
timelineRef.current = timeline;
const getRangeCenterEventId = useCallback((): string | undefined => {
const { linkedTimelines, range } = timelineRef.current;
if (range.end <= range.start) return undefined;
const mid = Math.floor((range.start + range.end - 1) / 2);
const [tm, baseIndex] = getTimelineAndBaseIndex(linkedTimelines, mid);
if (!tm) return undefined;
return getTimelineEvent(tm, getTimelineRelativeIndex(mid, baseIndex))?.getId();
}, []);
const persistTimelineScroll = useCallback(
(roomId: string) => {
if (eventId) return;
const scrollEl = scrollRef.current;
if (!scrollEl) return;
const distanceFromBottom =
scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
saveRoomScrollState(roomId, {
top: scrollEl.scrollTop,
atBottom: distanceFromBottom <= 50,
range: timelineRef.current.range,
});
},
[eventId]
);
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
const liveTimelineLinked =
timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
const canPaginateBack =
typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
const rangeAtStart = timeline.range.start === 0;
const rangeAtEnd = timeline.range.end === eventsLength;
const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
// Historical / non-live windows are never "at bottom" of the room.
// When we return to the live end, re-check scroll — IntersectionObserver may
// not re-fire if the anchor stayed intersecting across the transition.
useEffect(() => {
if (!liveTimelineLinked || !rangeAtEnd) {
setAtBottom(false);
return;
}
const scrollEl = scrollRef.current;
if (scrollEl && isScrolledToBottom(scrollEl, 100)) {
setAtBottom(true);
}
}, [liveTimelineLinked, rangeAtEnd]);
const handleTimelinePagination = useTimelinePagination(
mx,
timeline,
setTimeline,
PAGINATION_LIMIT
);
const getScrollElement = useCallback(() => scrollRef.current, []);
const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
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,
});
const loadEventTimeline = useEventTimelineLoader(
mx,
room,
useCallback(
(evtId, lTimelines, evtAbsIndex) => {
if (!alive()) return;
const evLength = getTimelinesEventsCount(lTimelines);
setFocusItem({
index: evtAbsIndex,
scrollTo: true,
highlight: evtId !== readUptoEventIdRef.current,
});
setTimeline({
linkedTimelines: lTimelines,
range: {
start: Math.max(evtAbsIndex - PAGINATION_LIMIT, 0),
end: Math.min(evtAbsIndex + PAGINATION_LIMIT, evLength),
},
});
},
[alive]
),
useCallback(() => {
if (!alive()) return;
setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
scrollToBottomRef.current.force = true;
}, [alive, room])
);
useLiveEventArrive(
room,
useCallback(
(mEvt: MatrixEvent) => {
const isOwnMessage = mEvt.getSender() === mx.getUserId();
const isBottomPinnedEvent =
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
const wasAwayFromBottom = !atBottomRef.current;
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
// Always update timeline with new message
setTimeline((ct) => ({
...ct,
range: {
start: ct.range.start + 1,
end: ct.range.end + 1,
},
}));
// Handle own messages and focus
if (isOwnMessage) {
setLatestUnreadMessage(null);
if (document.hasFocus() && (!unreadInfo)) {
const roomId = mEvt.getRoomId();
if (roomId) {
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
}
}
// Sending while scrolled up jumps to latest — offer return to previous spot
if (wasAwayFromBottom && isBottomPinnedEvent) {
const anchorEventId =
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
if (anchorEventId) {
setReturnToEventId(anchorEventId);
}
}
} else if (!document.hasFocus() && !unreadInfo) {
// Show unread notification for other users' messages when unfocused
setUnreadInfo(getRoomUnreadInfo(room));
}
// Preserve the pre-update bottom state so newly added chat messages keep the view pinned.
if (shouldStickToBottom) {
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = !isOwnMessage;
scrollToBottomRef.current.force = true;
}
// Show unread message preview for incoming messages when not at bottom
if (!isOwnMessage && !atBottomRef.current) {
const senderName =
getMemberDisplayName(room, mEvt.getSender() ?? '') ??
getMxIdLocalPart(mEvt.getSender() ?? '') ??
'Unknown';
const content = mEvt.getContent();
const messageText = content.body ?? content.msgtype ?? 'New message';
setLatestUnreadMessage({
sender: senderName,
content: messageText.length > 100 ? `${messageText.substring(0, 100)}...` : messageText,
});
}
},
[mx, room, unreadInfo, hideActivity, markAsRead, eventId, getViewportAnchorEventId, getRangeCenterEventId, setReturnToEventId]
)
);
const handleOpenEvent = useCallback(
async (
evtId: string,
highlight = true,
onScroll: ((scrolled: boolean) => void) | undefined = undefined
) => {
const evtTimeline = getEventTimeline(room, evtId);
const absoluteIndex =
evtTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, evtTimeline, evtId);
if (typeof absoluteIndex === 'number') {
const scrolled = scrollToItem(absoluteIndex, {
behavior: 'smooth',
align: 'center',
stopInView: true,
});
if (onScroll) onScroll(scrolled);
setFocusItem({
index: absoluteIndex,
scrollTo: false,
highlight,
});
} else {
setTimeline(getEmptyTimeline());
loadEventTimeline(evtId);
}
},
[room, timeline, scrollToItem, loadEventTimeline]
);
useLiveTimelineRefresh(
room,
useCallback(() => {
if (liveTimelineLinked) {
setTimeline(getInitialTimeline(room));
}
}, [room, liveTimelineLinked])
);
// Stay at bottom when room editor resize
useResizeObserver(
useMemo(() => {
let mounted = false;
return (entries) => {
if (!mounted) {
// skip initial mounting call
mounted = true;
return;
}
if (!roomInputRef.current) return;
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
const scrollElement = getScrollElement();
if (!editorBaseEntry || !scrollElement) return;
if (restoringScrollRef.current) return;
if (isScrolledToBottom(scrollElement)) {
autoScrollToBottom(scrollElement, false, true);
}
};
}, [getScrollElement, roomInputRef]),
useCallback(() => roomInputRef.current, [roomInputRef])
);
useResizeObserver(
useCallback((entries) => {
const timelineContent = timelineContentRef.current;
const scrollElement = getScrollElement();
if (!timelineContent || !scrollElement) return;
const timelineEntry = getResizeObserverEntry(timelineContent, entries);
if (!timelineEntry) return;
const nextHeight = timelineEntry.contentRect.height;
const previousHeight = timelineContentHeightRef.current;
timelineContentHeightRef.current = nextHeight;
if (restoringScrollRef.current) {
const saved = savedScrollRef.current;
if (saved) {
if (saved.atBottom) {
scrollElement.scrollTop = scrollElement.scrollHeight;
} else {
scrollElement.scrollTop = saved.top;
}
}
return;
}
if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) {
return;
}
autoScrollToBottom(scrollElement, false, true);
}, [getScrollElement]),
useCallback(() => timelineContentRef.current, [])
);
const tryAutoMarkAsRead = useCallback(() => {
const readUptoEventId = readUptoEventIdRef.current;
if (!readUptoEventId) {
requestAnimationFrame(() => markAsRead(room.roomId, hideActivity));
return;
}
const evtTimeline = getEventTimeline(room, readUptoEventId);
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
if (latestTimeline === room.getLiveTimeline()) {
requestAnimationFrame(() => markAsRead(room.roomId, hideActivity));
}
}, [room, hideActivity, markAsRead]);
useIntersectionObserver(
useCallback(
(entries) => {
const target = atBottomAnchorRef.current;
if (!target) return;
const targetEntry = getIntersectionObserverEntry(target, entries);
if (!targetEntry) return;
// Leave the live bottom immediately so Jump to Latest stays available.
// Only intersection clears atBottom here; !atLiveEnd is handled by the
// liveTimelineLinked/rangeAtEnd effect so we don't stick false when the
// live end reconnects without a new intersection event.
if (!targetEntry.isIntersecting) {
setAtBottom(false);
return;
}
if (!atLiveEndRef.current) return;
setAtBottom(true);
setLatestUnreadMessage(null);
if (document.hasFocus()) {
tryAutoMarkAsRead();
}
},
[tryAutoMarkAsRead]
),
useCallback(
() => ({
root: getScrollElement(),
rootMargin: '100px',
}),
[getScrollElement]
),
useCallback(() => atBottomAnchorRef.current, [])
);
useDocumentFocusChange(
useCallback(
(inFocus) => {
if (inFocus && atBottomRef.current) {
if (unreadInfo?.inLiveTimeline) {
handleOpenEvent(unreadInfo.readUptoEventId, false, (scrolled) => {
// the unread event is already in view
// so, try mark as read;
if (!scrolled) {
tryAutoMarkAsRead();
}
});
return;
}
tryAutoMarkAsRead();
}
},
[tryAutoMarkAsRead, unreadInfo, handleOpenEvent]
)
);
// Handle up arrow edit
useKeyDown(
window,
useCallback(
(evt) => {
if (
isKeyHotkey('arrowup', evt) &&
editableActiveElement() &&
document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' &&
isEmptyEditor(editor)
) {
const editableEvt = getLatestEditableEvt(room.getLiveTimeline(), (mEvt) =>
canEditEvent(mx, mEvt)
);
const editableEvtId = editableEvt?.getId();
if (!editableEvtId) return;
setEditId(editableEvtId);
evt.preventDefault();
}
},
[mx, room, editor]
)
);
useEffect(() => {
if (eventId) {
setTimeline(getEmptyTimeline());
loadEventTimeline(eventId);
}
}, [eventId, loadEventTimeline]);
// Restore return-to-previous when switching rooms (survives remount via cache).
useEffect(() => {
setReturnToEventIdState(getRoomReturnAnchor(room.roomId) ?? null);
}, [room.roomId]);
// Scroll to latest when clicking on already-selected room
useEffect(() => {
if (scrollToLatest) {
clearRoomScrollState(room.roomId);
setLatestUnreadMessage(null);
setUnreadInfo(undefined);
setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
scrollToBottomRef.current.force = true;
}
}, [scrollToLatest, room]);
// Remember scroll position when leaving a room (switch DM, nav away, etc.).
useLayoutEffect(() => {
const roomId = room.roomId;
return () => {
persistTimelineScroll(roomId);
};
}, [room.roomId, persistTimelineScroll]);
// Keep scroll cache updated while reading (useEffect cleanup runs after DOM teardown).
useEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl || eventId) return undefined;
const roomId = room.roomId;
let raf = 0;
const onScroll = () => {
if (restoringScrollRef.current) return;
window.cancelAnimationFrame(raf);
raf = window.requestAnimationFrame(() => {
persistTimelineScroll(roomId);
// Keep Jump to Latest in sync with real scroll position when on the live end.
// IntersectionObserver alone can miss transitions and leave atBottom stuck.
if (atLiveEndRef.current) {
const at = isScrolledToBottom(scrollEl, 100);
setAtBottom(at);
if (at) setLatestUnreadMessage(null);
}
});
};
scrollEl.addEventListener('scroll', onScroll, { passive: true });
return () => {
window.cancelAnimationFrame(raf);
scrollEl.removeEventListener('scroll', onScroll);
};
}, [room.roomId, eventId, persistTimelineScroll]);
// Restore saved scroll or default to bottom on first open.
useLayoutEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl) return undefined;
if (scrollToLatest) {
autoScrollToBottom(scrollEl, false, true);
return undefined;
}
const saved = savedScrollRef.current;
if (!saved) {
autoScrollToBottom(scrollEl, false, true);
return undefined;
}
restoringScrollRef.current = true;
setAtBottom(saved.atBottom);
const restore = () => {
if (saved.atBottom) {
scrollEl.scrollTop = scrollEl.scrollHeight;
} else {
scrollEl.scrollTop = saved.top;
}
};
restore();
const raf1 = window.requestAnimationFrame(() => {
restore();
window.requestAnimationFrame(() => {
restore();
window.setTimeout(() => {
restore();
window.setTimeout(() => {
restoringScrollRef.current = false;
}, 150);
}, 50);
});
});
return () => {
window.cancelAnimationFrame(raf1);
restoringScrollRef.current = false;
};
}, []);
// if live timeline is linked and unreadInfo change
// Scroll to last read message
useLayoutEffect(() => {
const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {};
if (readUptoEventId && inLiveTimeline && scrollTo) {
const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
const evtTimeline = getEventTimeline(room, readUptoEventId);
const absoluteIndex =
evtTimeline && getEventIdAbsoluteIndex(linkedTimelines, evtTimeline, readUptoEventId);
if (absoluteIndex) {
scrollToItem(absoluteIndex, {
behavior: 'instant',
align: 'start',
stopInView: true,
});
}
}
}, [room, unreadInfo, scrollToItem]);
// scroll to focused message
useLayoutEffect(() => {
if (focusItem && focusItem.scrollTo) {
scrollToItem(focusItem.index, {
behavior: 'instant',
align: 'center',
stopInView: true,
});
}
setTimeout(() => {
if (!alive()) return;
setFocusItem((currentItem) => {
if (currentItem === focusItem) return undefined;
return currentItem;
});
}, 2000);
}, [alive, focusItem, scrollToItem]);
// scroll to bottom of timeline
const scrollToBottomCount = scrollToBottomRef.current.count;
useLayoutEffect(() => {
if (scrollToBottomCount > 0 && !restoringScrollRef.current) {
const scrollEl = scrollRef.current;
if (scrollEl) {
autoScrollToBottom(
scrollEl,
scrollToBottomRef.current.smooth,
scrollToBottomRef.current.force
);
if (atLiveEndRef.current) {
setAtBottom(true);
}
}
}
}, [scrollToBottomCount]);
// Remove unreadInfo on mark as read
useEffect(() => {
if (!unread) {
setUnreadInfo(undefined);
}
}, [unread]);
// scroll out of view msg editor in view.
useEffect(() => {
if (editId) {
const editMsgElement =
(scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
undefined;
if (editMsgElement) {
scrollToElement(editMsgElement, {
align: 'center',
behavior: 'smooth',
stopInView: true,
});
}
}
}, [scrollToElement, editId]);
// Setup copy handler for better copy formatting (Name - Time: Message)
useEffect(() => {
const getMessageData = (eventId: string) => {
const timelineSet = room.getUnfilteredTimelineSet();
const evtTimeline = timelineSet.getTimelineForEvent(eventId);
if (!evtTimeline) return null;
const mEvent = evtTimeline.getEvents().find((e) => e.getId() === eventId);
if (!mEvent) return null;
const sender = getMemberDisplayName(room, mEvent.getSender() ?? '')
?? getMxIdLocalPart(mEvent.getSender() ?? '')
?? mEvent.getSender()
?? 'Unknown';
const content = mEvent.getContent().body ?? '';
return {
sender,
ts: mEvent.getTs(),
content,
};
};
return setupCopyHandler(scrollRef, getMessageData, hour24Clock);
}, [room, hour24Clock]);
const handleJumpToLatest = () => {
const anchorEventId =
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
if (anchorEventId) {
setReturnToEventId(anchorEventId);
}
if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true });
}
clearRoomScrollState(room.roomId);
setLatestUnreadMessage(null);
// Already on the live timeline: only snap the virtual window to the end.
// A full getInitialTimeline() rebuild feels like a chat reload when far back.
if (!eventId && liveTimelineLinked) {
setTimeline((ct) => {
const evLength = getTimelinesEventsCount(ct.linkedTimelines);
return {
linkedTimelines: ct.linkedTimelines,
range: {
start: Math.max(evLength - PAGINATION_LIMIT, 0),
end: evLength,
},
};
});
} else {
setTimeline(getInitialTimeline(room));
}
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
scrollToBottomRef.current.force = true;
};
const handleReturnToPrevious = () => {
if (!returnToEventId) return;
const targetId = returnToEventId;
setReturnToEventId(null);
handleOpenEvent(targetId);
};
const handleClearReturnToPrevious = () => {
setReturnToEventId(null);
};
const handleJumpToUnread = () => {
if (unreadInfo?.readUptoEventId) {
setTimeline(getEmptyTimeline());
loadEventTimeline(unreadInfo.readUptoEventId);
}
};
const handleMarkAsRead = () => {
markAsRead(room.roomId, hideActivity);
};
const handleOpenReply: MouseEventHandler = useCallback(
async (evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id');
if (!targetId) return;
handleOpenEvent(targetId);
},
[handleOpenEvent]
);
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
evt.preventDefault();
evt.stopPropagation();
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) {
console.warn('Button should have "data-user-id" attribute!');
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) {
console.warn('Button should have "data-user-id" attribute!');
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, startThread = false) => {
const replyId = evt.currentTarget.getAttribute('data-event-id');
if (!replyId) {
console.warn('Button should have "data-event-id" attribute!');
return;
}
const replyEvt = room.findEventById(replyId);
if (!replyEvt) return;
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content;
const { 'm.relates_to': relation } = startThread
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
: replyEvt.getWireContent();
const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') {
setReplyDraft({
userId: senderId,
eventId: replyId,
body,
formattedBody,
relation,
});
setTimeout(() => ReactEditor.focus(editor), 100);
}
if (startThread) {
setActiveThreadId(replyId);
}
},
[room, setReplyDraft, setActiveThreadId, editor]
);
/** Opens the thread view for the given thread root event ID. */
const handleOpenThread = useCallback(
(threadRootId: string) => {
setActiveThreadId(threadRootId);
},
[setActiveThreadId]
);
const handleReactionToggle = useCallback(
(targetEventId: string, key: string, shortcode?: string) => {
const relations = getEventReactions(room.getUnfilteredTimelineSet(), 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,
MessageEvent.Reaction as any,
getReactionContent(targetEventId, key, rShortcode)
);
},
[mx, room]
);
const handleEdit = useCallback(
(editEvtId?: string) => {
if (editEvtId) {
setEditId(editEvtId);
return;
}
setEditId(undefined);
ReactEditor.focus(editor);
},
[editor]
);
const { t } = useTranslation();
const renderMatrixEvent = useMatrixEventRenderer<
[string, MatrixEvent, number, EventTimelineSet, boolean]
>(
{
[MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight;
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 (
<Message
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
reply={
replyEventId && (
<Reply
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
onOpenThread={handleOpenThread}
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(senderId)}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
/>
)}
</Message>
);
},
[MessageEvent.RoomMessageEncrypted]: (mEventId, mEvent, item, timelineSet, collapse) => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight;
return (
<Message
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
reply={
replyEventId && (
<Reply
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
onOpenThread={handleOpenThread}
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}
>
<EncryptedContent mEvent={mEvent}>
{() => {
if (mEvent.isRedacted()) return <RedactedContent />;
if (mEvent.getType() === MessageEvent.Sticker)
return (
<MSticker
content={mEvent.getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
);
if (mEvent.getType() === MessageEvent.RoomMessage) {
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}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
/>
);
}
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
return (
<Text>
<MessageNotDecryptedContent />
</Text>
);
return (
<Text>
<MessageUnsupportedContent />
</Text>
);
}}
</EncryptedContent>
</Message>
);
},
[MessageEvent.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
const highlighted = focusItem?.index === item && focusItem.highlight;
return (
<Message
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
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}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<MSticker
content={mEvent.getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
)}
</Message>
);
},
[StateEvent.RoomMember]: (mEventId, mEvent, item) => {
const membershipChanged = isMembershipChanged(mEvent);
if (membershipChanged && hideMembershipEvents) return null;
if (!membershipChanged && hideNickAvatarEvents) return null;
const highlighted = focusItem?.index === item && focusItem.highlight;
const parsed = parseMemberEvent(mEvent);
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={parsed.icon}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
{parsed.body}
</Text>
</Box>
}
/>
</Event>
);
},
[StateEvent.RoomName]: (mEventId, mEvent, item) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{t('Organisms.RoomCommon.changed_room_name')}
</Text>
</Box>
}
/>
</Event>
);
},
[StateEvent.RoomTopic]: (mEventId, mEvent, item) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{' changed room topic'}
</Text>
</Box>
}
/>
</Event>
);
},
[StateEvent.RoomAvatar]: (mEventId, mEvent, item) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{' changed room avatar'}
</Text>
</Box>
}
/>
</Event>
);
},
[StateEvent.CallMember]: (mEventId, mEvent, item) => {
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const content = mEvent.getContent();
const prevContent = mEvent.getPrevContent();
// Check current content for active devices
const calls = content['m.calls'];
const hasDevices = Array.isArray(calls) && calls.some((call: { 'm.devices'?: unknown[] }) =>
Array.isArray(call['m.devices']) && call['m.devices'].length > 0
);
// Check previous content for active devices
const prevCalls = prevContent['m.calls'];
const hadDevices = Array.isArray(prevCalls) && prevCalls.some((call: { 'm.devices'?: unknown[] }) =>
Array.isArray(call['m.devices']) && call['m.devices'].length > 0
);
// Determine if this is a join or leave
const isJoining = hasDevices && !hadDevices;
const isLeaving = !hasDevices && hadDevices;
// Skip if no change (refresh events or device updates within same call)
if (!isJoining && !isLeaving) return null;
// Add to pending call events for grouping
// Return a special marker that eventRenderer will recognize
pendingCallEventsRef.current.push({
senderId,
senderName,
isJoining,
ts: mEvent.getTs(),
item,
mEventId,
mEvent,
});
// Return special marker for call event (will be handled by eventRenderer)
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
},
},
(mEventId, mEvent, item) => {
if (!showHiddenEvents) return null;
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Code}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{' sent '}
<code className={customHtmlCss.Code}>{mEvent.getType()}</code>
{' state event'}
</Text>
</Box>
}
/>
</Event>
);
},
(mEventId, mEvent, item) => {
if (!showHiddenEvents) return null;
if (Object.keys(mEvent.getContent()).length === 0) return null;
if (mEvent.getRelation()) return null;
if (mEvent.isRedaction()) return null;
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Code}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{' sent '}
<code className={customHtmlCss.Code}>{mEvent.getType()}</code>
{' event'}
</Text>
</Box>
}
/>
</Event>
);
}
);
let prevEvent: MatrixEvent | undefined;
let isPrevRendered = false;
let newDivider = false;
let dayDivider = false;
const groupedCarouselEventIds = new Set<string>();
// Clear pending call events at the start of each render
pendingCallEventsRef.current = [];
/**
* Renders a summary of accumulated call events
*/
const renderCallEventSummary = (callEvents: CallEventInfo[]): React.ReactNode => {
if (callEvents.length === 0) return null;
// Group by user, tracking the sequence of actions to preserve order
const userActions = new Map<string, {
senderName: string;
firstAction: 'join' | 'leave';
lastAction: 'join' | 'leave';
joins: number;
leaves: number;
}>();
callEvents.forEach((evt) => {
const existing = userActions.get(evt.senderId);
const action = evt.isJoining ? 'join' : 'leave';
if (existing) {
existing.lastAction = action;
if (evt.isJoining) {
existing.joins += 1;
} else {
existing.leaves += 1;
}
} else {
userActions.set(evt.senderId, {
senderName: evt.senderName,
firstAction: action,
lastAction: action,
joins: evt.isJoining ? 1 : 0,
leaves: evt.isJoining ? 0 : 1,
});
}
});
// Build summary text
const summaryParts: string[] = [];
userActions.forEach((actions) => {
const { joins, leaves, senderName, firstAction, lastAction } = actions;
const total = joins + leaves;
if (total === 1) {
// Single action
summaryParts.push(`${senderName} ${firstAction === 'join' ? 'joined' : 'left'}`);
} else if (joins > 0 && leaves > 0) {
// Mixed joins and leaves - describe based on first action and cycles
const startedWith = firstAction === 'join' ? 'joined' : 'left';
const opposite = firstAction === 'join' ? 'left' : 'joined';
const cycles = Math.min(joins, leaves);
if (firstAction === lastAction) {
// Ended on same action as started (odd number of same action)
// e.g., join-leave-join or leave-join-leave
if (cycles === 1) {
summaryParts.push(`${senderName} ${startedWith}, ${opposite}, then ${startedWith} again`);
} else {
summaryParts.push(`${senderName} ${startedWith} and ${opposite} ${cycles} times, then ${startedWith}`);
}
} else {
// Ended on opposite action (equal joins/leaves or started-opposite pattern)
if (cycles === 1) {
summaryParts.push(`${senderName} ${startedWith}, then ${opposite}`);
} else {
summaryParts.push(`${senderName} ${startedWith} and ${opposite} ${cycles} times`);
}
}
} else if (joins > 1) {
summaryParts.push(`${senderName} joined ${joins} times`);
} else if (leaves > 1) {
summaryParts.push(`${senderName} left ${leaves} times`);
}
});
// Use the first event for rendering metadata
const firstEvent = callEvents[0];
const lastEvent = callEvents[callEvents.length - 1];
const timeJSX = (
<Time
ts={lastEvent.ts}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event
key={`call-summary-${firstEvent.mEventId}`}
data-message-item={firstEvent.item}
data-message-id={firstEvent.mEventId}
room={room}
mEvent={firstEvent.mEvent}
highlight={false}
messageSpacing={messageSpacing}
canDelete={false}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Phone}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
{summaryParts.join(', ')}
</Text>
</Box>
}
/>
</Event>
);
};
const renderImageCarouselMessage = (
carouselEvents: CarouselRenderableEvent[],
mEventId: string,
mEvent: MatrixEvent,
item: number,
timelineSet: EventTimelineSet,
collapse: boolean
): React.ReactNode => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const orderedCarouselEvents = [...carouselEvents].sort((a, b) => a.index - b.index);
return (
<Message
key={`carousel-${mEventId}`}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
reply={
replyEventId && (
<Reply
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
onOpenThread={handleOpenThread}
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(senderId)}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<CarouselScroller>
{orderedCarouselEvents.map((carouselEvent) => (
<Box
key={carouselEvent.mEventId}
className={css.CarouselItem}
style={{ width: getCarouselItemWidth(carouselEvent.content) }}
>
<MImage
content={carouselEvent.content}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
outlined={messageLayout === MessageLayout.Bubble}
/>
</Box>
))}
</CarouselScroller>
)}
</Message>
);
};
const eventRenderer = (item: number, index: number, allItems: number[]) => {
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
if (!eventTimeline) return null;
const timelineSet = eventTimeline?.getTimelineSet();
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
const mEventId = mEvent?.getId();
if (!mEvent || !mEventId) return null;
const eventSender = mEvent.getSender();
if (eventSender && ignoredUsersSet.has(eventSender)) {
return null;
}
if (mEvent.isRedacted() && !showHiddenEvents) {
return null;
}
if (!newDivider && readUptoEventIdRef.current) {
newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
}
if (!dayDivider) {
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
}
if (groupedCarouselEventIds.has(mEventId)) {
prevEvent = mEvent;
isPrevRendered = true;
return null;
}
const collapsed =
isPrevRendered &&
!dayDivider &&
(!newDivider || eventSender === mx.getUserId()) &&
prevEvent !== undefined &&
prevEvent.getSender() === eventSender &&
prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
const getEventByItem = (
timelineItem: number
): { event: MatrixEvent; eventId: string } | undefined => {
const [tm, bIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, timelineItem);
if (!tm) return undefined;
const event = getTimelineEvent(tm, getTimelineRelativeIndex(timelineItem, bIndex));
const nextEventId = event?.getId();
if (!event || !nextEventId) return undefined;
return { event, eventId: nextEventId };
};
const carouselMetadata = getCarouselEventMetadata(mEvent);
const carouselEvents: CarouselRenderableEvent[] = [];
if (carouselMetadata && eventSender) {
carouselEvents.push({
mEvent,
mEventId,
item,
content: carouselMetadata.content,
index: carouselMetadata.index,
});
for (let nextIndex = index + 1; nextIndex < allItems.length; nextIndex += 1) {
const nextEvt = getEventByItem(allItems[nextIndex]);
if (!nextEvt) break;
const nextSender = nextEvt.event.getSender();
const nextMeta = getCarouselEventMetadata(nextEvt.event);
if (!nextMeta || nextSender !== eventSender || nextMeta.uuid !== carouselMetadata.uuid) {
break;
}
groupedCarouselEventIds.add(nextEvt.eventId);
carouselEvents.push({
mEvent: nextEvt.event,
mEventId: nextEvt.eventId,
item: allItems[nextIndex],
content: nextMeta.content,
index: nextMeta.index,
});
if (carouselEvents.length >= carouselMetadata.total) {
break;
}
}
}
let rawEventJSX: React.ReactNode = null;
if (!reactionOrEditEvent(mEvent)) {
rawEventJSX =
carouselEvents.length > 1
? renderImageCarouselMessage(carouselEvents, mEventId, mEvent, item, timelineSet, collapsed)
: renderMatrixEvent(
mEvent.getType(),
typeof mEvent.getStateKey() === 'string',
mEventId,
mEvent,
item,
timelineSet,
collapsed
);
}
// Check if this is a call event that should be grouped
const isCallEventPending = rawEventJSX === ('CALL_EVENT_PENDING' as unknown as React.ReactNode);
// Determine if we need to flush pending call events
// Flush when: we hit a non-call event, or this is the last item
const isLastItem = index === allItems.length - 1;
const pendingCallEvents = pendingCallEventsRef.current;
const shouldFlushCallEvents = pendingCallEvents.length > 0 && (!isCallEventPending || isLastItem);
let callSummaryJSX: React.ReactNode = null;
if (shouldFlushCallEvents) {
callSummaryJSX = renderCallEventSummary([...pendingCallEvents]);
pendingCallEventsRef.current = []; // Clear array
}
// If this was a call event marker, don't render it directly
const eventJSX = isCallEventPending ? null : rawEventJSX;
prevEvent = mEvent;
isPrevRendered = !!eventJSX || !!callSummaryJSX;
const newDividerJSX =
newDivider && (eventJSX || callSummaryJSX) && eventSender !== mx.getUserId() ? (
<MessageBase space={messageSpacing}>
<TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
<Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
<Text size="L400">New Messages</Text>
</Badge>
</TimelineDivider>
</MessageBase>
) : null;
const dayDividerJSX =
dayDivider && (eventJSX || callSummaryJSX) ? (
<MessageBase space={messageSpacing}>
<TimelineDivider variant="Surface">
<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>
</TimelineDivider>
</MessageBase>
) : null;
// Handle various combinations of dividers, call summary, and regular events
const hasContent = eventJSX || callSummaryJSX;
const hasDividers = newDividerJSX || dayDividerJSX;
if (hasContent && hasDividers) {
if (newDividerJSX) newDivider = false;
if (dayDividerJSX) dayDivider = false;
return (
<React.Fragment key={mEventId}>
{newDividerJSX}
{dayDividerJSX}
{callSummaryJSX}
{eventJSX}
</React.Fragment>
);
}
if (callSummaryJSX && eventJSX) {
return (
<React.Fragment key={mEventId}>
{callSummaryJSX}
{eventJSX}
</React.Fragment>
);
}
return callSummaryJSX || eventJSX;
};
const showUnreadTop =
!!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
return (
<Box grow="Yes" style={{ position: 'relative' }}>
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
<Box
ref={timelineContentRef}
direction="Column"
justifyContent="End"
data-chatroll=""
style={{
minHeight: '100%',
padding: `${config.space.S600} 0`,
userSelect: 'none',
WebkitUserSelect: 'none',
}}
>
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
<div
style={{
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
}`,
}}
>
<RoomIntro room={room} />
</div>
)}
{(canPaginateBack || !rangeAtStart) &&
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</>
) : (
<>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
))}
{getItems().map(eventRenderer)}
{(!liveTimelineLinked || !rangeAtEnd) &&
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase ref={observeFrontAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</>
) : (
<>
<MessageBase ref={observeFrontAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
))}
{/* 1×1 so IntersectionObserver reliably detects the live bottom */}
<span
ref={atBottomAnchorRef}
aria-hidden
style={{ display: 'block', width: 1, height: 1 }}
/>
</Box>
</Scroll>
{(showUnreadTop || returnToEventId) && (
<TimelineFloat position="Top">
{returnToEventId && (
<>
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowTop} />}
onClick={handleReturnToPrevious}
>
<Text size="L400">Return to Previous</Text>
</Chip>
<IconButton
title="Dismiss"
aria-label="Dismiss return to previous"
size="300"
radii="Pill"
variant="SurfaceVariant"
onClick={handleClearReturnToPrevious}
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</>
)}
{showUnreadTop && (
<>
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.MessageUnread} />}
onClick={handleJumpToUnread}
>
<Text size="L400">Jump to Unread</Text>
</Chip>
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.CheckTwice} />}
onClick={handleMarkAsRead}
>
<Text size="L400">Mark as Read</Text>
</Chip>
</>
)}
</TimelineFloat>
)}
{!atLiveBottom && (
<TimelineFloat position="Bottom">
<Box direction="Column" gap="200" alignItems="Center">
{latestUnreadMessage && (
<Box
style={{
backgroundColor: color.Surface.Container,
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R400,
maxWidth: toRem(400),
border: `1px solid ${color.Surface.ContainerLine}`,
}}
>
<Text size="T300" priority="300" style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
<b>{latestUnreadMessage.sender}:</b> {latestUnreadMessage.content}
</Text>
</Box>
)}
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={handleJumpToLatest}
>
<Text size="L400">Jump to Latest</Text>
</Chip>
</Box>
</TimelineFloat>
)}
</Box>
);
}