Media Gallery: opening it scrolls the room timeline into the past while it loads older media #163

Closed
opened 2026-09-17 23:06:34 -04:00 by jared · 2 comments
Owner

Jared: "When opening the Media Gallery the message timeline zooms to the past as it loads the old images when this should not happen!"

The gallery paginates the room to find media and that pagination is landing in the live timeline the user is looking at (shared Room timeline / paginateEventTimeline on the live timeline, or the gallery's fetch driving the same virtualiser). Fix: fetch media via a separate EventTimelineSet (unfiltered) or /messages with a filter, never the live timeline; the room view must not move. Regression test: open the gallery in a long room and assert the timeline scroll position is unchanged (Playwright, E2E tier).

Jared: "When opening the Media Gallery the message timeline zooms to the past as it loads the old images when this should not happen!" The gallery paginates the room to find media and that pagination is landing in the live timeline the user is looking at (shared `Room` timeline / `paginateEventTimeline` on the live timeline, or the gallery's fetch driving the same virtualiser). Fix: fetch media via a **separate `EventTimelineSet`** (unfiltered) or `/messages` with a filter, never the live timeline; the room view must not move. Regression test: open the gallery in a long room and assert the timeline scroll position is unchanged (Playwright, E2E tier).
jared added this to the Features 2026-Q4 milestone 2026-09-17 23:06:34 -04:00
jared added the bugpriority: highuxarea: media labels 2026-09-17 23:06:34 -04:00
jared self-assigned this 2026-09-17 23:06:34 -04:00
Author
Owner

Root cause (confirmed in code, 2026-09-17)

The gallery paginates the room's live timeline — the very object the message list is rendering:

// src/app/features/room/MediaGallery.tsx:762
const hasMore = await mx.paginateEventTimeline(room.getLiveTimeline(), { backwards: true, limit: 100 });

RoomTimeline does not hold events; it holds a numeric window range: { start, end } of absolute indices into the concatenated linkedTimelines event arrays (RoomTimeline.tsx:588-604, timelineSegments). Backwards pagination in matrix-js-sdk prepends: EventTimeline.addEvent(..., toStartOfTimeline) does this.events.splice(0, 0, event); this.baseIndex++ (node_modules/matrix-js-sdk/src/models/event-timeline.ts:382-391). So after the gallery pulls one page, index N in the live timeline now points at an event 100 places older than before. RoomTimeline never learns about that shift:

  • Its own paginator compensates via recalibratePagination(lTimelines, timelinesEventsCount, backwards) (RoomTimeline.tsx:334-380) — it snapshots the per-timeline event counts before calling paginateEventTimeline and shifts range by the delta afterwards. The gallery's call bypasses that entirely.
  • useLiveEventArrive ignores the prepended events (data.liveEvent is false for back-paginated events, RoomTimeline.tsx:388-397), so no range correction happens on the event either.

On the next render of RoomTimeline (anything — a receipt, typing, a settings atom, scroll), getItems() maps the unchanged range through the shifted arrays and the viewport shows older messages: the "zoom into the past". Each gallery page (100 events, auto-fired by the IntersectionObserver sentinel at MediaGallery.tsx:783) moves it another 100 events back. Two secondary symptoms fall out of the same shift:

  1. rangeAtEnd = range.end === eventsLength becomes false (RoomTimeline.tsx:605), so the timeline thinks it is no longer at the live end — new messages stop auto-scrolling and "Jump to latest" appears even though the user never scrolled.
  2. The gallery's tabCounts and getFilteredEvents (MediaGallery.tsx:733-745, 818-830) also read the live timeline, so the two panels are coupled in both directions.

Same bug elsewhere (found by grepping paginateEventTimeline(room.getLiveTimeline())

Caller Effect on the timeline behind it
room-settings/RoomActivityLog.tsx:366 (50/page) Room Settings is a modal over the room; close it after a few "load more" and the timeline has jumped.
room-settings/ExportRoomHistory.tsx:76,180 Worst case: loops up to MAX_EXPORT_PAGES into the live timeline. Export a month of a busy room and the timeline behind it is thousands of events in the past (and the SDK keeps every one of those events in memory for the session).
message-search/MessageSearch.tsx:96 ("load more history" for client-side search of encrypted rooms) Deliberate — search needs the events in a decrypted timeline — and the search view is its own route so RoomTimeline is normally unmounted. Leave as is; verify it's not reachable while a room is open in a split layout.
room/thread/ThreadTimeline.tsx:217 Paginates the thread's timeline set, not the live one. Not affected.

Fix options

A. Give the gallery its own timeline set (recommended — the Element Web FilePanel approach). Never touch the live timeline.

  • Unencrypted rooms: room.getOrCreateFilteredTimelineSet(filter) with a server-side filter { room: { timeline: { types: ['m.room.message'], contains_url: true } } } (mx.getOrCreateFilter('FILTER_FILES_' + userId, filter) once, cache the id). paginateEventTimeline on that set's live timeline hits /messages?filter=… — the server returns only media events, so a page of 100 is 100 media items instead of 100 events of which 3 are images. Faster gallery and no coupling. The SDK API exists in 41.7 (room.ts:2004) and paginateEventTimeline dispatches on eventTimeline.getTimelineSet(), so nothing else changes.
  • Encrypted rooms: contains_url can't match ciphertext, so use a private unfiltered new EventTimelineSet(room, { timelineSupport: true }) (not registered with the room → doesn't receive sync events, doesn't affect anything), seed its backward token from the live timeline's earliest neighbour, paginate that, decrypt (decryptAllTimelineEvent, already used by RoomTimeline) and filter by msgtype client-side — exactly what the gallery does today, just on a private array. Element does this branch too (FilePanel.tsx, fetchFileEventsServer).
  • Live updates: listen for RoomEvent.Timeline with data.liveEvent and prepend matching new media to the gallery's list (today it "works" only because it re-reads the live timeline on every load).
  • Move tabCounts/getFilteredEvents onto the gallery's set. Redactions: keep the existing isRedacted() filter; the private set gets RoomEvent.Redaction via the room, or re-check on render.
  • Reuse the same helper in RoomActivityLog (state events only — a filter with types: ['m.room.member','m.room.power_levels',…] is server-side too) and ExportRoomHistory (unfiltered private set; also fixes the memory retention).

Estimated size: ~150 lines in a new src/app/hooks/useRoomMediaTimeline.ts (or utils/timelineSet.ts) + the three call-site swaps. Risk is low because RoomTimeline is untouched.

B. Make RoomTimeline tolerate external prepends. Subscribe to RoomEvent.Timeline with toStartOfTimeline === true on the live timeline set and setTimeline(cs => ({...cs, range: {start: cs.range.start + 1, end: cs.range.end + 1}})) per event, while suppressing it during its own pagination (which already recalibrates). Smaller diff but it changes the most sensitive component in the app, double-counts if the suppression flag is wrong, and does nothing for the memory/perf side. Not recommended.

C. Band-aid: gate the sentinel so the gallery only auto-paginates while the timeline is scrolled to the bottom, or paginate in smaller pages. Doesn't fix the jump, only makes it rarer. Not recommended.

Recommendation: A, gallery first (the reported symptom), then the export and activity log as a follow-up commit using the same helper. No behaviour change is needed in RoomTimeline.

## Root cause (confirmed in code, 2026-09-17) The gallery paginates **the room's live timeline** — the very object the message list is rendering: ```ts // src/app/features/room/MediaGallery.tsx:762 const hasMore = await mx.paginateEventTimeline(room.getLiveTimeline(), { backwards: true, limit: 100 }); ``` `RoomTimeline` does not hold events; it holds a numeric window `range: { start, end }` of **absolute indices** into the concatenated `linkedTimelines` event arrays (`RoomTimeline.tsx:588-604`, `timelineSegments`). Backwards pagination in matrix-js-sdk **prepends**: `EventTimeline.addEvent(..., toStartOfTimeline)` does `this.events.splice(0, 0, event); this.baseIndex++` (`node_modules/matrix-js-sdk/src/models/event-timeline.ts:382-391`). So after the gallery pulls one page, index `N` in the live timeline now points at an event **100 places older** than before. `RoomTimeline` never learns about that shift: - Its own paginator compensates via `recalibratePagination(lTimelines, timelinesEventsCount, backwards)` (`RoomTimeline.tsx:334-380`) — it snapshots the per-timeline event counts before calling `paginateEventTimeline` and shifts `range` by the delta afterwards. The gallery's call bypasses that entirely. - `useLiveEventArrive` ignores the prepended events (`data.liveEvent` is false for back-paginated events, `RoomTimeline.tsx:388-397`), so no range correction happens on the event either. On the next render of `RoomTimeline` (anything — a receipt, typing, a settings atom, scroll), `getItems()` maps the *unchanged* `range` through the *shifted* arrays and the viewport shows older messages: the "zoom into the past". Each gallery page (100 events, auto-fired by the `IntersectionObserver` sentinel at `MediaGallery.tsx:783`) moves it another 100 events back. Two secondary symptoms fall out of the same shift: 1. `rangeAtEnd = range.end === eventsLength` becomes false (`RoomTimeline.tsx:605`), so the timeline thinks it is no longer at the live end — new messages stop auto-scrolling and "Jump to latest" appears even though the user never scrolled. 2. The gallery's `tabCounts` and `getFilteredEvents` (`MediaGallery.tsx:733-745, 818-830`) also read the live timeline, so the two panels are coupled in both directions. ### Same bug elsewhere (found by grepping `paginateEventTimeline(room.getLiveTimeline()`) | Caller | Effect on the timeline behind it | | :-- | :-- | | `room-settings/RoomActivityLog.tsx:366` (50/page) | Room Settings is a modal over the room; close it after a few "load more" and the timeline has jumped. | | `room-settings/ExportRoomHistory.tsx:76,180` | Worst case: loops up to `MAX_EXPORT_PAGES` into the live timeline. Export a month of a busy room and the timeline behind it is thousands of events in the past (and the SDK keeps every one of those events in memory for the session). | | `message-search/MessageSearch.tsx:96` ("load more history" for client-side search of encrypted rooms) | Deliberate — search *needs* the events in a decrypted timeline — and the search view is its own route so `RoomTimeline` is normally unmounted. Leave as is; verify it's not reachable while a room is open in a split layout. | | `room/thread/ThreadTimeline.tsx:217` | Paginates the **thread's** timeline set, not the live one. Not affected. | ## Fix options **A. Give the gallery its own timeline set (recommended — the Element Web FilePanel approach).** Never touch the live timeline. - Unencrypted rooms: `room.getOrCreateFilteredTimelineSet(filter)` with a server-side filter `{ room: { timeline: { types: ['m.room.message'], contains_url: true } } }` (`mx.getOrCreateFilter('FILTER_FILES_' + userId, filter)` once, cache the id). `paginateEventTimeline` on that set's live timeline hits `/messages?filter=…` — the server returns **only media events**, so a page of 100 is 100 media items instead of 100 events of which 3 are images. Faster gallery *and* no coupling. The SDK API exists in 41.7 (`room.ts:2004`) and `paginateEventTimeline` dispatches on `eventTimeline.getTimelineSet()`, so nothing else changes. - Encrypted rooms: `contains_url` can't match ciphertext, so use a private unfiltered `new EventTimelineSet(room, { timelineSupport: true })` (not registered with the room → doesn't receive sync events, doesn't affect anything), seed its backward token from the live timeline's earliest neighbour, paginate that, decrypt (`decryptAllTimelineEvent`, already used by `RoomTimeline`) and filter by `msgtype` client-side — exactly what the gallery does today, just on a private array. Element does this branch too (`FilePanel.tsx`, `fetchFileEventsServer`). - Live updates: listen for `RoomEvent.Timeline` with `data.liveEvent` and prepend matching new media to the gallery's list (today it "works" only because it re-reads the live timeline on every load). - Move `tabCounts`/`getFilteredEvents` onto the gallery's set. Redactions: keep the existing `isRedacted()` filter; the private set gets `RoomEvent.Redaction` via the room, or re-check on render. - Reuse the same helper in `RoomActivityLog` (state events only — a filter with `types: ['m.room.member','m.room.power_levels',…]` is server-side too) and `ExportRoomHistory` (unfiltered private set; also fixes the memory retention). Estimated size: ~150 lines in a new `src/app/hooks/useRoomMediaTimeline.ts` (or `utils/timelineSet.ts`) + the three call-site swaps. Risk is low because `RoomTimeline` is untouched. **B. Make `RoomTimeline` tolerate external prepends.** Subscribe to `RoomEvent.Timeline` with `toStartOfTimeline === true` on the live timeline set and `setTimeline(cs => ({...cs, range: {start: cs.range.start + 1, end: cs.range.end + 1}}))` per event, while suppressing it during its own pagination (which already recalibrates). Smaller diff but it changes the most sensitive component in the app, double-counts if the suppression flag is wrong, and does nothing for the memory/perf side. Not recommended. **C. Band-aid: gate the sentinel** so the gallery only auto-paginates while the timeline is scrolled to the bottom, or paginate in smaller pages. Doesn't fix the jump, only makes it rarer. Not recommended. Recommendation: **A**, gallery first (the reported symptom), then the export and activity log as a follow-up commit using the same helper. No behaviour change is needed in `RoomTimeline`.
Author
Owner

Reproduced and fixed — d929143f

Reproduction (Playwright against a local Synapse, scripts/dev-homeserver.sh + scripts/dev-seed.py, 400-message room with 40 images):

step first visible last visible Jump to latest
room open #391 bob live 1 no
gallery open (2 pages loaded) #391 bob live 1 no
one live message arrives #196 #202 yes
gallery scrolled, another live message #96 #102 yes

Exactly the predicted mechanism: each 100-event page prepended to the live timeline shifts RoomTimeline's index window 100 events into the past; the shift becomes visible on the next render (here a live message) and at-bottom tracking is lost.

Fix = option A. utils/detachedTimeline.ts + hooks/useRoomMediaTimeline.ts: the gallery pages through its own timeline set — a room-registered filtered set with a server-side contains_url filter in plain rooms (a page is 100 media events, so the 40 images arrived in fewer requests than before), a private EventTimelineSet seeded from the live timeline in encrypted rooms (raw history, decrypt, filter by msgtype, live events fed in after decryption, redactions removed). RoomActivityLog uses the same helper with a type-only filter (safe when encrypted); ExportRoomHistory pages a private set so a full export no longer parks thousands of events in the live timeline. RoomTimeline is untouched.

After the fix, same script: plain room — timeline stays at the bottom through gallery pages, live messages keep auto-scrolling, no "Jump to latest"; all 40 images shown. Encrypted room (200 encrypted events, 20 encrypted images sent by a separate crypto-capable client): same result, 20/20 images, 0 undecryptable, "Beginning of history" reached. Activity log (4× load more) and a full history export (plain: 408 messages incl. img0.png; encrypted: 206, all decrypted) also leave the timeline where it was.

Unit tests: utils/detachedTimeline.test.ts (seeding, back-token copy, live timeline untouched by prepends, filter selection plain vs encrypted, per-filter caching).

Known limitation (pre-existing, unchanged): media posted inside threads is not in the gallery — thread replies are partitioned out of the room timeline by the SDK. Noting it on #165.

## Reproduced and fixed — `d929143f` **Reproduction** (Playwright against a local Synapse, `scripts/dev-homeserver.sh` + `scripts/dev-seed.py`, 400-message room with 40 images): | step | first visible | last visible | Jump to latest | |---|---|---|---| | room open | #391 | bob live 1 | no | | gallery open (2 pages loaded) | #391 | bob live 1 | no | | **one live message arrives** | **#196** | **#202** | **yes** | | gallery scrolled, another live message | **#96** | **#102** | yes | Exactly the predicted mechanism: each 100-event page prepended to the live timeline shifts `RoomTimeline`'s index window 100 events into the past; the shift becomes visible on the next render (here a live message) and at-bottom tracking is lost. **Fix = option A.** `utils/detachedTimeline.ts` + `hooks/useRoomMediaTimeline.ts`: the gallery pages through its own timeline set — a room-registered filtered set with a server-side `contains_url` filter in plain rooms (a page is 100 media events, so the 40 images arrived in fewer requests than before), a private `EventTimelineSet` seeded from the live timeline in encrypted rooms (raw history, decrypt, filter by msgtype, live events fed in after decryption, redactions removed). `RoomActivityLog` uses the same helper with a type-only filter (safe when encrypted); `ExportRoomHistory` pages a private set so a full export no longer parks thousands of events in the live timeline. `RoomTimeline` is untouched. **After the fix, same script:** plain room — timeline stays at the bottom through gallery pages, live messages keep auto-scrolling, no "Jump to latest"; all 40 images shown. Encrypted room (200 encrypted events, 20 encrypted images sent by a separate crypto-capable client): same result, 20/20 images, 0 undecryptable, "Beginning of history" reached. Activity log (4× load more) and a full history export (plain: 408 messages incl. `img0.png`; encrypted: 206, all decrypted) also leave the timeline where it was. Unit tests: `utils/detachedTimeline.test.ts` (seeding, back-token copy, live timeline untouched by prepends, filter selection plain vs encrypted, per-filter caching). Known limitation (pre-existing, unchanged): media posted inside threads is not in the gallery — thread replies are partitioned out of the room timeline by the SDK. Noting it on #165.
jared closed this issue 2026-09-18 00:19:36 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#163