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:
Robin 2023-05-12 12:27:41 -04:00 committed by GitHub
parent a597da26a0
commit 87e2274ae7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 517 additions and 78 deletions

View file

@ -156,7 +156,7 @@
"@types/fs-extra": "^11.0.0", "@types/fs-extra": "^11.0.0",
"@types/geojson": "^7946.0.8", "@types/geojson": "^7946.0.8",
"@types/glob-to-regexp": "^0.4.1", "@types/glob-to-regexp": "^0.4.1",
"@types/jest": "29.2.5", "@types/jest": "29.2.6",
"@types/katex": "^0.16.0", "@types/katex": "^0.16.0",
"@types/lodash": "^4.14.168", "@types/lodash": "^4.14.168",
"@types/modernizr": "^3.5.3", "@types/modernizr": "^3.5.3",

View file

@ -24,7 +24,7 @@ import { TimelineWindow } from "matrix-js-sdk/src/timeline-window";
import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; import { EventType, RelationType } from "matrix-js-sdk/src/@types/event";
import { SyncState } from "matrix-js-sdk/src/sync"; import { SyncState } from "matrix-js-sdk/src/sync";
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/models/room-member"; 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 { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent } from "matrix-js-sdk/src/client"; import { ClientEvent } from "matrix-js-sdk/src/client";
import { Thread, ThreadEvent } from "matrix-js-sdk/src/models/thread"; 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 { interface IProps {
// The js-sdk EventTimelineSet object for the timeline sequence we are // 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 // 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 // added to support virtual rooms
// events from the overlay timeline set will be added by localTimestamp // events from the overlay timeline set will be added by localTimestamp
// into the main timeline // into the main timeline
// back paging not yet supported
overlayTimelineSet?: EventTimelineSet; overlayTimelineSet?: EventTimelineSet;
// filter events from overlay timeline // filter events from overlay timeline
overlayTimelineSetFilter?: (event: MatrixEvent) => boolean; 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. // this particular event should be the first or last to be unpaginated.
const eventId = scrollToken; const eventId = scrollToken;
const marker = this.state.events.findIndex((ev) => { // The event in question could belong to either the main timeline or
return ev.getId() === eventId; // 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) { if (count > 0) {
debuglog("Unpaginating", count, "in direction", dir); debuglog("Unpaginating", count, "in direction", dir);
this.timelineWindow?.unpaginate(count, backwards); this.timelineWindow!.unpaginate(count, backwards);
}
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents(); if (overlayCount > 0) {
this.buildLegacyCallEventGroupers(events); debuglog("Unpaginating", count, "from overlay timeline in direction", dir);
this.setState({ this.overlayTimelineWindow!.unpaginate(overlayCount, backwards);
events, }
liveEvents,
firstVisibleEventIndex,
});
// We can now paginate in the unpaginated direction const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
if (backwards) { this.buildLegacyCallEventGroupers(events);
this.setState({ canBackPaginate: true }); this.setState({
} else { events,
this.setState({ canForwardPaginate: true }); 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); debuglog("Initiating paginate; backwards:" + backwards);
this.setState<null>({ [paginatingKey]: true }); 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) { if (this.unmounted) {
return false; return false;
} }
if (this.overlayTimelineWindow) {
await this.extendOverlayWindowToCoverMainWindow();
}
debuglog("paginate complete backwards:" + backwards + "; success:" + r); debuglog("paginate complete backwards:" + backwards + "; success:" + r);
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents(); 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 => { 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()) { if (this.canResetTimeline()) {
this.loadTimeline(); this.loadTimeline();
@ -783,7 +833,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (this.unmounted) return; if (this.unmounted) return;
// ignore events for other rooms // 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, // we could skip an update if the event isn't in our timeline,
// but that's probably an early optimisation. // but that's probably an early optimisation.
@ -796,10 +846,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
} }
// ignore events for other rooms // ignore events for other rooms
const roomId = thread.roomId; if (!this.hasTimelineSetFor(thread.roomId)) return;
if (roomId !== this.props.timelineSet.room?.roomId) {
return;
}
// we could skip an update if the event isn't in our timeline, // we could skip an update if the event isn't in our timeline,
// but that's probably an early optimisation. // but that's probably an early optimisation.
@ -817,10 +864,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
} }
// ignore events for other rooms // ignore events for other rooms
const roomId = ev.getRoomId(); if (!this.hasTimelineSetFor(ev.getRoomId())) return;
if (roomId !== this.props.timelineSet.room?.roomId) {
return;
}
// we could skip an update if the event isn't in our timeline, // we could skip an update if the event isn't in our timeline,
// but that's probably an early optimisation. // but that's probably an early optimisation.
@ -834,7 +878,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (this.unmounted) return; if (this.unmounted) return;
// ignore events for other rooms // 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 // ignore events for other users
if (member.userId != MatrixClientPeg.get().credentials?.userId) return; if (member.userId != MatrixClientPeg.get().credentials?.userId) return;
@ -857,7 +901,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (this.unmounted) return; if (this.unmounted) return;
// ignore events for other rooms // 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, // we could skip an update if the event isn't in our timeline,
// but that's probably an early optimisation. // but that's probably an early optimisation.
@ -877,7 +921,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (this.unmounted) return; if (this.unmounted) return;
// ignore events for other rooms // ignore events for other rooms
if (room !== this.props.timelineSet.room) return; if (!this.hasTimelineSetFor(room.roomId)) return;
this.reloadEvents(); this.reloadEvents();
}; };
@ -905,7 +949,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
// Can be null for the notification timeline, etc. // Can be null for the notification timeline, etc.
if (!this.props.timelineSet.room) return; 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; 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 * (re)-load the event timeline, and initialise the scroll state, centered
* around the given event. * around the given event.
@ -1417,8 +1503,14 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.setState( this.setState(
{ {
canBackPaginate: !!this.timelineWindow?.canPaginate(EventTimeline.BACKWARDS), canBackPaginate:
canForwardPaginate: !!this.timelineWindow?.canPaginate(EventTimeline.FORWARDS), (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, timelineLoading: false,
}, },
() => { () => {
@ -1494,11 +1586,10 @@ class TimelinePanel extends React.Component<IProps, IState> {
// This is a hot-path optimization by skipping a promise tick // This is a hot-path optimization by skipping a promise tick
// by repeating a no-op sync branch in // by repeating a no-op sync branch in
// TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline // 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 // if we've got an eventId, and the timeline exists, we can skip
// the promise tick. // the promise tick.
this.timelineWindow.load(eventId, INITIAL_SIZE); this.timelineWindow.load(eventId, INITIAL_SIZE);
this.overlayTimelineWindow?.load(undefined, INITIAL_SIZE);
// in this branch this method will happen in sync time // in this branch this method will happen in sync time
onLoaded(); onLoaded();
return; return;
@ -1506,9 +1597,10 @@ class TimelinePanel extends React.Component<IProps, IState> {
const prom = this.timelineWindow.load(eventId, INITIAL_SIZE).then(async (): Promise<void> => { const prom = this.timelineWindow.load(eventId, INITIAL_SIZE).then(async (): Promise<void> => {
if (this.overlayTimelineWindow) { 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 // with more correct position when main TL eventId is truthy
await this.overlayTimelineWindow.load(undefined, INITIAL_SIZE); await this.overlayTimelineWindow.load(undefined, INITIAL_SIZE);
await this.extendOverlayWindowToCoverMainWindow();
} }
}); });
this.buildLegacyCallEventGroupers(); this.buildLegacyCallEventGroupers();
@ -1541,23 +1633,33 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.reloadEvents(); 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"> { private getEvents(): Pick<IState, "events" | "liveEvents" | "firstVisibleEventIndex"> {
const mainEvents: MatrixEvent[] = this.timelineWindow?.getEvents() || []; const mainEvents = this.timelineWindow!.getEvents();
const eventFilter = this.props.overlayTimelineSetFilter || Boolean; let overlayEvents = this.overlayTimelineWindow?.getEvents() ?? [];
const overlayEvents = this.overlayTimelineWindow?.getEvents().filter(eventFilter) || []; if (this.props.overlayTimelineSetFilter !== undefined) {
overlayEvents = overlayEvents.filter(this.props.overlayTimelineSetFilter);
}
// maintain the main timeline event order as returned from the HS // maintain the main timeline event order as returned from the HS
// merge overlay events at approximately the right position based on local timestamp // merge overlay events at approximately the right position based on local timestamp
const events = overlayEvents.reduce( const events = overlayEvents.reduce(
(acc: MatrixEvent[], overlayEvent: MatrixEvent) => { (acc: MatrixEvent[], overlayEvent: MatrixEvent) => {
// find the first main tl event with a later timestamp // 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 // insert overlay event into timeline at approximately the right place
if (index > -1) { // if it's beyond the edge of the main window, hide it so that expanding
acc.splice(index, 0, overlayEvent); // 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 { } else {
acc.push(overlayEvent); acc.splice(index, 0, overlayEvent);
} }
return acc; return acc;
}, },
@ -1574,14 +1676,14 @@ class TimelinePanel extends React.Component<IProps, IState> {
client.decryptEventIfNeeded(event); 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 // 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. // should use this list, so that they don't advance into pending events.
const liveEvents = [...events]; const liveEvents = [...events];
// if we're at the end of the live timeline, append the pending 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(); const pendingEvents = this.props.timelineSet.getPendingEvents();
events.push( events.push(
...pendingEvents.filter((event) => { ...pendingEvents.filter((event) => {

View file

@ -37,6 +37,8 @@ import {
ThreadFilterType, ThreadFilterType,
} from "matrix-js-sdk/src/models/thread"; } from "matrix-js-sdk/src/models/thread";
import React, { createRef } from "react"; import React, { createRef } from "react";
import { mocked } from "jest-mock";
import { forEachRight } from "lodash";
import TimelinePanel from "../../../src/components/structures/TimelinePanel"; import TimelinePanel from "../../../src/components/structures/TimelinePanel";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; 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 { flushPromises, mkMembership, mkRoom, stubClient } from "../../test-utils";
import { mkThread } from "../../test-utils/threads"; import { mkThread } from "../../test-utils/threads";
import { createMessageEventContent } from "../../test-utils/events"; 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 newReceipt = (eventId: string, userId: string, readTs: number, fullyReadTs: number): MatrixEvent => {
const receiptContent = { const receiptContent = {
@ -57,14 +63,21 @@ const newReceipt = (eventId: string, userId: string, readTs: number, fullyReadTs
return new MatrixEvent({ content: receiptContent, type: EventType.Receipt }); return new MatrixEvent({ content: receiptContent, type: EventType.Receipt });
}; };
const getProps = (room: Room, events: MatrixEvent[]): TimelinePanel["props"] => { const mkTimeline = (room: Room, events: MatrixEvent[]): [EventTimeline, EventTimelineSet] => {
const timelineSet = { room: room as Room } as EventTimelineSet; const timelineSet = {
room: room as Room,
getLiveTimeline: () => timeline,
getTimelineForEvent: () => timeline,
getPendingEvents: () => [],
} as unknown as EventTimelineSet;
const timeline = new EventTimeline(timelineSet); const timeline = new EventTimeline(timelineSet);
events.forEach((event) => timeline.addEvent(event, { toStartOfTimeline: true })); events.forEach((event) => timeline.addEvent(event, { toStartOfTimeline: false }));
timelineSet.getLiveTimeline = () => timeline;
timelineSet.getTimelineForEvent = () => timeline; return [timeline, timelineSet];
timelineSet.getPendingEvents = () => events; };
timelineSet.room!.getEventReadUpTo = () => events[1].getId() ?? null;
const getProps = (room: Room, events: MatrixEvent[]): TimelinePanel["props"] => {
const [, timelineSet] = mkTimeline(room, events);
return { return {
timelineSet, timelineSet,
@ -97,6 +110,63 @@ const setupTestData = (): [MatrixClient, Room, MatrixEvent[]] => {
return [client, room, events]; 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", () => { describe("TimelinePanel", () => {
beforeEach(() => { beforeEach(() => {
stubClient(); stubClient();
@ -180,6 +250,46 @@ describe("TimelinePanel", () => {
expect(props.onEventScrolledIntoView).toHaveBeenCalledWith(events[1].getId()); 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", () => { describe("onRoomTimeline", () => {
it("ignores events for other timelines", () => { it("ignores events for other timelines", () => {
const [client, room, events] = setupTestData(); const [client, room, events] = setupTestData();
@ -268,6 +378,8 @@ describe("TimelinePanel", () => {
render(<TimelinePanel {...props} />); render(<TimelinePanel {...props} />);
await flushPromises();
const event = new MatrixEvent({ type: RoomEvent.Timeline }); const event = new MatrixEvent({ type: RoomEvent.Timeline });
const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: true }; const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: true };
client.emit(RoomEvent.Timeline, event, room, false, false, data); client.emit(RoomEvent.Timeline, event, room, false, false, data);
@ -279,8 +391,7 @@ describe("TimelinePanel", () => {
}); });
describe("with overlayTimeline", () => { describe("with overlayTimeline", () => {
// Trying to understand why this is not passing anymore it("renders merged timeline", async () => {
it.skip("renders merged timeline", () => {
const [client, room, events] = setupTestData(); const [client, room, events] = setupTestData();
const virtualRoom = mkRoom(client, "virtualRoomId"); const virtualRoom = mkRoom(client, "virtualRoomId");
const virtualCallInvite = new MatrixEvent({ const virtualCallInvite = new MatrixEvent({
@ -296,24 +407,242 @@ describe("TimelinePanel", () => {
const virtualEvents = [virtualCallInvite, ...mockEvents(virtualRoom), virtualCallMetaEvent]; const virtualEvents = [virtualCallInvite, ...mockEvents(virtualRoom), virtualCallMetaEvent];
const { timelineSet: overlayTimelineSet } = getProps(virtualRoom, virtualEvents); const { timelineSet: overlayTimelineSet } = getProps(virtualRoom, virtualEvents);
const props = { const { container } = render(
...getProps(room, events), <TimelinePanel
overlayTimelineSet, {...getProps(room, events)}
overlayTimelineSetFilter: isCallEvent, 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"); it.each([
const eventTileIds = [...eventTiles].map((tileElement) => tileElement.getAttribute("data-event-id")); ["when it starts with no overlay events", true],
expect(eventTileIds).toEqual([ ["to get enough overlay events", false],
// main timeline events are included ])("expands the initial window %s", async (_s, startWithEmptyOverlayWindow) => {
events[1].getId(), const [client, room, events] = setupTestData();
events[0].getId(), const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events);
// virtual timeline call event is included
virtualCallInvite.getId(), let overlayEventsPage1: MatrixEvent[];
// virtual call event has no tile renderer => not rendered 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 01 and event 0 should be unpaginated
// Overlay events 23 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 13 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 12 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));
});
}); });
}); });

View file

@ -537,7 +537,7 @@ export function mkStubRoom(
on: jest.fn(), on: jest.fn(),
off: jest.fn(), off: jest.fn(),
} as unknown as RoomState, } as unknown as RoomState,
eventShouldLiveIn: jest.fn().mockReturnValue({}), eventShouldLiveIn: jest.fn().mockReturnValue({ shouldLiveInRoom: true, shouldLiveInThread: false }),
fetchRoomThreads: jest.fn().mockReturnValue(Promise.resolve()), fetchRoomThreads: jest.fn().mockReturnValue(Promise.resolve()),
findEventById: jest.fn().mockReturnValue(undefined), findEventById: jest.fn().mockReturnValue(undefined),
findPredecessor: jest.fn().mockReturnValue({ roomId: "", eventId: null }), findPredecessor: jest.fn().mockReturnValue({ roomId: "", eventId: null }),

View file

@ -2187,7 +2187,7 @@
dependencies: dependencies:
"@types/istanbul-lib-report" "*" "@types/istanbul-lib-report" "*"
"@types/jest@*", "@types/jest@29.2.5": "@types/jest@*":
version "29.2.5" version "29.2.5"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.2.5.tgz#c27f41a9d6253f288d1910d3c5f09484a56b73c0" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.2.5.tgz#c27f41a9d6253f288d1910d3c5f09484a56b73c0"
integrity sha512-H2cSxkKgVmqNHXP7TC2L/WUorrZu8ZigyRywfVzv6EyBlxj39n4C00hjXYQWsbwqgElaj/CiAeSRmk5GoaKTgw== integrity sha512-H2cSxkKgVmqNHXP7TC2L/WUorrZu8ZigyRywfVzv6EyBlxj39n4C00hjXYQWsbwqgElaj/CiAeSRmk5GoaKTgw==
@ -2195,6 +2195,14 @@
expect "^29.0.0" expect "^29.0.0"
pretty-format "^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": "@types/jsdom@^20.0.0":
version "20.0.1" version "20.0.1"
resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-20.0.1.tgz#07c14bc19bd2f918c1929541cdaacae894744808" resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-20.0.1.tgz#07c14bc19bd2f918c1929541cdaacae894744808"