markAsRead ran on every room visit (timeline at the bottom and focused) and sent a threaded receipt for every unread thread, so a reply in a thread you started or replied in lost its unread badge the moment you glanced at the room, without opening the thread. Reads from just viewing the timeline are now "passive": - threads you follow (started, replied in, or were mentioned in) stay unread until their panel is opened; other threads are still cleared so they don't keep the room dot lit forever; - while a followed thread has an unread reply, the main receipt is scoped to the main timeline instead of unthreaded, because an unthreaded receipt also reads every older thread reply (the next main message would clear the thread anyway). The check also asks whether the latest reply is read, since the thread's count lags when the reply and a main message share a sync; - the thread open in the panel is skipped, as the panel sends its own receipt (was two identical receipts per reply). Explicit "mark as read" (room menu, Escape, bulk actions) still clears everything. Unit tests for each rule plus a local-homeserver e2e that checks the server's per-thread count survives a reply + newer main message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2633 lines
94 KiB
TypeScript
2633 lines
94 KiB
TypeScript
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,
|
||
sendRoomEvent,
|
||
} 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';
|
||
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, MarkAsReadOptions } 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');
|
||
const [hapticFeedback] = useSetting(settingsAtom, 'hapticFeedback');
|
||
// [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));
|
||
// [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();
|
||
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, passiveReadRef.current),
|
||
);
|
||
}
|
||
|
||
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, passiveReadRef.current),
|
||
);
|
||
return;
|
||
}
|
||
const evtTimeline = getEventTimeline(room, readUptoEventId);
|
||
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
|
||
if (latestTimeline === room.getLiveTimeline()) {
|
||
requestAnimationFrame(() =>
|
||
markAsRead(mx, room.roomId, hideActivity, passiveReadRef.current),
|
||
);
|
||
}
|
||
}, [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);
|
||
tick('reaction', hapticFeedback);
|
||
sendRoomEvent(
|
||
mx,
|
||
room.roomId,
|
||
null,
|
||
MessageEvent.Reaction,
|
||
getReactionContent(targetEventId, key, rShortcode),
|
||
);
|
||
},
|
||
[mx, room, hapticFeedback],
|
||
);
|
||
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)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|