/* 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 = } /> ); }, (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 =