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