From 87e2274ae793b0e496d9bf8d414b8352583452f1 Mon Sep 17 00:00:00 2001
From: Robin <robin@robin.town>
Date: Fri, 12 May 2023 12:27:41 -0400
Subject: [PATCH] Enable pagination for overlay timelines (#10757)

* Update @types/jest to 29.2.6

This adds the correct types for the contexts field on mock objects, which I'll need shortly

* Enable pagination for overlay timelines
---
 package.json                                  |   2 +-
 src/components/structures/TimelinePanel.tsx   | 202 +++++++---
 .../structures/TimelinePanel-test.tsx         | 379 ++++++++++++++++--
 test/test-utils/test-utils.ts                 |   2 +-
 yarn.lock                                     |  10 +-
 5 files changed, 517 insertions(+), 78 deletions(-)

diff --git a/package.json b/package.json
index 4a48d01b3d..0fd276c666 100644
--- a/package.json
+++ b/package.json
@@ -156,7 +156,7 @@
         "@types/fs-extra": "^11.0.0",
         "@types/geojson": "^7946.0.8",
         "@types/glob-to-regexp": "^0.4.1",
-        "@types/jest": "29.2.5",
+        "@types/jest": "29.2.6",
         "@types/katex": "^0.16.0",
         "@types/lodash": "^4.14.168",
         "@types/modernizr": "^3.5.3",
diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx
index 75f8fe0d4f..66bf331177 100644
--- a/src/components/structures/TimelinePanel.tsx
+++ b/src/components/structures/TimelinePanel.tsx
@@ -24,7 +24,7 @@ import { TimelineWindow } from "matrix-js-sdk/src/timeline-window";
 import { EventType, RelationType } from "matrix-js-sdk/src/@types/event";
 import { SyncState } from "matrix-js-sdk/src/sync";
 import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/models/room-member";
-import { debounce, throttle } from "lodash";
+import { debounce, findLastIndex, throttle } from "lodash";
 import { logger } from "matrix-js-sdk/src/logger";
 import { ClientEvent } from "matrix-js-sdk/src/client";
 import { Thread, ThreadEvent } from "matrix-js-sdk/src/models/thread";
@@ -73,6 +73,12 @@ const debuglog = (...args: any[]): void => {
     }
 };
 
