diff --git a/src/app/features/room/RoomTimeline.test.tsx b/src/app/features/room/RoomTimeline.test.tsx index aa6820f707..49c4cd2cde 100644 --- a/src/app/features/room/RoomTimeline.test.tsx +++ b/src/app/features/room/RoomTimeline.test.tsx @@ -1,6 +1,6 @@ import { EventEmitter } from 'events'; import { forwardRef, useImperativeHandle, type ReactNode } from 'react'; -import { act, render } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import type { Editor } from 'slate'; import type { Room } from '$types/matrix-sdk'; @@ -17,10 +17,19 @@ const { rendererCtxPermissions, rendererCtxSettings, processedTimelineOptions, + processedRowsVisible, + processedRowIds, windowFocused, rowItemIndex, rowRenders, eventRedacted, + unrenderedJumpTarget, + liveTimeline, + eventTimeline, + navigateRoomMock, + vListProps, + timelineSyncOptions, + timelineActionsOptions, } = vi.hoisted(() => ({ vListHandle: { scrollSize: 1000, @@ -33,15 +42,18 @@ const { }, timelineSync: { eventsLength: 1, + prependVersion: 0, timeline: { linkedTimelines: [] }, liveTimelineLinked: true, backwardStatus: 'idle', forwardStatus: 'idle', canPaginateBack: false, - focusItem: undefined as { index: number; scrollTo: boolean; highlight: boolean } | undefined, + jumpFailed: false, + focusItem: undefined as { eventId: string; scrollTo: boolean; highlight: boolean } | undefined, setFocusItem: vi.fn<() => void>(), setTimeline: vi.fn<() => void>(), loadEventTimeline: vi.fn<() => void>(), + cancelEventTimelineLoad: vi.fn<() => void>(), handleTimelinePagination: vi.fn<() => void>(), }, setUnreadTimelineMock: vi.fn<() => void>(), @@ -53,10 +65,24 @@ const { processedTimelineOptions: { current: undefined as Record | undefined, }, + processedRowsVisible: { current: true }, + processedRowIds: { current: ['$evt1'] as string[] }, windowFocused: { current: false }, rowItemIndex: { current: 0 }, rowRenders: { count: 0 }, eventRedacted: { current: false }, + unrenderedJumpTarget: { + current: undefined as { eventId: string; rawIndex: number } | undefined, + }, + liveTimeline: { + getState: () => undefined, + getEvents: () => [{ getId: () => '$evt1' }] as unknown[], + }, + eventTimeline: { current: undefined as object | undefined }, + navigateRoomMock: vi.fn<() => void>(), + vListProps: { shift: false, shiftValues: [] as boolean[] }, + timelineSyncOptions: { current: undefined as Record | undefined }, + timelineActionsOptions: { current: undefined as Record | undefined }, })); let lastOnScroll: ((offset: number) => void) | undefined; @@ -67,14 +93,18 @@ vi.mock('virtua', () => ({ data, children, onScroll, + shift, }: { data: unknown[]; children: (item: unknown, index: number) => ReactNode; onScroll?: (offset: number) => void; + shift?: boolean; }, ref ) { lastOnScroll = onScroll; + vListProps.shift = shift ?? false; + vListProps.shiftValues.push(vListProps.shift); useImperativeHandle(ref, () => vListHandle); return ( // Outer element is the VList scroll container (messageListRef's first @@ -106,7 +136,7 @@ vi.mock('$hooks/useMessageEdit', () => ({ })); vi.mock('$hooks/useRoomNavigate', () => ({ - useRoomNavigate: () => ({ navigateRoom: vi.fn<() => void>() }), + useRoomNavigate: () => ({ navigateRoom: navigateRoomMock }), })); vi.mock('$hooks/useSpace', () => ({ useSpaceOptionally: () => undefined })); @@ -120,24 +150,30 @@ vi.mock('$state/hooks/userRoomProfile', () => ({ })); vi.mock('$hooks/timeline/useTimelineSync', () => ({ - useTimelineSync: () => ({ - ...timelineSync, - setUnreadInfo: setUnreadTimelineMock, - }), + useTimelineSync: (options: Record) => { + timelineSyncOptions.current = options; + return { + ...timelineSync, + setUnreadInfo: setUnreadTimelineMock, + }; + }, })); vi.mock('$hooks/timeline/useTimelineActions', () => ({ - useTimelineActions: () => ({ - handleUserClick: vi.fn<() => void>(), - handleUsernameClick: vi.fn<() => void>(), - handleReplyClick: vi.fn<() => void>(), - handleReactionToggle: vi.fn<() => void>(), - handleEdit: vi.fn<() => void>(), - handleResend: vi.fn<() => void>(), - handleDeleteFailedSend: vi.fn<() => void>(), - handleOpenReply: vi.fn<() => void>(), - setOpenThread: vi.fn<() => void>(), - }), + useTimelineActions: (options: Record) => { + timelineActionsOptions.current = options; + return { + handleUserClick: vi.fn<() => void>(), + handleUsernameClick: vi.fn<() => void>(), + handleReplyClick: vi.fn<() => void>(), + handleReactionToggle: vi.fn<() => void>(), + handleEdit: vi.fn<() => void>(), + handleResend: vi.fn<() => void>(), + handleDeleteFailedSend: vi.fn<() => void>(), + handleOpenReply: vi.fn<() => void>(), + setOpenThread: vi.fn<() => void>(), + }; + }, })); vi.mock('$hooks/timeline/useProcessedTimeline', async (importOriginal) => { @@ -163,14 +199,36 @@ vi.mock('$hooks/timeline/useProcessedTimeline', async (importOriginal) => { reactionsKey: '', content: undefined, } as unknown as ProcessedEvent; + // Same object per event id every call so the row memo can compare by identity. + const eventsById = new Map([['$evt1', fakeEvent]]); + const rowFor = (id: string, index: number): ProcessedEvent => { + const existing = eventsById.get(id); + if (existing) return existing; + const row = { + ...fakeEvent, + id, + itemIndex: index, + mEvent: { + getType: () => 'm.room.message', + getStateKey: () => undefined, + getTs: () => Date.now(), + getSender: () => '@me:example.org', + getId: () => id, + isRedacted: () => false, + }, + } as unknown as ProcessedEvent; + eventsById.set(id, row); + return row; + }; return { ...actual, useProcessedTimeline: (options: Record) => { processedTimelineOptions.current = options; - // Same object every call so the row memo can compare eventData by identity. fakeEvent.itemIndex = rowItemIndex.current; fakeEvent.isRedacted = eventRedacted.current; - return (options.items as number[]).length === 0 ? [] : [fakeEvent]; + return !processedRowsVisible.current || (options.items as number[]).length === 0 + ? [] + : processedRowIds.current.map(rowFor); }, }; }); @@ -226,10 +284,18 @@ vi.mock('$components/room-intro', () => ({ RoomIntro: () => null })); vi.mock('$utils/timeline', () => ({ getRoomUnreadInfo: () => getRoomUnreadInfoMock(), - getEventTimeline: () => undefined, + getEventTimeline: (_room: unknown, eventId: string) => + unrenderedJumpTarget.current?.eventId === eventId ? {} : eventTimeline.current, getFirstLinkedTimeline: () => undefined, getInitialTimeline: () => undefined, - getEventIdAbsoluteIndex: () => undefined, + getEventIdAbsoluteIndex: () => unrenderedJumpTarget.current?.rawIndex, + isNewestLiveEvent: ( + room: { getLiveTimeline: () => { getEvents?: () => { getId?: () => string }[] } }, + id: string + ) => { + const events = room.getLiveTimeline().getEvents?.() ?? []; + return events[events.length - 1]?.getId?.() === id; + }, })); vi.mock('$utils/notifications', () => ({ markAsRead: vi.fn<() => void>() })); @@ -268,7 +334,8 @@ const fireResize = (element: Element) => { const roomEmitter = new EventEmitter(); const room = { roomId: '!room:example.org', - getLiveTimeline: () => undefined, + getLiveTimeline: () => liveTimeline, + findEventById: () => undefined, on: roomEmitter.on.bind(roomEmitter), removeListener: roomEmitter.removeListener.bind(roomEmitter), } as unknown as Room; @@ -287,23 +354,41 @@ const getContentEl = (container: HTMLElement) => { }; const renderTimeline = () => render(); +const settleInitialScroll = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); beforeEach(() => { getRoomUnreadInfoMock.mockReset(); rendererCtxPermissions.canRedact = false; rendererCtxSettings.hideReads = false; processedTimelineOptions.current = undefined; + timelineSyncOptions.current = undefined; + timelineActionsOptions.current = undefined; + processedRowsVisible.current = true; + processedRowIds.current = ['$evt1']; windowFocused.current = false; rowItemIndex.current = 0; rowRenders.count = 0; eventRedacted.current = false; + unrenderedJumpTarget.current = undefined; + eventTimeline.current = liveTimeline; + liveTimeline.getEvents = () => [{ getId: () => '$evt1' }]; + navigateRoomMock.mockReset(); + vListProps.shift = false; + vListProps.shiftValues.length = 0; timelineSync.eventsLength = 1; + timelineSync.prependVersion = 0; timelineSync.focusItem = undefined; timelineSync.canPaginateBack = false; timelineSync.liveTimelineLinked = true; + timelineSync.jumpFailed = false; timelineSync.backwardStatus = 'idle'; timelineSync.forwardStatus = 'idle'; (timelineSync.handleTimelinePagination as ReturnType).mockReset(); + (timelineSync.cancelEventTimelineLoad as ReturnType).mockReset(); + (timelineSync.loadEventTimeline as ReturnType).mockReset(); }); describe('RoomTimeline content ResizeObserver', () => { @@ -329,9 +414,7 @@ describe('RoomTimeline content ResizeObserver', () => { // Let the mount-time initial scroll and its 80ms timer settle, then // isolate the content-resize behavior. - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); + await settleInitialScroll(); vListHandle.scrollToIndex.mockClear(); const contentEl = getContentEl(container); @@ -346,9 +429,7 @@ describe('RoomTimeline content ResizeObserver', () => { it('does not re-pin on content growth after scrolling off the bottom', async () => { const { container } = renderTimeline(); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); + await settleInitialScroll(); // Scroll far off the bottom: scrollSize - offset - viewportSize >= 100. act(() => lastOnScroll?.(0)); @@ -360,18 +441,164 @@ describe('RoomTimeline content ResizeObserver', () => { expect(vListHandle.scrollToIndex).not.toHaveBeenCalled(); }); + it('resolves a jump target by event id, not by raw timeline index', async () => { + timelineSync.liveTimelineLinked = false; + const { rerender } = renderTimeline(); + + await settleInitialScroll(); + vListHandle.scrollToIndex.mockClear(); + + rowItemIndex.current = 9; + timelineSync.focusItem = { eventId: '$evt1', scrollTo: true, highlight: true }; + rerender(); + + expect(vListHandle.scrollToIndex).toHaveBeenCalledWith( + 0, + expect.objectContaining({ align: 'center' }) + ); + }); + + it('treats a jump to the final live row as latest', async () => { + const { rerender, queryByText } = render( + + ); + + await settleInitialScroll(); + vListHandle.scrollToIndex.mockClear(); + + timelineSync.focusItem = { eventId: '$evt1', scrollTo: true, highlight: true }; + rerender(); + + expect(vListHandle.scrollToIndex).toHaveBeenCalledWith( + 0, + expect.objectContaining({ align: 'end' }) + ); + expect(queryByText('Jump to Latest')).toBeNull(); + expect(navigateRoomMock).toHaveBeenCalledWith(room.roomId, undefined, { replace: true }); + }); + + it('does not treat the final rendered row from a historical timeline as latest', async () => { + eventTimeline.current = {}; + const { rerender, getByText } = render( + + ); + + await settleInitialScroll(); + vListHandle.scrollToIndex.mockClear(); + + timelineSync.focusItem = { eventId: '$evt1', scrollTo: true, highlight: true }; + rerender(); + + expect(vListHandle.scrollToIndex).toHaveBeenCalledWith( + 0, + expect.objectContaining({ align: 'center' }) + ); + expect(getByText('Jump to Latest')).toBeTruthy(); + expect(navigateRoomMock).not.toHaveBeenCalled(); + }); + + it('does not treat a jump target as latest while the room has newer events', async () => { + eventTimeline.current = liveTimeline; + timelineSync.liveTimelineLinked = true; + // The room's newest event is not the jump target: it is still 40 events ahead. + (liveTimeline as unknown as { getEvents: () => { getId: () => string }[] }).getEvents = () => [ + { getId: () => '$evt1' }, + { getId: () => '$newer' }, + ]; + + const { rerender, getByText } = render( + + ); + + await settleInitialScroll(); + vListHandle.scrollToIndex.mockClear(); + + timelineSync.focusItem = { eventId: '$evt1', scrollTo: true, highlight: true }; + rerender(); + + expect(vListHandle.scrollToIndex).toHaveBeenCalledWith( + 0, + expect.objectContaining({ align: 'center' }) + ); + expect(navigateRoomMock).not.toHaveBeenCalled(); + expect(getByText('Jump to Latest')).toBeTruthy(); + }); + + it('preserves the viewport without snapping back to an old target after a prepend', async () => { + timelineSync.liveTimelineLinked = false; + const { rerender } = render(); + + await settleInitialScroll(); + vListHandle.scrollToIndex.mockClear(); + + timelineSync.focusItem = { eventId: '$evt1', scrollTo: false, highlight: true }; + timelineSync.eventsLength = 2; + timelineSync.prependVersion = 1; + vListProps.shiftValues.length = 0; + rerender(); + + expect(vListProps.shiftValues).toContain(true); + expect(vListHandle.scrollToIndex).not.toHaveBeenCalled(); + }); + + it('retries an unresolved focus after timeline events are rendered', async () => { + timelineSync.liveTimelineLinked = false; + timelineSync.focusItem = { eventId: '$evt1', scrollTo: true, highlight: true }; + processedRowsVisible.current = false; + const { rerender } = renderTimeline(); + + expect(vListHandle.scrollToIndex).not.toHaveBeenCalled(); + + processedRowsVisible.current = true; + timelineSync.eventsLength = 2; + rerender(); + + expect(vListHandle.scrollToIndex).toHaveBeenCalledWith( + 0, + expect.objectContaining({ align: 'center' }) + ); + }); + + it('cancels a pending context load when opening an already-rendered event', () => { + renderTimeline(); + + const handleOpenEvent = timelineActionsOptions.current?.handleOpenEvent as + | ((eventId: string) => void) + | undefined; + act(() => handleOpenEvent?.('$evt1')); + + expect(timelineSync.cancelEventTimelineLoad).toHaveBeenCalled(); + }); + + it('keeps a fresh highlight visible for two seconds when refocusing the same event', () => { + vi.useFakeTimers(); + try { + timelineSync.focusItem = { eventId: '$evt1', scrollTo: false, highlight: true }; + const { rerender } = renderTimeline(); + + act(() => vi.advanceTimersByTime(1500)); + timelineSync.focusItem = { eventId: '$evt1', scrollTo: false, highlight: true }; + rerender(); + act(() => vi.advanceTimersByTime(600)); + + expect(timelineSync.setFocusItem).not.toHaveBeenCalledWith(undefined); + + act(() => vi.advanceTimersByTime(1400)); + expect(timelineSync.setFocusItem).toHaveBeenCalledWith(undefined); + } finally { + vi.useRealTimers(); + } + }); + it('scrolls to the nearest visible row when the jump target is filtered out', async () => { const { rerender } = renderTimeline(); // Let the mount-time initial scroll settle, then isolate the focus jump. - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); + await settleInitialScroll(); vListHandle.scrollToIndex.mockClear(); - // The mocked timeline exposes one row (itemIndex 0), so raw index 5 has no - // exact row to match. - timelineSync.focusItem = { index: 5, scrollTo: true, highlight: true }; + unrenderedJumpTarget.current = { eventId: '$hidden', rawIndex: 5 }; + timelineSync.focusItem = { eventId: '$hidden', scrollTo: true, highlight: true }; rerender(); expect(vListHandle.scrollToIndex).toHaveBeenCalledWith( @@ -383,8 +610,8 @@ describe('RoomTimeline content ResizeObserver', () => { const [setFocusItemCall] = (timelineSync.setFocusItem as ReturnType).mock.calls; type FocusItem = NonNullable; const updater = setFocusItemCall?.[0] as (prev: FocusItem) => FocusItem; - expect(updater({ index: 5, scrollTo: true, highlight: true })).toEqual({ - index: 0, + expect(updater({ eventId: '$hidden', scrollTo: true, highlight: true })).toEqual({ + eventId: '$evt1', scrollTo: false, highlight: true, }); @@ -441,15 +668,9 @@ describe('failed backfill on an empty timeline', () => { timelineSync.canPaginateBack = true; timelineSync.backwardStatus = 'error'; - const { container, getByText } = renderTimeline(); + const { getByText } = renderTimeline(); - expect(container.textContent).toContain('Failed to load history.'); - expect(container.querySelector('[data-testid="vlist-content"]')?.textContent).not.toContain( - 'placeholder' - ); - const listWrapper = container.querySelector('[data-testid="vlist-scroll"]') - ?.parentElement as HTMLElement; - expect(listWrapper.style.opacity).toBe('1'); + expect(getByText('Failed to load history.')).toBeVisible(); act(() => { getByText('Retry').click(); @@ -499,12 +720,12 @@ describe('MemoizedTimelineItem', () => { expect(rowRenders.count).toBe(before); }); - it('does not re-render for a focusItem pointing at the -1 merged-row sentinel', () => { + it('does not re-render a merged relation row for a focusItem targeting another event', () => { rowItemIndex.current = -1; const { rerender } = renderTimeline(); const before = rowRenders.count; - timelineSync.focusItem = { index: -1, highlight: true, scrollTo: false }; + timelineSync.focusItem = { eventId: '$other', highlight: true, scrollTo: false }; rerender(); expect(rowRenders.count).toBe(before); @@ -521,6 +742,74 @@ describe('MemoizedTimelineItem', () => { }); }); +describe('jump reveal and focus-regain read receipts', () => { + it('keeps the timeline hidden while a jump is still pending', () => { + timelineSync.jumpFailed = false; + const { getByText } = render( + + ); + + expect(getByText('canRedact:false hideReads:false')).not.toBeVisible(); + }); + + it('restarts a route jump when the Room instance is replaced with the same id', () => { + const replacementRoom = Object.create(room) as Room; + const { rerender } = render( + + ); + expect(timelineSync.loadEventTimeline).toHaveBeenCalledTimes(1); + + rerender( + + ); + + expect(timelineSync.loadEventTimeline).toHaveBeenCalledTimes(2); + }); + + it('reveals the timeline when the jump fails instead of leaving a blank room', () => { + timelineSync.jumpFailed = false; + const { getByText, rerender } = render( + + ); + expect(getByText('canRedact:false hideReads:false')).not.toBeVisible(); + + timelineSync.jumpFailed = true; + act(() => { + rerender(); + }); + + expect(getByText('canRedact:false hideReads:false')).toBeVisible(); + }); + + it('restores bottom state when a jump fails', async () => { + const { getByText, queryByText } = renderTimeline(); + await waitFor(() => expect(getByText('canRedact:false hideReads:false')).toBeVisible()); + + act(() => lastOnScroll?.(0)); + expect(getByText('Jump to Latest')).toBeTruthy(); + + const onJumpError = timelineSyncOptions.current?.onJumpError as (() => void) | undefined; + act(() => onJumpError?.()); + + expect(queryByText('Jump to Latest')).toBeNull(); + }); + + it('clears a notification route when an own message returns to the live timeline', async () => { + timelineSync.jumpFailed = true; + const { getByText, queryByText } = render( + + ); + await waitFor(() => expect(getByText('canRedact:false hideReads:false')).toBeVisible()); + expect(getByText('Jump to Latest')).toBeTruthy(); + + const onReturnToLive = timelineSyncOptions.current?.onReturnToLive as (() => void) | undefined; + act(() => onReturnToLive?.()); + + expect(navigateRoomMock).toHaveBeenCalledWith(room.roomId, undefined, { replace: true }); + expect(queryByText('Jump to Latest')).toBeNull(); + }); +}); + describe('unread read marker (normal sync)', () => { it('feeds the read marker to timeline processing and clears it on window blur', () => { getRoomUnreadInfoMock.mockReturnValue({ @@ -566,9 +855,7 @@ describe('scroll-edge pagination', () => { it('marks the timeline as at the bottom when jumping to latest', async () => { const { getByText, queryByText } = renderTimeline(); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); + await waitFor(() => expect(getByText('canRedact:false hideReads:false')).toBeVisible()); // Simulate a stale virtualizer measurement reporting the viewport above the // bottom. A programmatic jump may not emit a follow-up scroll event. @@ -579,6 +866,7 @@ describe('scroll-edge pagination', () => { getByText('Jump to Latest').click(); }); + expect(timelineSync.cancelEventTimelineLoad).toHaveBeenCalled(); expect(queryByText('Jump to Latest')).toBeNull(); }); @@ -618,9 +906,7 @@ describe('backfill scroll anchoring', () => { const { rerender } = renderTimeline(); // Let the mount-time initial scroll settle, then watch backfill only. - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); + await settleInitialScroll(); vListHandle.scrollToIndex.mockClear(); timelineSync.backwardStatus = 'loading'; @@ -637,9 +923,7 @@ describe('backfill scroll anchoring', () => { it('does not scroll away after a backfill if the user had scrolled up', async () => { const { rerender } = renderTimeline(); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); + await settleInitialScroll(); act(() => lastOnScroll?.(0)); vListHandle.scrollToIndex.mockClear(); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index fde976baf0..1d2da3e970 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -62,6 +62,7 @@ import { getFirstLinkedTimeline, getInitialTimeline, getEventIdAbsoluteIndex, + isNewestLiveEvent, } from '$utils/timeline'; import { useTimelineSync } from '$hooks/timeline/useTimelineSync'; import { useTimelineActions } from '$hooks/timeline/useTimelineActions'; @@ -112,9 +113,8 @@ const getDayDividerText = (ts: number) => { const focusItemAffectsEvent = (focusItem: unknown, eventData: ProcessedEvent | undefined) => { if (!focusItem || typeof focusItem !== 'object' || !eventData) return false; - const index = 'index' in focusItem ? focusItem.index : undefined; - // itemIndex -1 marks a merged relation row, which is never a focus target. - return typeof index === 'number' && index >= 0 && index === eventData.itemIndex; + const focusEventId = 'eventId' in focusItem ? focusItem.eventId : undefined; + return typeof focusEventId === 'string' && focusEventId === eventData.id; }; const eventIdAffectsEvent = (eventId: string | null | undefined, eventData?: ProcessedEvent) => @@ -304,7 +304,8 @@ const MemoizedTimelineItem = memo( prev.eventData.eventSender === next.eventData.eventSender && prev.eventData.editId === next.eventData.editId && prev.eventData.reactionsKey === next.eventData.reactionsKey && - prev.eventData.content === next.eventData.content + prev.eventData.content === next.eventData.content && + prev.eventData.sendStatus === next.eventData.sendStatus ); } ); @@ -366,6 +367,8 @@ export function RoomTimeline({ const handleEdit = propsOnEditId ?? internalEdit.handleEdit; const { navigateRoom } = useRoomNavigate(); const isInactivePanel = useIsInactivePanel(); + const isInactivePanelRef = useRef(isInactivePanel); + isInactivePanelRef.current = isInactivePanel; // Shared renderer context — replaces 17+ inline useSetting calls, linkifyOpts, // htmlReactParserOptions, and the permissions block that were duplicated with @@ -545,6 +548,11 @@ export function RoomTimeline({ }, [setAtBottom] ); + const handleJumpError = useCallback(() => setAtBottom(true), [setAtBottom]); + const handleReturnToLive = useCallback(() => { + if (eventId) navigateRoom(room.roomId, undefined, { replace: true }); + setAtBottom(true); + }, [eventId, navigateRoom, room.roomId, setAtBottom]); const timelineSync = useTimelineSync({ room, @@ -557,8 +565,14 @@ export function RoomTimeline({ setUnreadInfo, hideReadsRef, readUptoEventIdRef, + isInactivePanelRef, + onJumpError: handleJumpError, + onReturnToLive: handleReturnToLive, isEventVisible: useCallback( (mEvent: MatrixEvent, timelineSet: EventTimelineSet) => { + const sender = mEvent.getSender(); + if (sender && ignoredUsersSet.has(sender)) return false; + const type = mEvent.getType(); const isEdit = isEditEvent(mEvent); const isReaction = isReactionEvent(mEvent); @@ -639,12 +653,25 @@ export function RoomTimeline({ return true; }, - [hiddenEvents, hideMemberInReadOnly, isReadOnly, hideMembershipEvents, hideNickAvatarEvents] + [ + hiddenEvents, + hideMemberInReadOnly, + isReadOnly, + hideMembershipEvents, + hideNickAvatarEvents, + ignoredUsersSet, + ] ), }); timelineSyncRef.current = timelineSync; + const previousPrependVersionRef = useRef(timelineSync.prependVersion); + const shiftForPrepend = previousPrependVersionRef.current !== timelineSync.prependVersion; + useLayoutEffect(() => { + previousPrependVersionRef.current = timelineSync.prependVersion; + }, [timelineSync.prependVersion]); + const eventsLengthRef = useRef(timelineSync.eventsLength); eventsLengthRef.current = timelineSync.eventsLength; @@ -661,12 +688,26 @@ export function RoomTimeline({ const forwardStatusRef = useRef(timelineSync.forwardStatus); forwardStatusRef.current = timelineSync.forwardStatus; - const getRawIndexToProcessedIndex = useCallback((rawIndex: number): number | undefined => { - const events = processedEventsRef.current; - const match = events.find((e) => e.itemIndex === rawIndex); - if (!match) return undefined; - return events.indexOf(match); - }, []); + const resolveFocusRowIndex = useCallback( + (focusEventId: string): number | undefined => { + const events = processedEventsRef.current; + const rowIndex = events.findIndex((e) => e.id === focusEventId); + if (rowIndex >= 0) return rowIndex; + + // Targets with no rendered row (thread replies, hidden membership/name + // events): land on the nearest visible row. + const evtTimeline = getEventTimeline(room, focusEventId); + if (!evtTimeline) return undefined; + const rawIndex = getEventIdAbsoluteIndex( + timelineSyncRef.current.timeline.linkedTimelines, + evtTimeline, + focusEventId + ); + if (rawIndex === undefined) return undefined; + return getProcessedRowIndexForRawTimelineIndex(events, rawIndex)?.rowIndex; + }, + [room] + ); useLayoutEffect(() => { if ( @@ -772,50 +813,62 @@ export function RoomTimeline({ }, [timelineSync.backwardStatus, scrollToBottom]); useEffect(() => { - let timeoutId: ReturnType | undefined; - if (timelineSync.focusItem) { - if (timelineSync.focusItem.scrollTo && vListRef.current) { - let processedIndex = getRawIndexToProcessedIndex(timelineSync.focusItem.index); - let focusRawIndex = timelineSync.focusItem.index; - if (processedIndex === undefined) { - // Jump targets with no rendered row (thread replies, hidden - // membership/name events): land on the nearest visible row. - const nearest = getProcessedRowIndexForRawTimelineIndex( - processedEventsRef.current, - timelineSync.focusItem.index - ); - if (nearest) { - processedIndex = nearest.rowIndex; - focusRawIndex = nearest.focusRawIndex; - } - } - if (processedIndex !== undefined) { - vListRef.current.scrollToIndex(processedIndex, { align: 'center' }); - timelineSync.setFocusItem((prev) => - prev ? { ...prev, index: focusRawIndex, scrollTo: false } : undefined - ); - } + if (!timelineSync.focusItem?.scrollTo || !vListRef.current) return; + const processedIndex = resolveFocusRowIndex(timelineSync.focusItem.eventId); + if (processedIndex === undefined) return; + + const landedId = processedEventsRef.current[processedIndex]?.id; + // Being the last processed row is not enough: a partly loaded window makes any + // row the last one. Only the room's newest event is the live end. + const isLiveEnd = + landedId === timelineSync.focusItem.eventId && + isNewestLiveEvent(room, timelineSync.focusItem.eventId) && + getEventTimeline(room, timelineSync.focusItem.eventId) === room.getLiveTimeline() && + timelineSync.liveTimelineLinked && + processedIndex === processedEventsRef.current.length - 1; + if (isLiveEnd) { + setAtBottom(true); + scrollToBottom(); + if (eventId === timelineSync.focusItem.eventId) { + navigateRoom(room.roomId, undefined, { replace: true }); } - timeoutId = setTimeout(() => { - timelineSync.setFocusItem(undefined); - }, 2000); + } else { + vListRef.current.scrollToIndex(processedIndex, { align: 'center' }); } - return () => { - if (timeoutId !== undefined) clearTimeout(timeoutId); - }; - }, [timelineSync.focusItem, timelineSync, reducedMotion, getRawIndexToProcessedIndex]); + timelineSyncRef.current.setFocusItem((prev) => + prev ? { ...prev, eventId: landedId ?? prev.eventId, scrollTo: false } : undefined + ); + }, [ + timelineSync.focusItem, + timelineSync.eventsLength, + timelineSync.liveTimelineLinked, + eventId, + navigateRoom, + resolveFocusRowIndex, + room, + scrollToBottom, + setAtBottom, + ]); + + useEffect(() => { + if (!timelineSync.focusItem) return undefined; + const timeoutId = setTimeout(() => { + timelineSyncRef.current.setFocusItem(undefined); + }, 2000); + return () => clearTimeout(timeoutId); + }, [timelineSync.focusItem]); useEffect(() => { - if (timelineSync.focusItem) { + if (timelineSync.focusItem || timelineSync.jumpFailed) { setIsReady(true); } - }, [timelineSync.focusItem]); + }, [timelineSync.focusItem, timelineSync.jumpFailed]); useEffect(() => { if (!eventId) return; - setIsReady(false); + if (!timelineSyncRef.current.jumpFailed) setIsReady(false); jumpToEvent(eventId); - }, [eventId, room.roomId, jumpToEvent]); + }, [eventId, room, jumpToEvent]); useEffect(() => { if (eventId) return; @@ -836,7 +889,12 @@ export function RoomTimeline({ : undefined; if (absoluteIndex !== undefined) { - const processedIndex = getRawIndexToProcessedIndex(absoluteIndex); + const rows = processedEventsRef.current; + const exactRow = rows.findIndex((e) => e.id === readUptoEventId); + const processedIndex = + exactRow >= 0 + ? exactRow + : getProcessedRowIndexForRawTimelineIndex(rows, absoluteIndex)?.rowIndex; if (processedIndex !== undefined && vListRef.current) { vListRef.current.scrollToIndex(processedIndex, { align: 'start' }); } @@ -847,14 +905,7 @@ export function RoomTimeline({ setUnreadInfo((prev) => (prev ? { ...prev, scrollTo: false } : prev)); } } - }, [ - room, - unreadInfo, - timelineSync.timeline.linkedTimelines, - eventId, - isReady, - getRawIndexToProcessedIndex, - ]); + }, [room, unreadInfo, timelineSync.timeline.linkedTimelines, eventId, isReady]); useEffect(() => { const el = messageListRef.current; @@ -937,37 +988,20 @@ export function RoomTimeline({ handleEdit, handleOpenEvent: (id) => { const anchorId = unwrapRelationJumpTarget(room, id); - let evtTimeline = getEventTimeline(room, anchorId); - let resolvedForIndex = anchorId; - if (!evtTimeline && anchorId !== id) { - evtTimeline = getEventTimeline(room, id); - resolvedForIndex = id; + let resolvedId = anchorId; + let processedIndex = resolveFocusRowIndex(anchorId); + if (processedIndex === undefined && anchorId !== id) { + resolvedId = id; + processedIndex = resolveFocusRowIndex(id); } - const absoluteIndex = evtTimeline - ? getEventIdAbsoluteIndex( - timelineSync.timeline.linkedTimelines, - evtTimeline, - resolvedForIndex - ) - : undefined; - if (typeof absoluteIndex === 'number') { - let processedIndex = getRawIndexToProcessedIndex(absoluteIndex); - let focusRawIndex = absoluteIndex; - if (processedIndex === undefined) { - const nearest = getProcessedRowIndexForRawTimelineIndex( - processedEventsRef.current, - absoluteIndex - ); - if (nearest) { - processedIndex = nearest.rowIndex; - focusRawIndex = nearest.focusRawIndex; - } - } - if (vListRef.current && processedIndex !== undefined) { + if (processedIndex !== undefined) { + timelineSync.cancelEventTimelineLoad(); + if (vListRef.current) { vListRef.current.scrollToIndex(processedIndex, { align: 'center' }); } - timelineSync.setFocusItem({ index: focusRawIndex, scrollTo: false, highlight: true }); + const landedId = processedEventsRef.current[processedIndex]?.id ?? resolvedId; + timelineSync.setFocusItem({ eventId: landedId, scrollTo: false, highlight: true }); } else { jumpToEvent(anchorId); } @@ -1040,7 +1074,7 @@ export function RoomTimeline({ useCallback( (inFocus) => { if (inFocus) { - if (atBottomState) tryAutoMarkAsRead(); + if (atBottomState && timelineSync.liveTimelineLinked) tryAutoMarkAsRead(); return; } // Re-anchor the divider at the last read when tabbing out while caught up. @@ -1330,7 +1364,7 @@ export function RoomTimeline({ ref={vListRef} data={processedEvents} - shift={shift} + shift={shift || shiftForPrepend} className={css.messageList} style={{ flex: 1, @@ -1404,6 +1438,7 @@ export function RoomTimeline({ outlined before={chipIcon(ArrowDown)} onClick={() => { + timelineSync.cancelEventTimelineLoad(); if (eventId) navigateRoom(room.roomId, undefined, { replace: true }); timelineSync.setTimeline(getInitialTimeline(room)); setAtBottom(true); diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 5531fee23a..8d5ef9992c 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -244,6 +244,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra ?.map((r) => `${r[0]}:${r[1].size}`) .join(',') ?? '', content: ev.getContent(), + sendStatus: ev.getAssociatedStatus(), })); // forceUpdateCounter makes this recompute whenever events arrive }, [room, threadRootId, thread, processedEvents, forceUpdateCounter]); @@ -534,16 +535,9 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra } }, [mx, threadRootId, handleEdit]); - // Map jumpToEventId to a focusItem index for useTimelineEventRenderer highlighting - const jumpIndex = jumpToEventId ? processedEvents.findIndex((e) => e.id === jumpToEventId) : -1; - const focusItem = - jumpIndex >= 0 && processedEvents[jumpIndex] - ? { - index: processedEvents[jumpIndex].itemIndex, - highlight: true, - scrollTo: false as const, - } - : undefined; + const focusItem = jumpToEventId + ? { eventId: jumpToEventId, highlight: true, scrollTo: false as const } + : undefined; const renderMatrixEvent = useTimelineEventRenderer({ room, diff --git a/src/app/hooks/timeline/useProcessedTimeline.test.tsx b/src/app/hooks/timeline/useProcessedTimeline.test.tsx index 7be970e0ca..1083fdef95 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.test.tsx +++ b/src/app/hooks/timeline/useProcessedTimeline.test.tsx @@ -60,6 +60,7 @@ function createEvent({ isRedacted: () => false, isRedaction: () => isRedaction, isEncrypted: () => false, + getAssociatedStatus: () => null, getRelation: () => relation ?? null, threadRootId, } as unknown as MatrixEvent; @@ -229,6 +230,40 @@ describe('useProcessedTimeline new-messages divider', () => { expect(result.current[2]).toMatchObject({ id: '$c', collapsed: true }); }); + it('rebuilds a cached row when its send status changes in place', () => { + let echoStatus: string | null = 'sending'; + const echo = createEvent({ id: '$echo', ts: 1_000_500, sender: MY_USER }); + Object.defineProperty(echo, 'getAssociatedStatus', { value: () => echoStatus }); + const events = [createEvent({ id: '$a', ts: 1_000_000 }), echo]; + const timeline = createTimeline(events); + const ignoredUsersSet = new Set(); + const { result, rerender } = renderHook( + ({ count }: { count: number }) => + useProcessedTimeline({ + items: Array.from({ length: count }, (_, index) => index), + linkedTimelines: [timeline], + ignoredUsersSet, + hiddenEvents, + mxUserId: MY_USER, + readUptoEventId: undefined, + hideMembershipEvents: true, + hideNickAvatarEvents: true, + isReadOnly: false, + hideMemberInReadOnly: false, + }), + { initialProps: { count: events.length } } + ); + const existingRows = [...result.current]; + + // Appending takes the append-only path, which reuses cached rows. + echoStatus = 'not_sent'; + events.push(createEvent({ id: '$c', ts: 1_001_000 })); + rerender({ count: events.length }); + + expect(result.current[1]).not.toBe(existingRows[1]); + expect(result.current[2]).toMatchObject({ id: '$c' }); + }); + it('rebuilds rows when an event lands mid-prefix instead of being appended', () => { const events = [ createEvent({ id: '$a', ts: 1_000_000 }), @@ -394,6 +429,146 @@ describe('useProcessedTimeline new-messages divider', () => { expect(dividerIds(processed)).toEqual([]); }); + it('renders exactly one divider when a reaction merges between the anchor and its row', () => { + const events = [ + createEvent({ id: '$a', ts: 1000 }), + createEvent({ id: '$b', ts: 3000 }), + createReaction('$r', '$a'), + ]; + const { result } = renderHook(() => + useProcessedTimeline({ + items: events.map((_, index) => index), + linkedTimelines: [createTimeline(events)], + ignoredUsersSet: new Set(), + hiddenEvents: { ...hiddenEvents, hiddenEventReactions: true }, + mxUserId: MY_USER, + readUptoEventId: '$a', + hideMembershipEvents: false, + hideNickAvatarEvents: false, + isReadOnly: false, + hideMemberInReadOnly: false, + }) + ); + + expect(dividerIds(result.current)).toHaveLength(1); + }); + + it('places a reaction whose target is filtered out by timeline order, not at the end', () => { + const ignored = '@ignored:example.org'; + const events = [ + createEvent({ id: '$a', ts: 1000 }), + createEvent({ id: '$hidden', ts: 2000, sender: ignored }), + createReaction('$r', '$hidden'), + createEvent({ id: '$b', ts: 4000 }), + createEvent({ id: '$c', ts: 5000 }), + ]; + const { result } = renderHook(() => + useProcessedTimeline({ + items: events.map((_, index) => index), + linkedTimelines: [createTimeline(events)], + ignoredUsersSet: new Set([ignored]), + hiddenEvents: { ...hiddenEvents, hiddenEventReactions: true }, + mxUserId: MY_USER, + readUptoEventId: undefined, + hideMembershipEvents: false, + hideNickAvatarEvents: false, + isReadOnly: false, + hideMemberInReadOnly: false, + }) + ); + + expect(renderedIds(result.current)).toEqual(['$a', '$r', '$b', '$c']); + }); + + it('keeps orphan edits ordered after a reaction has moved beside its parent', () => { + const ignored = '@ignored:example.org'; + const events = [ + createEvent({ id: '$a' }), + createEvent({ id: '$b' }), + createReaction('$reaction', '$a'), + createEvent({ id: '$hidden', sender: ignored }), + createEdit('$edit', '$hidden'), + createEvent({ id: '$c' }), + ]; + const { result } = renderHook(() => + useProcessedTimeline({ + items: events.map((_, index) => index), + linkedTimelines: [createTimeline(events)], + ignoredUsersSet: new Set([ignored]), + hiddenEvents: { + ...hiddenEvents, + hiddenEventReactions: true, + hiddenEventEdits: true, + }, + mxUserId: MY_USER, + readUptoEventId: undefined, + hideMembershipEvents: false, + hideNickAvatarEvents: false, + isReadOnly: false, + hideMemberInReadOnly: false, + }) + ); + + expect(renderedIds(result.current)).toEqual(['$a', '$reaction', '$b', '$edit', '$c']); + }); + + it('keeps a reaction next to its target when timestamps are equal', () => { + const events = [ + createEvent({ id: '$a', ts: 1000 }), + createEvent({ id: '$b', ts: 1000 }), + createEvent({ id: '$c', ts: 1000 }), + createReaction('$r', '$a'), + ]; + const { result } = renderHook(() => + useProcessedTimeline({ + items: events.map((_, index) => index), + linkedTimelines: [createTimeline(events)], + ignoredUsersSet: new Set(), + hiddenEvents: { ...hiddenEvents, hiddenEventReactions: true }, + mxUserId: MY_USER, + readUptoEventId: undefined, + hideMembershipEvents: false, + hideNickAvatarEvents: false, + isReadOnly: false, + hideMemberInReadOnly: false, + }) + ); + + expect(renderedIds(result.current)).toEqual(['$a', '$r', '$b', '$c']); + }); + + it('drops a cached row whose event id was rewritten in place by the remote echo', () => { + let echoId = '~!room:txn1'; + const ignoredUsersSet = new Set(); + const base = createEvent({ id: 'placeholder', ts: 1000 }); + const echo = Object.create(base, { + getId: { value: () => echoId }, + }) as MatrixEvent; + const events: MatrixEvent[] = [echo]; + + const { result, rerender } = renderHook(() => + useProcessedTimeline({ + items: events.map((_, index) => index), + linkedTimelines: [createTimeline(events)], + ignoredUsersSet, + hiddenEvents, + mxUserId: MY_USER, + readUptoEventId: undefined, + hideMembershipEvents: false, + hideNickAvatarEvents: false, + isReadOnly: false, + hideMemberInReadOnly: false, + }) + ); + expect(renderedIds(result.current)).toEqual(['~!room:txn1']); + + echoId = '$real'; + events.push(createEvent({ id: '$c', ts: 2000 })); + rerender(); + + expect(renderedIds(result.current)).toEqual(['$real', '$c']); + }); + it('renders no divider without a read receipt', () => { const processed = processTimeline( [createEvent({ id: '$a' }), createEvent({ id: '$b' })], @@ -545,14 +720,15 @@ describe('getProcessedRowIndexForRawTimelineIndex (fuzz)', () => { ); const start = faker.number.int({ min: -1, max: nextIndex + 2 }); - // Reference: item indices are non-decreasing, so the addressable target - // is the last row with a non-sentinel index not beyond the raw index. const candidates = rows .map((row, rowIndex) => ({ rowIndex, itemIndex: row.itemIndex })) .filter((c) => c.itemIndex >= 0 && c.itemIndex <= start); - const last = candidates.at(-1); - const expected = last - ? { rowIndex: last.rowIndex, focusRawIndex: last.itemIndex } + const best = candidates.reduce<(typeof candidates)[number] | undefined>( + (acc, c) => (acc === undefined || c.itemIndex > acc.itemIndex ? c : acc), + undefined + ); + const expected = best + ? { rowIndex: best.rowIndex, focusRawIndex: best.itemIndex } : undefined; expect(getProcessedRowIndexForRawTimelineIndex(rows, start), `seed ${seed}`).toEqual( @@ -853,6 +1029,7 @@ function createEncryptedEvent(id: string, ts: number) { getTs: () => ts, isRedacted: () => false, isRedaction: () => false, + getAssociatedStatus: () => null, isEncrypted: () => true, getRelation: () => null, threadRootId: undefined, diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 137655db67..0a4592bd2c 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -1,5 +1,5 @@ import { useMemo, useRef } from 'react'; -import type { MatrixEvent, EventTimelineSet, EventTimeline } from '$types/matrix-sdk'; +import type { MatrixEvent, EventStatus, EventTimelineSet, EventTimeline } from '$types/matrix-sdk'; import { EventType } from '$types/matrix-sdk'; import { isMembershipChanged, @@ -50,6 +50,8 @@ export interface ProcessedEvent { editId: string | undefined; reactionsKey: string; content: unknown; + // MatrixEvent status changes in place, so include it in row memoization. + sendStatus: EventStatus | null; } /** Raw timeline indices for skipped events (reactions, edits, …) have no row; walk backward to a visible one. */ @@ -106,16 +108,20 @@ type ProcessedEventDraft = Omit< | 'editId' | 'reactionsKey' | 'content' + | 'sendStatus' >; type TimelineEventEntry = { mEvent: MatrixEvent; timelineSet: EventTimelineSet; isRedacted: boolean; + id: string | undefined; + ts: number; // Decryption rewrites a MatrixEvent in place, so identity alone does not prove a cached // row still matches it. Undefined for unencrypted events. clearType: string | undefined; clearContent: unknown; + sendStatus: EventStatus | null; }; const flattenTimelineEvents = (linkedTimelines: EventTimeline[]): TimelineEventEntry[] => { @@ -128,8 +134,11 @@ const flattenTimelineEvents = (linkedTimelines: EventTimeline[]): TimelineEventE mEvent, timelineSet, isRedacted: mEvent.isRedacted(), + id: mEvent.getId(), + ts: mEvent.getTs(), clearType: encrypted ? mEvent.getType() : undefined, clearContent: encrypted ? mEvent.getContent() : undefined, + sendStatus: mEvent.getAssociatedStatus(), }); }); }); @@ -141,9 +150,12 @@ const isCachedEntryCurrent = ( current: TimelineEventEntry | undefined ): boolean => cached.mEvent === current?.mEvent && + cached.id === current.id && + cached.ts === current.ts && cached.isRedacted === current.isRedacted && cached.clearType === current.clearType && - cached.clearContent === current.clearContent; + cached.clearContent === current.clearContent && + cached.sendStatus === current.sendStatus; const computeCollapseAndDividers = ( drafts: ProcessedEventDraft[], @@ -157,12 +169,13 @@ const computeCollapseAndDividers = ( let isPrevRendered = false; let newDivider = false; let dayDivider = false; + let dividerPlaced = false; return drafts.map((draft) => { const { mEvent, eventSender } = draft; const type = mEvent.getType(); - if (!newDivider && readUptoEventId) { + if (!newDivider && !dividerPlaced && readUptoEventId) { const prevId = prevEvent ? prevEvent.getId() : undefined; newDivider = prevId === readUptoEventId || draft.id === carriedDividerId; } @@ -198,7 +211,10 @@ const computeCollapseAndDividers = ( prevEvent = mEvent; isPrevRendered = true; - if (willRenderNewDivider) newDivider = false; + if (willRenderNewDivider) { + newDivider = false; + dividerPlaced = true; + } if (willRenderDayDivider) dayDivider = false; const editId = getEditedEvent(draft.id, mEvent, draft.timelineSet)?.getId(); @@ -214,6 +230,7 @@ const computeCollapseAndDividers = ( editId, reactionsKey, content, + sendStatus: mEvent.getAssociatedStatus(), }; }); }; @@ -231,38 +248,56 @@ const mergeDraftsAndExtras = ( ({ collapsed: _c, willRenderNewDivider: _n, willRenderDayDivider: _d, ...draft }) => draft ); - const extraDrafts = extras - .map(({ mEvent, timelineSet, parentId, itemIndex = -1 }) => ({ - draft: { - id: mEvent.getId()!, - itemIndex, - mEvent, - isRedacted: mEvent.isRedacted(), - timelineSet, - eventSender: mEvent.getSender() ?? null, - }, - effectiveTs: mEvent.getTs(), - parentId, - })) - .toSorted((a, b) => a.effectiveTs - b.effectiveTs); + const extraDrafts = extras.map(({ mEvent, timelineSet, parentId, itemIndex = -1 }) => ({ + draft: { + id: mEvent.getId()!, + itemIndex, + mEvent, + isRedacted: mEvent.isRedacted(), + timelineSet, + eventSender: mEvent.getSender() ?? null, + }, + parentId, + })); const buckets: ProcessedEventDraft[][] = Array.from( { length: resultDrafts.length + 1 }, () => [] ); const indexById = new Map(resultDrafts.map((draft, index) => [draft.id, index])); + let timelineOrderIndex: { itemIndex: number; bucket: number }[] | undefined; + + const bucketByTimelineOrder = (itemIndex: number): number => { + if (itemIndex < 0) return resultDrafts.length; + if (!timelineOrderIndex) { + timelineOrderIndex = resultDrafts + .flatMap((draft, index) => + draft.itemIndex < 0 ? [] : [{ itemIndex: draft.itemIndex, bucket: index + 1 }] + ) + .toSorted((a, b) => a.itemIndex - b.itemIndex); + + let maxBucket = 0; + for (const entry of timelineOrderIndex) { + maxBucket = Math.max(maxBucket, entry.bucket); + entry.bucket = maxBucket; + } + } - for (const extra of extraDrafts) { - const extraTs = extra.effectiveTs; - const parentIdx = indexById.get(extra.parentId) ?? -1; - let low = parentIdx + 1; - let high = resultDrafts.length; + let low = 0; + let high = timelineOrderIndex.length; while (low < high) { const mid = low + Math.floor((high - low) / 2); - if (resultDrafts[mid]!.mEvent.getTs() <= extraTs) low = mid + 1; + if (timelineOrderIndex[mid]!.itemIndex <= itemIndex) low = mid + 1; else high = mid; } - buckets[low]!.push(extra.draft); + return low === 0 ? 0 : timelineOrderIndex[low - 1]!.bucket; + }; + + for (const extra of extraDrafts) { + const parentIdx = indexById.get(extra.parentId); + buckets[ + parentIdx === undefined ? bucketByTimelineOrder(extra.draft.itemIndex) : parentIdx + 1 + ]!.push(extra.draft); } const mergedDrafts: ProcessedEventDraft[] = [...buckets[0]!]; @@ -573,6 +608,7 @@ const processTimelineItems = ( ?.map((r) => `${r[0]}:${r[1].size}`) .join(',') ?? '', content: mEvent.getContent(), + sendStatus: mEvent.getAssociatedStatus(), }); state.prevEvent = mEvent; diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.test.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.test.tsx index 5b3a244871..5d201e1784 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.test.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.test.tsx @@ -917,7 +917,7 @@ describe('useTimelineEventRenderer', () => { function renderMessageAtItem( mEvent: MatrixEvent, item: number, - focusItem?: { index: number; highlight: boolean; scrollTo: boolean } + focusItem?: { eventId: string; highlight: boolean; scrollTo: boolean } ) { const opts = { ...rendererOpts, state: { ...rendererOpts.state, focusItem } }; const { result } = renderHook(() => useTimelineEventRenderer(opts)); @@ -941,40 +941,49 @@ describe('useTimelineEventRenderer', () => { replyEventId: undefined, }); - it('highlights the focused row for a regular raw timeline index', () => { + it('highlights the row whose event id matches', () => { const { container } = renderMessageAtItem(msgEvent('$a:example.com'), 3, { - index: 3, + eventId: '$a:example.com', highlight: true, scrollTo: false, }); expect(highlightOf(container)).toBe('true'); }); - it('does not highlight a different row', () => { - const { container } = renderMessageAtItem(msgEvent('$a:example.com'), 4, { - index: 3, + it('does not highlight a different event', () => { + const { container } = renderMessageAtItem(msgEvent('$b:example.com'), 3, { + eventId: '$a:example.com', highlight: true, scrollTo: false, }); expect(highlightOf(container)).toBe('false'); }); - it('never highlights merged relation rows, which all carry itemIndex -1', () => { - // ThreadDrawer derives focusItem.index from the row's itemIndex, and every - // merged extra shares the -1 sentinel, so -1 must highlight nothing. + it('highlights the same event regardless of the raw index it renders at', () => { + for (const item of [0, 3, 40]) { + const { container } = renderMessageAtItem(msgEvent('$a:example.com'), item, { + eventId: '$a:example.com', + highlight: true, + scrollTo: false, + }); + expect(highlightOf(container)).toBe('true'); + } + }); + + it('does not highlight merged relation rows that share the itemIndex -1 sentinel', () => { const jumpTarget = renderMessageAtItem(msgEvent('$edit-1:example.com'), -1, { - index: -1, + eventId: '$reaction-2:example.com', highlight: true, scrollTo: false, }); expect(highlightOf(jumpTarget.container)).toBe('false'); const otherExtra = renderMessageAtItem(msgEvent('$reaction-2:example.com'), -1, { - index: -1, + eventId: '$reaction-2:example.com', highlight: true, scrollTo: false, }); - expect(highlightOf(otherExtra.container)).toBe('false'); + expect(highlightOf(otherExtra.container)).toBe('true'); }); }); }); diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 8bf1cbc014..664241152d 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -87,6 +87,7 @@ import * as customHtmlCss from '$styles/CustomHtml.css'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; import type { ForwardedMessageProps } from '$features/room/message'; import { EncryptedContent, Message, Reactions } from '$features/room/message'; +import type { TimelineFocusItem } from '$hooks/timeline/useTimelineSync'; import { useSableCosmetics } from '$hooks/useSableCosmetics'; import { useRoomMemberHydration } from '$hooks/useRoomMemberHydration'; @@ -291,11 +292,8 @@ function ThreadReplyChip({ ); } -// Merged relation rows share the itemIndex -1 sentinel, so -1 targets nothing. -const isFocusHighlighted = ( - focusItem: { index: number; highlight: boolean } | undefined, - item: number -) => item >= 0 && focusItem?.index === item && focusItem.highlight; +const isFocusHighlighted = (focusItem: TimelineFocusItem | undefined, mEventId: string) => + focusItem?.eventId === mEventId && focusItem.highlight; export interface TimelineEventRendererOptions { room: Room; @@ -325,7 +323,7 @@ export interface TimelineEventRendererOptions { hideThreadChip?: boolean; }; state: { - focusItem?: { index: number; highlight: boolean; scrollTo: boolean }; + focusItem?: TimelineFocusItem; editId?: string; activeReplyId?: string; openThreadId?: string; @@ -421,7 +419,7 @@ export function useTimelineEventRenderer({ timelineSet: EventTimelineSet, markedVariant: 'suppress' | 'plain' = 'suppress' ) { - const highlighted = isFocusHighlighted(focusItem, item); + const highlighted = isFocusHighlighted(focusItem, mEventId); const marked = markedVariant === 'plain' ? activeReplyId === mEventId @@ -450,7 +448,7 @@ export function useTimelineEventRenderer({ item: number, timelineSet: EventTimelineSet ) { - const highlighted = isFocusHighlighted(focusItem, item); + const highlighted = isFocusHighlighted(focusItem, mEventId); const marked = activeReplyId === mEventId && !suppressMark; const senderId = mEvent.getSender() ?? ''; const senderName = getSenderDisplayName(senderId); @@ -518,7 +516,7 @@ export function useTimelineEventRenderer({ ) => { if (!hiddenEventEdits) return null; - const highlighted = isFocusHighlighted(focusItem, item); + const highlighted = isFocusHighlighted(focusItem, mEventId); const marked = activeReplyId === mEventId && suppressMark !== true; const senderId = mEvent.getSender() ?? ''; const senderName = getSenderDisplayName(senderId); diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index fadc476771..a9d49e1f90 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -2,11 +2,24 @@ import { EventEmitter } from 'events'; import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { faker } from '@faker-js/faker'; -import type { Room } from '$types/matrix-sdk'; -import { Direction, MatrixEventEvent, RoomEvent } from '$types/matrix-sdk'; +import type { EventTimelineSet, MatrixEvent, Room } from '$types/matrix-sdk'; +import { + createClient, + Direction, + EventStatus, + EventTimeline, + MatrixEvent as SdkMatrixEvent, + MatrixEventEvent, + Room as SdkRoom, + RoomEvent, +} from '$types/matrix-sdk'; import { countVisibleAmongNewest, useTimelineSync } from './useTimelineSync'; import { getRoomUnreadInfo } from '$utils/timeline'; +import { markAsRead } from '$utils/notifications'; +import { isWindowFocused } from '$utils/dom'; +import { setRoomFocusedWindow } from '$client/slidingSync'; import type * as TimelineUtils from '$utils/timeline'; +import type * as DomUtils from '$utils/dom'; vi.mock('@sentry/react', () => ({ default: {}, @@ -32,11 +45,30 @@ vi.mock('$utils/notifications', () => ({ markAsRead: vi.fn<() => Promise>().mockResolvedValue(undefined), })); +vi.mock('$utils/dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isWindowFocused: vi.fn(actual.isWindowFocused), + }; +}); + +vi.mock('$client/slidingSync', () => ({ + setRoomFocusedWindow: vi.fn<() => void>(), +})); + +const { getSlidingSyncManager } = vi.hoisted(() => ({ + getSlidingSyncManager: vi.fn<() => unknown>(), +})); + +vi.mock('$client/initMatrix', () => ({ getSlidingSyncManager })); + type FakeTimeline = { getEvents: () => unknown[]; getNeighbouringTimeline: () => undefined; getPaginationToken: () => undefined; getRoomId: () => string; + getTimelineSet: () => FakeTimelineSet | undefined; }; type FakeTimelineSet = EventEmitter & { @@ -49,12 +81,13 @@ type FakeRoom = Room & emit: EventEmitter['emit']; }; -function createTimeline(events: unknown[] = [{}]): FakeTimeline { +function createTimeline(events: unknown[] = [{}], timelineSet?: FakeTimelineSet): FakeTimeline { return { getEvents: () => events, getNeighbouringTimeline: () => undefined, getPaginationToken: () => undefined, getRoomId: () => '!room:test', + getTimelineSet: () => timelineSet, }; } @@ -67,11 +100,12 @@ function createRoom( events: unknown[]; timeline: FakeTimeline; } { + const timelineSet = new EventEmitter() as FakeTimelineSet; const timeline = { ...createTimeline(events), getRoomId: () => roomId, + getTimelineSet: () => timelineSet, }; - const timelineSet = new EventEmitter() as FakeTimelineSet; timelineSet.getLiveTimeline = () => timeline; timelineSet.getTimelineForEvent = () => undefined; @@ -138,6 +172,7 @@ const makeMx = (extra: Record = {}) => getUserId: () => '@alice:test', on: mxEmitter.on.bind(mxEmitter), removeListener: mxEmitter.removeListener.bind(mxEmitter), + paginateEventTimeline: vi.fn<() => Promise>(() => Promise.resolve(false)), ...extra, }) as never; @@ -148,6 +183,9 @@ function makeEvent(sender: string, roomId: string) { getRoomId: () => roomId, getTs: () => Date.now(), getRelation: () => undefined, + isSending: () => sender === '@alice:test', + isRelation: () => false, + isRedaction: () => false, }; } @@ -164,6 +202,8 @@ function emitLiveTimelineEvent( }); } +const flushRaf = () => new Promise((r) => requestAnimationFrame(() => r(undefined))); + const makeTimeline = (ids: string[]) => { const timelineSet = { id: `set-${ids.join('')}` }; return { @@ -231,6 +271,41 @@ describe('countVisibleAmongNewest', () => { }); describe('useTimelineSync', () => { + it('reloads a classic-sync jump window after its timeline resets', async () => { + const { room, timelineSet } = createRoom(); + const targetTimeline = createTimeline([{ getId: () => '$target' }], timelineSet); + const getEventTimeline = vi.fn<(set: unknown) => Promise>((set) => { + targetTimeline.getTimelineSet = () => set as FakeTimelineSet; + return Promise.resolve(targetTimeline); + }); + getSlidingSyncManager.mockReturnValue(undefined); + const { result } = renderHook(() => + useTimelineSync({ + room: room as Room, + mx: makeMx({ getEventTimeline }), + eventId: '$target', + isAtBottom: false, + isAtBottomRef: { current: false }, + scrollToBottom: vi.fn<() => void>(), + unreadInfo: undefined, + setUnreadInfo: vi.fn<() => void>(), + hideReadsRef: { current: false }, + readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, + }) + ); + + await act(async () => { + await result.current.loadEventTimeline('$target'); + }); + await act(async () => { + timelineSet.emit(RoomEvent.TimelineReset); + await Promise.resolve(); + }); + + expect(getEventTimeline).toHaveBeenCalledTimes(2); + }); + it('does not snap a non-bottom user to latest after TimelineReset', async () => { const { room, timelineSet, events } = createRoom(); const scrollToBottom = vi.fn<() => void>(); @@ -246,6 +321,7 @@ describe('useTimelineSync', () => { setUnreadInfo: vi.fn<() => void>(), hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, }) ); @@ -278,6 +354,7 @@ describe('useTimelineSync', () => { setUnreadInfo: vi.fn<() => void>(), hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, }) ); @@ -289,6 +366,47 @@ describe('useTimelineSync', () => { expect(scrollToBottom).toHaveBeenCalledWith('instant'); }); + it('leaves a loaded jump window alone when the unfiltered set resets', async () => { + const { room, timelineSet } = createRoom(); + const targetEvent = { getId: () => '$target' }; + const reloadedTimeline = createTimeline([targetEvent], timelineSet); + const getEventTimeline = vi.fn<(set: unknown) => Promise>((set) => { + reloadedTimeline.getTimelineSet = () => set as FakeTimelineSet; + return Promise.resolve(reloadedTimeline); + }); + getSlidingSyncManager.mockReturnValue({}); + const { result } = renderHook(() => + useTimelineSync({ + room: room as Room, + mx: makeMx({ getEventTimeline }), + eventId: '$target', + isAtBottom: false, + isAtBottomRef: { current: false }, + scrollToBottom: vi.fn<() => void>(), + unreadInfo: undefined, + setUnreadInfo: vi.fn<() => void>(), + hideReadsRef: { current: false }, + readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, + }) + ); + + // RoomTimeline drives the initial load for an event route. + await act(async () => { + await result.current.loadEventTimeline('$target'); + }); + const loadsBeforeReset = getEventTimeline.mock.calls.length; + expect(loadsBeforeReset).toBe(1); + + await act(async () => { + timelineSet.emit(RoomEvent.TimelineReset); + await Promise.resolve(); + }); + + expect(getEventTimeline.mock.calls.length).toBe(loadsBeforeReset); + expect(result.current.timeline.linkedTimelines).toEqual([reloadedTimeline]); + }); + it('resets timeline state when room.roomId changes and eventId is not set', async () => { const roomOne = createRoom('!room:one'); const roomTwo = createRoom('!room:two'); @@ -307,6 +425,7 @@ describe('useTimelineSync', () => { setUnreadInfo: vi.fn<() => void>(), hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, }), { initialProps: { @@ -344,6 +463,7 @@ describe('useTimelineSync', () => { setUnreadInfo: vi.fn<() => void>(), hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, }), { initialProps: { @@ -379,6 +499,7 @@ describe('useTimelineSync', () => { setUnreadInfo: vi.fn<() => void>(), hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, }), { initialProps: { @@ -395,6 +516,55 @@ describe('useTimelineSync', () => { expect(result.current.timeline.linkedTimelines[0]).toBe(roomOne.timelineSet.getLiveTimeline()); }); + it('ignores a pending result for the previous room even when the event id is unchanged', async () => { + const roomOne = createRoom('!room:one'); + const roomTwo = createRoom('!room:two'); + const targetId = '$same:test'; + const oldTargetTimeline = { + ...createTimeline([{ getId: () => targetId }], roomOne.timelineSet), + getNeighbouringTimeline: () => undefined, + }; + roomOne.timelineSet.getTimelineForEvent = () => oldTargetTimeline as never; + let resolveJump: ((timeline: unknown) => void) | undefined; + const mx = makeMx({ + getEventTimeline: vi.fn<() => Promise>( + () => + new Promise((resolve) => { + resolveJump = resolve; + }) + ), + }); + const options = { + mx, + eventId: targetId, + isAtBottom: false, + isAtBottomRef: { current: false }, + scrollToBottom: vi.fn<() => void>(), + unreadInfo: undefined, + setUnreadInfo: vi.fn<() => void>(), + hideReadsRef: { current: false }, + readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, + }; + const { result, rerender } = renderHook(({ room }) => useTimelineSync({ ...options, room }), { + initialProps: { room: roomOne.room as Room }, + }); + + let pending: Promise | undefined; + await act(async () => { + pending = result.current.loadEventTimeline(targetId); + await Promise.resolve(); + }); + rerender({ room: roomTwo.room as Room }); + await act(async () => { + resolveJump?.(oldTargetTimeline); + await pending; + }); + + expect(result.current.timeline.linkedTimelines).not.toContain(oldTargetTimeline); + expect(result.current.focusItem).toBeUndefined(); + }); + describe('auto-follow on live message', () => { it('scrolls to bottom with smooth behavior for an incoming message from another user', async () => { const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true); @@ -412,6 +582,7 @@ describe('useTimelineSync', () => { setUnreadInfo: vi.fn<() => void>(), hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, }) ); @@ -439,6 +610,7 @@ describe('useTimelineSync', () => { setUnreadInfo: vi.fn<() => void>(), hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, }) ); @@ -469,6 +641,7 @@ describe('useTimelineSync', () => { setUnreadInfo, hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, }) ); @@ -497,6 +670,7 @@ describe('useTimelineSync', () => { setUnreadInfo: vi.fn<() => void>(), hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, }) ); @@ -523,6 +697,7 @@ describe('useTimelineSync', () => { setUnreadInfo: vi.fn<() => void>(), hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, }) ); @@ -553,6 +728,7 @@ describe('useTimelineSync', () => { setUnreadInfo: vi.fn<() => void>(), hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, }) ); @@ -589,6 +765,7 @@ const syncOpts = ( setUnreadInfo: vi.fn<() => void>(), hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, isEventVisible, }); @@ -779,6 +956,191 @@ describe('back-pagination', () => { expect(result.current.backwardStatus).toBe('idle'); expect(paginateEventTimeline).toHaveBeenCalledTimes(1); }); + + it('releases the lock after a reset so a later request still runs', async () => { + const { room, timelineSet } = createPaginableRoom(); + const resolvers: ((value: boolean) => void)[] = []; + const paginateEventTimeline = vi.fn<() => Promise>( + () => new Promise((resolve) => resolvers.push(resolve)) + ); + const { result } = renderHook(() => + useTimelineSync(syncOpts(room, paginateEventTimeline, () => false)) + ); + + let paginatePromise: Promise | undefined; + await act(async () => { + paginatePromise = result.current.handleTimelinePagination(true); + await Promise.resolve(); + }); + + await act(async () => { + timelineSet.emit(RoomEvent.TimelineReset); + resolvers[0]?.(true); + await paginatePromise; + }); + expect(result.current.backwardStatus).toBe('idle'); + + await act(async () => { + const retry = result.current.handleTimelinePagination(true); + await Promise.resolve(); + resolvers[1]?.(true); + await retry; + }); + + expect(paginateEventTimeline).toHaveBeenCalledTimes(2); + expect(result.current.backwardStatus).toBe('idle'); + }); + }); +}); + +// Two timelines expose direction-specific edge tokens. +function createChainedRoom(roomId = '!room:test') { + const olderEvents: unknown[] = [{}]; + const liveEvents: unknown[] = [{}]; + const tokens = { + olderBackward: 'older-back' as string | undefined, + olderForward: 'older-forward-stale' as string | undefined, + liveBackward: undefined as string | undefined, + liveForward: 'live-forward' as string | undefined, + }; + + const timelineSet = new EventEmitter() as FakeTimelineSet; + const older = { + getEvents: () => olderEvents, + getPaginationToken: (d: Direction) => + d === Direction.Backward ? tokens.olderBackward : tokens.olderForward, + getNeighbouringTimeline: (d: Direction) => (d === Direction.Forward ? live : undefined), + getRoomId: () => roomId, + getTimelineSet: () => timelineSet, + }; + const live = { + getEvents: () => liveEvents, + getPaginationToken: (d: Direction) => + d === Direction.Backward ? tokens.liveBackward : tokens.liveForward, + getNeighbouringTimeline: (d: Direction) => (d === Direction.Backward ? older : undefined), + getRoomId: () => roomId, + getTimelineSet: () => timelineSet, + }; + timelineSet.getLiveTimeline = () => live as unknown as FakeTimeline; + timelineSet.getTimelineForEvent = () => undefined; + + const roomEmitter = new EventEmitter(); + const room = { + on: roomEmitter.on.bind(roomEmitter), + removeListener: roomEmitter.removeListener.bind(roomEmitter), + emit: roomEmitter.emit.bind(roomEmitter), + roomId, + getUnfilteredTimelineSet: () => timelineSet as never, + getEventReadUpTo: () => null, + getThread: () => null, + getLiveTimeline: () => live, + getUnreadNotificationCount: () => 0, + getMyMembership: () => 'join', + getMember: () => null, + hasEncryptionStateEvent: () => false, + client: { getUserId: () => '@alice:test' }, + } as unknown as FakeRoom; + + return { room, timelineSet, older, live, olderEvents, liveEvents, tokens }; +} + +describe('pagination continuation bounds', () => { + it('reads the newest timeline for the forward continuation token, not the oldest', async () => { + const chain = createChainedRoom(); + const paginateEventTimeline = vi.fn<() => Promise>(async () => { + chain.liveEvents.push({}); + chain.tokens.liveForward = undefined; + return true; + }); + const { result } = renderHook(() => + useTimelineSync(syncOpts(chain.room, paginateEventTimeline, () => false)) + ); + + await act(async () => { + await result.current.handleTimelinePagination(false); + }); + + expect(chain.older.getPaginationToken(Direction.Forward)).toBe('older-forward-stale'); + expect(paginateEventTimeline).toHaveBeenCalledTimes(1); + expect(result.current.forwardStatus).toBe('idle'); + }); + + it('settles to idle when the continuation token disappears between iterations', async () => { + const { room, events } = createPaginableRoom(); + const timeline = room.getUnfilteredTimelineSet().getLiveTimeline() as unknown as { + getPaginationToken: () => string | undefined; + }; + const paginateEventTimeline = vi.fn<() => Promise>(async () => { + events.push({}); + timeline.getPaginationToken = () => undefined; + return true; + }); + const { result } = renderHook(() => + useTimelineSync(syncOpts(room, paginateEventTimeline, () => false)) + ); + + await act(async () => { + await result.current.handleTimelinePagination(true); + }); + + expect(paginateEventTimeline).toHaveBeenCalledTimes(1); + expect(result.current.backwardStatus).toBe('idle'); + }); + + it('settles to idle when the timeline chain empties mid-flight', async () => { + const { room, events } = createPaginableRoom(); + let resolvePaginate: ((value: boolean) => void) | undefined; + const paginateEventTimeline = vi.fn<() => Promise>( + () => + new Promise((resolve) => { + resolvePaginate = resolve; + }) + ); + const { result } = renderHook(() => + useTimelineSync(syncOpts(room, paginateEventTimeline, () => false)) + ); + + let paginatePromise: Promise | undefined; + await act(async () => { + paginatePromise = result.current.handleTimelinePagination(true); + await Promise.resolve(); + }); + expect(result.current.backwardStatus).toBe('loading'); + + await act(async () => { + // Simulate a limited sync replacing the current chain. + events.length = 0; + result.current.setTimeline({ linkedTimelines: [] }); + resolvePaginate?.(true); + await paginatePromise; + }); + + expect(paginateEventTimeline).toHaveBeenCalledTimes(1); + expect(result.current.backwardStatus).toBe('idle'); + }); + + it('deduplicates concurrent requests per direction', async () => { + const chain = createChainedRoom(); + const paginateEventTimeline = vi.fn<() => Promise>(async () => { + chain.liveEvents.push({}, {}, {}, {}, {}); + return true; + }); + const { result } = renderHook(() => + useTimelineSync(syncOpts(chain.room, paginateEventTimeline, () => true)) + ); + + await act(async () => { + await Promise.all([ + result.current.handleTimelinePagination(true), + result.current.handleTimelinePagination(true), + result.current.handleTimelinePagination(false), + result.current.handleTimelinePagination(false), + ]); + }); + + expect(paginateEventTimeline).toHaveBeenCalledTimes(2); + expect(result.current.backwardStatus).toBe('idle'); + expect(result.current.forwardStatus).toBe('idle'); }); }); @@ -788,24 +1150,43 @@ const renderSyncHook = ( isAtBottom?: boolean; mx?: Record; readUptoEventId?: string; + isInactivePanel?: boolean; + eventId?: string; + isEventVisible?: (mEvent: MatrixEvent, timelineSet: EventTimelineSet) => boolean; } = {} ) => { const scrollToBottom = vi.fn<() => void>(); - const isAtBottom = options.isAtBottom ?? true; - const { result } = renderHook(() => - useTimelineSync({ - room: room as Room, - mx: (options.mx ?? makeMx()) as never, - isAtBottom, - isAtBottomRef: { current: isAtBottom }, - scrollToBottom, - unreadInfo: undefined, - setUnreadInfo: vi.fn<() => void>(), - hideReadsRef: { current: false }, - readUptoEventIdRef: { current: options.readUptoEventId }, - }) + const setUnreadInfo = vi.fn<() => void>(); + const onJumpError = vi.fn<() => void>(); + const onReturnToLive = vi.fn<() => void>(); + const initialIsAtBottom = options.isAtBottom ?? true; + const { result, rerender } = renderHook( + ({ + eventId, + isAtBottom = initialIsAtBottom, + }: { + eventId: string | undefined; + isAtBottom?: boolean; + }) => + useTimelineSync({ + room: room as Room, + mx: (options.mx ?? makeMx()) as never, + eventId, + isAtBottom, + isAtBottomRef: { current: isAtBottom }, + scrollToBottom, + unreadInfo: undefined, + setUnreadInfo, + hideReadsRef: { current: false }, + readUptoEventIdRef: { current: options.readUptoEventId }, + isInactivePanelRef: { current: options.isInactivePanel ?? false }, + onJumpError, + onReturnToLive, + isEventVisible: options.isEventVisible, + }), + { initialProps: { eventId: options.eventId, isAtBottom: initialIsAtBottom } } ); - return { result, scrollToBottom }; + return { result, scrollToBottom, setUnreadInfo, onJumpError, onReturnToLive, rerender }; }; const makeLiveEvent = (roomId: string, ts: number) => ({ @@ -817,76 +1198,356 @@ const makeLiveEvent = (roomId: string, ts: number) => ({ }); describe('live-arrive edge cases', () => { - it('ignores timeline events emitted for a different room', async () => { - const { room, timeline, events } = createRoom(); - const otherRoom = createRoom('!other:test'); - const { scrollToBottom } = renderSyncHook(room); + it('returns to the live timeline for a real SDK local echo', async () => { + const mx = createClient({ + baseUrl: 'https://example.com', + accessToken: 'token', + userId: '@alice:test', + timelineSupport: true, + }); + const room = new SdkRoom('!room:test', mx, '@alice:test'); + const timelineSet = room.getUnfilteredTimelineSet(); + const historicalTimeline = new EventTimeline(timelineSet); + const scrollToBottom = vi.fn<() => void>(); + const onReturnToLive = vi.fn<() => void>(); + const { result } = renderHook(() => + useTimelineSync({ + room, + mx, + isAtBottom: false, + isAtBottomRef: { current: false }, + scrollToBottom, + unreadInfo: undefined, + setUnreadInfo: vi.fn<() => void>(), + hideReadsRef: { current: false }, + readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, + onReturnToLive, + }) + ); + const localEcho = new SdkMatrixEvent({ + type: 'm.room.message', + event_id: '~!room:test:txn', + room_id: room.roomId, + sender: '@alice:test', + origin_server_ts: Date.now(), + content: { msgtype: 'm.text', body: 'after jump' }, + }); + localEcho.setStatus(EventStatus.SENDING); + act(() => { + result.current.setTimeline({ linkedTimelines: [historicalTimeline] }); + }); await act(async () => { - events.push({}); - room.emit( - RoomEvent.Timeline, - makeLiveEvent(otherRoom.room.roomId, Date.now()), - otherRoom.room, - false, - false, - { - liveEvent: true, - timeline, - } - ); + room.addPendingEvent(localEcho, 'txn'); await Promise.resolve(); }); - expect(scrollToBottom).not.toHaveBeenCalled(); + expect(result.current.timeline.linkedTimelines).toContain(room.getLiveTimeline()); + expect(onReturnToLive).toHaveBeenCalledOnce(); + expect(scrollToBottom).toHaveBeenCalledWith('instant'); }); - it('ignores removed events arriving with non-live data', async () => { + it('returns to the live end when an own message is sent while scrolled up', async () => { const { room, timeline, events } = createRoom(); - const { scrollToBottom } = renderSyncHook(room); + const { onReturnToLive, scrollToBottom } = renderSyncHook(room, { isAtBottom: false }); + const ownEvent = { + ...makeEvent('@alice:test', room.roomId), + isSending: () => true, + }; await act(async () => { - events.push({}); - room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, false, true, { - liveEvent: false, + events.push(ownEvent); + room.emit(RoomEvent.Timeline, ownEvent, room, false, false, { + liveEvent: true, timeline, }); await Promise.resolve(); }); - expect(scrollToBottom).not.toHaveBeenCalled(); + expect(onReturnToLive).toHaveBeenCalledOnce(); + expect(scrollToBottom).toHaveBeenCalledWith('instant'); }); - it('treats only recent non-live events as part of the reconnect backfill window', async () => { - const { room, timeline, events } = createRoom(); - const { scrollToBottom } = renderSyncHook(room); - - // A reconnect pages events onto the live timeline without liveEvent: true; - // anything older than the 60 s grace window is history, not an arrival. + it.each([ + ['a remote live event', '@bob:test', true, false, false], + ['an own synced event', '@alice:test', true, false, false], + ['an own cached event', '@alice:test', false, true, false], + ['an own relation', '@alice:test', true, true, true], + ])('keeps history displayed for %s', async (_label, sender, liveEvent, isSending, isRelation) => { + const { room, timelineSet, timeline } = createRoom(); + const historicalTimeline = createTimeline([{}], timelineSet); + const { result, onReturnToLive } = renderSyncHook(room, { isAtBottom: false }); + + act(() => { + result.current.setTimeline({ linkedTimelines: [historicalTimeline as never] }); + }); await act(async () => { - events.push({}); room.emit( RoomEvent.Timeline, - makeLiveEvent(room.roomId, Date.now() - 120_000), + { + ...makeEvent(sender, room.roomId), + isSending: () => isSending, + isRelation: () => isRelation, + isRedaction: () => false, + }, room, false, false, - { liveEvent: false, timeline } + { liveEvent, timeline } ); await Promise.resolve(); }); - expect(scrollToBottom).not.toHaveBeenCalled(); + expect(result.current.timeline.linkedTimelines).toEqual([historicalTimeline]); + expect(onReturnToLive).not.toHaveBeenCalled(); + }); + + it('tracks live unread events while a historical timeline is displayed', async () => { + const { room, timelineSet, timeline } = createRoom(); + const historicalTimeline = createTimeline([{}], timelineSet); + const unread = { + readUptoEventId: '$read:test', + inLiveTimeline: true, + scrollTo: false, + }; + vi.mocked(getRoomUnreadInfo).mockReturnValue(unread); + const { result, setUnreadInfo } = renderSyncHook(room, { isAtBottom: false }); + + act(() => { + result.current.setTimeline({ linkedTimelines: [historicalTimeline as never] }); + }); await act(async () => { - events.push({}); room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, false, false, { - liveEvent: false, + liveEvent: true, timeline, }); await Promise.resolve(); }); - expect(scrollToBottom).toHaveBeenCalledWith('instant'); - }); + + expect(setUnreadInfo).toHaveBeenCalledWith(unread); + }); + + it('signals a prepend on the displayed timeline', async () => { + const { room, timeline } = createRoom(); + const { result } = renderSyncHook(room, { isAtBottom: false }); + + expect(result.current.prependVersion).toBe(0); + await act(async () => { + room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, true, false, { + liveEvent: false, + timeline, + }); + await Promise.resolve(); + }); + + expect(result.current.prependVersion).toBe(1); + }); + + it('signals a filtered prepend batched with a visible append', async () => { + const { room, timeline, events } = createRoom(); + const prependedEvent = makeLiveEvent(room.roomId, 1) as unknown as MatrixEvent; + const appendedEvent = makeLiveEvent(room.roomId, 2) as unknown as MatrixEvent; + const isEventVisible = (mEvent: MatrixEvent) => mEvent === appendedEvent; + const { result } = renderSyncHook(room, { isAtBottom: false, isEventVisible }); + + await act(async () => { + room.emit(RoomEvent.Timeline, prependedEvent, room, true, false, { + liveEvent: false, + timeline, + }); + events.push({}); + room.emit(RoomEvent.Timeline, appendedEvent, room, false, false, { + liveEvent: true, + timeline, + }); + await Promise.resolve(); + }); + + expect(result.current.prependVersion).toBe(1); + }); + + it('ignores timeline events emitted for a different room', async () => { + const { room, timeline, events } = createRoom(); + const otherRoom = createRoom('!other:test'); + const { scrollToBottom } = renderSyncHook(room); + + await act(async () => { + events.push({}); + room.emit( + RoomEvent.Timeline, + makeLiveEvent(otherRoom.room.roomId, Date.now()), + otherRoom.room, + false, + false, + { + liveEvent: true, + timeline, + } + ); + await Promise.resolve(); + }); + + expect(scrollToBottom).not.toHaveBeenCalled(); + }); + + it('ignores removed events arriving with non-live data', async () => { + const { room, timeline, events } = createRoom(); + const { scrollToBottom } = renderSyncHook(room); + + await act(async () => { + events.pop(); + room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, false, true, { + liveEvent: false, + timeline, + }); + await Promise.resolve(); + }); + + expect(scrollToBottom).not.toHaveBeenCalled(); + }); + + it('renders a stale non-live event appended to the live timeline', async () => { + const { room, timeline, events } = createRoom(); + const { result } = renderSyncHook(room); + const before = result.current.timeline; + vi.mocked(isWindowFocused).mockReturnValue(true); + await act(async () => { + await flushRaf(); + }); + vi.mocked(markAsRead).mockClear(); + + await act(async () => { + events.push({}); + room.emit( + RoomEvent.Timeline, + makeLiveEvent(room.roomId, Date.now() - 29 * 60_000), + room, + false, + false, + { liveEvent: false, timeline } + ); + await flushRaf(); + }); + + expect(result.current.timeline).not.toBe(before); + expect(markAsRead).not.toHaveBeenCalled(); + vi.mocked(isWindowFocused).mockReturnValue(false); + }); + + it('renders a removal', async () => { + const { room, timeline, events } = createRoom(); + const { result } = renderSyncHook(room); + const before = result.current.timeline; + + await act(async () => { + events.pop(); + room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, false, true, { + liveEvent: false, + timeline, + }); + await Promise.resolve(); + }); + + expect(result.current.timeline).not.toBe(before); + }); + + it('ignores events emitted for a thread timeline set', async () => { + const { room, events } = createRoom(); + const otherSet = new EventEmitter() as FakeTimelineSet; + const threadTimeline = { ...createTimeline(events), getTimelineSet: () => otherSet }; + const { result, scrollToBottom } = renderSyncHook(room); + const before = result.current.timeline; + + await act(async () => { + room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, false, false, { + liveEvent: true, + timeline: threadTimeline, + }); + await Promise.resolve(); + }); + + expect(result.current.timeline).toBe(before); + expect(scrollToBottom).not.toHaveBeenCalled(); + }); + + it('does not treat a threaded reply as an arrival when it lands on the main set', async () => { + const { room, timeline, events } = createRoom(); + const { result } = renderSyncHook(room); + const before = result.current.timeline; + vi.mocked(isWindowFocused).mockReturnValue(true); + await act(async () => { + await flushRaf(); + }); + vi.mocked(markAsRead).mockClear(); + + await act(async () => { + events.push({}); + room.emit( + RoomEvent.Timeline, + { + ...makeLiveEvent(room.roomId, Date.now()), + threadRootId: '$root:test', + getId: () => '$reply:test', + getContent: () => ({ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root:test' } }), + getRelation: () => ({ rel_type: 'm.thread', event_id: '$root:test' }), + }, + room, + false, + false, + { liveEvent: true, timeline } + ); + await flushRaf(); + }); + + expect(result.current.timeline).not.toBe(before); + expect(markAsRead).not.toHaveBeenCalled(); + vi.mocked(isWindowFocused).mockReturnValue(false); + }); + + it('scrolls for a genuinely live event on the main timeline', async () => { + const { room, timeline, events } = createRoom(); + const { scrollToBottom } = renderSyncHook(room); + + await act(async () => { + events.push({}); + room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, false, false, { + liveEvent: true, + timeline, + }); + await Promise.resolve(); + }); + + expect(scrollToBottom).toHaveBeenCalledWith('instant'); + }); + + it('does not mark a room read while it sits behind the room list', async () => { + vi.mocked(isWindowFocused).mockReturnValue(true); + const emitLive = async (room: FakeRoom, timeline: FakeTimeline, events: unknown[]) => { + await act(async () => { + events.push({}); + room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, false, false, { + liveEvent: true, + timeline, + }); + await flushRaf(); + }); + }; + + const active = createRoom(); + renderSyncHook(active.room); + await emitLive(active.room, active.timeline, active.events); + expect(markAsRead).toHaveBeenCalled(); + + vi.mocked(markAsRead).mockClear(); + + const inactive = createRoom('!behind:test'); + renderSyncHook(inactive.room, { isInactivePanel: true }); + await emitLive(inactive.room, inactive.timeline, inactive.events); + expect(markAsRead).not.toHaveBeenCalled(); + + vi.mocked(isWindowFocused).mockRestore(); + }); it('ignores events emitted on a non-live timeline of the same room', async () => { const { room, events } = createRoom(); @@ -907,16 +1568,14 @@ describe('live-arrive edge cases', () => { expect(scrollToBottom).not.toHaveBeenCalled(); }); - it('re-anchors after a sliding sync reset and ignores events on the detached timeline', async () => { + it('re-anchors after a sliding sync reset without treating the old timeline as an arrival', async () => { const { room, timelineSet } = createRoom(); const { scrollToBottom } = renderSyncHook(room); const oldTimeline = timelineSet.getLiveTimeline(); const freshEvents: unknown[] = []; - const freshTimeline = createTimeline(freshEvents); + const freshTimeline = createTimeline(freshEvents, timelineSet); timelineSet.getLiveTimeline = () => freshTimeline; - // An event arriving on the OLD timeline is dropped even though the live - // timeline did grow — pushing to `events` instead would pass either way. await act(async () => { freshEvents.push({}); room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, false, false, { @@ -964,6 +1623,7 @@ describe('live-arrive edge cases', () => { setUnreadInfo, hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, }) ); @@ -1036,6 +1696,7 @@ describe('event jump recovery', () => { getEvents: () => olderEvents, getPaginationToken: () => undefined, getRoomId: () => room.roomId, + getTimelineSet: () => timelineSet, getNeighbouringTimeline: (direction: Direction) => direction === Direction.Forward ? targetTimeline : undefined, }; @@ -1043,22 +1704,25 @@ describe('event jump recovery', () => { getEvents: () => [{ getId: () => targetId }], getPaginationToken: () => undefined, getRoomId: () => room.roomId, + getTimelineSet: () => timelineSet, getNeighbouringTimeline: (direction: Direction) => direction === Direction.Backward ? olderTimeline : undefined, }; - const roomInitialSync = vi.fn<() => Promise>(() => Promise.resolve(undefined)); const getLatestTimeline = vi.fn<() => Promise>(() => Promise.resolve(undefined)); return { room, + timelineSet, olderTimeline, targetTimeline, - roomInitialSync, getLatestTimeline, mx: makeMx({ - roomInitialSync, getLatestTimeline, - getEventTimeline: vi.fn<() => Promise>(() => Promise.resolve(targetTimeline)), + // The resolved timeline belongs to whichever set the hook passed in. + getEventTimeline: vi.fn<(set: unknown) => Promise>((set) => { + targetTimeline.getTimelineSet = () => set as FakeTimelineSet; + return Promise.resolve(targetTimeline); + }), }), }; }; @@ -1066,6 +1730,7 @@ describe('event jump recovery', () => { it('backfills and jumps to a permalink target that is not in the loaded history', async () => { const fixture = setupUnloadedTarget('$target:test'); const { result, scrollToBottom } = renderSyncHook(fixture.room, { + eventId: '$target:test', isAtBottom: false, mx: fixture.mx, }); @@ -1074,21 +1739,351 @@ describe('event jump recovery', () => { await result.current.loadEventTimeline('$target:test'); }); - expect(fixture.roomInitialSync).toHaveBeenCalled(); - expect(fixture.getLatestTimeline).toHaveBeenCalled(); + expect(fixture.getLatestTimeline).not.toHaveBeenCalled(); // The whole linked chain is adopted, not just the timeline holding the event. expect(result.current.timeline.linkedTimelines).toEqual([ fixture.olderTimeline, fixture.targetTimeline, ]); - // Absolute index across the chain: 2 older events precede the target. - expect(result.current.focusItem).toEqual({ index: 2, scrollTo: true, highlight: true }); + expect(result.current.focusItem).toEqual({ + eventId: '$target:test', + scrollTo: true, + highlight: true, + }); + expect(scrollToBottom).not.toHaveBeenCalled(); + }); + + it('backfills a non-route jump target that is not in the loaded history', async () => { + const fixture = setupUnloadedTarget('$reply:test'); + const { result } = renderSyncHook(fixture.room, { + isAtBottom: false, + mx: fixture.mx, + }); + + await act(async () => { + await result.current.loadEventTimeline('$reply:test'); + }); + + expect(result.current.timeline.linkedTimelines).toEqual([ + fixture.olderTimeline, + fixture.targetTimeline, + ]); + expect(result.current.focusItem).toEqual({ + eventId: '$reply:test', + scrollTo: true, + highlight: true, + }); + }); + + it('protects a non-route jump until it returns to the live bottom', async () => { + const fixture = setupUnloadedTarget('$reply:test'); + let resolveJump: ((timeline: unknown) => void) | undefined; + const mx = makeMx({ + getLatestTimeline: fixture.getLatestTimeline, + getEventTimeline: vi.fn<() => Promise>( + () => + new Promise((resolve) => { + resolveJump = resolve; + }) + ), + }); + const focusedWindow = vi.mocked(setRoomFocusedWindow); + focusedWindow.mockClear(); + const { result, rerender } = renderSyncHook(fixture.room, { isAtBottom: false, mx }); + + let pending: Promise | undefined; + act(() => { + pending = result.current.loadEventTimeline('$reply:test'); + }); + expect(focusedWindow).toHaveBeenLastCalledWith(fixture.room.roomId, true); + + await act(async () => { + resolveJump?.(fixture.targetTimeline); + await pending; + }); + act(() => result.current.setFocusItem(undefined)); + + expect(focusedWindow).not.toHaveBeenLastCalledWith(fixture.room.roomId, false); + + act(() => { + result.current.setTimeline({ linkedTimelines: [fixture.room.getLiveTimeline()] }); + }); + rerender({ eventId: undefined, isAtBottom: true }); + + expect(focusedWindow).toHaveBeenLastCalledWith(fixture.room.roomId, false); + }); + + it('keeps a route jump protected at the live bottom until its route clears', async () => { + const fixture = setupUnloadedTarget('$target:test'); + const focusedWindow = vi.mocked(setRoomFocusedWindow); + focusedWindow.mockClear(); + const { result, rerender } = renderSyncHook(fixture.room, { + eventId: '$target:test', + isAtBottom: false, + mx: fixture.mx, + }); + + await act(async () => { + await result.current.loadEventTimeline('$target:test'); + }); + act(() => { + result.current.setTimeline({ linkedTimelines: [fixture.room.getLiveTimeline()] }); + }); + rerender({ eventId: '$target:test', isAtBottom: true }); + + expect(focusedWindow).toHaveBeenLastCalledWith(fixture.room.roomId, true); + }); + + it('keeps the newest result when the same target is loaded twice', async () => { + const targetId = '$target:test'; + const fixture = setupUnloadedTarget(targetId); + const newerTimeline = { + ...createTimeline([{ getId: () => targetId }], fixture.timelineSet), + getNeighbouringTimeline: () => undefined, + }; + const resolvers: Array<(timeline: unknown) => void> = []; + const jumpSets: unknown[] = []; + const mx = makeMx({ + getEventTimeline: vi.fn<(set: unknown) => Promise>((set) => { + jumpSets.push(set); + return new Promise((resolve) => resolvers.push(resolve)); + }), + getLatestTimeline: fixture.getLatestTimeline, + }); + const { result } = renderSyncHook(fixture.room, { isAtBottom: false, mx }); + + let first: Promise | undefined; + let second: Promise | undefined; + await act(async () => { + first = result.current.loadEventTimeline(targetId); + second = result.current.loadEventTimeline(targetId); + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + newerTimeline.getTimelineSet = () => jumpSets[1] as FakeTimelineSet; + resolvers[1]?.(newerTimeline); + await second; + }); + expect(result.current.timeline.linkedTimelines).toEqual([newerTimeline]); + + await act(async () => { + fixture.targetTimeline.getTimelineSet = () => jumpSets[0] as FakeTimelineSet; + resolvers[0]?.(fixture.targetTimeline); + await first; + }); + expect(result.current.timeline.linkedTimelines).toEqual([newerTimeline]); + }); + + it('fills context on both sides of the jump target before focusing it', async () => { + const fixture = setupUnloadedTarget('$target:test'); + const paginateEventTimeline = vi.fn<() => Promise>(() => Promise.resolve(true)); + const mx = makeMx({ + getLatestTimeline: fixture.getLatestTimeline, + paginateEventTimeline, + getEventTimeline: vi.fn<(set: unknown) => Promise>((set) => { + fixture.targetTimeline.getTimelineSet = () => set as FakeTimelineSet; + return Promise.resolve(fixture.targetTimeline); + }), + }); + const { result } = renderSyncHook(fixture.room, { isAtBottom: false, mx }); + + await act(async () => { + await result.current.loadEventTimeline('$target:test'); + }); + + // /context returns the target alone, so both directions must be filled or the + // target renders as the newest row. + const directions = paginateEventTimeline.mock.calls.map( + (call) => (call as unknown as [unknown, { backwards: boolean }])[1].backwards + ); + expect(directions).toContain(true); + expect(directions).toContain(false); + }); + + it('fails a jump rather than rendering a target without newer context', async () => { + const fixture = setupUnloadedTarget('$target:test'); + const paginateEventTimeline = vi.fn< + (timeline: unknown, options: { backwards: boolean }) => Promise + >((_timeline, { backwards }) => + backwards ? Promise.resolve(true) : Promise.reject(new Error('forward context failed')) + ); + const { result, onJumpError } = renderSyncHook(fixture.room, { + eventId: '$target:test', + isAtBottom: false, + mx: makeMx({ + getEventTimeline: vi.fn<(set: unknown) => Promise>((set) => { + fixture.targetTimeline.getTimelineSet = () => set as FakeTimelineSet; + return Promise.resolve(fixture.targetTimeline); + }), + paginateEventTimeline, + }), + }); + + await act(async () => { + await result.current.loadEventTimeline('$target:test'); + }); + + expect(result.current.focusItem).toBeUndefined(); + expect(result.current.jumpFailed).toBe(true); + expect(onJumpError).toHaveBeenCalledOnce(); + }); + + it('stays on the live timeline when the target is already the newest event', async () => { + const { room, timeline, events } = createRoom(); + events.length = 0; + events.push({ getId: () => '$newest' }); + timeline.getEvents = () => events; + const getEventTimeline = vi.fn<() => Promise>(() => Promise.resolve(undefined)); + const { result } = renderSyncHook(room, { + isAtBottom: false, + mx: makeMx({ getEventTimeline }), + }); + + await act(async () => { + await result.current.loadEventTimeline('$newest'); + }); + + expect(getEventTimeline).not.toHaveBeenCalled(); + expect(result.current.focusItem).toMatchObject({ eventId: '$newest', scrollTo: true }); + }); + + it('ignores a pending result after local navigation cancels it', async () => { + const targetId = '$target:test'; + const fixture = setupUnloadedTarget(targetId); + let resolveJump: ((timeline: unknown) => void) | undefined; + const mx = makeMx({ + getLatestTimeline: fixture.getLatestTimeline, + getEventTimeline: vi.fn<() => Promise>( + () => new Promise((resolve) => (resolveJump = resolve)) + ), + }); + const { result } = renderSyncHook(fixture.room, { isAtBottom: false, mx }); + + let pending: Promise | undefined; + await act(async () => { + pending = result.current.loadEventTimeline(targetId); + await Promise.resolve(); + await Promise.resolve(); + }); + act(() => result.current.cancelEventTimelineLoad()); + await act(async () => { + resolveJump?.(fixture.targetTimeline); + await pending; + }); + + expect(result.current.timeline.linkedTimelines).not.toContain(fixture.targetTimeline); + expect(result.current.focusItem).toBeUndefined(); + }); + + it('rejects a thread timeline instead of installing it as the room timeline', async () => { + const fixture = setupUnloadedTarget('$target:test'); + const otherSet = new EventEmitter() as FakeTimelineSet; + const threadTimeline = { + ...createTimeline([{ getId: () => '$target:test' }], otherSet), + getNeighbouringTimeline: () => undefined, + }; + const mx = makeMx({ + getLatestTimeline: vi.fn<() => Promise>(() => Promise.resolve(undefined)), + getEventTimeline: vi.fn<() => Promise>(() => Promise.resolve(threadTimeline)), + }); + const { result, scrollToBottom } = renderSyncHook(fixture.room, { + eventId: '$target:test', + isAtBottom: false, + mx, + }); + + await act(async () => { + await result.current.loadEventTimeline('$target:test'); + }); + + expect(result.current.timeline.linkedTimelines).not.toContain(threadTimeline); + expect(result.current.focusItem).toBeUndefined(); + expect(result.current.jumpFailed).toBe(true); + expect(scrollToBottom).toHaveBeenCalledWith('instant'); + }); + + it('reports a failed jump so the timeline can still be revealed', async () => { + const { room } = createRoom(); + const mx = makeMx({ + getLatestTimeline: vi.fn<() => Promise>(() => Promise.resolve(undefined)), + getEventTimeline: vi.fn<() => Promise>(() => Promise.reject(new Error('nope'))), + }); + const { result, onJumpError } = renderSyncHook(room, { + eventId: '$gone:test', + isAtBottom: false, + mx, + }); + + expect(result.current.jumpFailed).toBe(false); + + await act(async () => { + await result.current.loadEventTimeline('$gone:test'); + }); + + expect(result.current.jumpFailed).toBe(true); + expect(onJumpError).toHaveBeenCalled(); + }); + + it('does not report a stale failure for a different jump target', async () => { + const { room } = createRoom(); + const mx = makeMx({ + getLatestTimeline: vi.fn<() => Promise>(() => Promise.resolve(undefined)), + getEventTimeline: vi.fn<() => Promise>(() => Promise.reject(new Error('nope'))), + }); + const { result, rerender } = renderSyncHook(room, { + eventId: '$gone:test', + isAtBottom: false, + mx, + }); + + await act(async () => { + await result.current.loadEventTimeline('$gone:test'); + }); + expect(result.current.jumpFailed).toBe(true); + + rerender({ eventId: '$other:test', isAtBottom: false }); + expect(result.current.jumpFailed).toBe(false); + }); + + it('ignores a jump failure for a target that is no longer current', async () => { + const { room } = createRoom(); + let rejectJump: ((error: Error) => void) | undefined; + const mx = makeMx({ + getLatestTimeline: vi.fn<() => Promise>(() => Promise.resolve(undefined)), + getEventTimeline: vi.fn<() => Promise>( + () => + new Promise((_resolve, reject) => { + rejectJump = reject; + }) + ), + }); + const { result, rerender, scrollToBottom } = renderSyncHook(room, { + eventId: '$a:test', + isAtBottom: false, + mx, + }); + + let pending: Promise | undefined; + await act(async () => { + pending = result.current.loadEventTimeline('$a:test'); + await Promise.resolve(); + }); + + rerender({ eventId: '$b:test', isAtBottom: false }); + await act(async () => { + rejectJump?.(new Error('nope')); + await pending; + }); + + expect(result.current.jumpFailed).toBe(false); expect(scrollToBottom).not.toHaveBeenCalled(); }); it('jumps without highlighting when the target is the read marker itself', async () => { const fixture = setupUnloadedTarget('$read:test'); const { result } = renderSyncHook(fixture.room, { + eventId: '$read:test', isAtBottom: false, mx: fixture.mx, readUptoEventId: '$read:test', @@ -1098,7 +2093,11 @@ describe('event jump recovery', () => { await result.current.loadEventTimeline('$read:test'); }); - expect(result.current.focusItem).toEqual({ index: 2, scrollTo: true, highlight: false }); + expect(result.current.focusItem).toEqual({ + eventId: '$read:test', + scrollTo: true, + highlight: false, + }); }); it('falls back to the initial timeline when a jump load times out', async () => { @@ -1111,7 +2110,11 @@ describe('event jump recovery', () => { // The homeserver never answers /context: hit the 12 s timeout. getEventTimeline: () => new Promise(() => {}), }); - const { result, scrollToBottom } = renderSyncHook(room, { isAtBottom: false, mx }); + const { result, scrollToBottom } = renderSyncHook(room, { + eventId: '$missing:test', + isAtBottom: false, + mx, + }); await act(async () => { const pending = result.current.loadEventTimeline('$missing:test'); @@ -1125,6 +2128,39 @@ describe('event jump recovery', () => { vi.useRealTimers(); } }); + + it('times out when jump context pagination never settles', async () => { + vi.useFakeTimers(); + try { + const fixture = setupUnloadedTarget('$target:test'); + const paginateEventTimeline = vi.fn<() => Promise>(() => new Promise(() => {})); + const { result, onJumpError, scrollToBottom } = renderSyncHook(fixture.room, { + eventId: '$target:test', + isAtBottom: false, + mx: makeMx({ + getEventTimeline: vi.fn<(set: unknown) => Promise>((set) => { + fixture.targetTimeline.getTimelineSet = () => set as FakeTimelineSet; + return Promise.resolve(fixture.targetTimeline); + }), + paginateEventTimeline, + }), + }); + + act(() => { + void result.current.loadEventTimeline('$target:test'); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(13_000); + }); + + expect(paginateEventTimeline).toHaveBeenCalledTimes(2); + expect(onJumpError).toHaveBeenCalledOnce(); + expect(scrollToBottom).toHaveBeenCalledWith('instant'); + expect(result.current.jumpFailed).toBe(true); + } finally { + vi.useRealTimers(); + } + }); }); describe('sliding sync chain relink', () => { @@ -1195,6 +2231,7 @@ describe('sync transport fuzz', () => { setUnreadInfo: vi.fn<() => void>(), hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, isEventVisible: (ev) => !(ev as unknown as { hidden?: boolean }).hidden, }) ); @@ -1272,6 +2309,7 @@ describe('decryption refresh coalescing', () => { setUnreadInfo: vi.fn<() => void>(), hideReadsRef: { current: false }, readUptoEventIdRef: { current: undefined }, + isInactivePanelRef: { current: false }, }); if (!seen.includes(sync.timeline)) seen.push(sync.timeline); return sync; diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 85aa4a66f3..eb0a3a648d 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -1,5 +1,13 @@ import type { Dispatch, SetStateAction } from 'react'; -import { startTransition, useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { + startTransition, + useState, + useMemo, + useCallback, + useRef, + useEffect, + useLayoutEffect, +} from 'react'; import to from 'await-to-js'; import * as Sentry from '@sentry/react'; import type { @@ -7,11 +15,11 @@ import type { Room, MatrixEvent, EventTimeline, - EventTimelineSet, EventTimelineSetHandlerMap, IRoomTimelineData, RoomEventHandlerMap, } from '$types/matrix-sdk'; +import type { EventTimelineSet } from '$types/matrix-sdk'; import { Direction, MatrixEventEvent, @@ -31,12 +39,20 @@ import { getEventIdAbsoluteIndex, getLiveTimeline, getRoomUnreadInfo, + isNewestLiveEvent, PAGINATION_LIMIT, } from '$utils/timeline'; +import { getSlidingSyncManager } from '$client/initMatrix'; +import { setRoomFocusedWindow } from '$client/slidingSync'; import { isWindowFocused } from '$utils/dom'; +import { isThreadRelationEvent } from '$utils/room/relations'; const EVENT_TIMELINE_LOAD_TIMEOUT_MS = 12000; +// Context fetched either side of a jump target. Kept below PAGINATION_LIMIT because +// it is on the jump's critical path; viewport fill extends it when needed. +const JUMP_CONTEXT_LIMIT = 20; + type PaginationStatus = 'idle' | 'loading' | 'error'; type TimelineState = { @@ -62,53 +78,75 @@ const withTimeout = async (promise: Promise, timeoutMs: number): Promise void, - onError: (err: Error | null) => void + onLoad: (eventId: string, requestId: number, linkedTimelines: EventTimeline[]) => void, + onError: (eventId: string, requestId: number, err: Error | null) => void ) => useCallback( - async (eventId: string) => + async (eventId: string, requestId: number, timelineSet: EventTimelineSet) => Sentry.startSpan({ name: 'timeline.jump_load', op: 'matrix.timeline' }, async () => { - const jumpLoadStart = performance.now(); - - if (!room.getUnfilteredTimelineSet().getTimelineForEvent(eventId)) { - await withTimeout( - mx.roomInitialSync(room.roomId, PAGINATION_LIMIT), - EVENT_TIMELINE_LOAD_TIMEOUT_MS - ); - await withTimeout( - mx.getLatestTimeline(room.getUnfilteredTimelineSet()), - EVENT_TIMELINE_LOAD_TIMEOUT_MS + try { + const jumpLoadStart = performance.now(); + + const [err, replyEvtTimeline] = await to( + withTimeout( + (async () => { + const loadedTimeline = await mx.getEventTimeline(timelineSet, eventId); + if (!loadedTimeline || loadedTimeline.getTimelineSet() !== timelineSet) { + return loadedTimeline; + } + + // /context uses limit=0, and viewport fill only paginates backwards, which + // would leave the target as the newest row. EventTimeline tracks a request + // per direction, so both run at once. + const [[backwardError], [forwardError]] = await Promise.all([ + to( + mx.paginateEventTimeline(loadedTimeline, { + backwards: true, + limit: JUMP_CONTEXT_LIMIT, + }) + ), + to( + mx.paginateEventTimeline(loadedTimeline, { + backwards: false, + limit: JUMP_CONTEXT_LIMIT, + }) + ), + ]); + if (backwardError) throw backwardError; + if (forwardError) throw forwardError; + return loadedTimeline; + })(), + EVENT_TIMELINE_LOAD_TIMEOUT_MS + ) ); - } - const [err, replyEvtTimeline] = await to( - withTimeout( - mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId), - EVENT_TIMELINE_LOAD_TIMEOUT_MS - ) - ); - if (!replyEvtTimeline) { - onError(err ?? null); - return; - } - const linkedTimelines = getLinkedTimelines(replyEvtTimeline); - const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId); + if (!replyEvtTimeline) { + onError(eventId, requestId, err ?? null); + return; + } + if (replyEvtTimeline.getTimelineSet() !== timelineSet) { + onError(eventId, requestId, null); + return; + } + const linkedTimelines = getLinkedTimelines(replyEvtTimeline); - if (absIndex === undefined) { - onError(err ?? null); - return; - } + if (getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId) === undefined) { + onError(eventId, requestId, err ?? null); + return; + } - Sentry.metrics.distribution( - 'sable.timeline.jump_load_ms', - performance.now() - jumpLoadStart - ); - onLoad(eventId, linkedTimelines, absIndex); + Sentry.metrics.distribution( + 'sable.timeline.jump_load_ms', + performance.now() - jumpLoadStart + ); + onLoad(eventId, requestId, linkedTimelines); + } catch (err) { + onError(eventId, requestId, err instanceof Error ? err : null); + } }), - [mx, room, onLoad, onError] + [mx, onLoad, onError] ); -// Unbounded, a run of entirely hidden pages recurses until the room's history runs out. +// Fetch at most three continuation pages when too few events are visible. const MAX_AUTO_CONTINUATIONS = 3; // Rendered events among the `count` just-fetched ones: head of the chain when @@ -172,116 +210,72 @@ const useTimelinePagination = ( startTransition(() => setTimeline(() => ({ linkedTimelines: newLTimelines }))); }; - return async (backwards: boolean, autoContinuations = 0) => { + const edgeTimeline = (lTimelines: EventTimeline[], backwards: boolean) => + backwards ? lTimelines[0] : lTimelines.at(-1); + + return async (backwards: boolean) => { const directionKey = backwards ? 'backward' : 'forward'; if (fetchingRef.current[directionKey]) return; - const { linkedTimelines: lTimelines } = timelineRef.current; - const timelineToPaginate = backwards ? lTimelines[0] : lTimelines.at(-1); - if (!timelineToPaginate) return; - - const paginationToken = timelineToPaginate.getPaginationToken( - backwards ? Direction.Backward : Direction.Forward - ); + const direction = backwards ? Direction.Backward : Direction.Forward; + const initialTimelines = timelineRef.current.linkedTimelines; + const initialTimeline = edgeTimeline(initialTimelines, backwards); + if (!initialTimeline) return; if ( - !paginationToken && - getTimelinesEventsCount(lTimelines) !== - getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate)) + !initialTimeline.getPaginationToken(direction) && + getTimelinesEventsCount(initialTimelines) !== + getTimelinesEventsCount(getLinkedTimelines(initialTimeline)) ) { - recalibratePagination(lTimelines); + recalibratePagination(initialTimelines); return; } fetchingRef.current[directionKey] = true; - if (alive()) { - (backwards ? setBackwardStatus : setForwardStatus)('loading'); - } + const setStatus = backwards ? setBackwardStatus : setForwardStatus; + if (alive()) setStatus('loading'); - // `continuing` tracks whether we hand the fetchingRef lock to a recursive - // continuation call below. The finally block must NOT reset the lock if - // the recursive call has already claimed it, otherwise there is a brief - // window where fetchingRef is false while the recursive paginate is in - // flight, allowing a third overlapping call to start on sparse pages. - let continuing = false; + // Keep one lock and loading state across all continuation requests. + let settledStatus: PaginationStatus = 'idle'; try { - const countBefore = getTimelinesEventsCount(lTimelines); + for (let attempt = 0; attempt <= MAX_AUTO_CONTINUATIONS; attempt += 1) { + // A reset may replace the chain while pagination is pending. + const lTimelines = timelineRef.current.linkedTimelines; + const timelineToPaginate = edgeTimeline(lTimelines, backwards); + if (!timelineToPaginate) return; + if (typeof timelineToPaginate.getPaginationToken(direction) !== 'string') return; - const [err] = await to(mx.paginateEventTimeline(timelineToPaginate, { backwards, limit })); + const countBefore = getTimelinesEventsCount(lTimelines); - if (err) { - if (alive()) { - (backwards ? setBackwardStatus : setForwardStatus)('error'); + const [err] = await to( + mx.paginateEventTimeline(timelineToPaginate, { backwards, limit }) + ); + + if (err) { + settledStatus = 'error'; + return; } - return; - } + if (!alive()) return; - if (alive()) { - // Re-read linkedTimelines after the await: a sliding sync reset may have - // replaced lTimelines[0] (via resetLiveTimeline) while pagination was in - // flight, making the captured lTimelines stale. Using the fresh ref - // ensures recalibratePagination rebuilds from the current live chain and - // that countAfter/stillHasToken comparisons are meaningful. const freshLTimelines = timelineRef.current.linkedTimelines; const firstTimeline = freshLTimelines[0]; - if (!firstTimeline) { - (backwards ? setBackwardStatus : setForwardStatus)('idle'); - return; - } + if (!firstTimeline) return; recalibratePagination(freshLTimelines); - const countAfter = getTimelinesEventsCount(getLinkedTimelines(firstTimeline)); - const fetched = countAfter - countBefore; - - let visibleFetched = fetched; - if (isEventVisible && fetched > 0) { - visibleFetched = countVisibleAmongNewest( - getLinkedTimelines(firstTimeline), - fetched, - backwards, - isEventVisible - ); - } + // The SDK links fetched events in a new EventTimeline. + const rebuiltTimelines = getLinkedTimelines(firstTimeline); + const fetched = getTimelinesEventsCount(rebuiltTimelines) - countBefore; + if (fetched <= 0) return; - let willContinue = false; - if (fetched > 0 && visibleFetched < 5 && autoContinuations < MAX_AUTO_CONTINUATIONS) { - const checkTimeline = backwards - ? freshLTimelines[0] - : freshLTimelines[freshLTimelines.length - 1]; - if (!checkTimeline) { - (backwards ? setBackwardStatus : setForwardStatus)('idle'); - return; - } - const checkDirection = backwards ? Direction.Backward : Direction.Forward; - const stillHasToken = - typeof getLinkedTimelines(checkTimeline)[0]?.getPaginationToken(checkDirection) === - 'string'; - if (stillHasToken) { - // Release lock so inner paginate can claim it, then mark continuing - // so the finally block below does NOT reset it after inner claims. - fetchingRef.current[directionKey] = false; - continuing = true; - willContinue = true; - paginate(backwards, autoContinuations + 1); - // At this point the inner paginate has synchronously set - // fetchingRef.current[directionKey] = true before hitting its own - // await. The finally below will skip the reset. - } - } - - // Stay in 'loading' across auto-continuation chunks so the spinner does not flicker. - if (!willContinue) { - (backwards ? setBackwardStatus : setForwardStatus)('idle'); - } + const visibleFetched = isEventVisible + ? countVisibleAmongNewest(rebuiltTimelines, fetched, backwards, isEventVisible) + : fetched; + if (visibleFetched >= 5) return; } } finally { - // Only release the lock if we did NOT hand it to a recursive continuation. - // If `continuing` is true the recursive call owns the lock and will release - // it in its own finally block. - if (!continuing) { - fetchingRef.current[directionKey] = false; - } + fetchingRef.current[directionKey] = false; + if (alive()) setStatus(settledStatus); } }; }, [mx, alive, setTimeline, limit, setBackwardStatus, setForwardStatus, isEventVisible]); @@ -289,16 +283,19 @@ const useTimelinePagination = ( return { paginate, backwardStatus, forwardStatus }; }; -const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => { +const useLiveEventArrive = ( + room: Room, + onArrive: ( + mEvent: MatrixEvent, + isLive: boolean, + timeline?: EventTimeline, + prepended?: boolean + ) => void +) => { const onArriveRef = useRef(onArrive); onArriveRef.current = onArrive; useEffect(() => { - // Both are mutable: if TimelineReset replaces the live EventTimeline object - // we re-anchor them together inside the handler so the isLive check always - // runs against the current timeline and a fresh 60 s backfill window. - let liveTimeline = getLiveTimeline(room); - let registeredAt = Date.now(); const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = ( mEvent: MatrixEvent, eventRoom: Room | undefined, @@ -308,30 +305,21 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) ) => { if (eventRoom?.roomId !== room.roomId) return; - // Lazily re-anchor on timeline replacement. Capturing liveTimeline once - // at registration causes events on the new timeline to fail the reference - // check and be silently dropped after a sync gap / reconnect. - const currentLiveTimeline = getLiveTimeline(room); - if (currentLiveTimeline !== liveTimeline) { - liveTimeline = currentLiveTimeline; - registeredAt = Date.now(); - } + if (data.timeline?.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; - const isLive = - data.liveEvent || - (!toStartOfTimeline && - !removed && - data.timeline === liveTimeline && - mEvent.getTs() >= registeredAt - 60_000); - if (!isLive) return; - onArriveRef.current(mEvent); + onArriveRef.current( + mEvent, + data.liveEvent === true && !toStartOfTimeline && !removed, + data.timeline, + toStartOfTimeline === true && !removed + ); }; const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = ( mEvent: MatrixEvent, eventRoom: Room | undefined ) => { if (eventRoom?.roomId !== room.roomId) return; - onArriveRef.current(mEvent); + onArriveRef.current(mEvent, false); }; room.on(RoomEvent.Timeline, handleTimelineEvent); @@ -417,9 +405,18 @@ export interface UseTimelineSyncOptions { setUnreadInfo: Dispatch>>; hideReadsRef: React.MutableRefObject; readUptoEventIdRef: React.MutableRefObject; + isInactivePanelRef: React.MutableRefObject; isEventVisible?: (mEvent: MatrixEvent, timelineSet: EventTimelineSet) => boolean; + onJumpError?: () => void; + onReturnToLive?: () => void; } +export type TimelineFocusItem = { + eventId: string; + scrollTo: boolean; + highlight: boolean; +}; + export function useTimelineSync({ room, mx, @@ -431,7 +428,10 @@ export function useTimelineSync({ setUnreadInfo, hideReadsRef, readUptoEventIdRef, + isInactivePanelRef, isEventVisible, + onJumpError, + onReturnToLive, }: UseTimelineSyncOptions) { const alive = useAlive(); @@ -439,14 +439,12 @@ export function useTimelineSync({ eventId ? getEmptyTimeline() : { linkedTimelines: getInitialTimeline(room).linkedTimelines } ); - const [focusItem, setFocusItem] = useState< - | { - index: number; - scrollTo: boolean; - highlight: boolean; - } - | undefined - >(); + const [focusItem, setFocusItem] = useState(); + const [jumpWindowActive, setJumpWindowActive] = useState(Boolean(eventId)); + + const [jumpFailedFor, setJumpFailedFor] = useState(); + const [prependVersion, setPrependVersion] = useState(0); + const jumpFailed = jumpFailedFor !== undefined && jumpFailedFor === eventId; const resetAutoScrollPendingRef = useRef(false); const pendingAutoScrollBehaviorRef = useRef<'instant' | 'smooth' | undefined>(undefined); @@ -466,67 +464,92 @@ export function useTimelineSync({ forwardStatus, } = useTimelinePagination(mx, timeline, setTimeline, PAGINATION_LIMIT, isEventVisible); - const prevEventsLengthRef = useRef(eventsLength); - useEffect(() => { - const prev = prevEventsLengthRef.current; - const delta = eventsLength - prev; - prevEventsLengthRef.current = eventsLength; - - if (delta === 0) return; - - const isBatch = delta > 1; - let batchSize: string; - if (delta === 1) batchSize = 'single'; - else if (delta <= 20) batchSize = 'small'; - else if (delta <= 100) batchSize = 'medium'; - else batchSize = 'large'; - - Sentry.addBreadcrumb({ - category: 'timeline.events', - message: `Timeline: ${delta} event${delta === 1 ? '' : 's'} added (${batchSize})`, - level: isBatch ? 'info' : 'debug', - data: { - delta, - batchSize, - eventsLength, - prevEventsLength: prev, - liveTimelineLinked, - atBottom: isAtBottom, - }, - }); - - if (delta > 50 && liveTimelineLinked) { - Sentry.captureMessage('Timeline: large event batch from sliding sync', { - level: 'warning', - extra: { delta, eventsLength, atBottom: isAtBottom }, - tags: { feature: 'timeline', batchSize }, - }); - } - }, [eventsLength, liveTimelineLinked, isAtBottom]); + const jumpRequestIdRef = useRef(0); + // Event id of the in-flight /context fetch. Restarting a jump bumps + // jumpRequestIdRef, which makes the open load discard its own result. + const inFlightJumpEventIdRef = useRef(undefined); + // A jump window is loaded and displayed; the room's own set holds it. + const jumpWindowLoadedRef = useRef(false); + const cancelEventTimelineLoad = useCallback(() => { + jumpRequestIdRef.current += 1; + inFlightJumpEventIdRef.current = undefined; + jumpWindowLoadedRef.current = false; + setJumpWindowActive(false); + }, []); + useLayoutEffect(() => { + setJumpWindowActive(Boolean(eventId)); + return () => { + jumpRequestIdRef.current += 1; + inFlightJumpEventIdRef.current = undefined; + jumpWindowLoadedRef.current = false; + }; + }, [room, eventId]); - const loadEventTimeline = useEventTimelineLoader( + const loadEventTimelineRequest = useEventTimelineLoader( mx, - room, useCallback( - (evtId, lTimelines, evtAbsIndex) => { - if (!alive()) return; + (evtId, requestId, lTimelines) => { + if (!alive() || requestId !== jumpRequestIdRef.current) return; + inFlightJumpEventIdRef.current = undefined; + setJumpFailedFor(undefined); setTimeline({ linkedTimelines: lTimelines }); setFocusItem({ - index: evtAbsIndex, + eventId: evtId, scrollTo: true, highlight: evtId !== readUptoEventIdRef.current, }); }, [alive, readUptoEventIdRef] ), - useCallback(() => { - if (!alive()) return; - setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - scrollToBottom('instant'); - }, [alive, room, scrollToBottom]) + useCallback( + (evtId: string, requestId: number) => { + if (!alive() || requestId !== jumpRequestIdRef.current) return; + inFlightJumpEventIdRef.current = undefined; + jumpWindowLoadedRef.current = false; + setJumpWindowActive(false); + setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); + setJumpFailedFor(evtId); + scrollToBottom('instant'); + onJumpError?.(); + }, + [alive, onJumpError, room, scrollToBottom] + ) ); + // The window lives in the room's own set; the response patch defers resets for it. + const loadEventTimeline = useCallback( + (evtId: string) => { + jumpRequestIdRef.current += 1; + inFlightJumpEventIdRef.current = evtId; + + // A notification for the newest message is the common case: stay on the live + // timeline instead of fetching a window that would only duplicate its tail. + if (isNewestLiveEvent(room, evtId)) { + inFlightJumpEventIdRef.current = undefined; + jumpWindowLoadedRef.current = false; + setJumpWindowActive(false); + setJumpFailedFor(undefined); + setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); + setFocusItem({ eventId: evtId, scrollTo: true, highlight: false }); + return Promise.resolve(); + } + + // The window stays in the room's own set so redactions, threads and receipts + // still reach it; the response patch defers resets while it is on screen. + jumpWindowLoadedRef.current = true; + setJumpWindowActive(true); + return loadEventTimelineRequest( + evtId, + jumpRequestIdRef.current, + room.getUnfilteredTimelineSet() + ); + }, + [loadEventTimelineRequest, room] + ); + + const linkedTimelinesRef = useRef(timeline.linkedTimelines); + linkedTimelinesRef.current = timeline.linkedTimelines; const lastScrolledAtEventsLengthRef = useRef(eventsLength); @@ -536,13 +559,41 @@ export function useTimelineSync({ useLiveEventArrive( room, useCallback( - (mEvt: MatrixEvent) => { + (mEvt: MatrixEvent, isLive: boolean, evtTimeline?: EventTimeline, prepended?: boolean) => { + const isDisplayedTimeline = + evtTimeline === undefined || linkedTimelinesRef.current.includes(evtTimeline); + if (isDisplayedTimeline) { + setTimeline((ct) => ({ ...ct })); + if (prepended) { + setPrependVersion((version) => version + 1); + } + } + + if (!isLive) return; + const { threadRootId } = mEvt; - if (threadRootId !== undefined) return; + if (threadRootId !== undefined && isThreadRelationEvent(mEvt, threadRootId)) return; + + if ( + mEvt.getSender() === mx.getUserId() && + mEvt.isSending() && + !mEvt.isRelation() && + !mEvt.isRedaction() && + (!isAtBottomRef.current || !atLiveEndRef.current) + ) { + cancelEventTimelineLoad(); + setJumpFailedFor(undefined); + resetAutoScrollPendingRef.current = true; + pendingAutoScrollBehaviorRef.current = 'instant'; + setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); + onReturnToLive?.(); + return; + } if (isAtBottomRef.current && atLiveEndRef.current) { if ( isWindowFocused() && + !isInactivePanelRef.current && (!unreadInfo?.readUptoEventId || mEvt.getSender() === mx.getUserId()) ) { requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideReadsRef.current)); @@ -557,16 +608,24 @@ export function useTimelineSync({ pendingAutoScrollBehaviorRef.current = mEvt.getSender() === mx.getUserId() || !isWindowFocused() ? 'instant' : 'smooth'; - setTimeline((ct) => ({ ...ct })); return; } - setTimeline((ct) => ({ ...ct })); if (!unreadInfo) { setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef] + [ + mx, + room, + isAtBottomRef, + unreadInfo, + setUnreadInfo, + hideReadsRef, + isInactivePanelRef, + cancelEventTimelineLoad, + onReturnToLive, + ] ) ); @@ -610,15 +669,33 @@ export function useTimelineSync({ useLiveTimelineRefresh( room, useCallback(() => { + if (eventId) { + if (jumpWindowLoadedRef.current && getSlidingSyncManager(mx)) return; + if (inFlightJumpEventIdRef.current !== eventId) void loadEventTimeline(eventId); + return; + } const wasAtBottom = isAtBottomRef.current; resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { scrollToBottom('instant'); } - }, [room, isAtBottomRef, scrollToBottom]) + }, [eventId, isAtBottomRef, loadEventTimeline, mx, room, scrollToBottom]) ); + // Declarative so the count cannot drift: a jump is protected for the whole time + // its window is loading or displayed, independently of its transient focus marker. + useEffect(() => { + if (!jumpWindowActive) return undefined; + setRoomFocusedWindow(room.roomId, true); + return () => setRoomFocusedWindow(room.roomId, false); + }, [room.roomId, jumpWindowActive]); + + // Release in-room jumps at the live bottom; route jumps clear with the route. + useEffect(() => { + if (!eventId && isAtBottom && liveTimelineLinked) setJumpWindowActive(false); + }, [eventId, isAtBottom, liveTimelineLinked]); + useRelationUpdate( room, useCallback(() => { @@ -689,13 +766,16 @@ export function useTimelineSync({ timeline, setTimeline, eventsLength, + prependVersion, liveTimelineLinked, canPaginateBack, backwardStatus, forwardStatus, handleTimelinePagination, loadEventTimeline, + cancelEventTimelineLoad, focusItem, setFocusItem, + jumpFailed, }; } diff --git a/src/app/hooks/useNotificationJumper.test.tsx b/src/app/hooks/useNotificationJumper.test.tsx new file mode 100644 index 0000000000..33fdb27a7e --- /dev/null +++ b/src/app/hooks/useNotificationJumper.test.tsx @@ -0,0 +1,248 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { Provider, createStore } from 'jotai'; +import type * as ReactRouterDom from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ClientEvent, SyncState } from '$types/matrix-sdk'; +import { activeSessionIdAtom, pendingNotificationAtom } from '$state/sessions'; +import { mDirectAtom } from '$state/mDirectList'; +import { roomToParentsAtom } from '$state/room/roomToParents'; + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn<() => void>(), + getSlidingSyncManager: vi.fn<() => unknown>(), + mx: undefined as unknown, +})); + +vi.mock('react-router-dom', async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => mocks.navigate, +})); + +vi.mock('$client/initMatrix', () => ({ + getSlidingSyncManager: mocks.getSlidingSyncManager, +})); + +vi.mock('./useMatrixClient', () => ({ + useMatrixClient: () => mocks.mx, +})); + +vi.mock('./useSyncState', () => ({ + useSyncState: () => {}, +})); + +import { NotificationJumper } from './useNotificationJumper'; + +const renderJumper = (initialTimeline?: 'live' | 'detached') => { + const listeners = new Map void>>(); + const liveTimeline = { + getNeighbouringTimeline: () => null, + getState: () => undefined, + }; + const detachedTimeline = { + getNeighbouringTimeline: () => null, + }; + let targetTimeline = + initialTimeline === 'live' + ? liveTimeline + : initialTimeline === 'detached' + ? detachedTimeline + : undefined; + const timelineSet = { + getLiveTimeline: () => liveTimeline, + getTimelineForEvent: () => targetTimeline, + }; + const room = { + roomId: '!room:example.org', + getMyMembership: () => 'join', + getCanonicalAlias: () => null, + getLiveTimeline: () => liveTimeline, + getUnfilteredTimelineSet: () => timelineSet, + }; + mocks.mx = { + getUserId: () => '@me:example.org', + getSyncState: () => SyncState.Syncing, + getRoom: () => room, + on: (event: string, listener: (...args: unknown[]) => void) => { + const eventListeners = listeners.get(event) ?? new Set(); + eventListeners.add(listener); + listeners.set(event, eventListeners); + }, + removeListener: (event: string, listener: (...args: unknown[]) => void) => { + listeners.get(event)?.delete(listener); + }, + }; + + const store = createStore(); + store.set(activeSessionIdAtom, '@me:example.org'); + store.set(mDirectAtom, { type: 'INITIALIZE', rooms: new Set([room.roomId]) }); + store.set(roomToParentsAtom, { type: 'INITIALIZE', roomToParents: new Map() }); + store.set(pendingNotificationAtom, { + roomId: room.roomId, + eventId: '$target', + targetSessionId: '@me:example.org', + }); + + const rendered = render( + + + + ); + + return { + loadTarget: () => { + targetTimeline = liveTimeline; + }, + emitRoom: () => { + listeners.get(ClientEvent.Room)?.forEach((listener) => listener()); + }, + emitSync: () => { + listeners + .get(ClientEvent.Sync) + ?.forEach((listener) => listener(SyncState.Syncing, SyncState.Syncing)); + }, + store, + unmount: rendered.unmount, + }; +}; + +describe('NotificationJumper', () => { + const subscriptionCallbacks = new Map void>(); + const prepareRoomSubscription = vi.fn<(_roomId: string, listener: () => void) => () => void>( + (roomId, listener) => { + subscriptionCallbacks.set(roomId, listener); + return () => subscriptionCallbacks.delete(roomId); + } + ); + const unsubscribeFromRoom = vi.fn<() => void>(); + const releaseRoomSubscriptionUnlessRouted = vi.fn<() => void>(); + + beforeEach(() => { + mocks.navigate.mockReset(); + prepareRoomSubscription.mockClear(); + unsubscribeFromRoom.mockClear(); + releaseRoomSubscriptionUnlessRouted.mockClear(); + mocks.getSlidingSyncManager.mockReset().mockReturnValue({ + isRoomActive: () => false, + isRoomSubscriptionTemporary: () => true, + prepareRoomSubscription, + releaseRoomSubscriptionUnlessRouted, + unsubscribeFromRoom, + }); + subscriptionCallbacks.clear(); + }); + + it('preloads the room and waits for that subscription response before navigating', async () => { + const jumper = renderJumper(); + + expect(mocks.navigate).not.toHaveBeenCalled(); + expect(prepareRoomSubscription).toHaveBeenCalledWith('!room:example.org', expect.any(Function)); + + jumper.loadTarget(); + jumper.emitRoom(); + expect(mocks.navigate).not.toHaveBeenCalled(); + + subscriptionCallbacks.get('!room:example.org')?.(); + + await waitFor(() => expect(mocks.navigate).toHaveBeenCalledOnce()); + expect(unsubscribeFromRoom).not.toHaveBeenCalled(); + expect(releaseRoomSubscriptionUnlessRouted).toHaveBeenCalledWith('!room:example.org'); + }); + + it('jumps anyway when the subscription is never confirmed', async () => { + vi.useFakeTimers(); + try { + const jumper = renderJumper(); + jumper.loadTarget(); + jumper.emitRoom(); + expect(mocks.navigate).not.toHaveBeenCalled(); + + // The subscription callback never fires, e.g. the server omitted the room. + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + + expect(mocks.navigate).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it('falls back to context after the subscription response omits the target', async () => { + renderJumper(); + expect(mocks.navigate).not.toHaveBeenCalled(); + + subscriptionCallbacks.get('!room:example.org')?.(); + + await waitFor(() => expect(mocks.navigate).toHaveBeenCalledOnce()); + }); + + it('releases the temporary subscription when navigation fails', async () => { + mocks.navigate.mockImplementationOnce(() => { + throw new Error('navigation failed'); + }); + renderJumper(); + + expect(() => subscriptionCallbacks.get('!room:example.org')?.()).not.toThrow(); + + await waitFor(() => expect(unsubscribeFromRoom).toHaveBeenCalledWith('!room:example.org')); + expect(releaseRoomSubscriptionUnlessRouted).not.toHaveBeenCalled(); + }); + + it('preloads an inactive room even when the notification event is already live', async () => { + renderJumper('live'); + + expect(mocks.navigate).not.toHaveBeenCalled(); + expect(prepareRoomSubscription).toHaveBeenCalledWith('!room:example.org', expect.any(Function)); + + subscriptionCallbacks.get('!room:example.org')?.(); + await waitFor(() => expect(mocks.navigate).toHaveBeenCalledOnce()); + }); + + // Only asserts that a detached target preloads a subscription instead of jumping. + // It cannot discriminate isEventInLiveTimelineChain: with a manager present + // waitingForTimelineRef is already true, so navigate is blocked either way. + it('preloads a subscription for a target held in a detached timeline', () => { + renderJumper('detached'); + + expect(mocks.navigate).not.toHaveBeenCalled(); + expect(prepareRoomSubscription).toHaveBeenCalledOnce(); + }); + + it('waits for a completed legacy sync before falling back to context', async () => { + mocks.getSlidingSyncManager.mockReturnValue(undefined); + const jumper = renderJumper(); + + expect(mocks.navigate).not.toHaveBeenCalled(); + jumper.loadTarget(); + jumper.emitRoom(); + expect(mocks.navigate).not.toHaveBeenCalled(); + + jumper.emitSync(); + + await waitFor(() => expect(mocks.navigate).toHaveBeenCalledOnce()); + }); + + it('cancels a superseded room subscription and only performs the latest jump', async () => { + const jumper = renderJumper(); + + act(() => { + jumper.store.set(pendingNotificationAtom, { + roomId: '!other:example.org', + eventId: '$other', + targetSessionId: '@me:example.org', + }); + }); + + expect(subscriptionCallbacks.has('!room:example.org')).toBe(false); + expect(unsubscribeFromRoom).toHaveBeenCalledWith('!room:example.org'); + expect(prepareRoomSubscription).toHaveBeenLastCalledWith( + '!other:example.org', + expect.any(Function) + ); + + subscriptionCallbacks.get('!other:example.org')?.(); + + await waitFor(() => expect(mocks.navigate).toHaveBeenCalledOnce()); + expect(mocks.navigate).toHaveBeenCalledWith('/home/!other%3Aexample.org/%24other'); + }); +}); diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 8a41fef8e6..8ee9e1122b 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -1,7 +1,10 @@ import { useCallback, useEffect, useRef } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; -import { SyncState, ClientEvent } from '$types/matrix-sdk'; +import type { Room } from '$types/matrix-sdk'; +import { SyncState, ClientEvent, Direction } from '$types/matrix-sdk'; +import { getSlidingSyncManager } from '$client/initMatrix'; +import { getEventTimeline, getFirstLinkedTimeline, getLiveTimeline } from '$utils/timeline'; import { activeSessionIdAtom, pendingNotificationAtom } from '../state/sessions'; import { mDirectAtom } from '../state/mDirectList'; import { useSyncState } from './useSyncState'; @@ -12,6 +15,20 @@ import { getOrphanParents, guessPerfectParent } from '../utils/room/hierarchy'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { createLogger } from '../utils/debug'; +const SUBSCRIPTION_WAIT_TIMEOUT_MS = 10000; + +const log = createLogger('NotificationJumper'); + +const isEventInLiveTimelineChain = (room: Room | null, eventId: string | undefined): boolean => { + if (!eventId) return true; + if (!room) return false; + const timeline = getEventTimeline(room, eventId); + return ( + timeline !== undefined && + getFirstLinkedTimeline(timeline, Direction.Forward) === getLiveTimeline(room) + ); +}; + export function NotificationJumper() { const [pending, setPending] = useAtom(pendingNotificationAtom); const activeSessionId = useAtomValue(activeSessionIdAtom); @@ -19,7 +36,6 @@ export function NotificationJumper() { const roomToParents = useAtomValue(roomToParentsAtom); const mx = useMatrixClient(); const navigate = useNavigate(); - const log = createLogger('NotificationJumper'); // Set true the moment we fire navigateRoom. Only reset when `pending` changes // to a new value (via the effect below). Do NOT reset inside performJump itself: @@ -27,6 +43,8 @@ export function NotificationJumper() { // churn re-calls performJump (from the ClientEvent.Room listener or effect // re-runs) before React has committed the null, causing repeated navigation. const jumpingRef = useRef(false); + const waitingForTimelineRef = useRef(false); + const timelineReadyRef = useRef(false); const performJump = useCallback(() => { if (!pending || jumpingRef.current) return; @@ -50,15 +68,21 @@ export function NotificationJumper() { const isSyncing = mx.getSyncState() === SyncState.Syncing; const room = mx.getRoom(pending.roomId); const isJoined = room?.getMyMembership() === 'join'; + const targetInLiveTimeline = isEventInLiveTimelineChain(room, pending.eventId); + const canJump = + !pending.eventId || + timelineReadyRef.current || + (!waitingForTimelineRef.current && targetInLiveTimeline); - if (isSyncing && isJoined) { + if (isSyncing && isJoined && canJump) { log.log('jumping to:', pending.roomId, pending.eventId); jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which // on mobile shows the space-nav panel first instead of the room timeline. const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, pending.roomId); + let path: string; if (mDirects.has(pending.roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, pending.eventId)); + path = getDirectRoomPath(roomIdOrAlias, pending.eventId); } else { // If the room lives inside a space, route through the space path so // SpaceRouteRoomProvider can resolve it — HomeRouteRoomProvider only @@ -70,31 +94,39 @@ export function NotificationJumper() { if (orphanParents.length > 0) { const parentSpace = guessPerfectParent(mx, pending.roomId, orphanParents) ?? orphanParents[0]; - navigate( - getSpaceRoomPath( - getCanonicalAliasOrRoomId(mx, parentSpace ?? pending.roomId), - roomIdOrAlias, - pending.eventId - ) + path = getSpaceRoomPath( + getCanonicalAliasOrRoomId(mx, parentSpace ?? pending.roomId), + roomIdOrAlias, + pending.eventId ); } else { - navigate(getHomeRoomPath(roomIdOrAlias, pending.eventId)); + path = getHomeRoomPath(roomIdOrAlias, pending.eventId); } } + + try { + navigate(path); + } catch (error) { + jumpingRef.current = false; + log.error('failed to navigate to notification:', error); + } setPending(null); // jumpingRef stays true until pending changes — see effect below. } else { - log.log('still waiting for room data...', { + log.log('still waiting to jump...', { isSyncing, hasRoom: !!room, membership: room?.getMyMembership(), + targetInLiveTimeline, }); } - }, [pending, activeSessionId, mx, mDirects, roomToParents, navigate, setPending, log]); + }, [pending, activeSessionId, mx, mDirects, roomToParents, navigate, setPending]); // Reset the guard only when pending is replaced (new notification or cleared). useEffect(() => { jumpingRef.current = false; + waitingForTimelineRef.current = false; + timelineReadyRef.current = false; }, [pending]); // Keep a stable ref to the latest performJump so that the listeners below @@ -105,6 +137,57 @@ export function NotificationJumper() { const performJumpRef = useRef(performJump); performJumpRef.current = performJump; + useEffect(() => { + if (!pending) return undefined; + if (pending.targetSessionId && pending.targetSessionId !== activeSessionId) return undefined; + if (pending.targetSessionId && mx.getUserId() !== pending.targetSessionId) return undefined; + + const manager = getSlidingSyncManager(mx); + const targetInLiveTimeline = isEventInLiveTimelineChain( + mx.getRoom(pending.roomId), + pending.eventId + ); + if (!manager && targetInLiveTimeline) return undefined; + waitingForTimelineRef.current = true; + + if (!manager) { + const onSync = (state: SyncState) => { + if (state !== SyncState.Syncing) return; + waitingForTimelineRef.current = false; + timelineReadyRef.current = true; + performJumpRef.current(); + }; + mx.on(ClientEvent.Sync, onSync); + return () => mx.removeListener(ClientEvent.Sync, onSync); + } + + const stopWaiting = manager.prepareRoomSubscription(pending.roomId, () => { + waitingForTimelineRef.current = false; + timelineReadyRef.current = true; + performJumpRef.current(); + }); + const temporarySubscription = manager.isRoomSubscriptionTemporary(pending.roomId); + + // The listener only fires for a response that carries the subscription. If the + // server omits it, or the subscription cap drops the room, jump anyway rather + // than leave the tap doing nothing. + const timeoutId = globalThis.setTimeout(() => { + if (!waitingForTimelineRef.current) return; + log.warn('subscription never confirmed, jumping without it'); + waitingForTimelineRef.current = false; + timelineReadyRef.current = true; + performJumpRef.current(); + }, SUBSCRIPTION_WAIT_TIMEOUT_MS); + + return () => { + globalThis.clearTimeout(timeoutId); + stopWaiting(); + if (!temporarySubscription) return; + if (jumpingRef.current) manager.releaseRoomSubscriptionUnlessRouted(pending.roomId); + else manager.unsubscribeFromRoom(pending.roomId); + }; + }, [pending, activeSessionId, mx]); + useSyncState( mx, // Stable callback — reads from ref, so useSyncState never re-registers. @@ -116,12 +199,12 @@ export function NotificationJumper() { useEffect(() => { if (!pending) return undefined; - const onRoom = () => performJumpRef.current(); - mx.on(ClientEvent.Room, onRoom); + const retryJump = () => performJumpRef.current(); + mx.on(ClientEvent.Room, retryJump); performJumpRef.current(); return () => { - mx.removeListener(ClientEvent.Room, onRoom); + mx.removeListener(ClientEvent.Room, retryJump); }; }, [pending, mx]); // performJump intentionally omitted — use ref above diff --git a/src/app/utils/timeline.ts b/src/app/utils/timeline.ts index 89bdd6ff07..70f09ce354 100644 --- a/src/app/utils/timeline.ts +++ b/src/app/utils/timeline.ts @@ -7,6 +7,12 @@ export const PAGINATION_LIMIT = 60; export const getLiveTimeline = (room: Room): EventTimeline => room.getUnfilteredTimelineSet().getLiveTimeline(); +/** True when `eventId` is the newest event the live timeline holds. */ +export const isNewestLiveEvent = (room: Room, eventId: string): boolean => { + const events = getLiveTimeline(room).getEvents?.() ?? []; + return events[events.length - 1]?.getId?.() === eventId; +}; + export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => { const timelineSet = room.getUnfilteredTimelineSet(); return timelineSet.getTimelineForEvent(eventId) ?? undefined; diff --git a/src/client/initMatrix.test.ts b/src/client/initMatrix.test.ts index 1482bbb962..af556b20ff 100644 --- a/src/client/initMatrix.test.ts +++ b/src/client/initMatrix.test.ts @@ -14,12 +14,139 @@ vi.mock('$utils/platform', async (importOriginal) => ({ })); import { + installSlidingSyncRequestPatch, newSlidingSyncConnId, ownsActiveMediaSession, resolvePollTimeoutMs, supportsSlidingSync, } from './initMatrix'; +describe('installSlidingSyncRequestPatch', () => { + it('normalizes expanded timelines before returning the response to the SDK', async () => { + const response = { + rooms: { + '!room:example.org': { + expanded_timeline: true, + timeline: [{ event_id: '$old:example.org' }], + prev_batch: 'back-token', + }, + }, + }; + const original = vi.fn<() => Promise>(async () => response); + const sanitizeOptimisticJoinResponse = vi.fn<(prepared: typeof response) => void>( + (prepared) => { + expect(prepared.rooms['!room:example.org']).toMatchObject({ limited: true }); + } + ); + const trackResponse = vi.fn<() => void>(); + const trackSubscriptionRequest = vi.fn<() => typeof trackResponse>(() => trackResponse); + const mx = { + slidingSync: original, + getRoom: () => undefined, + } as unknown as MatrixClient; + const manager = { + isPaused: () => false, + getActiveRoomSubscriptionIds: () => new Set(), + trackSubscriptionRequest, + sanitizeOptimisticJoinResponse, + }; + + installSlidingSyncRequestPatch(mx, manager as never); + const prepared = await mx.slidingSync( + { extensions: {}, room_subscriptions: { '!room:example.org': {} } } as never, + '', + undefined + ); + + expect(prepared.rooms['!room:example.org']).toMatchObject({ limited: true }); + expect(trackSubscriptionRequest).toHaveBeenCalledWith(new Set(['!room:example.org'])); + expect(trackResponse).toHaveBeenCalledWith(response); + expect(sanitizeOptimisticJoinResponse).toHaveBeenCalledWith(prepared); + }); + + it('normalizes an unflagged expansion before returning it to the SDK', async () => { + const response = { + rooms: { + '!room:example.org': { + timeline: [{ event_id: '$old:example.org' }, { event_id: '$known:example.org' }], + prev_batch: 'back-token', + }, + }, + }; + const original = vi.fn<() => Promise>(async () => response); + const liveTimeline = { + getEvents: () => [{ getId: () => '$known:example.org' }], + }; + const mx = { + slidingSync: original, + getRoom: () => ({ + getLiveTimeline: () => liveTimeline, + getUnfilteredTimelineSet: () => ({ getLiveTimeline: () => liveTimeline }), + }), + } as unknown as MatrixClient; + const manager = { + isPaused: () => false, + getActiveRoomSubscriptionIds: () => new Set(), + trackSubscriptionRequest: vi.fn<() => () => void>(() => () => {}), + sanitizeOptimisticJoinResponse: vi.fn<() => void>(), + }; + + installSlidingSyncRequestPatch(mx, manager as never); + const prepared = await mx.slidingSync({ extensions: {} } as never, '', undefined); + + expect(prepared.rooms['!room:example.org']).toMatchObject({ limited: true }); + }); + + it('resets a gapped room before returning the response to the SDK', async () => { + const response = { + rooms: { + '!room:example.org': { + timeline: [{ event_id: '$new:example.org' }], + limited: true, + prev_batch: 'back-token', + }, + }, + }; + const resetLiveTimeline = vi.fn<() => void>(); + const resetRoomTimeline = vi.fn<() => void>(); + const resetNotifTimelineSet = vi.fn<() => void>(); + const original = vi.fn<() => Promise>(async () => response); + const liveTimeline = { + getEvents: () => [{ getId: () => '$old:example.org', isSending: () => false }], + getState: () => ({}), + }; + const mx = { + slidingSync: original, + getRoom: () => ({ + oldState: {}, + currentState: {}, + emit: vi.fn<() => void>(), + getLiveTimeline: () => liveTimeline, + getUnfilteredTimelineSet: () => ({ + getLiveTimeline: () => liveTimeline, + resetLiveTimeline, + }), + clearLoadedMembersIfNeeded: () => Promise.resolve(), + resetLiveTimeline: resetRoomTimeline, + }), + resetNotifTimelineSet, + } as unknown as MatrixClient; + const manager = { + isPaused: () => false, + getActiveRoomSubscriptionIds: () => new Set(), + trackSubscriptionRequest: vi.fn<() => () => void>(() => () => {}), + sanitizeOptimisticJoinResponse: vi.fn<() => void>(), + }; + + installSlidingSyncRequestPatch(mx, manager as never); + await mx.slidingSync({ extensions: {} } as never, '', undefined); + + expect(resetLiveTimeline).toHaveBeenCalledWith('back-token'); + expect(resetRoomTimeline).not.toHaveBeenCalled(); + expect(resetNotifTimelineSet).toHaveBeenCalledOnce(); + }); +}); + const alice = { userId: '@alice:example.org' } as Session; const bob = { userId: '@bob:example.org' } as Session; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 338e65b70d..37d8c4a639 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -35,7 +35,7 @@ import { revokeOAuthToken } from './oauthTokenRevocation'; import { clearSecretStorageKeys, cryptoCallbacks } from './secretStorageKeys'; import type { SlidingSyncDiagnostics } from './slidingSync'; import { - markExpandedTimelinesLimited, + prepareSlidingSyncTimelines, scopeTypingExtension, SlidingSyncManager, } from './slidingSync'; @@ -161,13 +161,20 @@ type SlidingSyncRequestWithConnId = MSC3575SlidingSyncRequest & { export const newSlidingSyncConnId = (): string => `sable-${globalThis.crypto?.randomUUID?.().slice(0, 8) ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`}`; -function installSlidingSyncRequestPatch(mx: MatrixClient, manager: SlidingSyncManager): void { +export function installSlidingSyncRequestPatch( + mx: MatrixClient, + manager: SlidingSyncManager +): void { slidingSyncRequestCleanupByClient.get(mx)?.(); const connId = newSlidingSyncConnId(); const mxWritable = mx as MatrixClientWithWritableSlidingSync; const original = mx.slidingSync.bind(mx) as SlidingSyncMethod; mxWritable.slidingSync = async (reqBody, baseUrl, abortSignal) => { + const req = reqBody as SlidingSyncRequestWithConnId; + const subscribedRoomIds = new Set(Object.keys(req.room_subscriptions ?? {})); + const trackResponse = manager.trackSubscriptionRequest(subscribedRoomIds); + // AbortError makes the SDK loop `continue` and reissue at the same `pos`, no sleep. if (manager.isPaused()) { await manager.waitForResume(); @@ -176,7 +183,6 @@ function installSlidingSyncRequestPatch(mx: MatrixClient, manager: SlidingSyncMa throw aborted; } - const req = reqBody as SlidingSyncRequestWithConnId; if (req.conn_id === undefined) { req.conn_id = connId; } @@ -185,10 +191,14 @@ function installSlidingSyncRequestPatch(mx: MatrixClient, manager: SlidingSyncMa scopeTypingExtension(req.extensions, roomIds); const response = await original(reqBody, baseUrl, abortSignal); + trackResponse(response); // Must run before the SDK processes the response. A throw would reach the SDK's // loop, which drops the response and retries the same `pos` forever. try { - markExpandedTimelinesLimited(response); + const completeTimelineReset = prepareSlidingSyncTimelines(response, mx, subscribedRoomIds); + if (completeTimelineReset) { + manager.trackTimelineResetCompletion(response, completeTimelineReset); + } manager.sanitizeOptimisticJoinResponse(response); } catch (error) { Sentry.captureException(error, { tags: { area: 'sliding_sync_response' } }); diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index dbca806e21..78b8de9778 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -2,9 +2,15 @@ * Unit tests for SlidingSyncManager memory management */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { MatrixClient, MatrixEvent, MSC3575List } from '$types/matrix-sdk'; +import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest'; +import type { + MatrixClient, + MatrixEvent, + MSC3575List, + MSC3575SlidingSyncResponse, +} from '$types/matrix-sdk'; import { + EventTimeline, EventType, KnownMembership, SlidingSyncEvent, @@ -13,7 +19,9 @@ import { } from '$types/matrix-sdk'; import { - markExpandedTimelinesLimited, + clearFocusedWindows, + setRoomFocusedWindow, + prepareSlidingSyncTimelines, scopeTypingExtension, SlidingSyncManager, } from './slidingSync'; @@ -209,6 +217,136 @@ describe('SlidingSyncManager initial request', () => { expect(settled).toHaveBeenCalledOnce(); }); + it('settles a prepared room after the response carrying its subscription completes', () => { + const manager = makeManager(makeMockMx()); + const ready = vi.fn<() => void>(); + manager.attach(); + const trackStaleResponse = manager.trackSubscriptionRequest([]); + manager.prepareRoomSubscription('!target:example.com', ready); + + const staleResponse = { + rooms: { '!target:example.com': { timeline: [] } }, + } as unknown as MSC3575SlidingSyncResponse; + trackStaleResponse(staleResponse); + fireLifecycle(SlidingSyncState.RequestFinished, staleResponse); + fireRoomData('!target:example.com', { timeline: [] }); + fireLifecycle(SlidingSyncState.Complete, staleResponse); + expect(ready).not.toHaveBeenCalled(); + + const unrelatedResponse = { rooms: {} } as unknown as MSC3575SlidingSyncResponse; + manager.trackSubscriptionRequest([])(unrelatedResponse); + fireLifecycle(SlidingSyncState.RequestFinished, unrelatedResponse); + fireLifecycle(SlidingSyncState.Complete, unrelatedResponse); + expect(ready).not.toHaveBeenCalled(); + + const subscriptionResponse = { rooms: {} } as unknown as MSC3575SlidingSyncResponse; + manager.trackSubscriptionRequest(['!target:example.com'])(subscriptionResponse); + fireLifecycle(SlidingSyncState.RequestFinished, subscriptionResponse); + fireLifecycle(SlidingSyncState.Complete, subscriptionResponse); + + expect(ready).toHaveBeenCalledOnce(); + }); + + it('settles an already-active room only after a request started for the notification', () => { + const roomId = '!target:example.com'; + const manager = makeManager(makeMockMx()); + manager.attach(); + manager.subscribeToRoom(roomId); + mocks.slidingSyncInstance.resend.mockClear(); + + const trackStaleResponse = manager.trackSubscriptionRequest([]); + const ready = vi.fn<() => void>(); + manager.prepareRoomSubscription(roomId, ready); + expect(mocks.slidingSyncInstance.resend).toHaveBeenCalledOnce(); + + const staleResponse = { + rooms: { [roomId]: { timeline: [] } }, + } as unknown as MSC3575SlidingSyncResponse; + trackStaleResponse(staleResponse); + fireLifecycle(SlidingSyncState.RequestFinished, staleResponse); + fireLifecycle(SlidingSyncState.Complete, staleResponse); + expect(ready).not.toHaveBeenCalled(); + + // Confirmed subscriptions are omitted from subsequent room_subscriptions deltas. + const nextResponse = { rooms: {} } as unknown as MSC3575SlidingSyncResponse; + manager.trackSubscriptionRequest([])(nextResponse); + fireLifecycle(SlidingSyncState.RequestFinished, nextResponse); + fireLifecycle(SlidingSyncState.Complete, nextResponse); + expect(ready).toHaveBeenCalledOnce(); + }); + + it('restores reset timeline events only when their response completes', () => { + const manager = makeManager(makeMockMx()); + const completion = vi.fn<() => void>(); + const response = { rooms: {} } as unknown as MSC3575SlidingSyncResponse; + manager.attach(); + manager.trackTimelineResetCompletion(response, completion); + + fireLifecycle(SlidingSyncState.Complete, { + rooms: {}, + } as unknown as MSC3575SlidingSyncResponse); + expect(completion).not.toHaveBeenCalled(); + + fireLifecycle(SlidingSyncState.Complete, response); + expect(completion).toHaveBeenCalledOnce(); + }); + + it('keeps a prepared room active when the route adopts it', () => { + vi.useFakeTimers(); + try { + const roomId = '!target:example.com'; + const manager = makeManager(makeMockMx()); + manager.prepareRoomSubscription(roomId, () => {}); + manager.releaseRoomSubscriptionUnlessRouted(roomId); + + manager.setActiveRoomSubscriptions([roomId]); + vi.runAllTimers(); + expect(manager.isRoomActive(roomId)).toBe(true); + + manager.setActiveRoomSubscriptions([]); + expect(manager.isRoomActive(roomId)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('releases a prepared room when the route does not adopt it', () => { + vi.useFakeTimers(); + try { + const roomId = '!target:example.com'; + const manager = makeManager(makeMockMx()); + manager.prepareRoomSubscription(roomId, () => {}); + manager.releaseRoomSubscriptionUnlessRouted(roomId); + + vi.runAllTimers(); + + expect(manager.isRoomActive(roomId)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('does not release a room claimed by a newer notification', () => { + vi.useFakeTimers(); + try { + const roomId = '!target:example.com'; + const manager = makeManager(makeMockMx()); + manager.prepareRoomSubscription(roomId, () => {}); + manager.releaseRoomSubscriptionUnlessRouted(roomId); + + manager.prepareRoomSubscription(roomId, () => {}); + vi.runAllTimers(); + + expect(manager.isRoomActive(roomId)).toBe(true); + + manager.releaseRoomSubscriptionUnlessRouted(roomId); + vi.runAllTimers(); + expect(manager.isRoomActive(roomId)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + it('includes receipt-only and account-data-only rooms in the settled unread delta', async () => { const manager = makeManager(makeMockMx()); const settled = vi.fn<(dirtyRoomIds: ReadonlySet) => void>(); @@ -866,15 +1004,19 @@ describe('scopeTypingExtension', () => { }); }); -describe('markExpandedTimelinesLimited', () => { +describe('prepareSlidingSyncTimelines', () => { it('marks an expanded timeline limited so the SDK reconciles the gap', () => { const resp = { rooms: { - '!dm:example.com': { unstable_expanded_timeline: true, prev_batch: 't1-2' }, + '!dm:example.com': { + unstable_expanded_timeline: true, + timeline: [{}], + prev_batch: 't1-2', + }, }, - } as unknown as Parameters[0]; + } as unknown as Parameters[0]; - markExpandedTimelinesLimited(resp); + prepareSlidingSyncTimelines(resp); expect(resp?.rooms['!dm:example.com']).toMatchObject({ limited: true }); }); @@ -884,27 +1026,162 @@ describe('markExpandedTimelinesLimited', () => { rooms: { '!quiet:example.com': { limited: false, prev_batch: 't1-2' }, }, - } as unknown as Parameters[0]; + } as unknown as Parameters[0]; - markExpandedTimelinesLimited(resp); + prepareSlidingSyncTimelines(resp); expect(resp?.rooms['!quiet:example.com']).toMatchObject({ limited: false }); }); - it('keeps an expanded timeline unlimited when there is no pagination token', () => { + it('leaves an all-live timeline untouched', () => { + const resp = { + rooms: { + '!active:example.com': { timeline: [{}, {}], num_live: 2 }, + }, + } as unknown as Parameters[0]; + + prepareSlidingSyncTimelines(resp); + + expect(resp?.rooms['!active:example.com']).not.toHaveProperty('limited'); + }); + + it('leaves an initial timeline untouched', () => { + const resp = { + rooms: { + '!initial:example.com': { initial: true, timeline: [{}, {}], num_live: 0 }, + }, + } as unknown as Parameters[0]; + + prepareSlidingSyncTimelines(resp); + + expect(resp?.rooms['!initial:example.com']).not.toHaveProperty('limited'); + }); + + it('marks an expanded timeline limited even when the response omits prev_batch', () => { + const resp = { + rooms: { + '!dm:example.com': { unstable_expanded_timeline: true, timeline: [{}] }, + }, + } as unknown as Parameters[0]; + + prepareSlidingSyncTimelines(resp); + + expect(resp?.rooms['!dm:example.com']).toHaveProperty('limited', true); + }); + + it('carries the room back-pagination token forward when prev_batch is omitted', () => { const resp = { rooms: { - '!dm:example.com': { unstable_expanded_timeline: true }, + '!dm:example.com': { unstable_expanded_timeline: true, timeline: [{}] }, }, - } as unknown as Parameters[0]; + } as unknown as Parameters[0]; + const mx = { + getRoom: () => ({ + getLiveTimeline: () => ({ getEvents: () => [], getPaginationToken: () => 't1-9' }), + getUnfilteredTimelineSet: () => ({ + getLiveTimeline: () => ({ getEvents: () => [], getPaginationToken: () => 't1-9' }), + getTimelines: () => [{}], + resetLiveTimeline: vi.fn<() => void>(), + }), + }), + } as unknown as Parameters[1]; - markExpandedTimelinesLimited(resp); + prepareSlidingSyncTimelines(resp, mx); + + expect(resp?.rooms['!dm:example.com']).toMatchObject({ limited: true, prev_batch: 't1-9' }); + }); + + it('accepts the stable MSC4186 expanded_timeline flag', () => { + const resp = { + rooms: { + '!dm:example.com': { expanded_timeline: true, timeline: [{}], prev_batch: 't1-3' }, + }, + } as unknown as Parameters[0]; + + prepareSlidingSyncTimelines(resp); + + expect(resp?.rooms['!dm:example.com']).toHaveProperty('limited', true); + }); + + it('does not clear pagination state for an empty expanded response', () => { + const resp = { + rooms: { + '!dm:example.com': { expanded_timeline: true }, + }, + } as unknown as Parameters[0]; + + prepareSlidingSyncTimelines(resp); + + expect(resp?.rooms['!dm:example.com']).not.toHaveProperty('limited'); + }); + + it.each([ + { + name: 'historic events before a known event', + timeline: ['$old', '$known'], + expected: true, + }, + { + name: 'historic and new events around a known event', + timeline: ['$old', '$known', '$new'], + expected: true, + }, + { name: 'an ordinary live overlap', timeline: ['$known', '$new'], expected: false }, + { name: 'only unknown events', timeline: ['$old', '$new'], expected: false }, + { name: 'only known events', timeline: ['$known'], expected: false }, + ])('classifies $name without expansion metadata', ({ timeline, expected }) => { + const resp = { + rooms: { + '!dm:example.com': { timeline: timeline.map((event_id) => ({ event_id })) }, + }, + } as unknown as Parameters[0]; + const mx = { + getRoom: () => ({ + getLiveTimeline: () => ({ + getEvents: () => [{ getId: () => '$known', isSending: () => false }], + getPaginationToken: () => 't1-9', + }), + getUnfilteredTimelineSet: () => ({ + getLiveTimeline: () => ({ + getEvents: () => [{ getId: () => '$known', isSending: () => false }], + getPaginationToken: () => 't1-9', + }), + getTimelines: () => [{}], + resetLiveTimeline: vi.fn<() => void>(), + }), + }), + } as unknown as Parameters[1]; + + prepareSlidingSyncTimelines(resp, mx); + + expect(resp?.rooms['!dm:example.com']?.limited).toBe(expected ? true : undefined); + }); + + it.each([-1, 1.5, 3])('ignores invalid num_live value %s', (numLive) => { + const resp = { + rooms: { + '!dm:example.com': { timeline: [{ event_id: '$new' }], num_live: numLive }, + }, + } as unknown as Parameters[0]; + + prepareSlidingSyncTimelines(resp); expect(resp?.rooms['!dm:example.com']).not.toHaveProperty('limited'); }); + it('ignores malformed timeline data', () => { + const resp = { + rooms: { + '!dm:example.com': { timeline: {} }, + }, + } as unknown as Parameters[0]; + + expect(() => prepareSlidingSyncTimelines(resp)).not.toThrow(); + expect(resp?.rooms['!dm:example.com']).not.toHaveProperty('limited'); + }); + it('tolerates a response without rooms', () => { - expect(() => markExpandedTimelinesLimited(null)).not.toThrow(); + expect(() => prepareSlidingSyncTimelines(null)).not.toThrow(); }); }); @@ -1624,3 +1901,210 @@ describe('SlidingSyncManager — membership leave auto-unsubscribe', () => { expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).not.toHaveBeenCalled(); }); }); + +const makeTimelineResetRoom = (eventIds: string[], sendingEventIds: string[] = []) => { + const sending = new Set(sendingEventIds); + const currentState = {}; + const oldState = {}; + let startState = oldState; + let events = eventIds.map((id) => ({ + getId: () => id, + isSending: () => sending.has(id), + })); + const liveTimeline = { + getEvents: () => events, + getPaginationToken: () => 't1-old', + getState: (direction: string) => + direction === EventTimeline.BACKWARDS ? startState : currentState, + }; + const resetTimelineSet = vi.fn<(back?: string) => void>(() => { + events = []; + startState = {}; + }); + const resetRoomTimeline = vi.fn<() => void>(); + const addEventToTimeline = vi.fn<(event: (typeof events)[number]) => void>((event) => { + events.push(event); + }); + const timelineSet = { + addEventToTimeline, + findEventById: (eventId: string) => events.find((event) => event.getId() === eventId), + getLiveTimeline: () => liveTimeline, + resetLiveTimeline: resetTimelineSet, + }; + const clearLoadedMembersIfNeeded = vi.fn<() => Promise>(() => Promise.resolve()); + const room = { + oldState, + currentState, + clearLoadedMembersIfNeeded, + emit: vi.fn<() => void>(), + getLiveTimeline: () => liveTimeline, + getUnfilteredTimelineSet: () => timelineSet, + resetLiveTimeline: resetRoomTimeline, + }; + return { + room, + addEventToTimeline, + clearLoadedMembersIfNeeded, + resetRoomTimeline, + resetTimelineSet, + startState: () => startState, + }; +}; + +const prepareRoomTimelineResponse = ( + room: unknown, + roomData: Record, + resetNotif = vi.fn<() => void>() +) => + prepareSlidingSyncTimelines( + { rooms: { '!room:example.org': roomData } } as unknown as MSC3575SlidingSyncResponse, + { + getRoom: () => room, + resetNotifTimelineSet: resetNotif, + } as unknown as MatrixClient + ); + +describe('prepareSlidingSyncTimelines reset boundary', () => { + afterEach(() => clearFocusedWindows()); + + it.each([ + [ + 'limited gap', + ['$old'], + { limited: true, timeline: [{ event_id: '$new' }], prev_batch: 't' }, + true, + ], + [ + 'limited overlap', + ['$old'], + { limited: true, timeline: [{ event_id: '$old' }, { event_id: '$new' }] }, + false, + ], + [ + 'initial gap', + ['$old'], + { initial: true, timeline: [{ event_id: '$new' }], prev_batch: 't' }, + true, + ], + // A pos expiry answers `initial` for every room; resetting an overlapping + // window would drop all loaded scrollback on each reconnect. + [ + 'initial overlap', + ['$old'], + { initial: true, timeline: [{ event_id: '$old' }, { event_id: '$new' }] }, + false, + ], + ['initial empty', ['$old'], { initial: true, timeline: [] }, false], + ['ordinary update', ['$old'], { timeline: [{ event_id: '$new' }] }, false], + ['empty cache', [], { limited: true, timeline: [{ event_id: '$new' }] }, false], + ])('%s reset decision', (_name, eventIds, roomData, shouldReset) => { + const { room, resetTimelineSet } = makeTimelineResetRoom(eventIds as string[]); + + prepareRoomTimelineResponse(room, roomData as Record); + + expect(resetTimelineSet).toHaveBeenCalledTimes(shouldReset ? 1 : 0); + }); + + it('flushes lazily loaded members only when the server reports limited', () => { + const overlapping = makeTimelineResetRoom(['$old']); + prepareRoomTimelineResponse(overlapping.room, { + limited: true, + timeline: [{ event_id: '$old' }, { event_id: '$new' }], + }); + expect(overlapping.resetTimelineSet).not.toHaveBeenCalled(); + expect(overlapping.clearLoadedMembersIfNeeded).toHaveBeenCalledOnce(); + + const expanded = makeTimelineResetRoom(['$old']); + prepareRoomTimelineResponse(expanded.room, { + expanded_timeline: true, + timeline: [{ event_id: '$old' }, { event_id: '$new' }], + }); + expect(expanded.clearLoadedMembersIfNeeded).not.toHaveBeenCalled(); + }); + + it('defers a gapped reset while a room shows a focused jump window', () => { + const { room, clearLoadedMembersIfNeeded, resetTimelineSet } = makeTimelineResetRoom(['$old']); + const roomData = { limited: true, timeline: [{ event_id: '$new' }], prev_batch: 't' }; + + setRoomFocusedWindow('!room:example.org', true); + prepareRoomTimelineResponse(room, { ...roomData }); + expect(resetTimelineSet).not.toHaveBeenCalled(); + expect(clearLoadedMembersIfNeeded).toHaveBeenCalledOnce(); + + setRoomFocusedWindow('!room:example.org', false); + prepareRoomTimelineResponse(room, { ...roomData }); + expect(resetTimelineSet).toHaveBeenCalledTimes(1); + expect(clearLoadedMembersIfNeeded).toHaveBeenCalledTimes(2); + }); + + it('restores a sending event after the response is merged', () => { + const { room, addEventToTimeline, resetTimelineSet } = makeTimelineResetRoom( + ['$old', '~local'], + ['~local'] + ); + + const completeTimelineReset = prepareRoomTimelineResponse(room, { + limited: true, + timeline: [{ event_id: '$new' }], + prev_batch: 't', + }); + completeTimelineReset?.(); + + expect(resetTimelineSet).toHaveBeenCalledOnce(); + expect(addEventToTimeline).toHaveBeenCalledOnce(); + }); + + it('resets only the unfiltered timeline and updates the room state references', () => { + const { room, resetRoomTimeline, resetTimelineSet, startState } = makeTimelineResetRoom([ + '$old', + ]); + + prepareRoomTimelineResponse(room, { + limited: true, + timeline: [{ event_id: '$new' }], + prev_batch: 'back-token', + }); + + expect(resetTimelineSet).toHaveBeenCalledWith('back-token'); + expect(resetRoomTimeline).not.toHaveBeenCalled(); + expect(room.oldState).toBe(startState()); + expect(room.currentState).toBe(room.getLiveTimeline().getState(EventTimeline.FORWARDS)); + }); + + it('resets the notification timeline once for a multi-room response', () => { + const first = makeTimelineResetRoom(['$old-a']); + const second = makeTimelineResetRoom(['$old-b']); + const resetNotifTimelineSet = vi.fn<() => void>(); + + prepareSlidingSyncTimelines( + { + rooms: { + '!a:example.org': { limited: true, timeline: [{ event_id: '$new-a' }], prev_batch: 't' }, + '!b:example.org': { limited: true, timeline: [{ event_id: '$new-b' }], prev_batch: 't' }, + }, + } as unknown as MSC3575SlidingSyncResponse, + { + getRoom: (roomId: string) => (roomId === '!a:example.org' ? first.room : second.room), + resetNotifTimelineSet, + } as unknown as MatrixClient + ); + + expect(first.resetTimelineSet).toHaveBeenCalledOnce(); + expect(second.resetTimelineSet).toHaveBeenCalledOnce(); + expect(resetNotifTimelineSet).toHaveBeenCalledOnce(); + }); + + it('caps room subscriptions at the MSC4186 maximum', () => { + const manager = makeManager(makeMockMx()); + manager.attach(); + + for (let i = 0; i < 140; i += 1) { + manager.subscribeToRoom(`!room${i}:example.com`); + } + + const lastCall = mocks.slidingSyncInstance.modifyRoomSubscriptions.mock.calls.at(-1); + const requested = (lastCall as unknown as [ReadonlySet])[0]; + expect(requested.size).toBe(100); + expect(requested.has('!room0:example.com')).toBe(true); + }); +}); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 2a85c8d800..e4bcdfe31c 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -7,8 +7,10 @@ import type { MSC3575RoomSubscription, MSC3575SlidingSyncResponse, Room, + EventTimelineSet, } from '$types/matrix-sdk'; import { + EventStatus, KnownMembership, MatrixEvent, MSC3575_WILDCARD, @@ -22,6 +24,7 @@ import { EventTimeline, EventEmitterEvents, ClientEvent, + RoomEvent, UNSTABLE_ELEMENT_FUNCTIONAL_USERS, } from '$types/matrix-sdk'; import { createLogger } from '$utils/debug'; @@ -36,6 +39,25 @@ const debugLog = createDebugLogger('slidingSync'); const LIST_JOINED = 'joined'; const LIST_INVITES = 'invites'; const LIST_TIMELINE_LIMIT = 1; + +// MSC4186 room_subscriptions maximum. +const MAX_ROOM_SUBSCRIPTIONS = 100; + +// Rooms showing a /context window for a jump. resetLiveTimeline has no forward +// token to hand back, so it sets resetAllTimelines and discards every timeline in +// the set — including the one the jump is rendering. Defer the reset for those. +// Ref-counted: two panels can show the same room, and one closing its window must +// not drop the other's protection. +const focusedWindowCounts = new Map(); + +export const setRoomFocusedWindow = (roomId: string, active: boolean): void => { + const count = focusedWindowCounts.get(roomId) ?? 0; + const next = active ? count + 1 : count - 1; + if (next > 0) focusedWindowCounts.set(roomId, next); + else focusedWindowCounts.delete(roomId); +}; + +export const clearFocusedWindows = (): void => focusedWindowCounts.clear(); const LIST_PAGE_SIZE = 30; const DEFAULT_POLL_TIMEOUT_MS = 45000; // Mirrors the js-sdk's own BUFFER_PERIOD_MS so our watchdog sits after its `clientTimeout`. @@ -48,6 +70,7 @@ const SPACE_SUBSCRIPTION_KEY = 'space'; const IMAGE_PACK_SUBSCRIPTION_KEY = 'image_packs'; const SPACE_IMAGE_PACK_SUBSCRIPTION_KEY = 'space_image_packs'; const ACTIVE_ROOM_TIMELINE_LIMIT = 50; +const ROUTE_ADOPTION_TIMEOUT_MS = 30_000; const OPTIMISTIC_JOIN_MAX_SYNC_CYCLES = 10; const OPTIMISTIC_JOIN_VERIFY_AFTER_CYCLES = 3; @@ -285,21 +308,185 @@ export const scopeTypingExtension = ( scopedTyping.rooms = [...roomIds]; }; -type ExpandedTimelineRoomData = MSC3575RoomData & { unstable_expanded_timeline?: boolean }; +type SlidingSyncTimelineRoomData = MSC3575RoomData & { + expanded_timeline?: boolean; + unstable_expanded_timeline?: boolean; +}; -// Synapse flags history re-sent for a raised timeline_limit only as -// `unstable_expanded_timeline`, but the SDK reconciles a gap on `limited`/`initial`, -// so without this it lands after the newest event. Drop once the SDK reads the flag. -export const markExpandedTimelinesLimited = (resp: MSC3575SlidingSyncResponse | null): void => { - if (!resp?.rooms) return; +type PreparedRoomSubscription = { + afterRequestId: number; + requiresSubscriptionResponse: boolean; + listener: () => void; +}; - for (const roomData of Object.values(resp.rooms)) { - const expanded = roomData as ExpandedTimelineRoomData; - // Without a token the SDK would clear the back-pagination token. - if (expanded.unstable_expanded_timeline === true && typeof expanded.prev_batch === 'string') { - expanded.limited = true; +type TrackedSlidingSyncResponse = { + requestId: number; + subscriptionRoomIds: ReadonlySet; +}; + +type TimelineResetCompletion = () => void; + +// Prepare room timelines before SlidingSyncSdk merges a response. +export const prepareSlidingSyncTimelines = ( + resp: MSC3575SlidingSyncResponse | null, + mx?: MatrixClient, + subscribedRoomIds?: ReadonlySet +): TimelineResetCompletion | null => { + if (!resp?.rooms) return null; + let didResetTimeline = false; + const pendingEventsToRestore: Array<{ + timelineSet: EventTimelineSet; + events: MatrixEvent[]; + }> = []; + + for (const [roomId, roomData] of Object.entries(resp.rooms)) { + const timelineData = roomData as SlidingSyncTimelineRoomData; + const serverReportedLimited = timelineData.limited === true; + const hasExpandedFlag = + timelineData.expanded_timeline === true || timelineData.unstable_expanded_timeline === true; + const numLive = timelineData.num_live; + const timeline = Array.isArray(timelineData.timeline) ? timelineData.timeline : []; + const timelineLength = timeline.length; + const room = mx?.getRoom(roomId); + const timelineSet = room?.getUnfilteredTimelineSet(); + const liveTimeline = room?.getLiveTimeline(); + const liveEvents = liveTimeline?.getEvents() ?? []; + // Limited responses may omit membership updates (MSC4186). + if (serverReportedLimited && room) { + void room.clearLoadedMembersIfNeeded().catch((error: unknown) => { + debugLog.warn('sync', 'Failed to flush lazily loaded members after a gap', { + roomId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + const liveEventIds: string[] = []; + for (const event of liveEvents) { + const eventId = event.getId(); + if (eventId) liveEventIds.push(eventId); + } + const knownEventIds = new Set(liveEventIds); + // MSC4186 notes that an expanded timeline can also be inferred from `num_live`. + const hasHistoricalEvents = + timelineData.initial !== true && + typeof numLive === 'number' && + Number.isInteger(numLive) && + numLive >= 0 && + numLive < timelineLength; + let hasHistoricalOverlap = false; + if (!hasExpandedFlag && !hasHistoricalEvents && timelineData.initial !== true) { + let sawUnknownEvent = false; + // If expansion metadata is missing, match the SDK's limited-sync split: + // unknown events before a known live event are scrollback. + hasHistoricalOverlap = timeline.some((event) => { + const eventId = event.event_id; + if (typeof eventId !== 'string') return false; + if (knownEventIds.has(eventId)) return sawUnknownEvent; + sawUnknownEvent = true; + return false; + }); + } + + const responseEventIds = timeline + .map((event) => event.event_id) + .filter((eventId): eventId is string => typeof eventId === 'string'); + const firstKnownResponseIndex = responseEventIds.findIndex((eventId) => + knownEventIds.has(eventId) + ); + const firstKnownLiveIndex = + firstKnownResponseIndex < 0 + ? -1 + : liveEventIds.indexOf(responseEventIds[firstKnownResponseIndex]!); + const hasSparseOverlap = firstKnownResponseIndex > 0 && firstKnownLiveIndex > 0; + + // The overlap scan needs a shared event to split on, so a zero-overlap window + // is invisible to it. A response for a room whose subscription we just asked + // for is the expansion we requested, not new tail. + const isRequestedExpansion = + subscribedRoomIds?.has(roomId) === true && + knownEventIds.size > 0 && + firstKnownResponseIndex < 0; + + const shouldMarkLimited = + timelineLength > 0 && + (hasExpandedFlag || hasHistoricalEvents || hasHistoricalOverlap || isRequestedExpansion); + + if (shouldMarkLimited) timelineData.limited = true; + + // SlidingSyncSdk does not reset a genuinely gapped initial or limited timeline. + // Without this, it presents separate windows as contiguous. + const isGapped = firstKnownResponseIndex < 0 || hasSparseOverlap; + if ( + knownEventIds.size > 0 && + room && + timelineSet && + !focusedWindowCounts.has(roomId) && + isGapped && + // An empty window carries no history to replace the cache with. + responseEventIds.length > 0 && + // resetLiveTimeline has no forward token to give, so without prev_batch the + // new live timeline reports "start of room" and back-pagination is dead. + typeof timelineData.prev_batch === 'string' && + // A pos expiry re-sends every subscription, so every room answers `initial`. + // Resetting on that alone drops all loaded scrollback on each reconnect. + (timelineData.initial === true || timelineData.limited === true) + ) { + const pendingEvents = liveEvents.filter((event) => event.isSending()); + if (pendingEvents.length > 0) { + pendingEventsToRestore.push({ timelineSet, events: pendingEvents }); + } + const previousOldState = room.oldState; + timelineSet.resetLiveTimeline( + typeof timelineData.prev_batch === 'string' ? timelineData.prev_batch : undefined + ); + const newLiveTimeline = timelineSet.getLiveTimeline(); + room.oldState = newLiveTimeline.getState(EventTimeline.BACKWARDS)!; + room.currentState = newLiveTimeline.getState(EventTimeline.FORWARDS)!; + if (room.oldState !== previousOldState) { + room.emit(RoomEvent.OldStateUpdated, room, previousOldState, room.oldState); + } + didResetTimeline = true; + continue; + } + + if (!shouldMarkLimited) { + continue; + } + + // The SDK clears the back-pagination token when `limited` has no `prev_batch`. + // Reuse the old token; overlapping events are deduplicated. + if (typeof timelineData.prev_batch !== 'string') { + const token = mx + ?.getRoom(roomId) + ?.getLiveTimeline() + .getPaginationToken(EventTimeline.BACKWARDS); + if (typeof token === 'string') timelineData.prev_batch = token; } } + + if (didResetTimeline) mx?.resetNotifTimelineSet(); + if (pendingEventsToRestore.length === 0) return null; + + return () => { + for (const { timelineSet, events } of pendingEventsToRestore) { + const liveTimeline = timelineSet.getLiveTimeline(); + for (const event of events) { + const eventId = event.getId(); + if ( + event.status === null || + event.status === EventStatus.CANCELLED || + !eventId || + timelineSet.findEventById(eventId) + ) { + continue; + } + timelineSet.addEventToTimeline(event, liveTimeline, { + toStartOfTimeline: false, + addToState: false, + }); + } + } + }; }; export class SlidingSyncManager { @@ -386,6 +573,29 @@ export class SlidingSyncManager { (dirtyRoomIds: ReadonlySet) => void >(); + private readonly trackedResponses = new WeakMap< + MSC3575SlidingSyncResponse, + TrackedSlidingSyncResponse + >(); + + private readonly timelineResetCompletions = new WeakMap< + MSC3575SlidingSyncResponse, + TimelineResetCompletion + >(); + + private readonly preparedRoomSubscriptions = new Map>(); + + private readonly routeActiveRoomSubscriptions = new Set(); + + private readonly temporaryRoomSubscriptions = new Set(); + + private readonly pendingRouteReleaseTimers = new Map< + string, + ReturnType + >(); + + private requestId = 0; + private previousListCounts: Map = new Map(); private readonly requestedListRangeEnds = new Map(); @@ -513,6 +723,8 @@ export class SlidingSyncManager { if (err || !resp || state !== SlidingSyncState.Complete) return; + this.timelineResetCompletions.get(resp)?.(); + this.timelineResetCompletions.delete(resp); this.recordServerMembershipRooms(resp); this.reassertOptimisticJoins(); @@ -625,6 +837,8 @@ export class SlidingSyncManager { }); }); + this.resolvePreparedRoomSubscriptions(resp); + globalThis.queueMicrotask(() => { if (this.disposed) return; this.responseProcessing = false; @@ -768,6 +982,11 @@ export class SlidingSyncManager { this.optimisticallyJoinedRoomIds.clear(); this.responseProcessing = false; this.responseSettledListeners.clear(); + this.preparedRoomSubscriptions.clear(); + this.pendingRouteReleaseTimers.forEach((timer) => globalThis.clearTimeout(timer)); + this.pendingRouteReleaseTimers.clear(); + this.routeActiveRoomSubscriptions.clear(); + this.temporaryRoomSubscriptions.clear(); this.dirtyRoomIds.clear(); this.roomDataAwaitingSyncCompletion.clear(); this.roomSubscriptionStatusListeners.forEach((listeners) => @@ -1155,6 +1374,94 @@ export class SlidingSyncManager { return () => this.responseSettledListeners.delete(listener); } + public trackSubscriptionRequest( + roomIds: Iterable + ): (response: MSC3575SlidingSyncResponse) => void { + const requestId = ++this.requestId; + const subscriptionRoomIds = new Set(roomIds); + return (response) => { + this.trackedResponses.set(response, { requestId, subscriptionRoomIds }); + }; + } + + public trackTimelineResetCompletion( + response: MSC3575SlidingSyncResponse, + completion: TimelineResetCompletion + ): void { + this.timelineResetCompletions.set(response, completion); + } + + public prepareRoomSubscription(roomId: string, listener: () => void): () => void { + this.cancelPendingRouteRelease(roomId); + const wasActive = this.isRoomActive(roomId); + const prepared: PreparedRoomSubscription = { + afterRequestId: this.requestId, + requiresSubscriptionResponse: !wasActive, + listener, + }; + const listeners = this.preparedRoomSubscriptions.get(roomId) ?? new Set(); + listeners.add(prepared); + this.preparedRoomSubscriptions.set(roomId, listeners); + if (!wasActive) this.subscribeToRoom(roomId); + else this.slidingSync.resend(); + if (!wasActive && this.isRoomActive(roomId)) { + this.temporaryRoomSubscriptions.add(roomId); + } + + return () => { + listeners.delete(prepared); + if (listeners.size === 0) this.preparedRoomSubscriptions.delete(roomId); + }; + } + + public releaseRoomSubscriptionUnlessRouted(roomId: string): void { + this.cancelPendingRouteRelease(roomId); + if (!this.temporaryRoomSubscriptions.has(roomId)) return; + if (this.routeActiveRoomSubscriptions.has(roomId)) { + this.temporaryRoomSubscriptions.delete(roomId); + return; + } + + const timer = globalThis.setTimeout(() => { + this.pendingRouteReleaseTimers.delete(roomId); + if ( + this.temporaryRoomSubscriptions.has(roomId) && + !this.routeActiveRoomSubscriptions.has(roomId) + ) { + this.unsubscribeFromRoom(roomId); + } + }, ROUTE_ADOPTION_TIMEOUT_MS); + this.pendingRouteReleaseTimers.set(roomId, timer); + } + + private cancelPendingRouteRelease(roomId: string): void { + const timer = this.pendingRouteReleaseTimers.get(roomId); + if (timer === undefined) return; + globalThis.clearTimeout(timer); + this.pendingRouteReleaseTimers.delete(roomId); + } + + public isRoomSubscriptionTemporary(roomId: string): boolean { + return this.temporaryRoomSubscriptions.has(roomId); + } + + private resolvePreparedRoomSubscriptions(response: MSC3575SlidingSyncResponse): void { + const tracked = this.trackedResponses.get(response); + if (!tracked) return; + + this.preparedRoomSubscriptions.forEach((listeners, roomId) => { + [...listeners].forEach((prepared) => { + const ready = + tracked.requestId > prepared.afterRequestId && + (!prepared.requiresSubscriptionResponse || tracked.subscriptionRoomIds.has(roomId)); + if (!ready) return; + listeners.delete(prepared); + prepared.listener(); + }); + if (listeners.size === 0) this.preparedRoomSubscriptions.delete(roomId); + }); + } + /** * Re-assert join for rooms the SDK reverted to "invite" because the server's * sliding-sync proxy still sent invite_state after a successful join. Runs @@ -1389,12 +1696,30 @@ export class SlidingSyncManager { } private syncRoomSubscriptions(): void { - const desiredSubscriptions = new Set([ - ...this.activeRoomSubscriptions, - ...this.sidebarRoomSubscriptions, - ...this.spaceSubscriptions, - ...this.imagePackRoomSubscriptions, - ]); + // MSC4186 rejects a request carrying more than MAX_ROOM_SUBSCRIPTIONS with + // M_INVALID_PARAM, so fill by priority and drop the rest. + const desiredSubscriptions = new Set(); + let dropped = 0; + [ + this.activeRoomSubscriptions, + this.sidebarRoomSubscriptions, + this.spaceSubscriptions, + this.imagePackRoomSubscriptions, + ].forEach((group) => + group.forEach((roomId) => { + if (desiredSubscriptions.has(roomId)) return; + if (desiredSubscriptions.size >= MAX_ROOM_SUBSCRIPTIONS) { + dropped += 1; + return; + } + desiredSubscriptions.add(roomId); + }) + ); + if (dropped > 0) { + log.warn( + `Sliding Sync dropped ${dropped} room subscriptions over the ${MAX_ROOM_SUBSCRIPTIONS} cap` + ); + } desiredSubscriptions.forEach((roomId) => { if (this.activeRoomSubscriptions.has(roomId)) { @@ -1546,6 +1871,8 @@ export class SlidingSyncManager { private removeActiveRoomSubscription(roomId: string): boolean { if (!this.activeRoomSubscriptions.has(roomId)) return false; + this.cancelPendingRouteRelease(roomId); + this.temporaryRoomSubscriptions.delete(roomId); const pendingListener = this.pendingRoomDataListeners.get(roomId); if (pendingListener) { this.slidingSync.removeListener(SlidingSyncEvent.RoomData, pendingListener); @@ -1572,6 +1899,14 @@ export class SlidingSyncManager { public setActiveRoomSubscriptions(roomIds: Iterable): void { if (this.disposed) return; const next = new Set(roomIds); + this.routeActiveRoomSubscriptions.clear(); + next.forEach((roomId) => { + this.routeActiveRoomSubscriptions.add(roomId); + this.temporaryRoomSubscriptions.delete(roomId); + }); + this.pendingRouteReleaseTimers.forEach((_timer, roomId) => + this.cancelPendingRouteRelease(roomId) + ); let changed = false; this.activeRoomSubscriptions.forEach((roomId) => { @@ -1593,12 +1928,15 @@ export class SlidingSyncManager { } public subscribeToRoom(roomId: string): void { + this.cancelPendingRouteRelease(roomId); + this.temporaryRoomSubscriptions.delete(roomId); if (this.disposed || !this.addActiveRoomSubscription(roomId)) return; this.syncRoomSubscriptions(); this.reportActiveSubscriptionCount(); } public unsubscribeFromRoom(roomId: string): void { + this.cancelPendingRouteRelease(roomId); if (this.disposed || !this.removeActiveRoomSubscription(roomId)) return; this.syncRoomSubscriptions(); this.reportActiveSubscriptionCount(); diff --git a/src/client/slidingSyncExpandedTimeline.test.ts b/src/client/slidingSyncExpandedTimeline.test.ts deleted file mode 100644 index 5f699e8d37..0000000000 --- a/src/client/slidingSyncExpandedTimeline.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { SlidingSyncSdk } from 'matrix-js-sdk/lib/sliding-sync-sdk'; -import type { MatrixClient, MSC3575RoomData, MSC3575SlidingSyncResponse } from '$types/matrix-sdk'; -import type { Logger } from 'matrix-js-sdk/lib/logger'; -import { createClient, EventTimeline } from '$types/matrix-sdk'; -import { markExpandedTimelinesLimited } from './slidingSync'; - -// Drives the real matrix-js-sdk room-data path, pinning the SDK's actual behaviour -// rather than our assumptions about it. - -const userId = '@me:example.com'; -const roomId = '!dm:example.com'; - -type RoomDataHandler = (roomId: string, data: MSC3575RoomData) => Promise; - -const silentLogger: Logger = { - trace: () => {}, - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - getChild: () => silentLogger, -}; - -const makeSdk = (): { mx: MatrixClient; deliver: RoomDataHandler } => { - const mx = createClient({ baseUrl: 'https://example.com', userId, accessToken: 'token' }); - - let roomDataHandler: RoomDataHandler | undefined; - const slidingSyncStub = { - on: (event: string, handler: unknown) => { - if (event === 'SlidingSync.RoomData') roomDataHandler = handler as RoomDataHandler; - }, - registerExtension: () => {}, - }; - - new SlidingSyncSdk(slidingSyncStub as never, mx, {}, { logger: silentLogger }); - if (!roomDataHandler) throw new Error('SlidingSyncSdk did not subscribe to room data'); - - return { mx, deliver: roomDataHandler }; -}; - -const message = (id: string, ts: number) => ({ - type: 'm.room.message', - event_id: id, - sender: '@them:example.com', - origin_server_ts: ts, - content: { msgtype: 'm.text', body: id }, -}); - -/** What a list sends at timeline_limit: 1 the first time it sees a room. */ -const initialRoomData = (newest: ReturnType): MSC3575RoomData => - ({ - initial: true, - required_state: [], - timeline: [newest], - limited: true, - prev_batch: 't1-0', - }) as unknown as MSC3575RoomData; - -/** What Synapse sends for a raised timeline_limit: history from the top of the - * room, `initial` and `limited` both unset. */ -const expandedResponse = (timeline: ReturnType[]): MSC3575SlidingSyncResponse => - ({ - pos: 'p2', - rooms: { - [roomId]: { - unstable_expanded_timeline: true, - required_state: [], - timeline, - prev_batch: 't1-5', - }, - }, - }) as unknown as MSC3575SlidingSyncResponse; - -const timelineIds = (mx: MatrixClient): string[] => - mx - .getRoom(roomId)! - .getLiveTimeline() - .getEvents() - .map((event) => event.getId()!); - -const history = [message('$e1', 100), message('$e2', 200), message('$e3', 300)]; -const newest = history[history.length - 1]!; - -describe('expanded timeline handling in matrix-js-sdk', () => { - it('appends the expanded history out of order when only Synapse flags it', async () => { - const { mx, deliver } = makeSdk(); - await deliver(roomId, initialRoomData(newest)); - expect(timelineIds(mx)).toEqual(['$e3']); - - const resp = expandedResponse(history); - await deliver(roomId, resp.rooms[roomId]!); - - expect(timelineIds(mx)).toEqual(['$e3', '$e1', '$e2']); - }); - - it('reconciles the gap into a correctly ordered timeline once marked limited', async () => { - const { mx, deliver } = makeSdk(); - await deliver(roomId, initialRoomData(newest)); - - const resp = expandedResponse(history); - markExpandedTimelinesLimited(resp); - await deliver(roomId, resp.rooms[roomId]!); - - expect(timelineIds(mx)).toEqual(['$e1', '$e2', '$e3']); - }); - - it('keeps a usable back-pagination token after reconciling', async () => { - const { mx, deliver } = makeSdk(); - await deliver(roomId, initialRoomData(newest)); - - const resp = expandedResponse(history); - markExpandedTimelinesLimited(resp); - await deliver(roomId, resp.rooms[roomId]!); - - expect(mx.getRoom(roomId)!.getLiveTimeline().getPaginationToken(EventTimeline.BACKWARDS)).toBe( - 't1-5' - ); - }); -}); diff --git a/src/client/slidingSyncTimelineReconciliation.test.ts b/src/client/slidingSyncTimelineReconciliation.test.ts new file mode 100644 index 0000000000..efe50057a3 --- /dev/null +++ b/src/client/slidingSyncTimelineReconciliation.test.ts @@ -0,0 +1,695 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SlidingSyncSdk } from 'matrix-js-sdk/lib/sliding-sync-sdk'; +import type { MatrixClient, MSC3575RoomData, MSC3575SlidingSyncResponse } from '$types/matrix-sdk'; +import type { Logger } from 'matrix-js-sdk/lib/logger'; +import { + createClient, + EventStatus, + EventTimeline, + MatrixEvent, + RoomEvent, +} from '$types/matrix-sdk'; +import { prepareSlidingSyncTimelines } from './slidingSync'; + +// Drives the real matrix-js-sdk room-data path, pinning the SDK's actual behaviour +// rather than our assumptions about it. + +const userId = '@me:example.com'; +const roomId = '!dm:example.com'; + +type RoomDataHandler = (roomId: string, data: MSC3575RoomData) => Promise; + +type ContextCapableClient = MatrixClient & { + getEventContext: (targetRoomId: string, eventId: string) => Promise; +}; + +const silentLogger: Logger = { + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + getChild: () => silentLogger, +}; + +const makeSdk = (): { mx: MatrixClient; sdk: SlidingSyncSdk; deliver: RoomDataHandler } => { + const mx = createClient({ + baseUrl: 'https://example.com', + userId, + accessToken: 'token', + timelineSupport: true, + }); + + let roomDataHandler: RoomDataHandler | undefined; + const slidingSyncStub = { + on: (event: string, handler: unknown) => { + if (event === 'SlidingSync.RoomData') roomDataHandler = handler as RoomDataHandler; + }, + registerExtension: () => {}, + }; + + const sdk = new SlidingSyncSdk(slidingSyncStub as never, mx, {}, { logger: silentLogger }); + if (!roomDataHandler) throw new Error('SlidingSyncSdk did not subscribe to room data'); + + return { mx, sdk, deliver: roomDataHandler }; +}; + +const message = (id: string, ts: number) => ({ + type: 'm.room.message', + event_id: id, + sender: '@them:example.com', + origin_server_ts: ts, + content: { msgtype: 'm.text', body: id }, +}); + +/** What a list sends at timeline_limit: 1 the first time it sees a room. */ +const initialRoomData = (newest: ReturnType): MSC3575RoomData => + ({ + initial: true, + required_state: [], + timeline: [newest], + limited: true, + prev_batch: 't1-0', + }) as unknown as MSC3575RoomData; + +/** What Synapse sends for a raised timeline_limit: history from the top of the + * room, `initial` and `limited` both unset. */ +const expandedResponse = (timeline: ReturnType[]): MSC3575SlidingSyncResponse => + ({ + pos: 'p2', + rooms: { + [roomId]: { + unstable_expanded_timeline: true, + required_state: [], + timeline, + prev_batch: 't1-5', + }, + }, + }) as unknown as MSC3575SlidingSyncResponse; + +const timelineIds = (mx: MatrixClient): string[] => + mx + .getRoom(roomId)! + .getLiveTimeline() + .getEvents() + .map((event) => event.getId()!); + +const linkedTimelineIds = (timeline: EventTimeline): string[] => { + let first = timeline; + while (first.getNeighbouringTimeline(EventTimeline.BACKWARDS)) { + first = first.getNeighbouringTimeline(EventTimeline.BACKWARDS)!; + } + + const ids: string[] = []; + let current: EventTimeline | null = first; + while (current) { + ids.push(...current.getEvents().map((event) => event.getId()!)); + current = current.getNeighbouringTimeline(EventTimeline.FORWARDS); + } + return ids; +}; + +const history = [message('$e1', 100), message('$e2', 200), message('$e3', 300)]; +const newest = history[history.length - 1]!; + +describe('timeline reconciliation in matrix-js-sdk', () => { + it('appends the expanded history out of order when only Synapse flags it', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + expect(timelineIds(mx)).toEqual(['$e3']); + + const resp = expandedResponse(history); + await deliver(roomId, resp.rooms[roomId]!); + + expect(timelineIds(mx)).toEqual(['$e3', '$e1', '$e2']); + }); + + it('reconciles the gap into a correctly ordered timeline once marked limited', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + + const resp = expandedResponse(history); + prepareSlidingSyncTimelines(resp); + await deliver(roomId, resp.rooms[roomId]!); + + expect(timelineIds(mx)).toEqual(['$e1', '$e2', '$e3']); + }); + + it('keeps a notification event newest when its room expands after resume', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + + const resp = expandedResponse(history); + delete (resp.rooms[roomId] as unknown as Record).prev_batch; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, resp.rooms[roomId]!); + + const ids = timelineIds(mx); + expect(ids).toEqual(['$e1', '$e2', '$e3']); + expect(ids.at(-1)).toBe('$e3'); + }); + + it('keeps a resumed notification newest when the expansion flag is missing', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + + const notification = message('$e4', 400); + await deliver(roomId, { + required_state: [], + timeline: [notification], + num_live: 1, + } as unknown as MSC3575RoomData); + + const resp = expandedResponse([...history, notification]); + const roomData = resp.rooms[roomId]!; + delete (roomData as unknown as Record).unstable_expanded_timeline; + roomData.num_live = 0; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, roomData); + + expect(timelineIds(mx)).toEqual(['$e1', '$e2', '$e3', '$e4']); + }); + + it.each(['expanded flag', 'num_live', 'overlap only'])( + 'replaces a sparse notification timeline using %s metadata', + async (metadata) => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + + const notification = message('$e6', 600); + await deliver(roomId, { + required_state: [], + timeline: [notification], + num_live: 1, + } as unknown as MSC3575RoomData); + const sparseLiveTimeline = mx.getRoom(roomId)!.getLiveTimeline(); + expect(timelineIds(mx)).toEqual(['$e3', '$e6']); + + const resp = expandedResponse([message('$e4', 400), message('$e5', 500), notification]); + const roomData = resp.rooms[roomId]!; + if (metadata !== 'expanded flag') { + delete (roomData as unknown as Record).unstable_expanded_timeline; + if (metadata === 'num_live') roomData.num_live = 1; + else delete roomData.num_live; + } + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, roomData); + + expect(mx.getRoom(roomId)!.getLiveTimeline()).not.toBe(sparseLiveTimeline); + expect(timelineIds(mx)).toEqual(['$e4', '$e5', '$e6']); + } + ); + + it('keeps an in-app notification newest when expansion metadata is omitted', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + + const notification = message('$e4', 400); + await deliver(roomId, { + required_state: [], + timeline: [notification], + num_live: 1, + } as unknown as MSC3575RoomData); + + const resp = expandedResponse([...history, notification]); + const roomData = resp.rooms[roomId]!; + delete (roomData as unknown as Record).unstable_expanded_timeline; + delete roomData.num_live; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, roomData); + + expect(timelineIds(mx)).toEqual(['$e1', '$e2', '$e3', '$e4']); + }); + + it('keeps the notification newest when both responses omit num_live', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + + const notification = message('$e4', 400); + await deliver(roomId, { + required_state: [], + timeline: [notification], + } as unknown as MSC3575RoomData); + + const resp = expandedResponse([...history, notification]); + const roomData = resp.rooms[roomId]!; + delete (roomData as unknown as Record).unstable_expanded_timeline; + delete roomData.num_live; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, roomData); + + expect(timelineIds(mx)).toEqual(['$e1', '$e2', '$e3', '$e4']); + }); + + it('keeps a notification jump on the live timeline after catch-up', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + + const notification = message('$e6', 600); + const resp = { + pos: 'p3', + rooms: { + [roomId]: { + required_state: [], + timeline: [message('$e4', 400), message('$e5', 500), notification], + }, + }, + } as unknown as MSC3575SlidingSyncResponse; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, resp.rooms[roomId]!); + + const timelineSet = mx.getRoom(roomId)!.getUnfilteredTimelineSet(); + const getEventContext = vi.spyOn(mx as ContextCapableClient, 'getEventContext'); + const notificationTimeline = await mx.getEventTimeline(timelineSet, '$e6'); + expect(getEventContext).not.toHaveBeenCalled(); + expect(notificationTimeline).toBe(timelineSet.getLiveTimeline()); + expect(linkedTimelineIds(notificationTimeline!)).toEqual(['$e3', '$e4', '$e5', '$e6']); + }); + + it('starts a new live timeline when a limited catch-up has no overlap', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + const room = mx.getRoom(roomId)!; + const oldLiveTimeline = room.getLiveTimeline(); + + const notification = message('$e6', 600); + const resp = { + pos: 'p3', + rooms: { + [roomId]: { + required_state: [], + timeline: [message('$e4', 400), message('$e5', 500), notification], + limited: true, + num_live: 1, + prev_batch: 'gap-token', + }, + }, + } as unknown as MSC3575SlidingSyncResponse; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, resp.rooms[roomId]!); + + const liveTimeline = mx.getRoom(roomId)!.getLiveTimeline(); + expect(liveTimeline).not.toBe(oldLiveTimeline); + expect(timelineIds(mx)).toEqual(['$e4', '$e5', '$e6']); + expect(liveTimeline.getPaginationToken(EventTimeline.BACKWARDS)).toBe('gap-token'); + expect(room.oldState).toBe(liveTimeline.getState(EventTimeline.BACKWARDS)); + expect(room.currentState).toBe(liveTimeline.getState(EventTimeline.FORWARDS)); + + const localEcho = new MatrixEvent({ + ...message('~local', 700), + room_id: roomId, + sender: userId, + }); + localEcho.setStatus(EventStatus.SENDING); + mx.getRoom(roomId)!.addPendingEvent(localEcho, 'txn'); + await deliver(roomId, { + required_state: [], + timeline: [message('$e8', 800)], + num_live: 1, + } as unknown as MSC3575RoomData); + + expect(timelineIds(mx)).toEqual(['$e4', '$e5', '$e6', '~local', '$e8']); + }); + + it('does not reset away a pending local echo', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + const room = mx.getRoom(roomId)!; + const liveTimeline = room.getLiveTimeline(); + const localEcho = new MatrixEvent({ + ...message('~local', 700), + room_id: roomId, + sender: userId, + }); + localEcho.setStatus(EventStatus.SENDING); + room.addPendingEvent(localEcho, 'txn'); + + const resp = expandedResponse([message('$e4', 400), message('$e5', 500), message('$e6', 600)]); + const completeTimelineReset = prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, resp.rooms[roomId]!); + completeTimelineReset?.(); + + expect(room.getLiveTimeline()).not.toBe(liveTimeline); + expect(timelineIds(mx)).toEqual(['$e4', '$e5', '$e6', '~local']); + }); + + it('does not duplicate a pending event whose remote echo arrives in the reset response', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + const room = mx.getRoom(roomId)!; + const localEcho = new MatrixEvent({ + ...message('~local', 700), + room_id: roomId, + sender: userId, + }); + localEcho.setStatus(EventStatus.SENDING); + room.addPendingEvent(localEcho, 'txn'); + const remoteEcho = { + ...message('$mine', 700), + sender: userId, + unsigned: { transaction_id: 'txn' }, + }; + + const resp = expandedResponse([ + message('$e4', 400), + message('$e5', 500), + message('$e6', 600), + remoteEcho, + ]); + const completeTimelineReset = prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, resp.rooms[roomId]!); + completeTimelineReset?.(); + + expect(timelineIds(mx)).toEqual(['$e4', '$e5', '$e6', '$mine']); + expect(localEcho.status).toBeNull(); + }); + + it('starts a new live timeline when an expanded catch-up has no overlap', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + + const resp = expandedResponse([message('$e4', 400), message('$e5', 500), message('$e6', 600)]); + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, resp.rooms[roomId]!); + + expect(timelineIds(mx)).toEqual(['$e4', '$e5', '$e6']); + }); + + it('keeps a usable back-pagination token when the gapped response omits prev_batch', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + + const resp = expandedResponse([message('$e4', 400), message('$e5', 500)]); + delete (resp.rooms[roomId] as unknown as Record).prev_batch; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, resp.rooms[roomId]!); + + // Resetting without a token would leave the room claiming "start of room", so + // the gap is left glued rather than trading it for dead scrollback. + expect( + mx.getRoom(roomId)!.getLiveTimeline().getPaginationToken(EventTimeline.BACKWARDS) + ).not.toBe(null); + }); + + it('keeps a limited response on the same timeline when it overlaps', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + const oldLiveTimeline = mx.getRoom(roomId)!.getLiveTimeline(); + + const roomData = { + required_state: [], + timeline: [newest, message('$e4', 400)], + limited: true, + num_live: 1, + prev_batch: 't1-5', + } as unknown as MSC3575RoomData; + const resp = { + pos: 'p3', + rooms: { [roomId]: roomData }, + } as unknown as MSC3575SlidingSyncResponse; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, roomData); + + expect(mx.getRoom(roomId)!.getLiveTimeline()).toBe(oldLiveTimeline); + expect(timelineIds(mx)).toEqual(['$e3', '$e4']); + }); + + it('does not treat an event in a detached timeline as live overlap', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + const room = mx.getRoom(roomId)!; + const detachedTimeline = room.getUnfilteredTimelineSet().addTimeline(); + room.addEventsToTimeline( + [mx.getEventMapper()(message('$e4', 400) as never)], + false, + false, + detachedTimeline + ); + + const roomData = { + required_state: [], + timeline: [message('$e4', 400), message('$e5', 500), message('$e6', 600)], + limited: true, + num_live: 1, + prev_batch: 'gap-token', + } as unknown as MSC3575RoomData; + const resp = { + pos: 'p3', + rooms: { [roomId]: roomData }, + } as unknown as MSC3575SlidingSyncResponse; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, roomData); + + expect(timelineIds(mx)).toEqual(['$e4', '$e5', '$e6']); + expect(room.getUnfilteredTimelineSet().findEventById('$e4')).toBe( + room.getLiveTimeline().getEvents()[0] + ); + }); + + it('updates old state while keeping current-state listeners attached', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + const room = mx.getRoom(roomId)!; + const previousOldState = room.oldState; + const previousCurrentState = room.currentState; + const oldStateUpdated = vi.fn<() => void>(); + const currentStateUpdated = vi.fn<() => void>(); + room.on(RoomEvent.OldStateUpdated, oldStateUpdated); + room.on(RoomEvent.CurrentStateUpdated, currentStateUpdated); + + const resp = expandedResponse([message('$e4', 400), message('$e5', 500)]); + prepareSlidingSyncTimelines(resp, mx); + + expect(room.oldState).not.toBe(previousOldState); + expect(oldStateUpdated).toHaveBeenCalledOnce(); + expect(room.currentState).toBe(previousCurrentState); + expect(currentStateUpdated).not.toHaveBeenCalled(); + }); + + it('keeps a cached timeline when an initial response has no events', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + const oldLiveTimeline = mx.getRoom(roomId)!.getLiveTimeline(); + + const roomData = { + initial: true, + required_state: [], + timeline: [], + prev_batch: 'empty-token', + } as unknown as MSC3575RoomData; + const resp = { + pos: 'p3', + rooms: { [roomId]: roomData }, + } as unknown as MSC3575SlidingSyncResponse; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, roomData); + + // An empty window carries no history to replace the cache with. + expect(mx.getRoom(roomId)!.getLiveTimeline()).toBe(oldLiveTimeline); + expect(timelineIds(mx)).toEqual(['$e3']); + }); + + it('resets a cached timeline when an initial response has no overlap', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + const oldLiveTimeline = mx.getRoom(roomId)!.getLiveTimeline(); + + const roomData = initialRoomData(message('$e6', 600)); + delete roomData.limited; + const resp = { + pos: 'p3', + rooms: { [roomId]: roomData }, + } as unknown as MSC3575SlidingSyncResponse; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, roomData); + + expect(mx.getRoom(roomId)!.getLiveTimeline()).not.toBe(oldLiveTimeline); + expect(timelineIds(mx)).toEqual(['$e6']); + }); + + it('keeps new events after the overlap in an unflagged expansion', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + + const next = message('$e4', 400); + const resp = expandedResponse([...history, next]); + const roomData = resp.rooms[roomId]!; + delete (roomData as unknown as Record).unstable_expanded_timeline; + delete roomData.num_live; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, roomData); + + expect(timelineIds(mx)).toEqual(['$e1', '$e2', '$e3', '$e4']); + }); + + it('uses partial num_live metadata to split history from a new event', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + + const next = message('$e4', 400); + const resp = expandedResponse([...history, next]); + const roomData = resp.rooms[roomId]!; + delete (roomData as unknown as Record).unstable_expanded_timeline; + roomData.num_live = 1; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, roomData); + + expect(timelineIds(mx)).toEqual(['$e1', '$e2', '$e3', '$e4']); + }); + + it('keeps the cached timeline when an initial response overlaps it', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + const oldLiveTimeline = mx.getRoom(roomId)!.getLiveTimeline(); + + const next = message('$e4', 400); + const roomData = { + initial: true, + required_state: [], + timeline: [newest, next], + num_live: 1, + prev_batch: 't1-5', + } as unknown as MSC3575RoomData; + const resp = { + pos: 'p2', + rooms: { [roomId]: roomData }, + } as unknown as MSC3575SlidingSyncResponse; + prepareSlidingSyncTimelines(resp, mx); + expect(roomData.limited).toBeUndefined(); + + // A pos expiry answers `initial` for every room. Resetting an overlapping + // window would drop loaded scrollback on every reconnect. + await deliver(roomId, roomData); + expect(mx.getRoom(roomId)!.getLiveTimeline()).toBe(oldLiveTimeline); + expect(timelineIds(mx)).toEqual(['$e3', '$e4']); + }); + + it('keeps the existing back-pagination token when prev_batch is omitted', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + const before = mx + .getRoom(roomId)! + .getLiveTimeline() + .getPaginationToken(EventTimeline.BACKWARDS); + expect(before).toBe('t1-0'); + + const resp = expandedResponse(history); + delete (resp.rooms[roomId] as unknown as Record).prev_batch; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, resp.rooms[roomId]!); + + expect(mx.getRoom(roomId)!.getLiveTimeline().getPaginationToken(EventTimeline.BACKWARDS)).toBe( + 't1-0' + ); + }); + + it('still orders the timeline when there is no token to carry forward', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, { ...initialRoomData(newest), prev_batch: undefined } as never); + + const resp = expandedResponse(history); + delete (resp.rooms[roomId] as unknown as Record).prev_batch; + prepareSlidingSyncTimelines(resp, mx); + await deliver(roomId, resp.rooms[roomId]!); + + expect(timelineIds(mx)).toEqual(['$e1', '$e2', '$e3']); + }); + + it('leaves an ordinary incremental overlap untouched', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + + const resp = expandedResponse([newest, message('$e4', 400)]); + const rd = resp.rooms[roomId] as unknown as Record; + delete rd.unstable_expanded_timeline; + prepareSlidingSyncTimelines(resp, mx); + + expect(rd.limited).toBeUndefined(); + expect(rd.prev_batch).toBe('t1-5'); + + await deliver(roomId, resp.rooms[roomId]!); + expect(timelineIds(mx)).toEqual(['$e3', '$e4']); + }); + + it('keeps a usable back-pagination token after reconciling', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(newest)); + + const resp = expandedResponse(history); + prepareSlidingSyncTimelines(resp); + await deliver(roomId, resp.rooms[roomId]!); + + expect(mx.getRoom(roomId)!.getLiveTimeline().getPaginationToken(EventTimeline.BACKWARDS)).toBe( + 't1-5' + ); + }); +}); + +describe('a window from a room we just subscribed to', () => { + const subscribed = new Set([roomId]); + + it('does not append older history below the live tail', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(message('$e9', 900))); + expect(timelineIds(mx)).toEqual(['$e9']); + + // Window around a notification target, older than everything we hold. + // No expanded flag, no num_live, not initial, not limited. + const roomData = { + required_state: [], + timeline: [message('$e1', 100), message('$e2', 200)], + prev_batch: 't1-5', + } as unknown as MSC3575RoomData; + const resp = { + pos: 'p9', + rooms: { [roomId]: roomData }, + } as unknown as MSC3575SlidingSyncResponse; + prepareSlidingSyncTimelines(resp, mx, subscribed); + await deliver(roomId, roomData); + + const liveTimeline = mx.getRoom(roomId)!.getLiveTimeline(); + expect(linkedTimelineIds(liveTimeline)).toEqual(['$e1', '$e2']); + expect(liveTimeline.getPaginationToken(EventTimeline.BACKWARDS)).toBe('t1-5'); + }); + + it('leaves an unsubscribed room to the overlap scan', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(message('$e9', 900))); + + const roomData = { + required_state: [], + timeline: [message('$e1', 100), message('$e2', 200)], + prev_batch: 't1-5', + } as unknown as MSC3575RoomData; + const resp = { + pos: 'p9', + rooms: { [roomId]: roomData }, + } as unknown as MSC3575SlidingSyncResponse; + prepareSlidingSyncTimelines(resp, mx, new Set()); + await deliver(roomId, roomData); + + expect(roomData.limited).not.toBe(true); + }); + + it('does not glue a newer window onto the live tail as if contiguous', async () => { + const { mx, deliver } = makeSdk(); + await deliver(roomId, initialRoomData(message('$e1', 100))); + + const roomData = { + required_state: [], + timeline: [message('$e8', 800), message('$e9', 900)], + prev_batch: 't1-9', + } as unknown as MSC3575RoomData; + const resp = { + pos: 'p9', + rooms: { [roomId]: roomData }, + } as unknown as MSC3575SlidingSyncResponse; + prepareSlidingSyncTimelines(resp, mx, subscribed); + await deliver(roomId, roomData); + + // A gap must not be presented as one contiguous timeline. + const liveTimeline = mx.getRoom(roomId)!.getLiveTimeline(); + expect(linkedTimelineIds(liveTimeline)).toEqual(['$e8', '$e9']); + expect(liveTimeline.getPaginationToken(EventTimeline.BACKWARDS)).toBe('t1-9'); + }); +}); diff --git a/src/sw/pushNotification.test.ts b/src/sw/pushNotification.test.ts new file mode 100644 index 0000000000..000c41dcfd --- /dev/null +++ b/src/sw/pushNotification.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EventType } from 'matrix-js-sdk/lib/@types/event'; +import { createPushNotifications } from './pushNotification'; + +describe('createPushNotifications', () => { + it('keeps the top-level event routing fields when push data contains stale copies', async () => { + const showNotification = vi + .fn<(title: string, options: NotificationOptions) => Promise>() + .mockResolvedValue(undefined); + const self = { + registration: { showNotification }, + } as unknown as ServiceWorkerGlobalScope; + const notifications = createPushNotifications(self, () => ({ + showMessageContent: true, + showEncryptedMessageContent: true, + })); + + await notifications.handlePushNotificationPushData({ + type: EventType.RoomMessage, + room_id: '!real:example.org', + event_id: '$real', + user_id: '@real:example.org', + data: { + room_id: '!stale:example.org', + event_id: '$stale', + user_id: '@stale:example.org', + }, + }); + + const options = showNotification.mock.calls[0]![1]; + expect(options.data).toMatchObject({ + room_id: '!real:example.org', + event_id: '$real', + user_id: '@real:example.org', + }); + }); +}); diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts index fdc6a8e7fd..df047c8651 100644 --- a/src/sw/pushNotification.ts +++ b/src/sw/pushNotification.ts @@ -129,6 +129,7 @@ export const createPushNotifications = ( const { senderTs, expiresAt } = getCallTiming(pushData.content, originTs); const data = { + ...pushData.data, type: pushData?.type, room_id: pushData?.room_id, event_id: pushData?.event_id, @@ -143,7 +144,6 @@ export const createPushNotifications = ( callRefEventId: pushData?.content?.['m.relates_to']?.event_id, callSenderTs: senderTs, callExpiresAt: expiresAt, - ...pushData.data, }; const callTag = pushData?.room_id ? `call-${pushData.room_id}` : undefined; @@ -160,12 +160,12 @@ export const createPushNotifications = ( const handleRoomMessageNotification = async (pushData: MatrixPushData) => { const data: Record = { + ...pushData.data, type: pushData?.type, room_id: pushData?.room_id, event_id: pushData?.event_id, user_id: pushData?.user_id, timestamp: Date.now(), - ...pushData.data, }; const notificationPayload = buildRoomMessageNotification({ roomName: pushData?.room_name, @@ -195,12 +195,12 @@ export const createPushNotifications = ( const handleEncryptedMessageNotification = async (pushData: MatrixPushData) => { const data: Record = { + ...pushData.data, type: pushData?.type, room_id: pushData?.room_id, event_id: pushData?.event_id, user_id: pushData?.user_id, timestamp: Date.now(), - ...pushData.data, }; const notificationPayload = buildRoomMessageNotification({ roomName: pushData?.room_name, @@ -239,11 +239,11 @@ export const createPushNotifications = ( if (!senderDisplayName && !roomName) body = ''; const data = { + ...pushData.data, type: pushData?.type, content: pushData?.content, user_id: pushData?.user_id, timestamp: Date.now(), - ...pushData.data, }; await showNotificationWithData('New Invitation', body, data, resolveSilent()); diff --git a/tests/e2e/fixtures/session.ts b/tests/e2e/fixtures/session.ts new file mode 100644 index 0000000000..881fb027fb --- /dev/null +++ b/tests/e2e/fixtures/session.ts @@ -0,0 +1,48 @@ +import { readFile } from 'node:fs/promises'; +import type { Page } from '@playwright/test'; +import { registerUser, type RegisteredUser } from './continuwuity'; + +export const PASSWORD = 'test-passw0rd'; + +export type InjectedSession = { + baseUrl: string; + userId: string; + deviceId: string; + accessToken: string; + slidingSyncOptIn?: boolean; +}; + +/** Reads the homeserver the global setup provisioned out of the saved storage state. */ +export async function homeserverBaseUrl(storageStatePath: string): Promise { + const state = JSON.parse(await readFile(storageStatePath, 'utf8')) as { + origins: { localStorage: { name: string; value: string }[] }[]; + }; + const entry = state.origins[0]!.localStorage.find((item) => item.name === 'matrixSessions')!; + return (JSON.parse(entry.value) as InjectedSession[])[0]!.baseUrl; +} + +/** + * Registers a throwaway account and injects its session before first paint, so a + * test starts from a known-empty account instead of the shared login fixture. + */ +export async function loginAsFreshUser( + page: Page, + baseUrl: string, + name: string, + slidingSyncOptIn = true +): Promise { + const user = await registerUser(baseUrl, name, PASSWORD); + const session: InjectedSession = { + baseUrl, + userId: user.userId, + deviceId: user.deviceId, + accessToken: user.accessToken, + slidingSyncOptIn, + }; + await page.addInitScript((injected: InjectedSession) => { + localStorage.setItem('matrixSessions', JSON.stringify([injected])); + localStorage.setItem('matrixActiveSession', JSON.stringify(injected.userId)); + localStorage.setItem('dismissNotice', 'true'); + }, session); + return user; +} diff --git a/tests/e2e/fixtures/timelineOrder.ts b/tests/e2e/fixtures/timelineOrder.ts new file mode 100644 index 0000000000..62c569751f --- /dev/null +++ b/tests/e2e/fixtures/timelineOrder.ts @@ -0,0 +1,59 @@ +import { expect, type Page } from '@playwright/test'; +import { getRoomMessages } from './continuwuity'; + +/** Event IDs for `tag`, in the server's canonical order. */ +export async function canonicalEventIds( + baseUrl: string, + token: string, + roomId: string, + tag: string +): Promise { + const messages = await getRoomMessages(baseUrl, token, roomId); + return messages + .filter((message) => message.body.startsWith(`${tag}-`)) + .map((message) => message.eventId); +} + +/** Canonical message IDs currently rendered, top to bottom. */ +export async function renderedEventIds(page: Page, canonicalIds: string[]): Promise { + const renderedIds = await page.locator('[data-message-id]').evaluateAll((elements) => + elements.flatMap((element) => { + const eventId = (element as HTMLElement).dataset.messageId; + return eventId ? [eventId] : []; + }) + ); + const canonical = new Set(canonicalIds); + return renderedIds.filter((eventId) => canonical.has(eventId)); +} + +/** + * Rendered rows must be a contiguous run of the canonical order. Fails both on + * out-of-order events and on a gap presented as adjacent. + */ +export function expectContiguousRun(rendered: string[], canonical: string[]): void { + expect(rendered.length, 'nothing rendered').toBeGreaterThan(0); + const start = canonical.indexOf(rendered[0]!); + expect( + start, + `first rendered row "${rendered[0]}" is not in the canonical order` + ).toBeGreaterThan(-1); + expect( + canonical.slice(start, start + rendered.length), + 'rendered rows are not a contiguous, in-order run of the canonical timeline' + ).toEqual(rendered); +} + +/** A reconciliation that re-adds events to a second timeline shows up as dupes. */ +export function expectNoDuplicateRows(rendered: string[]): void { + const seen = new Set(rendered); + expect([...seen], 'a message is rendered more than once').toEqual(rendered); +} + +/** Scrolls the timeline up until `text` back-paginates into view. */ +export async function wheelToTopUntilVisible(page: Page, text: string): Promise { + await expect(async () => { + await page.mouse.move(640, 400); + await page.mouse.wheel(0, -2400); + expect(await page.getByText(text, { exact: true }).count()).toBeGreaterThan(0); + }).toPass({ timeout: 120_000, intervals: [500] }); +} diff --git a/tests/e2e/notification-jump.spec.ts b/tests/e2e/notification-jump.spec.ts new file mode 100644 index 0000000000..b0b5289479 --- /dev/null +++ b/tests/e2e/notification-jump.spec.ts @@ -0,0 +1,386 @@ +import { test, expect, type Page } from '@playwright/test'; +import { + createRoom, + inviteUser, + joinRoom, + registerUser, + sendText, + type RegisteredUser, +} from './fixtures/continuwuity'; +import { homeserverBaseUrl, loginAsFreshUser, PASSWORD } from './fixtures/session'; +import { + canonicalEventIds, + expectContiguousRun, + expectNoDuplicateRows, + renderedEventIds, + wheelToTopUntilVisible, +} from './fixtures/timelineOrder'; +import { AppShell } from './pages/AppShell'; + +// Room subscriptions ask for the newest 50 events (ACTIVE_ROOM_TIMELINE_LIMIT) and +// lists for 1, so a burst over 50 guarantees the catch-up window shares no event +// with the cached tail. +const BURST_SIZE = 60; +const SEED_SIZE = 10; +const SYNC_TIMEOUT = 180_000; + +type Fixture = { + app: AppShell; + hsBaseUrl: string; + tag: string; + alice: RegisteredUser; + bob: RegisteredUser; + roomId: string; + /** Event ids of the seeded messages, oldest first. */ + seedIds: string[]; +}; + +let txnCounter = 1; +const nextTxnId = () => { + txnCounter += 1; + return txnCounter; +}; + +/** A fresh account in a two-person room whose latest messages are already loaded. */ +async function openSeededRoom( + page: Page, + storageStatePath: string, + prefix: string, + slidingSyncOptIn: boolean +): Promise { + const hsBaseUrl = await homeserverBaseUrl(storageStatePath); + const tag = `${prefix}-${process.pid}-${Date.now().toString(36)}`; + const app = new AppShell(page); + const alice = await loginAsFreshUser(page, hsBaseUrl, `${tag}-a`, slidingSyncOptIn); + const bob = await registerUser(hsBaseUrl, `${tag}-b`, PASSWORD); + + const roomId = await createRoom(hsBaseUrl, alice.accessToken, { + name: `${tag} Relay`, + preset: 'private_chat', + }); + await inviteUser(hsBaseUrl, alice.accessToken, roomId, bob.userId); + await joinRoom(hsBaseUrl, bob.accessToken, roomId); + + const seedIds: string[] = []; + for (let i = 1; i <= SEED_SIZE; i += 1) { + seedIds.push( + await sendText(hsBaseUrl, alice.accessToken, roomId, `${tag}-seed-${i}`, nextTxnId()) + ); + } + + return { app, hsBaseUrl, tag, alice, bob, roomId, seedIds }; +} + +async function enterRoom(page: Page, fixture: Fixture): Promise { + await page.goto('/'); + await expect(page.getByText(`${fixture.tag} Relay`).first()).toBeVisible({ + timeout: SYNC_TIMEOUT, + }); + await fixture.app.openRoom(`${fixture.tag} Relay`); + await expect(page.getByText(`${fixture.tag}-seed-${SEED_SIZE}`, { exact: true })).toBeVisible({ + timeout: SYNC_TIMEOUT, + }); +} + +/** Sends a burst Alice never syncs, and returns the id of the `index`-th message. */ +async function sendMissedBurst(fixture: Fixture, index: number): Promise { + let targetEventId = ''; + for (let i = 1; i <= BURST_SIZE; i += 1) { + const eventId = await sendText( + fixture.hsBaseUrl, + fixture.bob.accessToken, + fixture.roomId, + `${fixture.tag}-burst-${i}`, + nextTxnId() + ); + if (i === index) targetEventId = eventId; + } + return targetEventId; +} + +/** + * The target must be on screen, and the newest message must not be: a jump that + * silently lands on the live tail otherwise satisfies a plain visibility check. + */ +async function expectJumpedTo(page: Page, fixture: Fixture, eventId: string): Promise { + await expect(fixture.app.messageByEventId(eventId)).toBeInViewport({ timeout: SYNC_TIMEOUT }); + await expect( + page.getByText(`${fixture.tag}-burst-${BURST_SIZE}`, { exact: true }) + ).not.toBeInViewport(); +} + +async function expectJumpedToLatest(page: Page, fixture: Fixture, eventId: string): Promise { + await expect(fixture.app.messageByEventId(eventId)).toBeInViewport({ timeout: SYNC_TIMEOUT }); + await expectOrderedTimeline(page, fixture); +} + +async function expectOrderedTimeline(page: Page, fixture: Fixture): Promise { + const canonical = await canonicalEventIds( + fixture.hsBaseUrl, + fixture.alice.accessToken, + fixture.roomId, + fixture.tag + ); + const rendered = await renderedEventIds(page, canonical); + expectNoDuplicateRows(rendered); + expectContiguousRun(rendered, canonical); +} + +const syncTransports = [ + { name: 'classic sync', slidingSyncOptIn: false }, + { name: 'sliding sync', slidingSyncOptIn: true }, +]; + +for (const transport of syncTransports) { + test.describe(`notification jumps (${transport.name})`, () => { + test.describe.configure({ timeout: 300_000 }); + + test('places a notification target from a missed burst in canonical order', async ({ + page, + context, + }, testInfo) => { + test.skip(testInfo.project.name === 'touch', 'covered by the mobile browser viewport'); + const fixture = await openSeededRoom( + page, + testInfo.project.use.storageState as string, + 'nj-mid', + transport.slidingSyncOptIn + ); + await enterRoom(page, fixture); + + await context.setOffline(true); + // Inside the newest-50 subscription window, so no /context fetch is needed. + const targetEventId = await sendMissedBurst(fixture, 40); + await fixture.app.receiveNotificationClick( + fixture.alice.userId, + fixture.roomId, + targetEventId + ); + await context.setOffline(false); + + await expectJumpedTo(page, fixture, targetEventId); + await expectOrderedTimeline(page, fixture); + }); + + test('places a notification target older than the catch-up window in canonical order', async ({ + page, + context, + }, testInfo) => { + test.skip(testInfo.project.name === 'touch', 'covered by the mobile browser viewport'); + const fixture = await openSeededRoom( + page, + testInfo.project.use.storageState as string, + 'nj-old', + transport.slidingSyncOptIn + ); + await enterRoom(page, fixture); + + await context.setOffline(true); + // Outside the newest-50 window, so the jump has to fetch the event's context. + const targetEventId = await sendMissedBurst(fixture, 5); + await fixture.app.receiveNotificationClick( + fixture.alice.userId, + fixture.roomId, + targetEventId + ); + await context.setOffline(false); + + await expectJumpedTo(page, fixture, targetEventId); + await expectOrderedTimeline(page, fixture); + }); + + test('does not present the two sides of a gap as adjacent while the room stays open', async ({ + page, + context, + }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'desktop-focused'); + const fixture = await openSeededRoom( + page, + testInfo.project.use.storageState as string, + 'nj-gap', + transport.slidingSyncOptIn + ); + await enterRoom(page, fixture); + + // No jump at all: the room lists ask for timeline_limit 1, so resuming after + // a missed burst delivers a single event that is not adjacent to the tail. + await context.setOffline(true); + await sendMissedBurst(fixture, BURST_SIZE); + await context.setOffline(false); + + await expect( + page.getByText(`${fixture.tag}-burst-${BURST_SIZE}`, { exact: true }) + ).toBeVisible({ + timeout: SYNC_TIMEOUT, + }); + await expectOrderedTimeline(page, fixture); + }); + + test('back-paginates contiguously after a notification jump', async ({ + page, + context, + }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'desktop-focused'); + const fixture = await openSeededRoom( + page, + testInfo.project.use.storageState as string, + 'nj-back', + transport.slidingSyncOptIn + ); + await enterRoom(page, fixture); + + await context.setOffline(true); + const targetEventId = await sendMissedBurst(fixture, 5); + await fixture.app.receiveNotificationClick( + fixture.alice.userId, + fixture.roomId, + targetEventId + ); + await context.setOffline(false); + + await expectJumpedTo(page, fixture, targetEventId); + + // The window's back-pagination token must point just before the window, not + // at some older position, or the history arrives spliced in from elsewhere. + await wheelToTopUntilVisible(page, `${fixture.tag}-seed-${SEED_SIZE}`); + await expectOrderedTimeline(page, fixture); + }); + + test('returns to a contiguous live tail when jumping to latest after a notification jump', async ({ + page, + context, + }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'desktop-focused'); + const fixture = await openSeededRoom( + page, + testInfo.project.use.storageState as string, + 'nj-live', + transport.slidingSyncOptIn + ); + await enterRoom(page, fixture); + + await context.setOffline(true); + const targetEventId = await sendMissedBurst(fixture, 5); + await fixture.app.receiveNotificationClick( + fixture.alice.userId, + fixture.roomId, + targetEventId + ); + await context.setOffline(false); + + await expectJumpedTo(page, fixture, targetEventId); + await expect(fixture.app.jumpToLatestButton).toBeVisible({ timeout: SYNC_TIMEOUT }); + await fixture.app.jumpToLatestButton.click(); + + await expect( + page.getByText(`${fixture.tag}-burst-${BURST_SIZE}`, { exact: true }) + ).toBeVisible({ + timeout: SYNC_TIMEOUT, + }); + await expectOrderedTimeline(page, fixture); + }); + + test('keeps the timeline ordered across two consecutive notification jumps', async ({ + page, + context, + }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'desktop-focused'); + const fixture = await openSeededRoom( + page, + testInfo.project.use.storageState as string, + 'nj-twice', + transport.slidingSyncOptIn + ); + await enterRoom(page, fixture); + + await context.setOffline(true); + const olderTarget = await sendMissedBurst(fixture, 5); + await fixture.app.receiveNotificationClick(fixture.alice.userId, fixture.roomId, olderTarget); + await context.setOffline(false); + + await expectJumpedTo(page, fixture, olderTarget); + await expectOrderedTimeline(page, fixture); + + const newerTarget = fixture.seedIds[0]!; + await fixture.app.receiveNotificationClick(fixture.alice.userId, fixture.roomId, newerTarget); + await expect(fixture.app.messageByEventId(newerTarget)).toBeVisible({ + timeout: SYNC_TIMEOUT, + }); + await expectOrderedTimeline(page, fixture); + }); + + test('places a cold-start notification target in canonical order', async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'desktop-focused'); + const fixture = await openSeededRoom( + page, + testInfo.project.use.storageState as string, + 'nj-cold', + transport.slidingSyncOptIn + ); + // No prior visit: the deep link is the very first navigation, so the room is + // uncached and the jump races the initial sync. + const targetEventId = await sendMissedBurst(fixture, 5); + + await fixture.app.openNotificationColdStart( + fixture.alice.userId, + fixture.roomId, + targetEventId + ); + + await expectJumpedTo(page, fixture, targetEventId); + await expectOrderedTimeline(page, fixture); + }); + + test('places a cold-start latest notification target in canonical order', async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name === 'touch', 'covered by the mobile browser viewport'); + const fixture = await openSeededRoom( + page, + testInfo.project.use.storageState as string, + 'nj-cold-latest', + transport.slidingSyncOptIn + ); + const targetEventId = await sendMissedBurst(fixture, BURST_SIZE); + + await fixture.app.openNotificationColdStart( + fixture.alice.userId, + fixture.roomId, + targetEventId + ); + + await expectJumpedToLatest(page, fixture, targetEventId); + }); + + test('places a notification target before the room has been opened in canonical order', async ({ + page, + context, + }, testInfo) => { + test.skip(testInfo.project.name === 'touch', 'covered by the mobile browser viewport'); + const fixture = await openSeededRoom( + page, + testInfo.project.use.storageState as string, + 'nj-unopened', + transport.slidingSyncOptIn + ); + await page.goto('/'); + await expect(page.getByText(`${fixture.tag} Relay`).first()).toBeVisible({ + timeout: SYNC_TIMEOUT, + }); + + await context.setOffline(true); + const targetEventId = await sendMissedBurst(fixture, 5); + await fixture.app.receiveNotificationClick( + fixture.alice.userId, + fixture.roomId, + targetEventId + ); + await context.setOffline(false); + + await expectJumpedTo(page, fixture, targetEventId); + await expectOrderedTimeline(page, fixture); + }); + }); +} diff --git a/tests/e2e/pages/AppShell.ts b/tests/e2e/pages/AppShell.ts index bdc4d93773..a8b4ddb1ae 100644 --- a/tests/e2e/pages/AppShell.ts +++ b/tests/e2e/pages/AppShell.ts @@ -39,6 +39,33 @@ export class AppShell { await this.page.getByRole('button', { name: new RegExp(`^${escaped}`) }).click(); } + /** + * The cold-launch half of notificationclick: with no window client to focus, + * the service worker calls openWindow() on `/to/:user_id/:room_id/:event_id`. + */ + async openNotificationColdStart(userId: string, roomId: string, eventId: string): Promise { + const segments = [userId, roomId, eventId].map((part) => encodeURIComponent(part)); + await this.page.goto(`/to/${segments.join('/')}`); + } + + /** + * The warm half: when a window client exists the service worker posts + * `notificationClick` to it and focuses it rather than navigating, so the app + * keeps its in-memory timeline. Delivers the same message the worker sends. + */ + async receiveNotificationClick(userId: string, roomId: string, eventId: string): Promise { + await this.page.evaluate( + (data) => { + navigator.serviceWorker.dispatchEvent(new MessageEvent('message', { data })); + }, + { type: 'notificationClick', userId, roomId, eventId, isInvite: false, isCall: false } + ); + } + + get jumpToLatestButton(): Locator { + return this.page.getByRole('button', { name: 'Jump to Latest' }); + } + async openRoomOptions(name: string): Promise { await this.room(name).hover(); await this.page.getByRole('button', { name: 'More Options' }).first().click(); diff --git a/tests/e2e/permalink-jump.spec.ts b/tests/e2e/permalink-jump.spec.ts index b8a9dfb254..2165e63b2f 100644 --- a/tests/e2e/permalink-jump.spec.ts +++ b/tests/e2e/permalink-jump.spec.ts @@ -111,6 +111,11 @@ test.describe('permalink jumps', () => { await expect(targetRow).toBeVisible({ timeout: 60_000 }); await expect(app.messageByEventId(latestId)).toHaveCount(0); + + const sentBody = `${tag}-after-jump`; + await app.sendTextMessage(sentBody); + await expect(page.getByText(sentBody, { exact: true })).toBeVisible({ timeout: 60_000 }); + await expect(app.messageByEventId(latestId)).toBeVisible({ timeout: 60_000 }); }); test('opens the thread when the permalink target is a thread reply', async ({ diff --git a/tests/e2e/timeline-recovery.spec.ts b/tests/e2e/timeline-recovery.spec.ts index 570aebb445..9febcadd37 100644 --- a/tests/e2e/timeline-recovery.spec.ts +++ b/tests/e2e/timeline-recovery.spec.ts @@ -114,7 +114,6 @@ test.describe('timeline recovery', () => { context, }, testInfo) => { test.skip(testInfo.project.name !== 'desktop', 'desktop-focused'); - test.fixme(true, 'failed-send Retry UI does not render for offline sends under sliding sync'); test.setTimeout(300_000); const storageStatePath = testInfo.project.use.storageState as string; const hsBaseUrl = await homeserverBaseUrl(storageStatePath); @@ -143,9 +142,11 @@ test.describe('timeline recovery', () => { const body = `${tag}-retry-body`; await app.sendTextMessage(body); - const failedStatus = page.getByText('Failed to send.', { exact: true }); - await expect(failedStatus).toHaveCount(1, { timeout: 180_000 }); - const retryButton = page.getByRole('button', { + const failedMessage = page.locator('[data-message-id]').filter({ hasText: body }); + await expect(failedMessage).toHaveCount(1, { timeout: 180_000 }); + const failedStatus = failedMessage.getByText('Failed to send.', { exact: true }); + await expect(failedStatus).toBeVisible(); + const retryButton = failedMessage.getByRole('button', { name: 'Retry', exact: true, }); @@ -162,9 +163,14 @@ test.describe('timeline recovery', () => { await expect(page.getByText(body, { exact: true })).toBeVisible(); await expect(failedStatus).toHaveCount(0, { timeout: 180_000 }); - const canonical = (await getRoomMessages(hsBaseUrl, user.accessToken, room)) - .map((m) => m.body) - .filter((b) => b === body); - expect(canonical).toEqual([body]); + // The local failure clears before the server confirms the retry. + await expect + .poll( + async () => + (await getRoomMessages(hsBaseUrl, user.accessToken, room)).filter((m) => m.body === body) + .length, + { timeout: 60_000 } + ) + .toBe(1); }); });