From 64ee350ed810bccb33a51f53bcf51bf6330bc874 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 1 Aug 2026 11:42:03 +0200 Subject: [PATCH 1/2] fix(timeline): render events that are still encrypted and refresh on decryption --- .../timeline/useProcessedTimeline.test.tsx | 16 ++++ .../hooks/timeline/useProcessedTimeline.ts | 15 ++-- .../hooks/timeline/useTimelineSync.test.tsx | 79 +++++++++++++------ src/app/hooks/timeline/useTimelineSync.ts | 18 ++++- 4 files changed, 94 insertions(+), 34 deletions(-) diff --git a/src/app/hooks/timeline/useProcessedTimeline.test.tsx b/src/app/hooks/timeline/useProcessedTimeline.test.tsx index 18e6545c9..aa636988a 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.test.tsx +++ b/src/app/hooks/timeline/useProcessedTimeline.test.tsx @@ -164,6 +164,22 @@ const dividerIds = (processed: ProcessedEvent[]) => processed.filter((e) => e.willRenderNewDivider).map((e) => e.id); describe('useProcessedTimeline new-messages divider', () => { + it('renders an event that is still encrypted', () => { + const processed = processTimeline( + [ + createEvent({ id: '$a' }), + createEvent({ + id: '$encrypted', + type: EventType.RoomMessageEncrypted as string, + content: { algorithm: 'm.megolm.v1.aes-sha2', ciphertext: 'AwgAEnB...' }, + }), + ], + undefined + ); + + expect(renderedIds(processed)).toEqual(['$a', '$encrypted']); + }); + it('keeps poll start events with default hidden-event settings', () => { const processed = processTimeline( [ diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index d7c6bba36..8b8689e47 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -69,19 +69,16 @@ export function getProcessedRowIndexForRawTimelineIndex( return bestRowIndex >= 0 ? { rowIndex: bestRowIndex, focusRawIndex: bestRawIndex } : undefined; } -// Decrypted room-message events are re-typed to this app-internal value; there is no SDK constant for it. -const ROOM_MESSAGE_DECRYPTED = 'm.room.message.encrypted'; - -const MESSAGE_EVENT_TYPES = new Set([ +const MESSAGE_EVENT_TYPES = new Set([ EventType.RoomMessage, - ROOM_MESSAGE_DECRYPTED, EventType.Sticker, EventType.RoomMessageEncrypted, ]); -export const STANDARD_RENDERED_EVENT_TYPES = new Set([ +export const STANDARD_RENDERED_EVENT_TYPES = new Set([ EventType.RoomMessage, - ROOM_MESSAGE_DECRYPTED, + // getType() reports this until decryption resolves the real type. + EventType.RoomMessageEncrypted, EventType.Sticker, M_POLL_START.name, EventType.RoomMember, @@ -92,9 +89,7 @@ export const STANDARD_RENDERED_EVENT_TYPES = new Set([ ]); const normalizeMessageType = (t: string): string => - t === (EventType.RoomMessageEncrypted as string) || t === ROOM_MESSAGE_DECRYPTED - ? EventType.RoomMessage - : t; + t === (EventType.RoomMessageEncrypted as string) ? EventType.RoomMessage : t; const isMessageRow = (mEvent: MatrixEvent): boolean => MESSAGE_EVENT_TYPES.has(mEvent.getType()) && !isEditEvent(mEvent); diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index b9e06e798..664aabbad 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -3,7 +3,7 @@ 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, RoomEvent } from '$types/matrix-sdk'; +import { Direction, MatrixEventEvent, RoomEvent } from '$types/matrix-sdk'; import { countVisibleAmongNewest, useTimelineSync } from './useTimelineSync'; import { getRoomUnreadInfo } from '$utils/timeline'; import type * as TimelineUtils from '$utils/timeline'; @@ -132,6 +132,15 @@ function createPaginableRoom(roomId = '!room:test') { return { room, timelineSet, events }; } +const mxEmitter = new EventEmitter(); +const makeMx = (extra: Record = {}) => + ({ + getUserId: () => '@alice:test', + on: mxEmitter.on.bind(mxEmitter), + removeListener: mxEmitter.removeListener.bind(mxEmitter), + ...extra, + }) as never; + function makeEvent(sender: string, roomId: string) { return { threadRootId: undefined, @@ -229,7 +238,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), isAtBottom: false, isAtBottomRef: { current: false }, scrollToBottom, @@ -261,7 +270,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom, @@ -289,7 +298,7 @@ describe('useTimelineSync', () => { ({ room, eventId }) => useTimelineSync({ room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), eventId, isAtBottom: false, isAtBottomRef: { current: false }, @@ -326,7 +335,7 @@ describe('useTimelineSync', () => { ({ room, eventId }) => useTimelineSync({ room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), eventId, isAtBottom: false, isAtBottomRef: { current: false }, @@ -361,7 +370,7 @@ describe('useTimelineSync', () => { ({ room }) => useTimelineSync({ room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), eventId: undefined, isAtBottom: false, isAtBottomRef: { current: false }, @@ -395,7 +404,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom, @@ -422,7 +431,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom, @@ -452,7 +461,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom, @@ -480,7 +489,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), isAtBottom: false, isAtBottomRef: { current: false }, scrollToBottom, @@ -506,7 +515,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom, @@ -536,7 +545,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom, @@ -572,7 +581,7 @@ const syncOpts = ( isEventVisible?: () => boolean ) => ({ room: room as Room, - mx: { getUserId: () => '@alice:test', paginateEventTimeline } as never, + mx: makeMx({ paginateEventTimeline }), isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom: vi.fn<() => void>(), @@ -786,7 +795,7 @@ const renderSyncHook = ( const { result } = renderHook(() => useTimelineSync({ room: room as Room, - mx: (options.mx ?? { getUserId: () => '@alice:test' }) as never, + mx: (options.mx ?? makeMx()) as never, isAtBottom, isAtBottomRef: { current: isAtBottom }, scrollToBottom, @@ -947,7 +956,7 @@ describe('live-arrive edge cases', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), isAtBottom: false, isAtBottomRef: { current: false }, scrollToBottom, @@ -971,6 +980,32 @@ describe('live-arrive edge cases', () => { expect(scrollToBottom).not.toHaveBeenCalled(); }); + it('re-renders when an event finishes decrypting', async () => { + const { room } = createRoom(); + const { result } = renderSyncHook(room); + const before = result.current.timeline; + + await act(async () => { + mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => room.roomId }); + await Promise.resolve(); + }); + + expect(result.current.timeline).not.toBe(before); + }); + + it('ignores decryption of an event in another room', async () => { + const { room } = createRoom(); + const { result } = renderSyncHook(room); + const before = result.current.timeline; + + await act(async () => { + mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => '!other:test' }); + await Promise.resolve(); + }); + + expect(result.current.timeline).toBe(before); + }); + it('re-renders when a late local echo updates (slow send acknowledgement)', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); @@ -1016,12 +1051,11 @@ describe('event jump recovery', () => { targetTimeline, roomInitialSync, getLatestTimeline, - mx: { - getUserId: () => '@alice:test', + mx: makeMx({ roomInitialSync, getLatestTimeline, getEventTimeline: vi.fn<() => Promise>(() => Promise.resolve(targetTimeline)), - }, + }), }; }; @@ -1067,13 +1101,12 @@ describe('event jump recovery', () => { vi.useFakeTimers(); try { const { room } = createRoom(); - const mx = { - getUserId: () => '@alice:test', + const mx = makeMx({ roomInitialSync: vi.fn<() => Promise>(() => Promise.resolve(undefined)), getLatestTimeline: vi.fn<() => Promise>(() => Promise.resolve(undefined)), // The homeserver never answers /context: hit the 12 s timeout. getEventTimeline: () => new Promise(() => {}), - }; + }); const { result, scrollToBottom } = renderSyncHook(room, { isAtBottom: false, mx }); await act(async () => { @@ -1099,7 +1132,7 @@ describe('sliding sync chain relink', () => { undefined; const paginateEventTimeline = vi.fn<() => Promise>(() => Promise.resolve(false)); const { result } = renderSyncHook(room, { - mx: { getUserId: () => '@alice:test', paginateEventTimeline }, + mx: makeMx({ paginateEventTimeline }), }); expect(result.current.eventsLength).toBe(1); @@ -1150,7 +1183,7 @@ describe('sync transport fuzz', () => { const { result, unmount } = renderHook(() => useTimelineSync({ room: room as Room, - mx: { getUserId: () => '@alice:test', paginateEventTimeline } as never, + mx: makeMx({ paginateEventTimeline }), isAtBottom: false, isAtBottomRef: { current: false }, scrollToBottom, diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 177ba1cc1..b7f1475a9 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -12,7 +12,13 @@ import type { IRoomTimelineData, RoomEventHandlerMap, } from '$types/matrix-sdk'; -import { Direction, RoomEvent, RelationType, ThreadEvent } from '$types/matrix-sdk'; +import { + Direction, + MatrixEventEvent, + RoomEvent, + RelationType, + ThreadEvent, +} from '$types/matrix-sdk'; import { useAlive } from '$hooks/useAlive'; import { useMatrixEvent } from '$hooks/useMatrixEvent'; @@ -574,6 +580,16 @@ export function useTimelineSync({ useMatrixEvent(room, RoomEvent.LocalEchoUpdated, handleLocalEchoUpdated); + const handleDecrypted = useCallback( + (mEvent: MatrixEvent) => { + if (mEvent.getRoomId() !== room.roomId) return; + setTimeline((ct) => ({ ...ct })); + }, + [room, setTimeline] + ); + + useMatrixEvent(mx, MatrixEventEvent.Decrypted, handleDecrypted); + useLiveTimelineRefresh( room, useCallback(() => { From 606daa5ed86deff41e7a1702390f0a8ef1ccaef7 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 1 Aug 2026 15:13:41 +0200 Subject: [PATCH 2/2] fix(timeline): clear stale jump to latest state --- src/app/features/room/RoomTimeline.test.tsx | 19 +++++++++++++++++++ src/app/features/room/RoomTimeline.tsx | 1 + 2 files changed, 20 insertions(+) diff --git a/src/app/features/room/RoomTimeline.test.tsx b/src/app/features/room/RoomTimeline.test.tsx index 8aaa67d79..e341644b9 100644 --- a/src/app/features/room/RoomTimeline.test.tsx +++ b/src/app/features/room/RoomTimeline.test.tsx @@ -548,6 +548,25 @@ describe('unread read marker (sliding sync)', () => { }); 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)); + }); + + // Simulate a stale virtualizer measurement reporting the viewport above the + // bottom. A programmatic jump may not emit a follow-up scroll event. + act(() => lastOnScroll?.(0)); + expect(getByText('Jump to Latest')).toBeTruthy(); + + act(() => { + getByText('Jump to Latest').click(); + }); + + expect(queryByText('Jump to Latest')).toBeNull(); + }); + it('paginates backwards near the top only while a pagination token exists', () => { const { rerender } = renderTimeline(); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 327e51505..378d2fb73 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1331,6 +1331,7 @@ export function RoomTimeline({ onClick={() => { if (eventId) navigateRoom(room.roomId, undefined, { replace: true }); timelineSync.setTimeline(getInitialTimeline(room)); + setAtBottom(true); scrollToBottom(); }} style={{