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
This commit is contained in:
parent
a597da26a0
commit
87e2274ae7
5 changed files with 517 additions and 78 deletions
|
@ -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",
|
||||
|
|
|
@ -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,15 +511,50 @@ 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);
|
||||
}
|
||||
|
||||
if (overlayCount > 0) {
|
||||
debuglog("Unpaginating", count, "from overlay timeline in direction", dir);
|
||||
this.overlayTimelineWindow!.unpaginate(overlayCount, backwards);
|
||||
}
|
||||
|
||||
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
|
||||
this.buildLegacyCallEventGroupers(events);
|
||||
|
@ -530,7 +570,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
} else {
|
||||
this.setState({ canForwardPaginate: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onPaginationRequest = (
|
||||
|
@ -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,24 +1633,34 @@ 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);
|
||||
} else {
|
||||
// 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.splice(index, 0, overlayEvent);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[...mainEvents],
|
||||
|
@ -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) => {
|
||||
|
|
|
@ -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} />);
|
||||
|
||||
const eventTiles = container.querySelectorAll(".mx_EventTile");
|
||||
const eventTileIds = [...eventTiles].map((tileElement) => tileElement.getAttribute("data-event-id"));
|
||||
expect(eventTileIds).toEqual([
|
||||
await waitFor(() =>
|
||||
expectEvents(container, [
|
||||
// main timeline events are included
|
||||
events[1].getId(),
|
||||
events[0].getId(),
|
||||
events[0],
|
||||
events[1],
|
||||
// virtual timeline call event is included
|
||||
virtualCallInvite.getId(),
|
||||
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(
|
||||
<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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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 }),
|
||||
|
|
10
yarn.lock
10
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"
|
||||
|
|
Loading…
Reference in a new issue