+const overlaysBefore = (overlayEvent: MatrixEvent, mainEvent: MatrixEvent): boolean =>
+    overlayEvent.localTimestamp < mainEvent.localTimestamp;
+
+const overlaysAfter = (overlayEvent: MatrixEvent, mainEvent: MatrixEvent): boolean =>
+    overlayEvent.localTimestamp >= mainEvent.localTimestamp;
+
 interface IProps {
     // The js-sdk EventTimelineSet object for the timeline sequence we are
     // representing.  This may or may not have a room, depending on what it's
@@ -83,7 +89,6 @@ interface IProps {
     // added to support virtual rooms
     // events from the overlay timeline set will be added by localTimestamp
     // into the main timeline
-    // back paging not yet supported
     overlayTimelineSet?: EventTimelineSet;
     // filter events from overlay timeline
     overlayTimelineSetFilter?: (event: MatrixEvent) => boolean;
@@ -506,30 +511,64 @@ class TimelinePanel extends React.Component<IProps, IState> {
         // this particular event should be the first or last to be unpaginated.
         const eventId = scrollToken;
 
-        const marker = this.state.events.findIndex((ev) => {
-            return ev.getId() === eventId;
-        });
+        // The event in question could belong to either the main timeline or
+        // overlay timeline; let's check both
+        const mainEvents = this.timelineWindow!.getEvents();
+        const overlayEvents = this.overlayTimelineWindow?.getEvents() ?? [];
 
-        const count = backwards ? marker + 1 : this.state.events.length - marker;
+        let marker = mainEvents.findIndex((ev) => ev.getId() === eventId);
+        let overlayMarker: number;
+        if (marker === -1) {
+            // The event must be from the overlay timeline instead
+            overlayMarker = overlayEvents.findIndex((ev) => ev.getId() === eventId);
+            marker = backwards
+                ? findLastIndex(mainEvents, (ev) => overlaysAfter(overlayEvents[overlayMarker], ev))
+                : mainEvents.findIndex((ev) => overlaysBefore(overlayEvents[overlayMarker], ev));
+        } else {
+            overlayMarker = backwards
+                ? findLastIndex(overlayEvents, (ev) => overlaysBefore(ev, mainEvents[marker]))
+                : overlayEvents.findIndex((ev) => overlaysAfter(ev, mainEvents[marker]));
+        }
+
+        // The number of events to unpaginate from the main timeline
+        let count: number;
+        if (marker === -1) {
+            count = 0;
+        } else {
+            count = backwards ? marker + 1 : mainEvents.length - marker;
+        }
+
+        // The number of events to unpaginate from the overlay timeline
+        let overlayCount: number;
+        if (overlayMarker === -1) {
+            overlayCount = 0;
+        } else {
+            overlayCount = backwards ? overlayMarker + 1 : overlayEvents.length - overlayMarker;
+        }
 
         if (count > 0) {
             debuglog("Unpaginating", count, "in direction", dir);
-            this.timelineWindow?.unpaginate(count, backwards);
+            this.timelineWindow!.unpaginate(count, backwards);
+        }
 
-            const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
-            this.buildLegacyCallEventGroupers(events);
-            this.setState({
-                events,
-                liveEvents,
-                firstVisibleEventIndex,
-            });
+        if (overlayCount > 0) {
+            debuglog("Unpaginating", count, "from overlay timeline in direction", dir);
+            this.overlayTimelineWindow!.unpaginate(overlayCount, backwards);
+        }
 
-            // We can now paginate in the unpaginated direction
-            if (backwards) {
-                this.setState({ canBackPaginate: true });
-            } else {
-                this.setState({ canForwardPaginate: true });
-            }
+        const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
+        this.buildLegacyCallEventGroupers(events);
+        this.setState({
+            events,
+            liveEvents,
+            firstVisibleEventIndex,
+        });
+
+        // We can now paginate in the unpaginated direction
+        if (backwards) {
+            this.setState({ canBackPaginate: true });
+        } else {
+            this.setState({ canForwardPaginate: true });
         }
     };
 
@@ -572,11 +611,15 @@ class TimelinePanel extends React.Component<IProps, IState> {
         debuglog("Initiating paginate; backwards:" + backwards);
         this.setState<null>({ [paginatingKey]: true });
 
-        return this.onPaginationRequest(this.timelineWindow, dir, PAGINATE_SIZE).then((r) => {
+        return this.onPaginationRequest(this.timelineWindow, dir, PAGINATE_SIZE).then(async (r) => {
             if (this.unmounted) {
                 return false;
             }
 
+            if (this.overlayTimelineWindow) {
+                await this.extendOverlayWindowToCoverMainWindow();
+            }
+
             debuglog("paginate complete backwards:" + backwards + "; success:" + r);
 
             const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
@@ -769,8 +812,15 @@ class TimelinePanel extends React.Component<IProps, IState> {
             });
     };
 
+    private hasTimelineSetFor(roomId: string | undefined): boolean {
+        return (
+            (roomId !== undefined && roomId === this.props.timelineSet.room?.roomId) ||
+            roomId === this.props.overlayTimelineSet?.room?.roomId
+        );
+    }
+
     private onRoomTimelineReset = (room: Room | undefined, timelineSet: EventTimelineSet): void => {
-        if (timelineSet !== this.props.timelineSet) return;
+        if (timelineSet !== this.props.timelineSet && timelineSet !== this.props.overlayTimelineSet) return;
 
         if (this.canResetTimeline()) {
             this.loadTimeline();
@@ -783,7 +833,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
         if (this.unmounted) return;
 
         // ignore events for other rooms
-        if (room !== this.props.timelineSet.room) return;
+        if (!this.hasTimelineSetFor(room.roomId)) return;
 
         // we could skip an update if the event isn't in our timeline,
         // but that's probably an early optimisation.
@@ -796,10 +846,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
         }
 
         // ignore events for other rooms
-        const roomId = thread.roomId;
-        if (roomId !== this.props.timelineSet.room?.roomId) {
-            return;
-        }
+        if (!this.hasTimelineSetFor(thread.roomId)) return;
 
         // we could skip an update if the event isn't in our timeline,
         // but that's probably an early optimisation.
@@ -817,10 +864,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
         }
 
         // ignore events for other rooms
-        const roomId = ev.getRoomId();
-        if (roomId !== this.props.timelineSet.room?.roomId) {
-            return;
-        }
+        if (!this.hasTimelineSetFor(ev.getRoomId())) return;
 
         // we could skip an update if the event isn't in our timeline,
         // but that's probably an early optimisation.
@@ -834,7 +878,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
         if (this.unmounted) return;
 
         // ignore events for other rooms
-        if (member.roomId !== this.props.timelineSet.room?.roomId) return;
+        if (!this.hasTimelineSetFor(member.roomId)) return;
 
         // ignore events for other users
         if (member.userId != MatrixClientPeg.get().credentials?.userId) return;
@@ -857,7 +901,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
         if (this.unmounted) return;
 
         // ignore events for other rooms
-        if (replacedEvent.getRoomId() !== this.props.timelineSet.room?.roomId) return;
+        if (!this.hasTimelineSetFor(replacedEvent.getRoomId())) return;
 
         // we could skip an update if the event isn't in our timeline,
         // but that's probably an early optimisation.
@@ -877,7 +921,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
         if (this.unmounted) return;
 
         // ignore events for other rooms
-        if (room !== this.props.timelineSet.room) return;
+        if (!this.hasTimelineSetFor(room.roomId)) return;
 
         this.reloadEvents();
     };
@@ -905,7 +949,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
         // Can be null for the notification timeline, etc.
         if (!this.props.timelineSet.room) return;
 
-        if (ev.getRoomId() !== this.props.timelineSet.room.roomId) return;
+        if (!this.hasTimelineSetFor(ev.getRoomId())) return;
 
         if (!this.state.events.includes(ev)) return;
 
@@ -1380,6 +1424,48 @@ class TimelinePanel extends React.Component<IProps, IState> {
         });
     }
 
+    private async extendOverlayWindowToCoverMainWindow(): Promise<void> {
+        const mainWindow = this.timelineWindow!;
+        const overlayWindow = this.overlayTimelineWindow!;
+        const mainEvents = mainWindow.getEvents();
+
+        if (mainEvents.length > 0) {
+            let paginationRequests: Promise<unknown>[];
+
+            // Keep paginating until the main window is covered
+            do {
+                paginationRequests = [];
+                const overlayEvents = overlayWindow.getEvents();
+
+                if (
+                    overlayWindow.canPaginate(EventTimeline.BACKWARDS) &&
+                    (overlayEvents.length === 0 ||
+                        overlaysAfter(overlayEvents[0], mainEvents[0]) ||
+                        !mainWindow.canPaginate(EventTimeline.BACKWARDS))
+                ) {
+                    // Paginating backwards could reveal more events to be overlaid in the main window
+                    paginationRequests.push(
+                        this.onPaginationRequest(overlayWindow, EventTimeline.BACKWARDS, PAGINATE_SIZE),
+                    );
+                }
+
+                if (
+                    overlayWindow.canPaginate(EventTimeline.FORWARDS) &&
+                    (overlayEvents.length === 0 ||
+                        overlaysBefore(overlayEvents.at(-1)!, mainEvents.at(-1)!) ||
+                        !mainWindow.canPaginate(EventTimeline.FORWARDS))
+                ) {
+                    // Paginating forwards could reveal more events to be overlaid in the main window
+                    paginationRequests.push(
+                        this.onPaginationRequest(overlayWindow, EventTimeline.FORWARDS, PAGINATE_SIZE),
+                    );
+                }
+
+                await Promise.all(paginationRequests);
+            } while (paginationRequests.length > 0);
+        }
+    }
+
     /**
      * (re)-load the event timeline, and initialise the scroll state, centered
      * around the given event.
@@ -1417,8 +1503,14 @@ class TimelinePanel extends React.Component<IProps, IState> {
 
             this.setState(
                 {
-                    canBackPaginate: !!this.timelineWindow?.canPaginate(EventTimeline.BACKWARDS),
-                    canForwardPaginate: !!this.timelineWindow?.canPaginate(EventTimeline.FORWARDS),
+                    canBackPaginate:
+                        (this.timelineWindow?.canPaginate(EventTimeline.BACKWARDS) ||
+                            this.overlayTimelineWindow?.canPaginate(EventTimeline.BACKWARDS)) ??
+                        false,
+                    canForwardPaginate:
+                        (this.timelineWindow?.canPaginate(EventTimeline.FORWARDS) ||
+                            this.overlayTimelineWindow?.canPaginate(EventTimeline.FORWARDS)) ??
+                        false,
                     timelineLoading: false,
                 },
                 () => {
@@ -1494,11 +1586,10 @@ class TimelinePanel extends React.Component<IProps, IState> {
         // This is a hot-path optimization by skipping a promise tick
         // by repeating a no-op sync branch in
         // TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline
-        if (this.props.timelineSet.getTimelineForEvent(eventId)) {
+        if (this.props.timelineSet.getTimelineForEvent(eventId) && !this.overlayTimelineWindow) {
             // if we've got an eventId, and the timeline exists, we can skip
             // the promise tick.
             this.timelineWindow.load(eventId, INITIAL_SIZE);
-            this.overlayTimelineWindow?.load(undefined, INITIAL_SIZE);
             // in this branch this method will happen in sync time
             onLoaded();
             return;
@@ -1506,9 +1597,10 @@ class TimelinePanel extends React.Component<IProps, IState> {
 
         const prom = this.timelineWindow.load(eventId, INITIAL_SIZE).then(async (): Promise<void> => {
             if (this.overlayTimelineWindow) {
-                // @TODO(kerrya) use timestampToEvent to load the overlay timeline
+                // TODO: use timestampToEvent to load the overlay timeline
                 // with more correct position when main TL eventId is truthy
                 await this.overlayTimelineWindow.load(undefined, INITIAL_SIZE);
+                await this.extendOverlayWindowToCoverMainWindow();
             }
         });
         this.buildLegacyCallEventGroupers();
@@ -1541,23 +1633,33 @@ class TimelinePanel extends React.Component<IProps, IState> {
         this.reloadEvents();
     }
 
-    // get the list of events from the timeline window and the pending event list
+    // get the list of events from the timeline windows and the pending event list
     private getEvents(): Pick<IState, "events" | "liveEvents" | "firstVisibleEventIndex"> {
-        const mainEvents: MatrixEvent[] = this.timelineWindow?.getEvents() || [];
-        const eventFilter = this.props.overlayTimelineSetFilter || Boolean;
-        const overlayEvents = this.overlayTimelineWindow?.getEvents().filter(eventFilter) || [];
+        const mainEvents = this.timelineWindow!.getEvents();
+        let overlayEvents = this.overlayTimelineWindow?.getEvents() ?? [];
+        if (this.props.overlayTimelineSetFilter !== undefined) {
+            overlayEvents = overlayEvents.filter(this.props.overlayTimelineSetFilter);
+        }
 
         // maintain the main timeline event order as returned from the HS
         // merge overlay events at approximately the right position based on local timestamp
         const events = overlayEvents.reduce(
             (acc: MatrixEvent[], overlayEvent: MatrixEvent) => {
                 // find the first main tl event with a later timestamp
-                const index = acc.findIndex((event) => event.localTimestamp > overlayEvent.localTimestamp);
+                const index = acc.findIndex((event) => overlaysBefore(overlayEvent, event));
                 // insert overlay event into timeline at approximately the right place
-                if (index > -1) {
-                    acc.splice(index, 0, overlayEvent);
+                // if it's beyond the edge of the main window, hide it so that expanding
+                // the main window doesn't cause new events to pop in and change its position
+                if (index === -1) {
+                    if (!this.timelineWindow!.canPaginate(EventTimeline.FORWARDS)) {
+                        acc.push(overlayEvent);
+                    }
+                } else if (index === 0) {
+                    if (!this.timelineWindow!.canPaginate(EventTimeline.BACKWARDS)) {
+                        acc.unshift(overlayEvent);
+                    }
                 } else {
-                    acc.push(overlayEvent);
+                    acc.splice(index, 0, overlayEvent);
                 }
                 return acc;
             },
@@ -1574,14 +1676,14 @@ class TimelinePanel extends React.Component<IProps, IState> {
                 client.decryptEventIfNeeded(event);
             });
 
-        const firstVisibleEventIndex = this.checkForPreJoinUISI(mainEvents);
+        const firstVisibleEventIndex = this.checkForPreJoinUISI(events);
 
         // Hold onto the live events separately. The read receipt and read marker
         // should use this list, so that they don't advance into pending events.
         const liveEvents = [...events];
 
         // if we're at the end of the live timeline, append the pending events
-        if (!this.timelineWindow?.canPaginate(EventTimeline.FORWARDS)) {
+        if (!this.timelineWindow!.canPaginate(EventTimeline.FORWARDS)) {
             const pendingEvents = this.props.timelineSet.getPendingEvents();
             events.push(
                 ...pendingEvents.filter((event) => {
diff --git a/test/components/structures/TimelinePanel-test.tsx b/test/components/structures/TimelinePanel-test.tsx
index 1b8a0c4a9a..01986c1d71 100644
--- a/test/components/structures/TimelinePanel-test.tsx
+++ b/test/components/structures/TimelinePanel-test.tsx
@@ -37,6 +37,8 @@ import {
     ThreadFilterType,
 } from "matrix-js-sdk/src/models/thread";
 import React, { createRef } from "react";
+import { mocked } from "jest-mock";
+import { forEachRight } from "lodash";
 
 import TimelinePanel from "../../../src/components/structures/TimelinePanel";
 import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
@@ -45,6 +47,10 @@ import { isCallEvent } from "../../../src/components/structures/LegacyCallEventG
 import { flushPromises, mkMembership, mkRoom, stubClient } from "../../test-utils";
 import { mkThread } from "../../test-utils/threads";
 import { createMessageEventContent } from "../../test-utils/events";
+import ScrollPanel from "../../../src/components/structures/ScrollPanel";
+
+// ScrollPanel calls this, but jsdom doesn't mock it for us
+HTMLDivElement.prototype.scrollBy = () => {};
 
 const newReceipt = (eventId: string, userId: string, readTs: number, fullyReadTs: number): MatrixEvent => {
     const receiptContent = {
@@ -57,14 +63,21 @@ const newReceipt = (eventId: string, userId: string, readTs: number, fullyReadTs
     return new MatrixEvent({ content: receiptContent, type: EventType.Receipt });
 };
 
-const getProps = (room: Room, events: MatrixEvent[]): TimelinePanel["props"] => {
-    const timelineSet = { room: room as Room } as EventTimelineSet;
+const mkTimeline = (room: Room, events: MatrixEvent[]): [EventTimeline, EventTimelineSet] => {
+    const timelineSet = {
+        room: room as Room,
+        getLiveTimeline: () => timeline,
+        getTimelineForEvent: () => timeline,
+        getPendingEvents: () => [],
+    } as unknown as EventTimelineSet;
     const timeline = new EventTimeline(timelineSet);
-    events.forEach((event) => timeline.addEvent(event, { toStartOfTimeline: true }));
-    timelineSet.getLiveTimeline = () => timeline;
-    timelineSet.getTimelineForEvent = () => timeline;
-    timelineSet.getPendingEvents = () => events;
-    timelineSet.room!.getEventReadUpTo = () => events[1].getId() ?? null;
+    events.forEach((event) => timeline.addEvent(event, { toStartOfTimeline: false }));
+
+    return [timeline, timelineSet];
+};
+
+const getProps = (room: Room, events: MatrixEvent[]): TimelinePanel["props"] => {
+    const [, timelineSet] = mkTimeline(room, events);
 
     return {
         timelineSet,
@@ -97,6 +110,63 @@ const setupTestData = (): [MatrixClient, Room, MatrixEvent[]] => {
     return [client, room, events];
 };
 
+const setupOverlayTestData = (client: MatrixClient, mainEvents: MatrixEvent[]): [Room, MatrixEvent[]] => {
+    const virtualRoom = mkRoom(client, "virtualRoomId");
+    const overlayEvents = mockEvents(virtualRoom, 5);
+
+    // Set the event order that we'll be looking for in the timeline
+    overlayEvents[0].localTimestamp = 1000;
+    mainEvents[0].localTimestamp = 2000;
+    overlayEvents[1].localTimestamp = 3000;
+    overlayEvents[2].localTimestamp = 4000;
+    overlayEvents[3].localTimestamp = 5000;
+    mainEvents[1].localTimestamp = 6000;
+    overlayEvents[4].localTimestamp = 7000;
+
+    return [virtualRoom, overlayEvents];
+};
+
+const expectEvents = (container: HTMLElement, events: MatrixEvent[]): void => {
+    const eventTiles = container.querySelectorAll(".mx_EventTile");
+    const eventTileIds = [...eventTiles].map((tileElement) => tileElement.getAttribute("data-event-id"));
+    expect(eventTileIds).toEqual(events.map((ev) => ev.getId()));
+};
+
+const withScrollPanelMountSpy = async (
+    continuation: (mountSpy: jest.SpyInstance<void, []>) => Promise<void>,
+): Promise<void> => {
+    const mountSpy = jest.spyOn(ScrollPanel.prototype, "componentDidMount");
+    try {
+        await continuation(mountSpy);
+    } finally {
+        mountSpy.mockRestore();
+    }
+};
+
+const setupPagination = (
+    client: MatrixClient,
+    timeline: EventTimeline,
+    previousPage: MatrixEvent[] | null,
+    nextPage: MatrixEvent[] | null,
+): void => {
+    timeline.setPaginationToken(previousPage === null ? null : "start", EventTimeline.BACKWARDS);
+    timeline.setPaginationToken(nextPage === null ? null : "end", EventTimeline.FORWARDS);
+    mocked(client).paginateEventTimeline.mockImplementation(async (tl, { backwards }) => {
+        if (tl === timeline) {
+            if (backwards) {
+                forEachRight(previousPage ?? [], (event) => tl.addEvent(event, { toStartOfTimeline: true }));
+            } else {
+                (nextPage ?? []).forEach((event) => tl.addEvent(event, { toStartOfTimeline: false }));
+            }
+            // Prevent any further pagination attempts in this direction
+            tl.setPaginationToken(null, backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS);
+            return true;
+        } else {
+            return false;
+        }
+    });
+};
+
 describe("TimelinePanel", () => {
     beforeEach(() => {
         stubClient();
@@ -180,6 +250,46 @@ describe("TimelinePanel", () => {
         expect(props.onEventScrolledIntoView).toHaveBeenCalledWith(events[1].getId());
     });
 
+    it("paginates", async () => {
+        const [client, room, events] = setupTestData();
+        const eventsPage1 = events.slice(0, 1);
+        const eventsPage2 = events.slice(1, 2);
+
+        // Start with only page 2 of the main events in the window
+        const [timeline, timelineSet] = mkTimeline(room, eventsPage2);
+        setupPagination(client, timeline, eventsPage1, null);
+
+        await withScrollPanelMountSpy(async (mountSpy) => {
+            const { container } = render(<TimelinePanel {...getProps(room, events)} timelineSet={timelineSet} />);
+
+            await waitFor(() => expectEvents(container, [events[1]]));
+
+            // ScrollPanel has no chance of working in jsdom, so we've no choice
+            // but to do some shady stuff to trigger the fill callback by hand
+            const scrollPanel = mountSpy.mock.contexts[0] as ScrollPanel;
+            scrollPanel.props.onFillRequest!(true);
+
+            await waitFor(() => expectEvents(container, [events[0], events[1]]));
+        });
+    });
+
+    it("unpaginates", async () => {
+        const [, room, events] = setupTestData();
+
+        await withScrollPanelMountSpy(async (mountSpy) => {
+            const { container } = render(<TimelinePanel {...getProps(room, events)} />);
+
+            await waitFor(() => expectEvents(container, [events[0], events[1]]));
+
+            // ScrollPanel has no chance of working in jsdom, so we've no choice
+            // but to do some shady stuff to trigger the unfill callback by hand
+            const scrollPanel = mountSpy.mock.contexts[0] as ScrollPanel;
+            scrollPanel.props.onUnfillRequest!(true, events[0].getId()!);
+
+            await waitFor(() => expectEvents(container, [events[1]]));
+        });
+    });
+
     describe("onRoomTimeline", () => {
         it("ignores events for other timelines", () => {
             const [client, room, events] = setupTestData();
@@ -268,6 +378,8 @@ describe("TimelinePanel", () => {
 
             render(<TimelinePanel {...props} />);
 
+            await flushPromises();
+
             const event = new MatrixEvent({ type: RoomEvent.Timeline });
             const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: true };
             client.emit(RoomEvent.Timeline, event, room, false, false, data);
@@ -279,8 +391,7 @@ describe("TimelinePanel", () => {
     });
 
     describe("with overlayTimeline", () => {
-        // Trying to understand why this is not passing anymore
-        it.skip("renders merged timeline", () => {
+        it("renders merged timeline", async () => {
             const [client, room, events] = setupTestData();
             const virtualRoom = mkRoom(client, "virtualRoomId");
             const virtualCallInvite = new MatrixEvent({
@@ -296,24 +407,242 @@ describe("TimelinePanel", () => {
             const virtualEvents = [virtualCallInvite, ...mockEvents(virtualRoom), virtualCallMetaEvent];
             const { timelineSet: overlayTimelineSet } = getProps(virtualRoom, virtualEvents);
 
-            const props = {
-                ...getProps(room, events),
-                overlayTimelineSet,
-                overlayTimelineSetFilter: isCallEvent,
-            };
+            const { container } = render(
+                <TimelinePanel
+                    {...getProps(room, events)}
+                    overlayTimelineSet={overlayTimelineSet}
+                    overlayTimelineSetFilter={isCallEvent}
+                />,
+            );
 
-            const { container } = render(<TimelinePanel {...props} />);
+            await waitFor(() =>
+                expectEvents(container, [
+                    // main timeline events are included
+                    events[0],
+                    events[1],
+                    // virtual timeline call event is included
+                    virtualCallInvite,
+                    // virtual call event has no tile renderer => not rendered
+                ]),
+            );
+        });
 
-            const eventTiles = container.querySelectorAll(".mx_EventTile");
-            const eventTileIds = [...eventTiles].map((tileElement) => tileElement.getAttribute("data-event-id"));
-            expect(eventTileIds).toEqual([
-                // main timeline events are included
-                events[1].getId(),
-                events[0].getId(),
-                // virtual timeline call event is included
-                virtualCallInvite.getId(),
-                // virtual call event has no tile renderer => not rendered
-            ]);
+        it.each([
+            ["when it starts with no overlay events", true],
+            ["to get enough overlay events", false],
+        ])("expands the initial window %s", async (_s, startWithEmptyOverlayWindow) => {
+            const [client, room, events] = setupTestData();
+            const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events);
+
+            let overlayEventsPage1: MatrixEvent[];
+            let overlayEventsPage2: MatrixEvent[];
+            let overlayEventsPage3: MatrixEvent[];
+            if (startWithEmptyOverlayWindow) {
+                overlayEventsPage1 = overlayEvents.slice(0, 3);
+                overlayEventsPage2 = [];
+                overlayEventsPage3 = overlayEvents.slice(3, 5);
+            } else {
+                overlayEventsPage1 = overlayEvents.slice(0, 2);
+                overlayEventsPage2 = overlayEvents.slice(2, 3);
+                overlayEventsPage3 = overlayEvents.slice(3, 5);
+            }
+
+            // Start with only page 2 of the overlay events in the window
+            const [overlayTimeline, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEventsPage2);
+            setupPagination(client, overlayTimeline, overlayEventsPage1, overlayEventsPage3);
+
+            const { container } = render(
+                <TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />,
+            );
+
+            await waitFor(() =>
+                expectEvents(container, [
+                    overlayEvents[0],
+                    events[0],
+                    overlayEvents[1],
+                    overlayEvents[2],
+                    overlayEvents[3],
+                    events[1],
+                    overlayEvents[4],
+                ]),
+            );
+        });
+
+        it("extends overlay window beyond main window at the start of the timeline", async () => {
+            const [client, room, events] = setupTestData();
+            const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events);
+            // Delete event 0 so the TimelinePanel will still leave some stuff
+            // unloaded for us to test with
+            events.shift();
+
+            const overlayEventsPage1 = overlayEvents.slice(0, 2);
+            const overlayEventsPage2 = overlayEvents.slice(2, 5);
+
+            // Start with only page 2 of the overlay events in the window
+            const [overlayTimeline, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEventsPage2);
+            setupPagination(client, overlayTimeline, overlayEventsPage1, null);
+
+            const { container } = render(
+                <TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />,
+            );
+
+            await waitFor(() =>
+                expectEvents(container, [
+                    // These first two are the newly loaded events
+                    overlayEvents[0],
+                    overlayEvents[1],
+                    overlayEvents[2],
+                    overlayEvents[3],
+                    events[0],
+                    overlayEvents[4],
+                ]),
+            );
+        });
+
+        it("extends overlay window beyond main window at the end of the timeline", async () => {
+            const [client, room, events] = setupTestData();
+            const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events);
+            // Delete event 1 so the TimelinePanel will still leave some stuff
+            // unloaded for us to test with
+            events.pop();
+
+            const overlayEventsPage1 = overlayEvents.slice(0, 2);
+            const overlayEventsPage2 = overlayEvents.slice(2, 5);
+
+            // Start with only page 1 of the overlay events in the window
+            const [overlayTimeline, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEventsPage1);
+            setupPagination(client, overlayTimeline, null, overlayEventsPage2);
+
+            const { container } = render(
+                <TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />,
+            );
+
+            await waitFor(() =>
+                expectEvents(container, [
+                    overlayEvents[0],
+                    events[0],
+                    overlayEvents[1],
+                    // These are the newly loaded events
+                    overlayEvents[2],
+                    overlayEvents[3],
+                    overlayEvents[4],
+                ]),
+            );
+        });
+
+        it("paginates", async () => {
+            const [client, room, events] = setupTestData();
+            const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events);
+
+            const eventsPage1 = events.slice(0, 1);
+            const eventsPage2 = events.slice(1, 2);
+
+            // Start with only page 1 of the main events in the window
+            const [timeline, timelineSet] = mkTimeline(room, eventsPage1);
+            const [, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEvents);
+            setupPagination(client, timeline, null, eventsPage2);
+
+            await withScrollPanelMountSpy(async (mountSpy) => {
+                const { container } = render(
+                    <TimelinePanel
+                        {...getProps(room, events)}
+                        timelineSet={timelineSet}
+                        overlayTimelineSet={overlayTimelineSet}
+                    />,
+                );
+
+                await waitFor(() => expectEvents(container, [overlayEvents[0], events[0]]));
+
+                // ScrollPanel has no chance of working in jsdom, so we've no choice
+                // but to do some shady stuff to trigger the fill callback by hand
+                const scrollPanel = mountSpy.mock.contexts[0] as ScrollPanel;
+                scrollPanel.props.onFillRequest!(false);
+
+                await waitFor(() =>
+                    expectEvents(container, [
+                        overlayEvents[0],
+                        events[0],
+                        overlayEvents[1],
+                        overlayEvents[2],
+                        overlayEvents[3],
+                        events[1],
+                        overlayEvents[4],
+                    ]),
+                );
+            });
+        });
+
+        it.each([
+            ["down", "main", true, false],
+            ["down", "overlay", true, true],
+            ["up", "main", false, false],
+            ["up", "overlay", false, true],
+        ])("unpaginates %s to an event from the %s timeline", async (_s1, _s2, backwards, fromOverlay) => {
+            const [client, room, events] = setupTestData();
+            const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events);
+
+            let marker: MatrixEvent;
+            let expectedEvents: MatrixEvent[];
+            if (backwards) {
+                if (fromOverlay) {
+                    marker = overlayEvents[1];
+                    // Overlay events 0−1 and event 0 should be unpaginated
+                    // Overlay events 2−3 should be hidden since they're at the edge of the window
+                    expectedEvents = [events[1], overlayEvents[4]];
+                } else {
+                    marker = events[0];
+                    // Overlay event 0 and event 0 should be unpaginated
+                    // Overlay events 1−3 should be hidden since they're at the edge of the window
+                    expectedEvents = [events[1], overlayEvents[4]];
+                }
+            } else {
+                if (fromOverlay) {
+                    marker = overlayEvents[4];
+                    // Only the last overlay event should be unpaginated
+                    expectedEvents = [
+                        overlayEvents[0],
+                        events[0],
+                        overlayEvents[1],
+                        overlayEvents[2],
+                        overlayEvents[3],
+                        events[1],
+                    ];
+                } else {
+                    // Get rid of overlay event 4 so we can test the case where no overlay events get unpaginated
+                    overlayEvents.pop();
+                    marker = events[1];
+                    // Only event 1 should be unpaginated
+                    // Overlay events 1−2 should be hidden since they're at the edge of the window
+                    expectedEvents = [overlayEvents[0], events[0]];
+                }
+            }
+
+            const [, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEvents);
+
+            await withScrollPanelMountSpy(async (mountSpy) => {
+                const { container } = render(
+                    <TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />,
+                );
+
+                await waitFor(() =>
+                    expectEvents(container, [
+                        overlayEvents[0],
+                        events[0],
+                        overlayEvents[1],
+                        overlayEvents[2],
+                        overlayEvents[3],
+                        events[1],
+                        ...(!backwards && !fromOverlay ? [] : [overlayEvents[4]]),
+                    ]),
+                );
+
+                // ScrollPanel has no chance of working in jsdom, so we've no choice
+                // but to do some shady stuff to trigger the unfill callback by hand
+                const scrollPanel = mountSpy.mock.contexts[0] as ScrollPanel;
+                scrollPanel.props.onUnfillRequest!(backwards, marker.getId()!);
+
+                await waitFor(() => expectEvents(container, expectedEvents));
+            });
         });
     });
 
diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts
index e414419359..b13a327853 100644
--- a/test/test-utils/test-utils.ts
+++ b/test/test-utils/test-utils.ts
@@ -537,7 +537,7 @@ export function mkStubRoom(
             on: jest.fn(),
             off: jest.fn(),
         } as unknown as RoomState,
-        eventShouldLiveIn: jest.fn().mockReturnValue({}),
+        eventShouldLiveIn: jest.fn().mockReturnValue({ shouldLiveInRoom: true, shouldLiveInThread: false }),
         fetchRoomThreads: jest.fn().mockReturnValue(Promise.resolve()),
         findEventById: jest.fn().mockReturnValue(undefined),
         findPredecessor: jest.fn().mockReturnValue({ roomId: "", eventId: null }),
diff --git a/yarn.lock b/yarn.lock
index b2b35faeab..7cfb5bf4c9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2187,7 +2187,7 @@
   dependencies:
     "@types/istanbul-lib-report" "*"
 
-"@types/jest@*", "@types/jest@29.2.5":
+"@types/jest@*":
   version "29.2.5"
   resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.2.5.tgz#c27f41a9d6253f288d1910d3c5f09484a56b73c0"
   integrity sha512-H2cSxkKgVmqNHXP7TC2L/WUorrZu8ZigyRywfVzv6EyBlxj39n4C00hjXYQWsbwqgElaj/CiAeSRmk5GoaKTgw==
@@ -2195,6 +2195,14 @@
     expect "^29.0.0"
     pretty-format "^29.0.0"
 
+"@types/jest@29.2.6":
+  version "29.2.6"
+  resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.2.6.tgz#1d43c8e533463d0437edef30b2d45d5aa3d95b0a"
+  integrity sha512-XEUC/Tgw3uMh6Ho8GkUtQ2lPhY5Fmgyp3TdlkTJs1W9VgNxs+Ow/x3Elh8lHQKqCbZL0AubQuqWjHVT033Hhrw==
+  dependencies:
+    expect "^29.0.0"
+    pretty-format "^29.0.0"
+
 "@types/jsdom@^20.0.0":
   version "20.0.1"
   resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-20.0.1.tgz#07c14bc19bd2f918c1929541cdaacae894744808"