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

2633 lines
94 KiB
TypeScript
Raw Normal View History

2023-10-06 13:44:06 +11:00
import React, {
Dispatch,
MouseEventHandler,
RefObject,
SetStateAction,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
Direction,
EventTimeline,
EventTimelineSet,
EventType,
2023-10-06 13:44:06 +11:00
EventTimelineSetHandlerMap,
2024-08-15 16:52:32 +02:00
IContent,
2023-10-06 13:44:06 +11:00
MatrixClient,
MatrixEvent,
MsgType,
RelationType,
2023-10-06 13:44:06 +11:00
Room,
RoomEvent,
RoomEventHandlerMap,
ThreadEvent,
2023-10-06 13:44:06 +11:00
} from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
2023-10-06 13:44:06 +11:00
import classNames from 'classnames';
import { ReactEditor } from 'slate-react';
import { Editor } from 'slate';
import to from 'await-to-js';
2024-07-08 16:57:10 +05:30
import { useAtomValue, useSetAtom } from 'jotai';
2023-10-06 13:44:06 +11:00
import {
Badge,
Box,
Chip,
ContainerColor,
Icon,
Icons,
Line,
Scroll,
Text,
as,
color,
config,
toRem,
} from 'folds';
2023-10-21 18:14:33 +11:00
import { isKeyHotkey } from 'is-hotkey';
2024-07-30 17:48:59 +05:30
import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next';
2024-09-11 17:07:02 +10:00
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
2023-10-06 13:44:06 +11:00
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive';
2023-10-14 16:08:43 +11:00
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
import { getForwardedMeta } from './message/forwardContent';
2023-10-06 13:44:06 +11:00
import {
DefaultPlaceholder,
CompactPlaceholder,
Reply,
ForwardedHeader,
2023-10-06 13:44:06 +11:00
MessageBase,
MessageUnsupportedContent,
Time,
MessageNotDecryptedContent,
RedactedContent,
MSticker,
PollContent,
ImageContent,
EventContent,
2023-10-06 13:44:06 +11:00
} from '../../components/message';
2024-07-30 17:48:59 +05:30
import {
factoryRenderLinkifyWithMention,
getReactCustomHtmlParser,
LINKIFY_OPTS,
makeMentionCustomProps,
renderMatrixMention,
} from '../../plugins/react-custom-html-parser';
2023-10-06 13:44:06 +11:00
import {
2023-10-14 16:08:43 +11:00
canEditEvent,
2023-10-06 13:44:06 +11:00
decryptAllTimelineEvent,
2023-10-14 16:08:43 +11:00
getEditedEvent,
getEventReactions,
getLatestEditableEvt,
2023-10-06 13:44:06 +11:00
getMemberDisplayName,
getMemberName,
2023-10-06 13:44:06 +11:00
getReactionContent,
isMembershipChanged,
2023-10-23 21:43:07 +11:00
reactionOrEditEvent,
sendRoomEvent,
2023-10-06 13:44:06 +11:00
} from '../../utils/room';
import { getLastEditDiff } from '../../utils/editDiff';
import { tick } from '../../utils/haptics';
import { GroupCandidate, GroupPlan, planMediaGroups } from '../../utils/mediaGroups';
import { MediaGroupGrid, RegroupChip } from './message/MediaGroupGrid';
import { Lightbox, toLightboxItems } from './MediaGallery';
import { getThumbMxc } from '../../utils/mediaThumb';
2023-10-06 13:44:06 +11:00
import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../state/settings';
2023-10-06 13:44:06 +11:00
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { Reactions, Message, Event, EncryptedContent } from './message';
2023-10-06 13:44:06 +11:00
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, MarkAsReadOptions } from '../../utils/notifications';
2023-10-06 13:44:06 +11:00
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';
2023-10-14 16:08:43 +11:00
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { ThreadSummary } from './thread/ThreadSummary';
2025-08-12 19:42:30 +05:30
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import { useStateEvent } from '../../hooks/useStateEvent';
import { RetentionContent, isExpired } from '../../utils/retention';
2023-10-14 16:08:43 +11:00
import { useKeyDown } from '../../hooks/useKeyDown';
2023-10-21 18:14:21 +11:00
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';
2024-07-08 16:57:10 +05:30
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
2024-07-30 17:48:59 +05:30
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
2024-09-09 18:45:20 +10:00
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsDirectRoom } from '../../hooks/useRoom';
2025-08-09 17:46:10 +05:30
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
2025-08-12 19:42:30 +05:30
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';
2023-10-06 13:44:06 +11:00
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}
/>
),
2023-10-06 13:44:06 +11:00
);
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>
),
2023-10-06 13:44:06 +11:00
);
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,
2023-10-06 13:44:06 +11:00
): 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>();
2023-10-06 13:44:06 +11:00
export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
2023-10-07 18:19:01 +11:00
const timelines: EventTimeline[] = [];
2023-10-06 13:44:06 +11:00
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,
2023-10-06 13:44:06 +11:00
): [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,
2023-10-06 13:44:06 +11:00
): 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,
2023-10-06 13:44:06 +11:00
) => {
const loadEventTimeline = useCallback(
async (eventId: string) => {
const [err, replyEvtTimeline] = await to(
mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId),
2023-10-06 13:44:06 +11:00
);
// 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;
};
2023-10-06 13:44:06 +11:00
if (!replyEvtTimeline) {
if (redirectToThread()) return;
2023-10-06 13:44:06 +11:00
onError(err ?? null);
return;
}
const linkedTimelines = getLinkedTimelines(replyEvtTimeline);
const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
if (absIndex === undefined) {
if (redirectToThread()) return;
2023-10-06 13:44:06 +11:00
onError(err ?? null);
return;
}
onLoad(eventId, linkedTimelines, absIndex);
},
[mx, room, onLoad, onError, onThreadRedirect],
2023-10-06 13:44:06 +11:00
);
return loadEventTimeline;
};
const useTimelinePagination = (
mx: MatrixClient,
timeline: Timeline,
setTimeline: Dispatch<SetStateAction<Timeline>>,
limit: number,
2023-10-06 13:44:06 +11:00
) => {
const timelineRef = useRef(timeline);
timelineRef.current = timeline;
const alive = useAlive();
const handleTimelinePagination = useMemo(() => {
let fetching = false;
const recalibratePagination = (
linkedTimelines: EventTimeline[],
timelinesEventsCount: number[],
backwards: boolean,
2023-10-06 13:44:06 +11:00
) => {
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
? {
2024-09-11 17:07:02 +10:00
start: currentTimeline.range.start + offsetRange,
end: currentTimeline.range.end + offsetRange,
}
2023-10-06 13:44:06 +11:00
: { ...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,
2023-10-06 13:44:06 +11:00
);
if (
!paginationToken &&
getTimelinesEventsCount(lTimelines) !==
2024-09-11 17:07:02 +10:00
getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
2023-10-06 13:44:06 +11:00
) {
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
return;
}
fetching = true;
const [err] = await to(
mx.paginateEventTimeline(timelineToPaginate, {
backwards,
limit,
}),
2023-10-06 13:44:06 +11:00
);
if (err) {
fetching = false;
2023-10-06 13:44:06 +11:00
return;
}
const fetchedTimeline =
timelineToPaginate.getNeighbouringTimeline(
backwards ? Direction.Backward : Direction.Forward,
2023-10-06 13:44:06 +11:00
) ?? timelineToPaginate;
// Decrypt all event ahead of render cycle
const roomId = fetchedTimeline.getRoomId();
const room = roomId ? mx.getRoom(roomId) : null;
if (room?.hasEncryptionStateEvent()) {
2023-10-06 13:44:06 +11:00
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,
2023-10-06 13:44:06 +11:00
) => {
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,
};
};
2025-08-12 19:42:30 +05:30
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
2023-10-06 13:44:06 +11:00
const mx = useMatrixClient();
2024-09-09 18:45:20 +10:00
const useAuthentication = useMediaAuthentication();
2025-02-26 21:44:53 +11:00
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
2023-10-06 13:44:06 +11:00
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
const [perMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
2023-10-06 13:44:06 +11:00
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).
2023-10-06 13:44:06 +11:00
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
const [hapticFeedback] = useSetting(settingsAtom, 'hapticFeedback');
// [Gitea #137] Re-render after "Show separately" (the Set itself is module-level).
const [, setSeparatedTick] = useState(0);
2023-10-06 13:44:06 +11:00
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
2023-10-30 07:14:58 +11:00
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
2023-10-06 13:44:06 +11:00
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;
2025-06-28 12:35:59 +02:00
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]);
2023-10-06 13:44:06 +11:00
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
// [Gitea #217] Reads from just viewing the timeline are passive: threads the
// user follows stay unread, and the open thread panel sends its own receipts.
const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(room.roomId));
const passiveReadRef = useRef<MarkAsReadOptions>({ passive: true });
passiveReadRef.current = { passive: true, openThreadId: activeThreadId ?? undefined };
// 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();
2025-08-12 19:42:30 +05:30
const creators = useRoomCreators(room);
2025-08-12 19:42:30 +05:30
const creatorsTag = useRoomCreatorsTag();
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const theme = useTheme();
const accessiblePowerTagColors = useAccessiblePowerTagColors(
theme.kind,
creatorsTag,
powerLevelTags,
2025-08-12 19:42:30 +05:30
);
const permissions = useRoomPermissions(creators, powerLevels);
const canRedact = permissions.action('redact', mx.getSafeUserId());
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
2025-08-12 19:42:30 +05:30
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
2023-10-14 16:08:43 +11:00
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>();
2024-07-08 16:57:10 +05:30
const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
2024-07-30 17:48:59 +05:30
const { navigateRoom } = useRoomNavigate();
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
2025-08-09 17:46:10 +05:30
const openUserRoomProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
2023-10-06 13:44:06 +11:00
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
2023-10-06 13:44:06 +11:00
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
const readUptoEventIdRef = useRef<string | undefined>(undefined);
2023-10-06 13:44:06 +11:00
if (unreadInfo) {
readUptoEventIdRef.current = unreadInfo.readUptoEventId;
}
const atBottomAnchorRef = useRef<HTMLElement>(null);
2023-10-19 17:40:01 +11:00
const [atBottom, setAtBottom] = useState<boolean>(true);
2023-10-06 13:44:06 +11:00
const atBottomRef = useRef(atBottom);
atBottomRef.current = atBottom;
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottomRef = useRef({
count: 0,
smooth: true,
});
const [focusItem, setFocusItem] = useState<
| {
2024-09-11 17:07:02 +10:00
index: number;
scrollTo: boolean;
highlight: boolean;
}
| undefined
>();
2023-10-06 13:44:06 +11:00
const alive = useAlive();
2024-07-30 17:48:59 +05:30
const linkifyOpts = useMemo<LinkifyOpts>(
() => ({
...LINKIFY_OPTS,
render: factoryRenderLinkifyWithMention((href) =>
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)),
2024-07-30 17:48:59 +05:30
),
}),
[mx, room, mentionClickHandler],
2024-07-30 17:48:59 +05:30
);
2023-10-06 13:44:06 +11:00
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
2024-07-30 17:48:59 +05:30
getReactCustomHtmlParser(mx, room.roomId, {
linkifyOpts,
2024-09-07 21:45:55 +08:00
useAuthentication,
2024-07-30 17:48:59 +05:30
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
2023-10-06 13:44:06 +11:00
}),
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication],
2023-10-06 13:44:06 +11:00
);
const parseMemberEvent = useMemberEventParser();
const [timeline, setTimeline] = useState<Timeline>(() =>
eventId ? getEmptyTimeline() : getInitialTimeline(room),
2023-10-06 13:44:06 +11:00
);
const timelineRef = React.useRef(timeline);
timelineRef.current = timeline;
2023-10-06 13:44:06 +11:00
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]);
2023-10-06 13:44:06 +11:00
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;
2023-10-19 17:40:01 +11:00
const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
2023-10-06 13:44:06 +11:00
const handleTimelinePagination = useTimelinePagination(
mx,
timeline,
setTimeline,
PAGINATION_LIMIT,
2023-10-06 13:44:06 +11:00
);
const getScrollElement = useCallback(() => scrollRef.current, []);
2023-10-14 16:08:43 +11:00
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,
[],
2023-10-14 16:08:43 +11:00
),
onEnd: handleTimelinePagination,
});
2023-10-06 13:44:06 +11:00
const loadEventTimeline = useEventTimelineLoader(
mx,
room,
useCallback(
(evtId, lTimelines, evtAbsIndex) => {
if (!alive()) return;
const evLength = getTimelinesEventsCount(lTimelines);
setFocusItem({
2023-10-06 13:44:06 +11:00
index: evtAbsIndex,
scrollTo: true,
2023-10-26 16:21:55 +11:00
highlight: evtId !== readUptoEventIdRef.current,
});
2023-10-06 13:44:06 +11:00
setTimeline({
linkedTimelines: lTimelines,
range: {
start: Math.max(evtAbsIndex - PAGINATION_LIMIT, 0),
end: Math.min(evtAbsIndex + PAGINATION_LIMIT, evLength),
},
});
},
[alive],
2023-10-06 13:44:06 +11:00
),
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],
),
2023-10-06 13:44:06 +11:00
);
useLiveEventArrive(
room,
useCallback(
(mEvt: MatrixEvent) => {
2023-10-19 17:40:01 +11:00
// 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
2023-10-21 18:14:21 +11:00
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, passiveReadRef.current),
);
2023-10-06 13:44:06 +11:00
}
if (!document.hasFocus() && !unreadInfo) {
2023-10-23 21:42:27 +11:00
setUnreadInfo(getRoomUnreadInfo(room));
2023-10-21 18:14:21 +11:00
}
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
2023-10-06 13:44:06 +11:00
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;
}
2023-10-06 13:44:06 +11:00
setTimeline((ct) => ({ ...ct }));
if (!unreadInfo) {
setUnreadInfo(getRoomUnreadInfo(room));
}
},
[mx, room, unreadInfo, hideActivity],
),
2023-10-06 13:44:06 +11:00
);
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],
);
2023-10-06 13:44:06 +11:00
useLiveTimelineRefresh(
room,
useCallback(() => {
if (liveTimelineLinked) {
setTimeline(getInitialTimeline(room));
}
}, [room, liveTimelineLinked]),
2023-10-06 13:44:06 +11:00
);
// Stay at bottom when room editor resize
useResizeObserver(
2023-10-19 17:40:01 +11:00
useMemo(() => {
let mounted = false;
return (entries) => {
if (!mounted) {
// skip initial mounting call
mounted = true;
return;
}
2023-10-06 13:44:06 +11:00
if (!roomInputRef.current) return;
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
const scrollElement = getScrollElement();
if (!editorBaseEntry || !scrollElement) return;
if (atBottomRef.current) {
scrollToBottom(scrollElement);
}
2023-10-19 17:40:01 +11:00
};
}, [getScrollElement, roomInputRef]),
useCallback(() => roomInputRef.current, [roomInputRef]),
2023-10-06 13:44:06 +11:00
);
2023-10-19 17:40:01 +11:00
const tryAutoMarkAsRead = useCallback(() => {
const readUptoEventId = readUptoEventIdRef.current;
if (!readUptoEventId) {
requestAnimationFrame(() =>
markAsRead(mx, room.roomId, hideActivity, passiveReadRef.current),
);
2023-10-19 17:40:01 +11:00
return;
}
const evtTimeline = getEventTimeline(room, readUptoEventId);
2023-10-19 17:40:01 +11:00
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
if (latestTimeline === room.getLiveTimeline()) {
requestAnimationFrame(() =>
markAsRead(mx, room.roomId, hideActivity, passiveReadRef.current),
);
2023-10-19 17:40:01 +11:00
}
2025-02-26 21:44:53 +11:00
}, [mx, room, hideActivity]);
2023-10-19 17:40:01 +11:00
2023-10-08 00:09:43 +11:00
const debounceSetAtBottom = useDebounce(
useCallback((entry: IntersectionObserverEntry) => {
if (!entry.isIntersecting) setAtBottom(false);
}, []),
{ wait: 1000 },
2023-10-08 00:09:43 +11:00
);
2023-10-06 13:44:06 +11:00
useIntersectionObserver(
2023-10-08 00:09:43 +11:00
useCallback(
(entries) => {
const target = atBottomAnchorRef.current;
if (!target) return;
const targetEntry = getIntersectionObserverEntry(target, entries);
if (targetEntry) debounceSetAtBottom(targetEntry);
2023-10-19 17:40:01 +11:00
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
setAtBottom(true);
if (document.hasFocus()) {
tryAutoMarkAsRead();
}
2023-10-19 17:40:01 +11:00
}
2023-10-08 00:09:43 +11:00
},
[debounceSetAtBottom, tryAutoMarkAsRead],
2023-10-08 00:09:43 +11:00
),
useCallback(
2023-10-06 13:44:06 +11:00
() => ({
root: getScrollElement(),
rootMargin: '100px',
}),
[getScrollElement],
2023-10-06 13:44:06 +11:00
),
useCallback(() => atBottomAnchorRef.current, []),
2023-10-06 13:44:06 +11:00
);
2023-10-21 18:14:21 +11:00
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;
}
2023-10-21 18:14:21 +11:00
tryAutoMarkAsRead();
}
},
[tryAutoMarkAsRead, unreadInfo, handleOpenEvent],
),
2023-10-21 18:14:21 +11:00
);
2023-10-14 16:08:43 +11:00
// Handle up arrow edit
useKeyDown(
window,
useCallback(
(evt) => {
if (
2023-10-21 18:14:33 +11:00
isKeyHotkey('arrowup', evt) &&
2023-10-14 16:08:43 +11:00
editableActiveElement() &&
document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' &&
isEmptyEditor(editor)
) {
const editableEvt = getLatestEditableEvt(room.getLiveTimeline(), (mEvt) =>
canEditEvent(mx, mEvt),
2023-10-14 16:08:43 +11:00
);
const editableEvtId = editableEvt?.getId();
if (!editableEvtId) return;
setEditId(editableEvtId);
2024-09-11 17:07:02 +10:00
evt.preventDefault();
2023-10-14 16:08:43 +11:00
}
},
[mx, room, editor],
),
2023-10-14 16:08:43 +11:00
);
// [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]);
2023-10-06 13:44:06 +11:00
useEffect(() => {
if (eventId) {
setTimeline(getEmptyTimeline());
loadEventTimeline(eventId);
}
}, [eventId, loadEventTimeline]);
// Scroll to bottom on initial timeline load
useLayoutEffect(() => {
const scrollEl = scrollRef.current;
2023-10-19 17:40:01 +11:00
if (scrollEl) {
scrollToBottom(scrollEl);
}
2023-10-06 13:44:06 +11:00
}, []);
2023-10-19 17:40:01 +11:00
// if live timeline is linked and unreadInfo change
// Scroll to last read message
2023-10-06 13:44:06 +11:00
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') {
2023-10-06 13:44:06 +11:00
scrollToItem(absoluteIndex, {
behavior: 'instant',
align: 'start',
stopInView: true,
});
2023-10-19 17:40:01 +11:00
}
2023-10-06 13:44:06 +11:00
}
}, [room, unreadInfo, scrollToItem]);
// scroll to focused message
useLayoutEffect(() => {
if (focusItem && focusItem.scrollTo) {
scrollToItem(focusItem.index, {
2023-10-06 13:44:06 +11:00
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]);
2023-10-06 13:44:06 +11:00
// 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]);
2023-10-19 17:40:01 +11:00
// Remove unreadInfo on mark as read
2023-10-06 13:44:06 +11:00
useEffect(() => {
2024-07-08 16:57:10 +05:30
if (!unread) {
2023-10-19 17:40:01 +11:00
setUnreadInfo(undefined);
2024-07-08 16:57:10 +05:30
}
}, [unread]);
2023-10-06 13:44:06 +11:00
2023-10-14 16:08:43 +11:00
// 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(() => {
2024-07-30 17:48:59 +05:30
if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true });
}
2023-10-06 13:44:06 +11:00
setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
}, [eventId, navigateRoom, room]);
2023-10-06 13:44:06 +11:00
const handleJumpToUnread = useCallback(() => {
2023-10-06 13:44:06 +11:00
if (unreadInfo?.readUptoEventId) {
setTimeline(getEmptyTimeline());
loadEventTimeline(unreadInfo.readUptoEventId);
}
}, [unreadInfo, loadEventTimeline]);
2023-10-06 13:44:06 +11:00
const handleMarkAsRead = useCallback(() => {
2025-02-26 21:44:53 +11:00
markAsRead(mx, room.roomId, hideActivity);
}, [mx, room, hideActivity]);
2023-10-06 13:44:06 +11:00
2024-08-15 16:52:32 +02:00
const handleOpenReply: MouseEventHandler = useCallback(
2023-10-06 13:44:06 +11:00
async (evt) => {
2024-08-15 16:52:32 +02:00
const targetId = evt.currentTarget.getAttribute('data-event-id');
if (!targetId) return;
captureReturnPoint();
handleOpenEvent(targetId);
2023-10-06 13:44:06 +11:00
},
[handleOpenEvent, captureReturnPoint],
2023-10-06 13:44:06 +11:00
);
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;
}
2025-08-09 17:46:10 +05:30
openUserRoomProfile(
room.roomId,
space?.roomId,
userId,
evt.currentTarget.getBoundingClientRect(),
2025-08-09 17:46:10 +05:30
);
2023-10-06 13:44:06 +11:00
},
[room, space, openUserRoomProfile],
2023-10-06 13:44:06 +11:00
);
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);
2023-10-06 13:44:06 +11:00
editor.insertNode(
createMentionElement(
userId,
name.startsWith('@') ? name : `@${name}`,
userId === mx.getUserId(),
),
2023-10-06 13:44:06 +11:00
);
ReactEditor.focus(editor);
moveCursor(editor);
},
[mx, room, editor],
2023-10-06 13:44:06 +11:00
);
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
2025-07-23 16:17:17 +01:00
(evt, startThread = false) => {
2023-10-06 13:44:06 +11:00
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;
}
2023-10-06 13:44:06 +11:00
const replyEvt = room.findEventById(replyId);
if (!replyEvt) return;
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
2024-08-15 16:52:32 +02:00
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content;
const { 'm.relates_to': relation } = replyEvt.getWireContent();
2023-10-06 13:44:06 +11:00
const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') {
setReplyDraft({
userId: senderId,
eventId: replyId,
body,
formattedBody,
2024-08-15 16:52:32 +02:00
relation,
2023-10-06 13:44:06 +11:00
});
setTimeout(() => ReactEditor.focus(editor), 100);
}
},
[room, setReplyDraft, setActiveThreadId, editor],
2023-10-06 13:44:06 +11:00
);
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);
tick('reaction', hapticFeedback);
sendRoomEvent(
mx,
2023-10-06 13:44:06 +11:00
room.roomId,
null,
MessageEvent.Reaction,
getReactionContent(targetEventId, key, rShortcode),
2023-10-06 13:44:06 +11:00
);
},
[mx, room, hapticFeedback],
2023-10-06 13:44:06 +11:00
);
2023-10-14 16:08:43 +11:00
const handleEdit = useCallback(
(editEvtId?: string) => {
if (editEvtId) {
setEditId(editEvtId);
return;
}
setEditId(undefined);
ReactEditor.focus(editor);
},
[editor],
2023-10-14 16:08:43 +11:00
);
const { t } = useTranslation();
2023-10-06 13:44:06 +11:00
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;
2024-08-15 16:52:32 +02:00
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);
2023-10-26 16:21:55 +11:00
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}
2024-12-16 21:55:15 +11:00
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} />
)}
</>
}
2025-02-26 21:44:53 +11:00
hideReadReceipts={hideActivity}
2025-06-28 12:35:59 +02:00
showDeveloperTools={showDeveloperTools}
2025-08-12 19:42:30 +05:30
memberPowerTag={getMemberPowerTag(senderId)}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
lotusTerminal={!!lotusTerminal}
2023-10-30 07:14:58 +11:00
>
{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;
2024-08-15 16:52:32 +02:00
const { replyEventId, threadRootId } = mEvent;
const forwardedMeta = getForwardedMeta(mEvent.getContent());
const highlighted = focusItem?.index === item && focusItem.highlight;
2023-10-30 07:14:58 +11:00
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}
2024-12-16 21:55:15 +11:00
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} />
)}
</>
}
2025-02-26 21:44:53 +11:00
hideReadReceipts={hideActivity}
2025-06-28 12:35:59 +02:00
showDeveloperTools={showDeveloperTools}
2025-08-12 19:42:30 +05:30
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}
2024-07-30 17:48:59 +05:30
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;
2023-10-30 07:14:58 +11:00
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}
2024-12-16 21:55:15 +11:00
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}
/>
)
}
2025-02-26 21:44:53 +11:00
hideReadReceipts={hideActivity}
2025-06-28 12:35:59 +02:00
showDeveloperTools={showDeveloperTools}
2025-08-12 19:42:30 +05:30
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
2023-10-30 07:14:58 +11:00
>
{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;
2023-10-06 13:44:06 +11:00
const highlighted = focusItem?.index === item && focusItem.highlight;
const parsed = parseMemberEvent(mEvent);
2023-10-30 07:14:58 +11:00
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
2023-10-06 13:44:06 +11:00
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()}
2025-02-26 21:44:53 +11:00
hideReadReceipts={hideActivity}
2025-06-28 12:35:59 +02:00
showDeveloperTools={showDeveloperTools}
2023-10-06 13:44:06 +11:00
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={parsed.icon}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
{parsed.body}
</Text>
</Box>
}
2023-10-06 13:44:06 +11:00
/>
</Event>
);
},
[StateEvent.RoomName]: (mEventId, mEvent, item) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
2023-10-06 13:44:06 +11:00
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
2023-10-06 13:44:06 +11:00
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()}
2025-02-26 21:44:53 +11:00
hideReadReceipts={hideActivity}
2025-06-28 12:35:59 +02:00
showDeveloperTools={showDeveloperTools}
2023-10-06 13:44:06 +11:00
>
<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>
}
2023-10-06 13:44:06 +11:00
/>
</Event>
);
},
[StateEvent.RoomTopic]: (mEventId, mEvent, item) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
2023-10-06 13:44:06 +11:00
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
2023-10-06 13:44:06 +11:00
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()}
2025-02-26 21:44:53 +11:00
hideReadReceipts={hideActivity}
2025-06-28 12:35:59 +02:00
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>
2023-10-06 13:44:06 +11:00
);
},
[StateEvent.RoomAvatar]: (mEventId, mEvent, item) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
2023-10-06 13:44:06 +11:00
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
2023-10-06 13:44:06 +11:00
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()}
2025-02-26 21:44:53 +11:00
hideReadReceipts={hideActivity}
2025-06-28 12:35:59 +02:00
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'}
2023-10-06 13:44:06 +11:00
</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>
);
},
+4
2026-03-07 18:03:32 +11:00
[StateEvent.GroupCallMemberPrefix]: (mEventId, mEvent, item) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
2026-05-23 17:20:41 +05:30
const content = mEvent.getContent();
2026-03-10 22:45:26 +11:00
const prevContent = mEvent.getPrevContent();
const callJoined = content.application;
if (callJoined && 'application' in prevContent) {
return null;
}
+4
2026-03-07 18:03:32 +11:00
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>
);
},
2023-10-06 13:44:06 +11:00
},
(mEventId, mEvent, item) => {
2023-10-06 13:44:06 +11:00
if (!showHiddenEvents) return null;
const highlighted = focusItem?.index === item && focusItem.highlight;
2023-10-06 13:44:06 +11:00
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}
/>
);
2023-10-06 13:44:06 +11:00
return (
<Event
key={mEvent.getId()}
data-message-item={item}
2023-10-14 16:08:43 +11:00
data-message-id={mEventId}
2023-10-06 13:44:06 +11:00
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
2025-02-26 21:44:53 +11:00
hideReadReceipts={hideActivity}
2025-06-28 12:35:59 +02:00
showDeveloperTools={showDeveloperTools}
2023-10-06 13:44:06 +11:00
>
<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) => {
2023-10-06 13:44:06 +11:00
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;
2023-10-06 13:44:06 +11:00
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}
/>
);
2023-10-06 13:44:06 +11:00
return (
<Event
key={mEvent.getId()}
data-message-item={item}
2023-10-14 16:08:43 +11:00
data-message-id={mEventId}
2023-10-06 13:44:06 +11:00
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
2025-02-26 21:44:53 +11:00
hideReadReceipts={hideActivity}
2025-06-28 12:35:59 +02:00
showDeveloperTools={showDeveloperTools}
2023-10-06 13:44:06 +11:00
>
<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>
);
},
);
2023-10-06 13:44:06 +11:00
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();
2023-10-06 13:44:06 +11:00
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;
}
2025-03-01 18:48:11 +11:00
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;
}
2025-03-01 18:48:11 +11:00
}
2023-10-06 13:44:06 +11:00
if (!newDivider && readUptoEventIdRef.current) {
newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
}
if (!dayDivider) {
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
}
const collapsed =
!perMessageProfiles &&
2023-10-06 13:44:06 +11:00
isPrevRendered &&
!dayDivider &&
(!newDivider || eventSender === mx.getUserId()) &&
2023-10-06 13:44:06 +11:00
prevEvent !== undefined &&
prevEvent.getSender() === eventSender &&
2023-10-06 13:44:06 +11:00
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,
);
2023-10-06 13:44:06 +11:00
prevEvent = mEvent;
isPrevRendered = !!eventJSX;
const newDividerJSX =
newDivider && eventJSX && eventSender !== mx.getUserId() ? (
2023-10-06 13:44:06 +11:00
<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 })}
2023-10-06 13:44:06 +11:00
</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}
2023-10-06 13:44:06 +11:00
>
<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` }}
2023-10-06 13:44:06 +11:00
>
{!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)}
/>
)}
</>
2023-10-06 13:44:06 +11:00
);
}