/* eslint-disable react/destructuring-assignment */
import React, {
Dispatch,
RefObject,
SetStateAction,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
Direction,
EventTimeline,
EventTimelineSet,
EventTimelineSetHandlerMap,
MatrixClient,
MatrixEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
} from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import classNames from 'classnames';
import { Editor } from 'slate';
import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai';
import { Box, Chip, Icon, Icons, Scroll, Text, as, config, toRem } from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { 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 {
DefaultPlaceholder,
Reply,
MessageBase,
MessageUnsupportedContent,
Time,
MessageNotDecryptedContent,
RedactedContent,
MSticker,
ImageContent,
EventContent,
STREAM_MESSAGE_SPACING,
StreamDayDivider,
CHANNEL_MESSAGE_SPACING,
ChannelDayDivider,
} from '../../components/message';
import {
factoryRenderLinkifyWithMention,
getReactCustomHtmlParser,
LINKIFY_OPTS,
makeMentionCustomProps,
renderMatrixMention,
} from '../../plugins/react-custom-html-parser';
import {
canEditEvent,
decryptAllTimelineEvent,
getEditedEvent,
getEventReactions,
getLatestEditableEvt,
getMemberDisplayName,
isBridgedRoom,
isMembershipChanged,
reactionOrEditEvent,
} from '../../utils/room';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import {
Reactions,
Message,
Event,
CallMessage,
CallAggregate,
SyslineMessage,
EncryptedContent,
useMessageInteractionHandlers,
} from './message';
import { ThreadSummaryCard } from './ThreadSummaryCard';
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
import * as customHtmlCss from '../../styles/CustomHtml.css';
import { RoomIntro } from '../../components/room-intro';
import {
getIntersectionObserverEntry,
useIntersectionObserver,
} from '../../hooks/useIntersectionObserver';
import { markAsRead } from '../../utils/notifications';
import { useDebounce } from '../../hooks/useDebounce';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference, timeDayMonYear, today, yesterday } from '../../utils/time';
import { isEmptyEditor } from '../../components/editor';
import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
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 { 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 { useIsOneOnOne } from '../../hooks/useRoom';
import { isNativePlatform } from '../../utils/capacitor';
import { useChannelsMode, useThreadDrawerOpen } from '../../hooks/useChannelsMode';
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';
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
)
);
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;
editor: Editor;
// Pixel height of the overlay composer painted by RoomView. The CSS var
// applied on the chat surface paints the bottom padding visually, but
// when this number transitions 0 → N at mount the scroll-content grows
// without a corresponding scrollTop bump — the latest message ends up
// hidden behind the composer. We use this prop in a layout effect to
// re-anchor the scroll to the bottom whenever it changes and the user
// was already at the bottom.
bottomOverlayHeight?: number;
// Native-only scroll-aware composer hide. Timeline owns the scroll
// element, RoomView owns the composer DOM — so direction detection
// lives here and reports up. Web ignores the callback entirely; the
// gating happens inside the listener attach effect.
onComposerHiddenChange?: (hidden: boolean) => void;
};
const PAGINATION_LIMIT = 80;
// Native scroll-aware composer thresholds. Asymmetric hysteresis: easier to
// reveal the composer than to hide it, so a flick up-then-down can't strand
// it in hidden state. NEAR_BOTTOM_PX matches roughly the visible breathing
// room below the last message (timeline paddingBottom + overlay padding) —
// while the user is within this band of the live edge, small scrolls never
// strip the composer.
const COMPOSER_HIDE_DELTA_PX = 12;
const COMPOSER_SHOW_DELTA_PX = 6;
const COMPOSER_NEAR_BOTTOM_PX = 200;
// Jump-to-latest FAB visibility thresholds. Decoupled from the auto-follow
// `atBottom` gate (which carries a 1s debounce designed for live-timeline
// tracking, not visual UI) — FAB needs eager response. Show / hide hysteresis
// avoids flicker at the boundary on tiny scrolls.
const FAB_SHOW_DISTANCE_PX = 150;
const FAB_HIDE_DISTANCE_PX = 50;
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>,
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.getSafeUserId());
if (!readUptoEventId) return undefined;
const evtTimeline = getEventTimeline(room, readUptoEventId);
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
return {
readUptoEventId,
inLiveTimeline: latestTimeline === room.getLiveTimeline(),
scrollTo,
};
};
// Channels-mode visibility filter. Single source of truth for the
// /channels/* surface — called from BOTH the rail-endpoint scan
// (`isRenderableTimelineEvent`) AND the renderer null-gate so the two never
// disagree on which events count as visible.
//
// Thread-relation hiding delegates to SDK's `room.eventShouldLiveIn`
// (matrix-js-sdk room.ts:891) — same predicate Element-web uses in
// `MessagePanel.filterEventsThatShouldLiveInThisTimeline`. Canonical
// because: (a) handles thread-root visible-in-both-timelines correctly;
// (b) handles orphan thread relations whose root never landed; (c)
// classifies redactions via target's thread; (d) classifies pending
// local-echoes the same way the SDK does — kills the «message flashes
// in main timeline before drawer» blink that hand-rolled relType check
// missed because the SDK adds the thread relation to the local-echo
// content asynchronously vs the predicate's `getRelation()` read.
//
// Bridged-room exception: SDK still partitions thread events into the
// thread, but on bridged rooms (Telegram puppets etc.) we want the
// thread events visible in main timeline because the bridge has no
// thread surface. Override returns false (don't hide).
//
// Service events (call.*, rtc.*, poll.*) stay hand-filtered — these
// are not thread-related and the SDK predicate has no opinion on them.
const isChannelsModeHidden = (room: Room, event: MatrixEvent, isBridged: boolean): boolean => {
const partition = room.eventShouldLiveIn(event);
if (!partition.shouldLiveInRoom) {
if (isBridged) return false;
return true;
}
const eventType = event.getType();
if (eventType.startsWith('m.call.')) return true;
if (eventType === 'm.rtc.notification') return true;
if (eventType === 'm.rtc.member') return true;
if (eventType === 'org.matrix.msc4143.rtc.member') return true;
if (eventType === 'm.poll.start') return true;
if (eventType === 'm.poll.response') return true;
if (eventType === 'm.poll.end') return true;
if (eventType === 'org.matrix.msc3381.poll.start') return true;
if (eventType === 'org.matrix.msc3381.poll.response') return true;
if (eventType === 'org.matrix.msc3381.poll.end') return true;
// m.reference relations (e.g. polls' votes) — partition reports
// shouldLiveInRoom: true for these but we never render them in the
// channel center column. SDK has no special predicate; explicit drop.
if (event.getRelation()?.rel_type === 'm.reference') return true;
return false;
};
export function RoomTimeline({
room,
eventId,
roomInputRef,
editor,
bottomOverlayHeight = 0,
onComposerHiddenChange,
}: RoomTimelineProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
// After P3c every room renders Stream. The DM-vs-group split that drove the
// membership-sysline gate flips to a 1:1 vs N>2 check via `useIsOneOnOne`,
// not the persisted `m.direct` flag — see plan §6.8.
const isOneOnOne = useIsOneOnOne();
const channelsMode = useChannelsMode();
// bridged-room check is cheap and stable for a route lifetime; recompute on
// each render is fine — `isBridgedRoom` walks the room state-event index,
// not the timeline.
const isBridged = channelsMode && isBridgedRoom(room);
// M2: «Reply in Thread» affordance is channels-mode-only. Bridged
// channels (Telegram puppets) get nothing because the bridge has no
// thread semantic — clicking would create a draft the bridge drops
// on send. Outside channels (DM/Bots/legacy //) the surface
// has no drawer and no thread UI per plan §6.7, so the button has
// nowhere to lead. Pre-M2 the button existed in those surfaces but
// never produced a visible thread.
const hideThreadReplyAffordance = !channelsMode || isBridged;
// M3a: thread summary pill rides a tighter gate — non-bridged channels
// are the only surface where threads are first-class. The card itself
// returns null when the root has no replies, so mounting on every
// visible row in channels-mode is safe (`useVirtualPaginator` keeps
// the rendered window bounded; SDK Room maxListeners is 100).
const showThreadSummary = channelsMode && !isBridged;
// M3: channels timeline uses avatar-first ChannelLayout (no rail, no
// bubble). DM/Bots stay on Stream rail. Bridged Telegram channels use
// Channel layout too — visually consistent with native channels — only
// the thread plus / cards differ (bridge has no thread semantic).
const messageLayout: 'stream' | 'channel' = channelsMode ? 'channel' : 'stream';
// Channels main timeline shares the thread-drawer bubble silhouette:
// dark `Surface.Container` card with the username + time INSIDE the
// bubble. Stream layout (DM/Bots) keeps its native bubble header so
// the flag is gated on channels-mode only.
const channelHeaderInBubble = channelsMode;
// M2: when the thread drawer is open, the channel composer is
// unmounted by RoomView (single-Slate-at-a-time pattern). Clicking
// the channel timeline's «Reply» menu would write a reply chip into
// an atom no surface reads, then fire `ReactEditor.focus(editor)`
// on a Slate editor that's no longer mounted (silent no-op) — dead
// click from the user's perspective. Hide the regular Reply
// affordance in this state. ThreadPlus stays gated separately by
// `hideThreadReplyAffordance` and remains active for re-opening the
// drawer to a different thread root.
const threadDrawerOpen = useThreadDrawerOpen();
const hideMainReplyAffordance = threadDrawerOpen;
// Captured for the channels-mode «Reply in Thread» branch inside
// useMessageInteractionHandlers — re-encoded via generatePath so
// passing the raw (already URL-encoded) values from useParams is fine.
const { spaceIdOrAlias, roomIdOrAlias } = useParams();
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 [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const ignoredUsersList = useIgnoredUsers();
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
// Reply-draft setter targets the MAIN composer of this room (channel
// / DM / legacy timeline). Drawer composer manages its own per-thread
// reply draft via DraftKey([roomId, rootId]) inside RoomInput.
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(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 canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const { navigateRoom } = useRoomNavigate();
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
const readUptoEventIdRef = useRef();
if (unreadInfo) {
readUptoEventIdRef.current = unreadInfo.readUptoEventId;
}
const atBottomAnchorRef = useRef(null);
const [atBottom, setAtBottom] = useState(true);
const atBottomRef = useRef(atBottom);
atBottomRef.current = atBottom;
// Jump-to-latest FAB visibility — driven by distance from the live edge,
// not by the debounced auto-follow `atBottom`. Initial `true` because the
// common open-room state is "scrolled to bottom"; deep-links to a specific
// event will fire scroll events that correct the state.
const [fabHidden, setFabHidden] = useState(true);
const fabHiddenRef = useRef(fabHidden);
fabHiddenRef.current = fabHidden;
// Pulse counter — bumped on each fresh live message while the FAB is
// showing. Drives the `key` of an icon-wrap span so React remounts it and
// the CSS keyframes restart.
const [pulseCount, setPulseCount] = useState(0);
const scrollRef = useRef(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(
() => ({
...LINKIFY_OPTS,
render: factoryRenderLinkifyWithMention((href) =>
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
),
}),
[mx, room, mentionClickHandler]
);
const htmlReactParserOptions = useMemo(
() =>
getReactCustomHtmlParser(mx, room.roomId, {
linkifyOpts,
useAuthentication,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
}),
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication]
);
const parseMemberEvent = useMemberEventParser();
const [timeline, setTimeline] = useState(() =>
eventId ? getEmptyTimeline() : getInitialTimeline(room)
);
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;
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])
);
// M2 channels-mode: hidden events (thread replies, RTC, polls, etc.)
// are appended to the SDK timelineSet and bump `getTimelinesEventsCount`,
// but the live handler early-returns without mutating state. The
// virtual `range` is indexed against the FULL SDK event-pool (see
// `getInitialTimeline` — `range.end = evLength`), so a +1 advance on
// the next visible event would lag behind by N hidden events and
// miss the visible one in the rendered window. Track skipped count
// here; the next visible event consumes `skipped + 1` as its delta.
const skippedHiddenLiveEventsRef = useRef(0);
// Counter is meaningful only against the CURRENT linkedTimelines —
// after a full rebase (loadEventTimeline / refresh / jump-to-latest /
// jump-to-unread), range is recomputed against the fresh evLength,
// so any pre-rebase skipped count would over-shoot range on the next
// visible event. Track `linkedTimelines` reference identity (replaced
// on every rebase) and zero the counter alongside.
useEffect(() => {
skippedHiddenLiveEventsRef.current = 0;
}, [timeline.linkedTimelines]);
useLiveEventArrive(
room,
useCallback(
(mEvt: MatrixEvent) => {
// Hidden events: skip render-state mutation (no blink), but
// record the count so the next visible event can advance the
// range by the correct delta.
if (channelsMode && isChannelsModeHidden(room, mEvt, isBridged)) {
skippedHiddenLiveEventsRef.current += 1;
return;
}
// Visible event arrival — snapshot + reset the skipped counter.
// `liveDelta` covers all SDK-appended events since the last
// visible one (skipped hidden + this one) so the virtual range
// stays aligned with `getTimelinesEventsCount(linkedTimelines)`.
const liveDelta = skippedHiddenLiveEventsRef.current + 1;
skippedHiddenLiveEventsRef.current = 0;
// «Sending while scrolled up jumps the timeline to live» — Telegram /
// WhatsApp / Slack pattern. After P3c every room renders Stream, so
// the own-message follow-to-bottom guard is universal.
const isOwnLiveStreamMessage =
mEvt.getSender() === mx.getUserId() &&
!reactionOrEditEvent(mEvt) &&
(mEvt.getType() === MessageEvent.RoomMessage ||
mEvt.getType() === MessageEvent.RoomMessageEncrypted ||
mEvt.getType() === MessageEvent.Sticker);
// 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 || isOwnLiveStreamMessage) {
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 evtRoomId = mEvt.getRoomId();
if (evtRoomId) {
requestAnimationFrame(() => markAsRead(mx, evtRoomId, hideActivity));
}
}
if (!document.hasFocus() && !unreadInfo) {
setUnreadInfo(getRoomUnreadInfo(room));
}
scrollToBottomRef.current.count += 1;
// Always instant — never smooth on incoming. Smooth animation
// briefly takes the at-bottom anchor out of the
// IntersectionObserver's intersection box during the
// animation frame, which the 1s debounce sometimes turns
// into a false-positive «user scrolled away» → atBottom flips
// false → next message stops auto-following. Direct snap
// matches Discord / Telegram and the drawer (M4 fix in
// ThreadDrawer.tsx) — predictable, no animation surface for
// the observer to misinterpret.
scrollToBottomRef.current.smooth = false;
if (isOwnLiveStreamMessage) {
setAtBottom(true);
if (!atLiveEndRef.current) {
// Sending while viewing an older event intentionally jumps the DM back
// to live; clear stale deep-link focus before replacing the timeline.
setFocusItem(undefined);
}
}
setTimeline((ct) => {
if (!atLiveEndRef.current && isOwnLiveStreamMessage) {
return getInitialTimeline(room);
}
return {
...ct,
range: {
start: ct.range.start + liveDelta,
end: ct.range.end + liveDelta,
},
};
});
return;
}
setTimeline((ct) => ({ ...ct }));
if (!unreadInfo) {
setUnreadInfo(getRoomUnreadInfo(room));
}
},
[mx, room, unreadInfo, hideActivity, channelsMode, isBridged]
)
);
// Pulse the FAB when a fresh message from another user lands on the live
// timeline while the user is scrolled away. Edits / reactions / own
// messages are filtered so the cue fires only for genuinely new content
// worth jumping back for.
useLiveEventArrive(
room,
useCallback(
(mEvt: MatrixEvent) => {
if (fabHiddenRef.current) return;
if (mEvt.getSender() === mx.getUserId()) return;
if (reactionOrEditEvent(mEvt)) return;
const type = mEvt.getType();
if (
type !== MessageEvent.RoomMessage &&
type !== MessageEvent.RoomMessageEncrypted &&
type !== MessageEvent.Sticker
) {
return;
}
setPulseCount((c) => c + 1);
},
[mx]
)
);
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]
);
const {
editId,
handleEdit,
handleOpenReply,
handleUserClick,
handleUsernameClick,
handleReplyClick,
handleReactionToggle,
} = useMessageInteractionHandlers({
room,
editor,
composerSuspended: threadDrawerOpen,
setReplyDraft,
onOpenEvent: handleOpenEvent,
channelsMode,
isBridged,
spaceIdOrAlias,
roomIdOrAlias,
});
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])
);
// Re-anchor to bottom when the overlay-composer height changes. At mount
// the composer measures async (RoomView's `useEffect` → `setComposerHeight`
// → CSS var → our paddingBottom grows), which adds N pixels to scrollHeight
// without bumping scrollTop, leaving the latest message hidden behind the
// composer. A layout effect on the prop catches every transition (initial
// 0 → N and subsequent multi-line/reply-preview growth) deterministically,
// unlike a ResizeObserver dispatch which can coalesce the mount and the
// resize into a single skipped first firing.
useLayoutEffect(() => {
if (!atBottomRef.current) return;
const el = getScrollElement();
if (el) scrollToBottom(el);
}, [bottomOverlayHeight, getScrollElement]);
// Single scroll listener covering two concerns:
// 1. FAB visibility (all platforms) — eager distance gate with show /
// hide hysteresis, decoupled from `atBottom`'s 1s debounce.
// 2. Composer slide/fade (Capacitor native only) — direction-tracked
// accumulator with asymmetric hysteresis; hide only fires past the
// near-bottom band so the timeline's bottom breathing room can be
// revealed by small scrolls without stripping the composer.
// The atBottom force-show effect below corrects composer state on
// programmatic jumps to bottom (jump-to-latest, send, layout re-anchor).
useEffect(() => {
const scrollEl = getScrollElement();
if (!scrollEl) return undefined;
const native = isNativePlatform();
let lastTop = scrollEl.scrollTop;
let accumulator = 0;
let lastDir: 1 | -1 | 0 = 0;
const handler = () => {
const top = scrollEl.scrollTop;
const distFromBottom = scrollEl.scrollHeight - top - scrollEl.clientHeight;
setFabHidden((prev) => {
if (distFromBottom > FAB_SHOW_DISTANCE_PX) return false;
if (distFromBottom <= FAB_HIDE_DISTANCE_PX) return true;
return prev;
});
if (!native || !onComposerHiddenChange) return;
const delta = top - lastTop;
lastTop = top;
if (delta === 0) return;
const dir: 1 | -1 = delta > 0 ? 1 : -1;
if (dir !== lastDir) {
accumulator = delta;
lastDir = dir;
} else {
accumulator += delta;
}
if (accumulator <= -COMPOSER_HIDE_DELTA_PX) {
if (distFromBottom > COMPOSER_NEAR_BOTTOM_PX) {
onComposerHiddenChange(true);
}
accumulator = 0;
} else if (accumulator >= COMPOSER_SHOW_DELTA_PX) {
onComposerHiddenChange(false);
accumulator = 0;
}
};
scrollEl.addEventListener('scroll', handler, { passive: true });
return () => scrollEl.removeEventListener('scroll', handler);
}, [getScrollElement, onComposerHiddenChange]);
useEffect(() => {
if (!isNativePlatform()) return;
if (atBottom && onComposerHiddenChange) onComposerHiddenChange(false);
}, [atBottom, onComposerHiddenChange]);
// Stay at bottom when scroll container resizes (e.g. Android keyboard open/close)
useResizeObserver(
useMemo(() => {
let mounted = false;
return (_entries: ResizeObserverEntry[]) => {
if (!mounted) {
mounted = true;
return;
}
if (atBottomRef.current) {
requestAnimationFrame(() => {
const el = getScrollElement();
if (el) scrollToBottom(el);
});
}
};
}, [getScrollElement]),
getScrollElement
);
const tryAutoMarkAsRead = useCallback(() => {
const readUptoEventId = readUptoEventIdRef.current;
if (!readUptoEventId) {
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
return;
}
const evtTimeline = getEventTimeline(room, readUptoEventId);
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
if (latestTimeline === room.getLiveTimeline()) {
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
}
}, [mx, room, hideActivity]);
const debounceSetAtBottom = useDebounce(
useCallback((entry: IntersectionObserverEntry) => {
if (!entry.isIntersecting) setAtBottom(false);
}, []),
{ wait: 1000 }
);
useIntersectionObserver(
useCallback(
(entries) => {
const target = atBottomAnchorRef.current;
if (!target) return;
const targetEntry = getIntersectionObserverEntry(target, entries);
if (targetEntry) debounceSetAtBottom(targetEntry);
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
setAtBottom(true);
if (document.hasFocus()) {
tryAutoMarkAsRead();
}
}
},
[debounceSetAtBottom, tryAutoMarkAsRead]
),
useCallback(
() => ({
root: getScrollElement(),
// Forgiving threshold: 100px was too tight — single tall
// message + minor touch-scroll inertia easily took users out
// of «at-bottom» state, after which incoming events stopped
// auto-following. 400px gives roughly two-three Stream rows
// of slack before treating the scroll as a deliberate detach,
// matching Discord / Slack auto-follow generosity.
rootMargin: '400px',
}),
[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) => {
// Drawer-active: the focused `` may be the drawer
// composer (also tagged `data-editable-name="RoomInput"`), so
// this selector alone can't distinguish main vs thread. Without
// this guard, ArrowUp in an empty drawer composer would put the
// CHANNEL timeline's latest event into edit mode — visible on
// desktop, where main timeline stays mounted next to the drawer.
if (threadDrawerOpen) return;
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;
handleEdit(editableEvtId);
evt.preventDefault();
}
},
[mx, room, editor, threadDrawerOpen, handleEdit]
)
);
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 (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) {
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 = () => {
if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true });
}
setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
};
const handleJumpToUnread = () => {
if (unreadInfo?.readUptoEventId) {
setTimeline(getEmptyTimeline());
loadEventTimeline(unreadInfo.readUptoEventId);
}
};
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
};
const { t } = useTranslation();
// Group `m.call.member` (StateEvent.GroupCallMemberPrefix) events into one
// aggregate bubble per CALL SESSION. Each session is delimited by «joined
// count went from 0 → ≥1, then back to 0». A session's anchor is its
// earliest event; the sender of that anchor is the call initiator and
// decides bubble side (own/peer).
//
// Why per-session and not just per-`call_id`: legacy MSC3401 room-scoped
// calls use an empty `call_id` (see matrix-js-sdk MembershipManager
// `INFO_SLOT_ID_LEGACY_CASE`), so every historical call in the same DM
// room shares the same slot id. Splitting on the «count returned to 0»
// boundary keeps each call's metrics — connectedAt / endedAt / duration —
// scoped to its own session, otherwise the bubble would smear the entire
// call history into one super-aggregate.
//
// Forward chronological walk per linked timeline tracks: (1) joined count
// per session so we can pin the conversation-duration window to «both
// joined» → «one left» and exclude the ringing prelude; (2) each
// currently-joined membership's absolute expiry so a crashed peer
// (join with no leave) doesn't keep the bubble in «Ongoing» forever —
// once the SDK's DEFAULT_EXPIRE_DURATION elapses past
// created_ts + expires, we treat the stale join as gone. Linear in
// #call-member events (rare); matches the inline `streamRenderableItemHasBefore`
// IIFE pattern just below.
const callAnchors: Map = (() => {
type CallScan = {
slotId: string;
anchorEventId: string;
anchorSenderId: string;
startTs: number;
endTs: number;
// TS when joined count first reached 2 (both sides connected). Used as
// the duration window start; falls back to startTs on pagination edge.
connectedAt: number | null;
// TS when joined count dropped from 2 → 1 for the last time. Null
// while the conversation is still live (or never began).
endedAt: number | null;
// Running tally of currently-joined memberships (per stateKey).
joinedCount: number;
participants: Set;
// state_key (mxid + device) -> is currently joined
memberStates: Map;
// state_key -> absolute expiry timestamp of the active join (ms since
// epoch). Cleared on leave. Used to detect crashed clients.
joinedAbsoluteExpiries: Map;
// At least one join observed in this session — guards session close
// and filters leave-only pagination fragments.
everJoined: boolean;
// Session terminated (count returned to 0 after everJoined). New
// events for this slot open a fresh session instead of appending.
closed: boolean;
};
// Per-slot ordered list of sessions. Newest session is `last(sessions)`;
// earlier entries are closed (count returned to 0).
const slotSessions = new Map();
// Hot data-pipeline path: nested for-of with early `continue` is the
// clearest expression of the state-machine. Array iteration helpers would
// require restructuring the dual-key membership ledger.
/* eslint-disable no-restricted-syntax, no-continue */
for (const tl of timeline.linkedTimelines) {
const events = tl.getEvents();
for (const ev of events) {
if (ev.getType() !== StateEvent.GroupCallMemberPrefix) continue;
const content = ev.getContent();
const prevContent = ev.getPrevContent() as Partial;
let slotId: string | null = null;
if (typeof content.call_id === 'string') {
slotId = content.call_id;
} else if (typeof prevContent.call_id === 'string') {
slotId = prevContent.call_id;
}
if (slotId == null) continue;
// `anchorEventId` is the React key for the merged call bubble; an
// empty fallback would collide across multiple eventless rows.
const evId = ev.getId();
if (!evId) continue;
const ts = ev.getTs();
const isJoin = !!content.application;
const wasPreviouslyJoined = !!prevContent.application;
const sender = ev.getSender() ?? '';
const stateKey = ev.getStateKey() ?? '';
let sessions = slotSessions.get(slotId);
if (!sessions) {
sessions = [];
slotSessions.set(slotId, sessions);
}
let scan = sessions[sessions.length - 1];
if (!scan || scan.closed) {
// Don't open a session for a stray leave event with no prior join
// context (its session paginated out — there is nothing to render
// and absorbing it would corrupt the next real session's counts).
if (!isJoin && !wasPreviouslyJoined) continue;
scan = {
slotId,
anchorEventId: evId,
anchorSenderId: sender,
startTs: ts,
endTs: ts,
connectedAt: null,
endedAt: null,
joinedCount: 0,
participants: new Set(),
memberStates: new Map(),
joinedAbsoluteExpiries: new Map(),
everJoined: false,
closed: false,
};
sessions.push(scan);
}
if (ts < scan.startTs) {
scan.startTs = ts;
scan.anchorEventId = evId;
scan.anchorSenderId = sender;
}
if (ts > scan.endTs) scan.endTs = ts;
const prevJoined = scan.memberStates.get(stateKey) ?? false;
if (isJoin && !prevJoined) {
scan.joinedCount += 1;
scan.everJoined = true;
if (scan.joinedCount === 2 && scan.connectedAt === null) {
scan.connectedAt = ts;
}
} else if (!isJoin && prevJoined) {
if (scan.joinedCount === 2) {
scan.endedAt = ts;
}
scan.joinedCount = Math.max(0, scan.joinedCount - 1);
}
scan.memberStates.set(stateKey, isJoin);
if (isJoin) {
const createdTs = typeof content.created_ts === 'number' ? content.created_ts : ts;
// `expires === 0` (or any non-positive value) is a misbehaving
// client emitting an instantly-stale membership — refuse and fall
// back to the SDK default rather than declaring the join expired
// on arrival.
const expiresDelta =
typeof content.expires === 'number' && content.expires > 0
? content.expires
: DEFAULT_EXPIRE_DURATION;
scan.joinedAbsoluteExpiries.set(stateKey, createdTs + expiresDelta);
} else {
scan.joinedAbsoluteExpiries.delete(stateKey);
}
// A leave with `prev_content.application` proves this sender was
// previously joined even if their join event paginated out of view.
// Counting it keeps `wasAnswered` correct when the user scrolls
// into the middle of a call.
if (sender && (isJoin || wasPreviouslyJoined)) scan.participants.add(sender);
// Close the session once everyone has left. The next event for this
// slot opens a fresh session — this is what separates back-to-back
// calls that share the same legacy room-scoped slot id.
if (scan.joinedCount === 0 && scan.everJoined) {
scan.closed = true;
}
}
}
/* eslint-enable no-restricted-syntax, no-continue */
const now = Date.now();
// Consecutive unsuccessful sessions from the same caller within this
// window collapse into one bubble (WhatsApp/iOS Recents-style anti-spam).
// Answered/ongoing calls always stand alone.
const MERGE_WINDOW_MS = 60 * 60 * 1000;
type SessionDisplay = {
scan: CallScan;
ongoing: boolean;
wasAnswered: boolean;
};
type DisplayUnit = {
// Anchor scan — its `anchorEventId` is the event the merged bubble
// attaches to in the timeline. We pick the LATEST scan in the group
// so the bubble shows at the most recent attempt's position, while
// earlier attempts disappear from view (the spam we want to hide).
anchor: SessionDisplay;
mergedCount: number;
// false once any answered/ongoing scan lands in the unit — sealed
// units never absorb later scans.
mergeable: boolean;
// Used by the merge predicate: the latest scan's endTs decides the
// gap to the next candidate.
lastEndTs: number;
};
const anchors = new Map();
// Second pass: collapse same-sender unanswered scans into merge units.
// Same justification as the upstream loop — pipeline expression with
// early `continue` is clearer than reduce+filter chains.
/* eslint-disable no-restricted-syntax, no-continue */
for (const sessions of slotSessions.values()) {
const units: DisplayUnit[] = [];
for (const scan of sessions) {
if (!scan.everJoined && scan.participants.size === 0) continue;
const ongoing = Array.from(scan.joinedAbsoluteExpiries.values()).some((exp) => exp > now);
const wasAnswered = scan.connectedAt !== null || scan.participants.size >= 2;
const display: SessionDisplay = { scan, ongoing, wasAnswered };
const mergeable = !ongoing && !wasAnswered;
const last = units[units.length - 1];
if (
mergeable &&
last !== undefined &&
last.mergeable &&
last.anchor.scan.anchorSenderId === scan.anchorSenderId &&
scan.startTs - last.lastEndTs <= MERGE_WINDOW_MS
) {
last.anchor = display;
last.mergedCount += 1;
last.lastEndTs = scan.endTs;
} else {
units.push({
anchor: display,
mergedCount: 1,
mergeable,
lastEndTs: scan.endTs,
});
}
}
for (const unit of units) {
const { scan, ongoing, wasAnswered } = unit.anchor;
const conversationStart = wasAnswered ? scan.connectedAt ?? scan.startTs : null;
const conversationEnd = wasAnswered && !ongoing ? scan.endedAt ?? scan.endTs : null;
anchors.set(scan.anchorEventId, {
callId: scan.slotId,
startTs: scan.startTs,
endTs: ongoing ? null : scan.endTs,
conversationStart,
conversationEnd,
ongoing,
wasAnswered,
participants: Array.from(scan.participants),
anchorSenderId: scan.anchorSenderId,
mergedCount: unit.mergedCount,
});
}
}
/* eslint-enable no-restricted-syntax, no-continue */
return anchors;
})();
const renderMatrixEvent = useMatrixEventRenderer<
[string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean]
>(
{
// Suppress DM-call service events from the timeline. In encrypted
// DMs this takes effect after per-event decryption re-render via
// EncryptedContent; the first render shows the "not decrypted" placeholder.
// Hardcoded strings — migrate to EventType.RTCNotification/RTCDecline
// when MSC4075/MSC4310 stabilize.
'org.matrix.msc4075.rtc.notification': () => null,
'org.matrix.msc4310.rtc.decline': () => null,
[MessageEvent.RoomMessage]: (
mEventId,
mEvent,
item,
timelineSet,
collapse,
streamRailStart,
streamRailEnd
) => {
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 (
)
}
reactions={
reactionRelations && (
)
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(senderId)}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={isOneOnOne}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
msgType={mEvent.getContent().msgtype ?? ''}
hideThreadReplyAffordance={hideThreadReplyAffordance}
hideMainReplyAffordance={hideMainReplyAffordance}
threadSummary={
showThreadSummary ? : undefined
}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
>
{mEvent.isRedacted() ? (
) : (
)}
);
},
[MessageEvent.RoomMessageEncrypted]: (
mEventId,
mEvent,
item,
timelineSet,
collapse,
streamRailStart,
streamRailEnd
) => {
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 (
{() => {
// After decrypt, DM-call service events still route through
// this branch (outer typeToRenderer dispatched on the pre-decrypt
// 'm.room.encrypted' type). Drop the whole row instead of falling
// through to MessageUnsupportedContent. Keys mirror the hardcoded
// literals in the outer filter — migrate together.
const decryptedType = mEvent.getType();
if (decryptedType === 'org.matrix.msc4075.rtc.notification') return null;
if (decryptedType === 'org.matrix.msc4310.rtc.decline') return null;
return (
)
}
reactions={
reactionRelations && (
)
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={isOneOnOne}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
msgType={mEvent.getContent().msgtype ?? ''}
hideThreadReplyAffordance={hideThreadReplyAffordance}
hideMainReplyAffordance={hideMainReplyAffordance}
threadSummary={
showThreadSummary ? (
) : undefined
}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
>
{(() => {
if (mEvent.isRedacted()) return ;
if (decryptedType === MessageEvent.Sticker)
return (
(
}
renderViewer={(p) => }
/>
)}
/>
);
if (decryptedType === 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 (
);
}
if (decryptedType === MessageEvent.RoomMessageEncrypted)
return (
);
return (
);
})()}
);
}}
);
},
[MessageEvent.Sticker]: (
mEventId,
mEvent,
item,
timelineSet,
collapse,
streamRailStart,
streamRailEnd
) => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
const highlighted = focusItem?.index === item && focusItem.highlight;
return (
)
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={isOneOnOne}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
hideThreadReplyAffordance={hideThreadReplyAffordance}
hideMainReplyAffordance={hideMainReplyAffordance}
threadSummary={
showThreadSummary ? : undefined
}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
>
{mEvent.isRedacted() ? (
) : (
(
}
renderViewer={(p) => }
/>
)}
/>
)}
);
},
[StateEvent.RoomMember]: (
mEventId,
mEvent,
item,
_timelineSet,
_collapse,
streamRailStart,
streamRailEnd
) => {
// 1:1 rooms always hide membership/nick/avatar syslines — they are
// pure noise in DMs. Group rooms (3+) respect the per-user settings.
if (isOneOnOne) return null;
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);
return (
);
},
[StateEvent.RoomName]: (
mEventId,
mEvent,
item,
_timelineSet,
_collapse,
streamRailStart,
streamRailEnd
) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName =
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
return (
{senderName}
{t('Organisms.RoomCommon.changed_room_name')}
>
}
highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
/>
);
},
[StateEvent.RoomTopic]: (
mEventId,
mEvent,
item,
_timelineSet,
_collapse,
streamRailStart,
streamRailEnd
) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName =
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
return (
{senderName}
{' changed room topic'}
>
}
highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
/>
);
},
[StateEvent.RoomAvatar]: (
mEventId,
mEvent,
item,
_timelineSet,
_collapse,
streamRailStart,
streamRailEnd
) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName =
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
return (
{senderName}
{' changed room avatar'}
>
}
highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
/>
);
},
[StateEvent.GroupCallMemberPrefix]: (
mEventId,
mEvent,
item,
_timelineSet,
_collapse,
streamRailStart,
streamRailEnd
) => {
// One aggregate bubble per call — only the call's anchor event
// renders, all other join/leave events for the same call_id are
// collapsed (null). `callAnchors` is computed above and mirrored
// in `isRenderableTimelineEvent` so rail-endpoints agree.
const aggregate = callAnchors.get(mEventId);
if (!aggregate) return null;
const highlighted = focusItem?.index === item && focusItem.highlight;
return (
);
},
},
(mEventId, mEvent, item, _timelineSet, _collapse, streamRailStart, streamRailEnd) => {
if (!showHiddenEvents) return null;
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = ;
return (
{senderName}
{' sent '}
{mEvent.getType()}
{' state event'}
}
/>
);
},
(mEventId, mEvent, item, _timelineSet, _collapse, streamRailStart, streamRailEnd) => {
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 = ;
return (
{senderName}
{' sent '}
{mEvent.getType()}
{' event'}
}
/>
);
}
);
let prevEvent: MatrixEvent | undefined;
let isPrevRendered = false;
let newDivider = false;
let dayDivider = false;
// Keep this in sync with renderMatrixEvent early `return null` branches above:
// Stream rail endpoints (start/end) use this predicate to skip hidden
// service / reaction / edit events when deciding whether a visible row is
// the first or last timeline dot.
const isRenderableTimelineEvent = (event: MatrixEvent): boolean => {
const eventIdForRender = event.getId();
if (!eventIdForRender) return false;
const senderId = event.getSender();
if (senderId && ignoredUsersSet.has(senderId)) return false;
if (event.isRedacted() && !showHiddenEvents) return false;
if (reactionOrEditEvent(event)) return false;
// RTC service events (notifications / declines) — once decrypted, the
// SDK's getType() returns the inner type, so this filter catches them.
// While still encrypted+pending-decryption, the event passes the
// RoomMessageEncrypted branch below as «renderable»; once decryption
// completes, the timeline emits a fresh render that re-evaluates this
// predicate with the inner type and falls through to false. The
// transient one-frame mismatch on rail endpoint computation self-heals
// on the very next pass — no code path needed.
const eventType = event.getType();
if (eventType === 'org.matrix.msc4075.rtc.notification') return false;
if (eventType === 'org.matrix.msc4310.rtc.decline') return false;
// Channels-mode filter — see `isChannelsModeHidden` above for the rule
// set and rationale. Same helper used by the renderer null-gate so the
// rail-endpoint scan and the actual render always agree on visibility.
if (channelsMode && isChannelsModeHidden(room, event, isBridged)) return false;
if (eventType === StateEvent.RoomMember) {
// Mirror the membership-sysline gate from the renderer above so the
// rail-endpoint scan and the actual render agree on visibility.
if (isOneOnOne) return false;
const membershipChanged = isMembershipChanged(event);
if (membershipChanged && hideMembershipEvents) return false;
if (!membershipChanged && hideNickAvatarEvents) return false;
return true;
}
if (eventType === StateEvent.GroupCallMemberPrefix) {
// Mirror the renderer: only the per-call anchor event is renderable;
// all other joins/leaves for the same call_id collapse into the
// aggregate bubble. Rail-endpoint scan must agree with the renderer.
return callAnchors.has(event.getId() ?? '');
}
if (
eventType === MessageEvent.RoomMessage ||
eventType === MessageEvent.RoomMessageEncrypted ||
eventType === MessageEvent.Sticker ||
eventType === StateEvent.RoomName ||
eventType === StateEvent.RoomTopic ||
eventType === StateEvent.RoomAvatar
) {
return true;
}
if (typeof event.getStateKey() === 'string') return showHiddenEvents;
if (!showHiddenEvents) return false;
if (Object.keys(event.getContent()).length === 0) return false;
if (event.getRelation()) return false;
// Note: redactions are intentionally NOT filtered here. The catch-all
// renderer still renders them when showHiddenEvents=true (dev tools),
// so the predicate must agree — otherwise rail endpoints would treat a
// visible redaction event as invisible and miscount «is there a renderable
// event before/after this row» on the dev-tools path.
return true;
};
const getTimelineItemEvent = (item: number): MatrixEvent | undefined => {
const [itemTimeline, itemBaseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
if (!itemTimeline) return undefined;
return getTimelineEvent(itemTimeline, getTimelineRelativeIndex(item, itemBaseIndex));
};
// Single forward + reverse pass that records, for each visible item, whether
// there is any RENDERABLE event before / after it. Used to compute Stream
// rail-start (no renderable before) and rail-end (no renderable after).
// Crucially this looks at renderability, not at `isPrevRendered` — the
// latter is mutated by reaction / edit / hidden service events and would
// otherwise reset rail-start in the middle of a continuous DM thread.
const { before: streamRenderableItemHasBefore, after: streamRenderableItemHasAfter } = (() => {
const before = new Map();
const after = new Map();
const items = getItems();
const renderableFlags = items.map((item) => {
const ev = getTimelineItemEvent(item);
return !!ev && isRenderableTimelineEvent(ev);
});
let seenBefore = false;
for (let index = 0; index < items.length; index += 1) {
before.set(items[index], seenBefore);
if (renderableFlags[index]) seenBefore = true;
}
let seenAfter = false;
for (let index = items.length - 1; index >= 0; index -= 1) {
after.set(items[index], seenAfter);
if (renderableFlags[index]) seenAfter = true;
}
return { before, after };
})();
const eventRenderer = (item: 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;
}
const collapsed =
isPrevRendered &&
!dayDivider &&
(!newDivider || eventSender === mx.getUserId()) &&
prevEvent !== undefined &&
prevEvent.getSender() === eventSender &&
prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
// streamRailStart looks at the precomputed «is there a renderable event
// before me in the visible window» — not at `isPrevRendered`, which is
// false for the row right after a reaction / edit / hidden service event
// and would otherwise restart the rail mid-conversation. Symmetric with
// streamRailEnd: only declare a row to be the rail's first dot when the
// visible window is sitting at the genuine timeline start AND no further
// back-pagination is possible — otherwise the «origin» dot would be a
// lie about an earlier untouched history.
const streamRailStart =
rangeAtStart && !canPaginateBack && streamRenderableItemHasBefore.get(item) === false;
const streamRailEnd =
liveTimelineLinked && rangeAtEnd && streamRenderableItemHasAfter.get(item) !== true;
// Channels-mode renderer gate — same helper as the predicate above so
// the rail-endpoint scan and the renderer never disagree on visibility.
const channelsModeHidden = channelsMode && isChannelsModeHidden(room, mEvent, isBridged);
const eventJSX =
channelsModeHidden || reactionOrEditEvent(mEvent)
? null
: renderMatrixEvent(
mEvent.getType(),
typeof mEvent.getStateKey() === 'string',
mEventId,
mEvent,
item,
timelineSet,
collapsed,
streamRailStart,
streamRailEnd
);
prevEvent = mEvent;
isPrevRendered = !!eventJSX;
if (newDivider && eventJSX) {
// TODO(P3c-followup): replace the legacy full-width unread divider with
// a Stream-native first-unread affordance — dot ring/pulse/brightness on
// the rail. The «Jump to unread» chip stays functional in the meantime.
newDivider = false;
}
const dayLabel = (() => {
if (today(mEvent.getTs())) return t('Room.today');
if (yesterday(mEvent.getTs())) return t('Room.yesterday');
return timeDayMonYear(mEvent.getTs());
})();
const renderDayDivider = () => (
{messageLayout === 'channel' ? (
) : (
)}
);
const dayDividerJSX = dayDivider && eventJSX ? renderDayDivider() : null;
if (eventJSX && dayDividerJSX) {
dayDivider = false;
return (
{dayDividerJSX}
{eventJSX}
);
}
return eventJSX;
};
return (
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
}
onClick={handleJumpToUnread}
>
{t('Room.jump_to_unread')}
}
onClick={handleMarkAsRead}
>
{t('Room.mark_as_read')}
)}
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
)}
{(canPaginateBack || !rangeAtStart) && (
<>
>
)}
{getItems().map(eventRenderer)}
{(!liveTimelineLinked || !rangeAtEnd) && (
<>
>
)}
);
}