/* 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 { 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 { Trans, 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, 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, timeDayMonthYear, 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 { 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; }; const PAGINATION_LIMIT = 80; type Timeline = { linkedTimelines: EventTimeline[]; range: ItemRange; }; const useEventTimelineLoader = ( mx: MatrixClient, room: Room, onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void, onError: (err: Error | null) => void ) => { const loadEventTimeline = useCallback( async (eventId: string) => { const [err, replyEvtTimeline] = await to( mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId) ); if (!replyEvtTimeline) { onError(err ?? null); return; } const linkedTimelines = getLinkedTimelines(replyEvtTimeline); const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId); if (absIndex === undefined) { onError(err ?? null); return; } onLoad(eventId, linkedTimelines, absIndex); }, [mx, room, onLoad, onError] ); return loadEventTimeline; }; const useTimelinePagination = ( mx: MatrixClient, timeline: Timeline, setTimeline: Dispatch>, limit: number ) => { const timelineRef = useRef(timeline); timelineRef.current = timeline; const alive = useAlive(); const handleTimelinePagination = useMemo(() => { let fetching = false; const recalibratePagination = ( linkedTimelines: EventTimeline[], timelinesEventsCount: number[], backwards: boolean ) => { const topTimeline = linkedTimelines[0]; const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt; const newLTimelines = getLinkedTimelines(topTimeline); const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline)); const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex); const topTmAddedEvt = timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0]; const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0); setTimeline((currentTimeline) => ({ linkedTimelines: newLTimelines, range: offsetRange > 0 ? { start: currentTimeline.range.start + offsetRange, end: currentTimeline.range.end + offsetRange, } : { ...currentTimeline.range }, })); }; return async (backwards: boolean) => { if (fetching) return; const { linkedTimelines: lTimelines } = timelineRef.current; const timelinesEventsCount = lTimelines.map(timelineToEventsCount); const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1]; if (!timelineToPaginate) return; const paginationToken = timelineToPaginate.getPaginationToken( backwards ? Direction.Backward : Direction.Forward ); if ( !paginationToken && getTimelinesEventsCount(lTimelines) !== getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate)) ) { recalibratePagination(lTimelines, timelinesEventsCount, backwards); return; } fetching = true; const [err] = await to( mx.paginateEventTimeline(timelineToPaginate, { backwards, limit, }) ); if (err) { // TODO: handle pagination error. return; } const fetchedTimeline = timelineToPaginate.getNeighbouringTimeline( backwards ? Direction.Backward : Direction.Forward ) ?? timelineToPaginate; // Decrypt all event ahead of render cycle const roomId = fetchedTimeline.getRoomId(); const room = roomId ? mx.getRoom(roomId) : null; if (room?.hasEncryptionStateEvent()) { await to(decryptAllTimelineEvent(mx, fetchedTimeline)); } fetching = false; if (alive()) { recalibratePagination(lTimelines, timelinesEventsCount, backwards); } }; }, [mx, alive, setTimeline, limit]); return handleTimelinePagination; }; const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => { useEffect(() => { const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = ( mEvent, eventRoom, toStartOfTimeline, removed, data ) => { if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return; onArrive(mEvent); }; const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => { if (eventRoom?.roomId !== room.roomId) return; onArrive(mEvent); }; room.on(RoomEvent.Timeline, handleTimelineEvent); room.on(RoomEvent.Redaction, handleRedaction); return () => { room.removeListener(RoomEvent.Timeline, handleTimelineEvent); room.removeListener(RoomEvent.Redaction, handleRedaction); }; }, [room, onArrive]); }; const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { useEffect(() => { const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => { if (r.roomId !== room.roomId) return; onRefresh(); }; room.on(RoomEvent.TimelineRefresh, handleTimelineRefresh); return () => { room.removeListener(RoomEvent.TimelineRefresh, handleTimelineRefresh); }; }, [room, onRefresh]); }; const getInitialTimeline = (room: Room) => { const linkedTimelines = getLinkedTimelines(getLiveTimeline(room)); const evLength = getTimelinesEventsCount(linkedTimelines); return { linkedTimelines, range: { start: Math.max(evLength - PAGINATION_LIMIT, 0), end: evLength, }, }; }; const getEmptyTimeline = () => ({ range: { start: 0, end: 0 }, linkedTimelines: [], }); const getRoomUnreadInfo = (room: Room, scrollTo = false) => { const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? ''); if (!readUptoEventId) return undefined; const evtTimeline = getEventTimeline(room, readUptoEventId); const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward); return { readUptoEventId, inLiveTimeline: latestTimeline === room.getLiveTimeline(), scrollTo, }; }; // 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 }: 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'; // 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; 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. requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, 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] ) ); 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]) ); // 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(); 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} > {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} > {(() => { 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} > {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); const iconSrc = parsed.icon === Icons.ArrowGoRightPlus ? Icons.ArrowGoRight : parsed.icon; const timeJSX = ( } /> ); }, [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); const timeJSX = (