Files
cinny/src/app/features/room/RoomTimeline.tsx
T
jaredandClaude Opus 5 22d46a7922
CI / Build & Quality Checks (push) Successful in 1m46s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 11s
CI / Playwright smoke (e2e) (push) Successful in 9m44s
feat(messages): reply quotes show a media thumbnail (#151)
A reply to an image, video or sticker used to quote just the filename.
The quote (timeline) and the composer's reply-draft preview now carry a
36 px thumbnail from the event's own thumbnail, decrypted for E2EE media
via the same hook the gallery uses — never the full-size file. Clicking
still jumps to the original.

useDecryptedMediaUrl and getThumbMxc moved out of MediaGallery into
hooks/ and utils/ so components/message can use them without a cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:27:23 -04:00

2615 lines
94 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.
import React, {
Dispatch,
MouseEventHandler,
RefObject,
SetStateAction,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
Direction,
EventTimeline,
EventTimelineSet,
EventType,
EventTimelineSetHandlerMap,
IContent,
MatrixClient,
MatrixEvent,
MsgType,
RelationType,
Room,
RoomEvent,
RoomEventHandlerMap,
ThreadEvent,
} 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,
Icon,
Icons,
Line,
Scroll,
Text,
as,
color,
config,
toRem,
} from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive';
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
import { getForwardedMeta } from './message/forwardContent';
import {
DefaultPlaceholder,
CompactPlaceholder,
Reply,
ForwardedHeader,
MessageBase,
MessageUnsupportedContent,
Time,
MessageNotDecryptedContent,
RedactedContent,
MSticker,
PollContent,
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,
getMemberName,
getReactionContent,
isMembershipChanged,
reactionOrEditEvent,
} from '../../utils/room';
import { getLastEditDiff } from '../../utils/editDiff';
import { GroupCandidate, GroupPlan, planMediaGroups } from '../../utils/mediaGroups';
import { MediaGroupGrid, RegroupChip } from './message/MediaGroupGrid';
import { Lightbox, toLightboxItems } from './MediaGallery';
import { getThumbMxc } from '../../utils/mediaThumb';
import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, 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 { markAsRead } from '../../utils/notifications';
import { useDebounce } from '../../hooks/useDebounce';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference } from '../../utils/time';
import { formatDayDivider } from '../../utils/formatTimestamp';
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { ThreadSummary } from './thread/ThreadSummary';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import { useStateEvent } from '../../hooks/useStateEvent';
import { RetentionContent, isExpired } from '../../utils/retention';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { RoomMediaLightbox } from './RoomMediaLightbox';
import { Image } from '../../components/media';
import { ImageViewer } from '../../components/image-viewer';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
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 { 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 { EditHistoryModal } from './message/EditHistoryModal';
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>
),
);
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);
};
/** [Gitea #137] Galleries the user asked to see as separate messages (keyed by the group's last event id). */
const separatedGalleries = new Set<string>();
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,
onThreadRedirect: (threadRootId: string) => void,
) => {
const loadEventTimeline = useCallback(
async (eventId: string) => {
const [err, replyEvtTimeline] = await to(
mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId),
);
// Thread events aren't locatable in the main timeline set (getEventTimeline
// returns undefined / no abs index). Best-effort: redirect to the thread panel
// when the fetched event belongs to a thread instead of surfacing an error.
const redirectToThread = () => {
const threadRootId = room.findEventById(eventId)?.threadRootId;
if (threadRootId) {
onThreadRedirect(threadRootId);
return true;
}
return false;
};
if (!replyEvtTimeline) {
if (redirectToThread()) return;
onError(err ?? null);
return;
}
const linkedTimelines = getLinkedTimelines(replyEvtTimeline);
const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
if (absIndex === undefined) {
if (redirectToThread()) return;
onError(err ?? null);
return;
}
onLoad(eventId, linkedTimelines, absIndex);
},
[mx, room, onLoad, onError, onThreadRedirect],
);
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) {
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;
};
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 [perMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const direct = useIsDirectRoom();
// Read positions are computed once in Room.tsx and provided via ReadPositionsContext
// so both RoomTimeline and ThreadTimeline consume the same value (Gitea #38).
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
// [Gitea #137] Re-render after "Show separately" (the Set itself is module-level).
const [, setSeparatedTick] = useState(0);
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 [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
// MSC1763 retention: messages older than this window are hidden from the
// timeline (unless "show hidden events" is on). Reactive so a policy change
// re-renders. `undefined` = no policy.
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const ignoredUsersList = useIgnoredUsers();
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
// Thread summary chips only mount for events that already carry thread data
// (perf: a chip subscribes room-level listeners, so mounting one per rendered
// message would exceed the SDK's emitter cap). This single room-level
// ThreadEvent.New subscription re-renders the timeline once when a brand-new
// thread appears, so the root's chip shows up without unrelated activity.
const [, setThreadNewTick] = useState(0);
useEffect(() => {
const handleThreadNew = () => setThreadNewTick((c) => c + 1);
room.on(ThreadEvent.New, handleThreadNew);
return () => {
room.removeListener(ThreadEvent.New, handleThreadNew);
};
}, [room]);
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 [editId, setEditId] = useState<string>();
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
// [Gitea #219] Timeline images open the shared media lightbox at that event.
const [lightboxEventId, setLightboxEventId] = useState<string | undefined>();
// [Gitea #137] Opened from a gallery grid: the viewer walks that group in send order.
const [lightboxGroup, setLightboxGroup] = useState<MatrixEvent[] | undefined>();
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 imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
const readUptoEventIdRef = useRef<string | undefined>(undefined);
if (unreadInfo) {
readUptoEventIdRef.current = unreadInfo.readUptoEventId;
}
const atBottomAnchorRef = useRef<HTMLElement>(null);
const [atBottom, setAtBottom] = useState<boolean>(true);
const atBottomRef = useRef(atBottom);
atBottomRef.current = atBottom;
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottomRef = useRef({
count: 0,
smooth: true,
});
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>(() =>
eventId ? getEmptyTimeline() : getInitialTimeline(room),
);
const timelineRef = React.useRef(timeline);
timelineRef.current = timeline;
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
// Perf-5: precompute base offsets once per linkedTimelines change instead of O(N×T) scan
const timelineSegments = useMemo<Array<[number, number, EventTimeline]>>(() => {
let base = 0;
return timeline.linkedTimelines.map((t) => {
const len = t.getEvents().length;
const seg: [number, number, EventTimeline] = [base, len, t];
base += len;
return seg;
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- eventsLength detects in-place timeline mutations
}, [timeline.linkedTimelines, eventsLength]);
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;
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;
}, [alive, room]),
useCallback(
(threadRootId: string) => {
if (!alive()) return;
setActiveThreadId(threadRootId);
},
[alive, setActiveThreadId],
),
);
useLiveEventArrive(
room,
useCallback(
(mEvt: MatrixEvent) => {
// if user is at bottom of timeline
// keep paginating timeline and conditionally mark as read
// otherwise we update timeline without paginating
// so timeline can be updated with evt like: edits, reactions etc
if (atBottomRef.current) {
if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
// Check if the document is in focus (user is actively viewing the app),
// and either there are no unread messages or the latest message is from the current user.
// If either condition is met, trigger the markAsRead function to send a read receipt.
const _roomId = mEvt.getRoomId();
if (_roomId) requestAnimationFrame(() => markAsRead(mx, _roomId, hideActivity));
}
if (!document.hasFocus() && !unreadInfo) {
setUnreadInfo(getRoomUnreadInfo(room));
}
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
setTimeline((ct) => ({
...ct,
range: {
start: ct.range.start + 1,
end: ct.range.end + 1,
},
}));
return;
}
// [Gitea #212] Your OWN new message always comes into view — that is
// what pressing Send means — even if the at-bottom flag went stale
// (in-place growth like a poll gaining a row briefly pushes the anchor
// out of the viewport) or you had scrolled up to read.
const ownNewMessage =
mEvt.getSender() === mx.getUserId() &&
!mEvt.isRelation() &&
!mEvt.isRedaction() &&
!mEvt.isState() &&
mEvt.getType() !== MessageEvent.Reaction;
if (ownNewMessage) {
if (atLiveEndRef.current) {
setAtBottom(true);
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
setTimeline((ct) => ({
...ct,
range: { start: ct.range.start + 1, end: ct.range.end + 1 },
}));
} else {
// Paginated back into history: jump to the live end like the
// "Jump to Latest" button does.
setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
}
return;
}
setTimeline((ct) => ({ ...ct }));
if (!unreadInfo) {
setUnreadInfo(getRoomUnreadInfo(room));
}
},
[mx, room, unreadInfo, hideActivity],
),
);
const handleOpenEvent = useCallback(
async (
evtId: string,
highlight = true,
onScroll: ((scrolled: boolean) => void) | undefined = undefined,
) => {
const evtTimeline = getEventTimeline(room, evtId);
const absoluteIndex =
evtTimeline &&
getEventIdAbsoluteIndex(timelineRef.current.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, 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 (atBottomRef.current) {
scrollToBottom(scrollElement);
}
};
}, [getScrollElement, roomInputRef]),
useCallback(() => roomInputRef.current, [roomInputRef]),
);
const tryAutoMarkAsRead = useCallback(() => {
const readUptoEventId = readUptoEventIdRef.current;
if (!readUptoEventId) {
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
return;
}
const evtTimeline = getEventTimeline(room, readUptoEventId);
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
if (latestTimeline === room.getLiveTimeline()) {
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
}
}, [mx, room, hideActivity]);
const debounceSetAtBottom = useDebounce(
useCallback((entry: IntersectionObserverEntry) => {
if (!entry.isIntersecting) setAtBottom(false);
}, []),
{ wait: 1000 },
);
useIntersectionObserver(
useCallback(
(entries) => {
const target = atBottomAnchorRef.current;
if (!target) return;
const targetEntry = getIntersectionObserverEntry(target, entries);
if (targetEntry) debounceSetAtBottom(targetEntry);
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
setAtBottom(true);
if (document.hasFocus()) {
tryAutoMarkAsRead();
}
}
},
[debounceSetAtBottom, 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],
),
);
// [Gitea #127] After a jump (reply quote, thread → main, search result) the
// reader is stranded far up the history; remember the message that was in
// the middle of the viewport and offer "Back to where you were".
const [returnPoint, setReturnPoint] = useState<{ eventId: string; at: number } | null>(null);
const captureReturnPoint = useCallback(() => {
const scroller = scrollRef.current;
if (!scroller) return;
const mid = scroller.getBoundingClientRect().top + scroller.clientHeight / 2;
const items = Array.from(scroller.querySelectorAll<HTMLElement>('[data-message-id]'));
const hit =
items.find((el) => {
const r = el.getBoundingClientRect();
return r.top <= mid && r.bottom >= mid;
}) ?? items[items.length - 1];
const id = hit?.getAttribute('data-message-id');
if (id) setReturnPoint({ eventId: id, at: Date.now() });
}, []);
// Route-driven jumps (thread panel / search / permalink): capture before the
// timeline is emptied by the effect below (effects run in declaration order).
const prevEventIdRef = useRef(eventId);
useEffect(() => {
const prev = prevEventIdRef.current;
prevEventIdRef.current = eventId;
if (eventId && eventId !== prev && prev === undefined) captureReturnPoint();
}, [eventId, captureReturnPoint]);
// Expire after 20 s.
useEffect(() => {
if (!returnPoint) return undefined;
const t = window.setTimeout(() => setReturnPoint(null), 20_000);
return () => window.clearTimeout(t);
}, [returnPoint]);
const handleReturn = useCallback(() => {
if (!returnPoint) return;
const target = returnPoint.eventId;
setReturnPoint(null);
if (eventId) navigateRoom(room.roomId, target);
else handleOpenEvent(target, false);
}, [returnPoint, eventId, navigateRoom, room.roomId, handleOpenEvent]);
useEffect(() => {
if (eventId) {
setTimeline(getEmptyTimeline());
loadEventTimeline(eventId);
}
}, [eventId, loadEventTimeline]);
// Scroll to bottom on initial timeline load
useLayoutEffect(() => {
const scrollEl = scrollRef.current;
if (scrollEl) {
scrollToBottom(scrollEl);
}
}, []);
// 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 (typeof absoluteIndex === 'number') {
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,
});
}
const timer = setTimeout(() => {
if (!alive()) return;
setFocusItem((currentItem) => {
if (currentItem === focusItem) return undefined;
return currentItem;
});
}, 2000);
return () => clearTimeout(timer);
}, [alive, focusItem, scrollToItem]);
// scroll to bottom of timeline
const scrollToBottomCount = scrollToBottomRef.current.count;
useLayoutEffect(() => {
if (scrollToBottomCount > 0) {
const scrollEl = scrollRef.current;
if (scrollEl)
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
}
}, [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]);
const handleJumpToLatest = useCallback(() => {
if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true });
}
setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
}, [eventId, navigateRoom, room]);
const handleJumpToUnread = useCallback(() => {
if (unreadInfo?.readUptoEventId) {
setTimeline(getEmptyTimeline());
loadEventTimeline(unreadInfo.readUptoEventId);
}
}, [unreadInfo, loadEventTimeline]);
const handleMarkAsRead = useCallback(() => {
markAsRead(mx, room.roomId, hideActivity);
}, [mx, room, hideActivity]);
const handleOpenReply: MouseEventHandler = useCallback(
async (evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id');
if (!targetId) return;
captureReturnPoint();
handleOpenEvent(targetId);
},
[handleOpenEvent, captureReturnPoint],
);
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 = getMemberName(room, 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;
}
if (startThread) {
// Open the thread panel instead of arming an m.thread reply in the main composer.
setActiveThreadId(replyId);
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 } = replyEvt.getWireContent();
const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') {
setReplyDraft({
userId: senderId,
eventId: replyId,
body,
formattedBody,
relation,
});
setTimeout(() => ReactEditor.focus(editor), 100);
}
},
[room, setReplyDraft, setActiveThreadId, editor],
);
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,
MatrixEvent[] | undefined,
string | undefined,
]
>(
{
[MessageEvent.RoomMessage]: (
mEventId,
mEvent,
item,
timelineSet,
collapse,
mediaGroup,
regroupId,
) => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent;
const forwardedMeta = getForwardedMeta(mEvent.getContent());
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 = getMemberName(room, 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 || (canDeleteOwn && 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={
<>
{forwardedMeta && (
<ForwardedHeader
mx={mx}
meta={forwardedMeta}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
onJump={() => navigateRoom(forwardedMeta.room_id, forwardedMeta.event_id)}
/>
)}
{replyEventId && (
<Reply
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
onThreadClick={setActiveThreadId}
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}
/>
)}
{(!threadRootId || threadRootId === mEventId) &&
(mEvent.getThread() !== undefined ||
mEvent.getServerAggregatedRelation(RelationType.Thread) !== undefined) && (
<ThreadSummary rootEvent={mEvent} room={room} onOpen={setActiveThreadId} />
)}
</>
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(senderId)}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
lotusTerminal={!!lotusTerminal}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : mediaGroup ? (
<MediaGroupGrid
events={mediaGroup}
mediaAutoLoad={mediaAutoLoad}
onOpen={(id) => {
setLightboxGroup(mediaGroup);
setLightboxEventId(id);
}}
onShowSeparately={() => {
separatedGalleries.add(mEventId);
setSeparatedTick((n) => n + 1);
}}
/>
) : (
<>
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
mEvent={mEvent}
/>
{regroupId && (
<RegroupChip
onClick={() => {
separatedGalleries.delete(regroupId);
setSeparatedTick((n) => n + 1);
}}
/>
)}
</>
)}
</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 forwardedMeta = getForwardedMeta(mEvent.getContent());
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 || (canDeleteOwn && 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={
<>
{forwardedMeta && (
<ForwardedHeader
mx={mx}
meta={forwardedMeta}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
onJump={() => navigateRoom(forwardedMeta.room_id, forwardedMeta.event_id)}
/>
)}
{replyEventId && (
<Reply
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
onThreadClick={setActiveThreadId}
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}
/>
)}
{(!threadRootId || threadRootId === mEventId) &&
(mEvent.getThread() !== undefined ||
mEvent.getServerAggregatedRelation(RelationType.Thread) !== undefined) && (
<ThreadSummary rootEvent={mEvent} room={room} onOpen={setActiveThreadId} />
)}
</>
}
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 = getMemberName(room, senderId);
return (
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
onEditHistoryClick={
editedEvent ? () => setEditHistoryEvent(mEvent) : undefined
}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
mEvent={mEvent}
/>
);
}
if (
mEvent.getType() === 'm.poll.start' ||
mEvent.getType() === 'org.matrix.msc3381.poll.start'
)
return <PollContent mEvent={mEvent} room={room} canRedact={canRedact} />;
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 || (canDeleteOwn && 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>
);
},
'org.matrix.msc3381.poll.start': (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 || (canDeleteOwn && 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} />
) : (
<PollContent mEvent={mEvent} room={room} canRedact={canRedact} />
)}
</Message>
);
},
'm.poll.start': (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 || (canDeleteOwn && 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} />
) : (
<PollContent mEvent={mEvent} room={room} canRedact={canRedact} />
)}
</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.RoomEncryption]: (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.Lock}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{' enabled end-to-end encryption'}
</Text>
</Box>
}
/>
</Event>
);
},
[StateEvent.RoomJoinRules]: (mEventId, mEvent, item) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const joinRule = mEvent.getContent<{ join_rule?: string }>().join_rule ?? 'unknown';
const ruleLabel: Record<string, string> = {
public: 'public',
invite: 'invite-only',
knock: 'knock',
restricted: 'restricted',
};
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.Setting}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{` set room join rule to ${ruleLabel[joinRule] ?? joinRule}`}
</Text>
</Box>
}
/>
</Event>
);
},
[StateEvent.RoomGuestAccess]: (mEventId, mEvent, item) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const access = mEvent.getContent<{ guest_access?: string }>().guest_access ?? 'unknown';
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.Setting}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{access === 'can_join' ? ' allowed guest access' : ' disabled guest access'}
</Text>
</Box>
}
/>
</Event>
);
},
[StateEvent.RoomCanonicalAlias]: (mEventId, mEvent, item) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const { alias } = mEvent.getContent<{ alias?: string }>();
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>
{alias ? ` set room address to ${alias}` : ' removed room address'}
</Text>
</Box>
}
/>
</Event>
);
},
// [Gitea #162] A ring (m.rtc.notification) that we neither answered nor
// declined, and whose ring window has passed, renders as a local-only
// "Missed call from X" line. Derived from events already in the
// timeline; nothing is sent. Disappears if our own call membership
// follows within the ring window (we picked up).
[EventType.RTCNotification]: (mEventId, mEvent, item) => {
const senderId = mEvent.getSender() ?? '';
if (!senderId || senderId === mx.getUserId()) return null;
const content = mEvent.getContent();
const mention =
content['m.mentions']?.room ||
content['m.mentions']?.user_ids?.includes(mx.getSafeUserId());
if (!mention) return null;
const lifetime = Math.min(
typeof content.lifetime === 'number' ? content.lifetime : 30000,
120000,
);
const liveEvents = room.getLiveTimeline().getEvents();
const memberEventsAfter = liveEvents.filter(
(ev) => ev.getType() === StateEvent.GroupCallMemberPrefix && ev.getTs() >= mEvent.getTs(),
);
// The ring is over once its window lapsed or the caller hung up.
const callerEndTs = memberEventsAfter
.find((ev) => ev.getSender() === senderId && !ev.getContent().application)
?.getTs();
if (callerEndTs === undefined && Date.now() < mEvent.getTs() + lifetime) return null;
// "Answered" = our own join between the ring and the caller hanging up
// (a later, separate call must not retro-actively answer this one).
const windowEnd = Math.min(
mEvent.getTs() + lifetime + 60_000,
callerEndTs !== undefined ? callerEndTs + 5_000 : Infinity,
);
const answered = memberEventsAfter.some(
(ev) =>
ev.getSender() === mx.getUserId() &&
ev.getTs() <= windowEnd &&
!!ev.getContent().application,
);
if (answered) return null;
const highlighted = focusItem?.index === item && focusItem.highlight;
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={false}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.PhoneDown}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" style={{ color: color.Critical.Main }}>
Missed {content['m.call.intent'] === 'video' ? 'video ' : ''}call from{' '}
<b>{senderName}</b>
</Text>
</Box>
}
/>
</Event>
);
},
[StateEvent.GroupCallMemberPrefix]: (mEventId, mEvent, item) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const content = mEvent.getContent();
const prevContent = mEvent.getPrevContent();
const callJoined = content.application;
if (callJoined && 'application' in prevContent) {
return null;
}
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={callJoined ? Icons.Phone : Icons.PhoneDown}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{callJoined ? ' joined the call' : ' ended the call'}
</Text>
</Box>
}
/>
</Event>
);
},
},
(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;
// Perf-5: O(T) → O(log T) via precomputed segments
const resolveItem = (
item: number,
): { eventTimeline: EventTimeline; baseIndex: number } | undefined => {
let lo = 0;
let hi = timelineSegments.length - 1;
while (lo <= hi) {
// eslint-disable-next-line no-bitwise
const mid = (lo + hi) >>> 1;
const [base, len] = timelineSegments[mid];
if (item < base) {
hi = mid - 1;
} else if (item >= base + len) {
lo = mid + 1;
} else {
return { eventTimeline: timelineSegments[mid][2], baseIndex: base };
}
}
return undefined;
};
const eventAt = (
item: number,
): { mEvent: MatrixEvent; timelineSet: EventTimelineSet } | undefined => {
const seg = resolveItem(item);
if (!seg) return undefined;
const mEvent = getTimelineEvent(
seg.eventTimeline,
getTimelineRelativeIndex(item, seg.baseIndex),
);
return mEvent ? { mEvent, timelineSet: seg.eventTimeline.getTimelineSet() } : undefined;
};
// [Gitea #137] Gallery grouping is planned lazily per render pass: the first
// media event we meet plans its whole run (looking both ways, so a virtual
// window that starts mid-run still agrees), and the plan is reused for the
// run's other members.
const groupPlans = new Map<number, GroupPlan | null>();
const candidateAt = (index: number): GroupCandidate | undefined => {
const found = eventAt(index);
if (!found) return undefined;
const { mEvent: ev, timelineSet } = found;
const sender = ev.getSender() ?? '';
const base = { sender, ts: ev.getTs(), hasRelation: false, redacted: false, mustEnd: false };
if (
reactionOrEditEvent(ev) ||
ev.getType() === 'm.room.redaction' ||
ignoredUsersSet.has(sender)
)
return { ...base, kind: 'skip' };
if (ev.getType() === StateEvent.RoomMember && hideMembershipEvents)
return { ...base, kind: 'skip' };
const msgtype = ev.getContent().msgtype;
const isMedia =
ev.getType() === MessageEvent.RoomMessage &&
(msgtype === MsgType.Image || msgtype === MsgType.Video) &&
!!getThumbMxc(ev);
if (!isMedia) return { ...base, kind: 'other' };
const id = ev.getId() ?? '';
const reactions = getEventReactions(timelineSet, id)?.getSortedAnnotationsByKey();
const hasThread =
ev.getThread() !== undefined ||
ev.getServerAggregatedRelation(RelationType.Thread) !== undefined;
return {
...base,
kind: 'media',
hasRelation: !!ev.getContent()['m.relates_to'],
redacted: ev.isRedacted(),
mustEnd: (reactions?.length ?? 0) > 0 || hasThread,
};
};
const mediaGroupFor = (
item: number,
): { hidden: boolean; events?: MatrixEvent[]; regroup?: string } | undefined => {
if (!groupPlans.has(item)) {
if (candidateAt(item)?.kind !== 'media') return undefined;
const plans = planMediaGroups(candidateAt, item);
plans.forEach((plan, index) => groupPlans.set(index, plan));
if (!plans.has(item)) groupPlans.set(item, null);
}
const plan = groupPlans.get(item);
if (!plan) return undefined;
const lastId = eventAt(plan.members[plan.members.length - 1])?.mEvent.getId() ?? '';
if (separatedGalleries.has(lastId))
return plan.renders ? { hidden: false, regroup: lastId } : undefined;
if (!plan.renders) return { hidden: true };
const events = plan.members
.map((index) => eventAt(index)?.mEvent)
.filter((ev): ev is MatrixEvent => !!ev);
return { hidden: false, events };
};
const eventRenderer = (item: number) => {
const resolved = resolveItem(item);
if (!resolved) return null;
const { eventTimeline, baseIndex } = resolved;
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;
}
// MSC1763: hide messages past the room's retention window (disappearing
// messages). Power users can still inspect via "show hidden events".
if (retentionMs && !showHiddenEvents && isExpired(mEvent.getTs(), retentionMs, Date.now())) {
return null;
}
if (mEvent.isRedacted() && !showHiddenEvents) {
// eslint-disable-next-line @typescript-eslint/no-shadow
const t = mEvent.getType();
if (
t !== MessageEvent.RoomMessage &&
t !== MessageEvent.RoomMessageEncrypted &&
t !== MessageEvent.Sticker
) {
return null;
}
}
if (!newDivider && readUptoEventIdRef.current) {
newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
}
if (!dayDivider) {
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
}
const collapsed =
!perMessageProfiles &&
isPrevRendered &&
!dayDivider &&
(!newDivider || eventSender === mx.getUserId()) &&
prevEvent !== undefined &&
prevEvent.getSender() === eventSender &&
prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
const mediaGroup = mediaGroupFor(item);
const eventJSX =
reactionOrEditEvent(mEvent) || mediaGroup?.hidden
? null
: renderMatrixEvent(
mEvent.getType(),
typeof mEvent.getStateKey() === 'string',
mEventId,
mEvent,
item,
timelineSet,
collapsed,
mediaGroup?.events,
mediaGroup?.regroup,
);
prevEvent = mEvent;
isPrevRendered = !!eventJSX;
const newDividerJSX =
newDivider && eventJSX && 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 ? (
<MessageBase space={messageSpacing}>
<TimelineDivider variant="Surface">
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
<Text size="L400">
{formatDayDivider(mEvent.getTs(), { hour24Clock, dateFormatString })}
</Text>
</Badge>
</TimelineDivider>
</MessageBase>
) : null;
if (eventJSX && (newDividerJSX || dayDividerJSX)) {
if (newDividerJSX) newDivider = false;
if (dayDividerJSX) dayDivider = false;
return (
<React.Fragment key={mEventId}>
{newDividerJSX}
{dayDividerJSX}
{eventJSX}
</React.Fragment>
);
}
return eventJSX;
};
return (
<>
<Box grow="Yes" style={{ position: 'relative' }}>
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
<TimelineFloat position="Top">
<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>
)}
<Scroll ref={scrollRef} visibility="Hover">
<Box
direction="Column"
justifyContent="End"
role="log"
aria-label="Message timeline"
aria-live="polite"
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
>
{!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>
</>
))}
<span ref={atBottomAnchorRef} />
</Box>
</Scroll>
{(!atBottom || returnPoint) && (
<TimelineFloat position="Bottom">
<Box gap="200" alignItems="Center">
{returnPoint && (
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={handleReturn}
>
<Text size="L400">Back to where you were</Text>
</Chip>
)}
{!atBottom && (
<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>
{editHistoryEvent && (
<EditHistoryModal
room={room}
mEvent={editHistoryEvent}
onClose={() => setEditHistoryEvent(undefined)}
/>
)}
{lightboxEventId && lightboxGroup && (
<Lightbox
items={toLightboxItems(room, lightboxGroup)}
initialIndex={Math.max(
0,
lightboxGroup.findIndex((ev) => ev.getId() === lightboxEventId),
)}
useAuthentication={useAuthentication}
onClose={() => {
setLightboxEventId(undefined);
setLightboxGroup(undefined);
}}
onJump={(id) => {
setLightboxEventId(undefined);
setLightboxGroup(undefined);
navigateRoom(room.roomId, id);
}}
/>
)}
{lightboxEventId && !lightboxGroup && (
<RoomMediaLightbox
room={room}
eventId={lightboxEventId}
onClose={() => setLightboxEventId(undefined)}
/>
)}
</>
);
}