From 1617493fa1fd958f5149c71123d9053b35355289 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 5 May 2023 18:53:18 -0400 Subject: [PATCH 1/2] Update @types/jest to 29.2.6 This adds the correct types for the contexts field on mock objects, which I'll need shortly --- package.json | 2 +- yarn.lock | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index bf7a093494f..7370d4c098f 100644 --- a/package.json +++ b/package.json @@ -157,7 +157,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/yarn.lock b/yarn.lock index 9faad3cf29d..f93acf9ea70 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" From 5c1d78c61ed17ebbc2d74898660b155b5339932a Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 5 May 2023 18:54:26 -0400 Subject: [PATCH 2/2] Enable pagination for overlay timelines --- src/components/structures/TimelinePanel.tsx | 202 +++++++--- .../structures/TimelinePanel-test.tsx | 381 ++++++++++++++++-- test/test-utils/test-utils.ts | 2 +- 3 files changed, 508 insertions(+), 77 deletions(-) diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 6172d6228a2..e174a71a315 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 { // 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() ?? []; + + 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; + } - const count = backwards ? marker + 1 : this.state.events.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 { debuglog("Initiating paginate; backwards:" + backwards); this.setState({ [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 { }); }; + 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, 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 { 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 { } // 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 { } // 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 { 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 { 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 { 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 { // 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 { }); } + private async extendOverlayWindowToCoverMainWindow(): Promise { + const mainWindow = this.timelineWindow!; + const overlayWindow = this.overlayTimelineWindow!; + const mainEvents = mainWindow.getEvents(); + + if (mainEvents.length > 0) { + let paginationRequests: Promise[]; + + // 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 { 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 { // 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 { const prom = this.timelineWindow.load(eventId, INITIAL_SIZE).then(async (): Promise => { 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 { 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 { - 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 { 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 1b8a0c4a9a7..01986c1d713 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) => Promise, +): Promise => { + 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(); + + 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(); + + 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(); + 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( + , + ); + + 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 + ]), + ); + }); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); - const { container } = render(); - - 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 - ]); + 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( + , + ); + + 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( + , + ); + + 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 e414419359c..b13a327853d 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 }),