/* Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { render, waitFor, screen } from "@testing-library/react"; import { ReceiptType, EventTimelineSet, EventType, MatrixClient, MatrixEvent, PendingEventOrdering, RelationType, Room, RoomEvent, RoomMember, RoomState, TimelineWindow, EventTimeline, FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent, ThreadFilterType, } from "matrix-js-sdk/src/matrix"; import React, { createRef } from "react"; import { Mocked, mocked } from "jest-mock"; import { forEachRight } from "lodash"; import { TooltipProvider } from "@vector-im/compound-web"; import TimelinePanel from "../../../src/components/structures/TimelinePanel"; import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { isCallEvent } from "../../../src/components/structures/LegacyCallEventGrouper"; import { filterConsole, flushPromises, mkMembership, mkRoom, stubClient } from "../../test-utils"; import { mkThread } from "../../test-utils/threads"; import { createMessageEventContent } from "../../test-utils/events"; import SettingsStore from "../../../src/settings/SettingsStore"; 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 = { [eventId]: { [ReceiptType.Read]: { [userId]: { ts: readTs } }, [ReceiptType.ReadPrivate]: { [userId]: { ts: readTs } }, [ReceiptType.FullyRead]: { [userId]: { ts: fullyReadTs } }, }, }; return new MatrixEvent({ content: receiptContent, type: EventType.Receipt }); }; const mkTimeline = (room: Room, events: MatrixEvent[]): [EventTimeline, EventTimelineSet] => { const timelineSet = { room: room as Room, getLiveTimeline: () => timeline, getTimelineForEvent: () => timeline, getPendingEvents: () => [] as MatrixEvent[], } as unknown as EventTimelineSet; const timeline = new EventTimeline(timelineSet); 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, manageReadReceipts: true, sendReadReceiptOnLoad: true, }; }; const mockEvents = (room: Room, count = 2): MatrixEvent[] => { const events: MatrixEvent[] = []; for (let index = 0; index < count; index++) { const event = new MatrixEvent({ room_id: room.roomId, event_id: `${room.roomId}_event_${index}`, type: EventType.RoomMessage, sender: "userId", content: createMessageEventContent("`Event${index}`"), origin_server_ts: index, }); event.localTimestamp = index; events.push(event); } return events; }; const setupTestData = (): [MatrixClient, Room, MatrixEvent[]] => { const client = MatrixClientPeg.safeGet(); const room = mkRoom(client, "roomId"); const events = mockEvents(room); 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", () => { let client: Mocked; let userId: string; filterConsole("checkForPreJoinUISI: showing all messages, skipping check"); beforeEach(() => { client = mocked(stubClient()); userId = client.getSafeUserId(); }); describe("read receipts and markers", () => { const roomId = "#room:example.com"; let room: Room; let timelineSet: EventTimelineSet; let timelinePanel: TimelinePanel; const ev1 = new MatrixEvent({ event_id: "ev1", sender: "@u2:m.org", origin_server_ts: 111, type: EventType.RoomMessage, content: createMessageEventContent("hello 1"), }); const ev2 = new MatrixEvent({ event_id: "ev2", sender: "@u2:m.org", origin_server_ts: 222, type: EventType.RoomMessage, content: createMessageEventContent("hello 2"), }); const renderTimelinePanel = async (): Promise => { const ref = createRef(); render( , { wrapper: TooltipProvider }, ); await flushPromises(); timelinePanel = ref.current!; }; const setUpTimelineSet = (threadRoot?: MatrixEvent) => { let thread: Thread | undefined = undefined; if (threadRoot) { thread = new Thread(threadRoot.getId()!, threadRoot, { client: client, room, }); } timelineSet = new EventTimelineSet(room, {}, client, thread); timelineSet.on(RoomEvent.Timeline, (...args) => { // TimelinePanel listens for live events on the client. // → Re-emit on the client. client.emit(RoomEvent.Timeline, ...args); }); }; beforeEach(() => { room = new Room(roomId, client, userId, { pendingEventOrdering: PendingEventOrdering.Detached }); }); afterEach(() => { TimelinePanel.roomReadMarkerTsMap = {}; }); it("when there is no event, it should not send any receipt", async () => { setUpTimelineSet(); await renderTimelinePanel(); await flushPromises(); // @ts-ignore await timelinePanel.sendReadReceipts(); expect(client.setRoomReadMarkers).not.toHaveBeenCalled(); expect(client.sendReadReceipt).not.toHaveBeenCalled(); }); describe("when there is a non-threaded timeline", () => { beforeEach(() => { setUpTimelineSet(); }); describe("and reading the timeline", () => { beforeEach(async () => { await renderTimelinePanel(); timelineSet.addLiveEvent(ev1, {}); await flushPromises(); // @ts-ignore await timelinePanel.sendReadReceipts(); // @ts-ignore Simulate user activity by calling updateReadMarker on the TimelinePanel. await timelinePanel.updateReadMarker(); }); it("should send a fully read marker and a public receipt", async () => { expect(client.setRoomReadMarkers).toHaveBeenCalledWith(roomId, ev1.getId()); expect(client.sendReadReceipt).toHaveBeenCalledWith(ev1, ReceiptType.Read); }); describe("and reading the timeline again", () => { beforeEach(async () => { client.sendReadReceipt.mockClear(); client.setRoomReadMarkers.mockClear(); // @ts-ignore Simulate user activity by calling updateReadMarker on the TimelinePanel. await timelinePanel.updateReadMarker(); }); it("should not send receipts again", () => { expect(client.sendReadReceipt).not.toHaveBeenCalled(); expect(client.setRoomReadMarkers).not.toHaveBeenCalled(); }); it("and forgetting the read markers, should send the stored marker again", async () => { timelineSet.addLiveEvent(ev2, {}); // Add the event to the room as well as the timeline, so we can find it when we // call findEventById in getEventReadUpTo. This is odd because in our test // setup, timelineSet is not actually the timelineSet of the room. await room.addLiveEvents([ev2], {}); room.addEphemeralEvents([newReceipt(ev2.getId()!, userId, 222, 200)]); await timelinePanel.forgetReadMarker(); expect(client.setRoomReadMarkers).toHaveBeenCalledWith(roomId, ev2.getId()); }); }); }); describe("and sending receipts is disabled", () => { beforeEach(async () => { client.isVersionSupported.mockResolvedValue(true); client.doesServerSupportUnstableFeature.mockResolvedValue(true); jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => { if (setting === "sendReadReceipt") return false; return undefined; }); }); afterEach(() => { mocked(SettingsStore.getValue).mockReset(); }); it("should send a fully read marker and a private receipt", async () => { await renderTimelinePanel(); timelineSet.addLiveEvent(ev1, {}); await flushPromises(); // @ts-ignore await timelinePanel.sendReadReceipts(); // Expect the private reception to be sent directly expect(client.sendReadReceipt).toHaveBeenCalledWith(ev1, ReceiptType.ReadPrivate); // Expect the fully_read marker not to be send yet expect(client.setRoomReadMarkers).not.toHaveBeenCalled(); client.sendReadReceipt.mockClear(); // @ts-ignore simulate user activity await timelinePanel.updateReadMarker(); // It should not send the receipt again. expect(client.sendReadReceipt).not.toHaveBeenCalledWith(ev1, ReceiptType.ReadPrivate); // Expect the fully_read marker to be sent after user activity. expect(client.setRoomReadMarkers).toHaveBeenCalledWith(roomId, ev1.getId()); }); }); }); describe("and there is a thread timeline", () => { const threadEv1 = new MatrixEvent({ event_id: "thread_ev1", sender: "@u2:m.org", origin_server_ts: 222, type: EventType.RoomMessage, content: { ...createMessageEventContent("hello 2"), "m.relates_to": { event_id: ev1.getId(), rel_type: RelationType.Thread, }, }, }); beforeEach(() => { client.supportsThreads.mockReturnValue(true); setUpTimelineSet(ev1); }); it("should send receipts but no fully_read when reading the thread timeline", async () => { await renderTimelinePanel(); timelineSet.addLiveEvent(threadEv1, {}); await flushPromises(); // @ts-ignore await timelinePanel.sendReadReceipts(); // fully_read is not supported for threads per spec expect(client.setRoomReadMarkers).not.toHaveBeenCalled(); expect(client.sendReadReceipt).toHaveBeenCalledWith(threadEv1, ReceiptType.Read); }); }); }); it("should scroll event into view when props.eventId changes", () => { const client = MatrixClientPeg.safeGet(); const room = mkRoom(client, "roomId"); const events = mockEvents(room); const props = { ...getProps(room, events), onEventScrolledIntoView: jest.fn(), }; const { rerender } = render(, { wrapper: TooltipProvider }); expect(props.onEventScrolledIntoView).toHaveBeenCalledWith(undefined); props.eventId = events[1].getId(); rerender(); 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(, { wrapper: TooltipProvider, }); 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(, { wrapper: TooltipProvider }); 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(); const otherTimelineSet = { room: room as Room } as EventTimelineSet; const otherTimeline = new EventTimeline(otherTimelineSet); const props = { ...getProps(room, events), onEventScrolledIntoView: jest.fn(), }; const paginateSpy = jest.spyOn(TimelineWindow.prototype, "paginate").mockClear(); render(, { wrapper: TooltipProvider }); const event = new MatrixEvent({ type: RoomEvent.Timeline, origin_server_ts: 0 }); const data = { timeline: otherTimeline, liveEvent: true }; client.emit(RoomEvent.Timeline, event, room, false, false, data); expect(paginateSpy).not.toHaveBeenCalled(); }); it("ignores timeline updates without a live event", () => { const [client, room, events] = setupTestData(); const props = getProps(room, events); const paginateSpy = jest.spyOn(TimelineWindow.prototype, "paginate").mockClear(); render(, { wrapper: TooltipProvider }); const event = new MatrixEvent({ type: RoomEvent.Timeline, origin_server_ts: 0 }); const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: false }; client.emit(RoomEvent.Timeline, event, room, false, false, data); expect(paginateSpy).not.toHaveBeenCalled(); }); it("ignores timeline where toStartOfTimeline is true", () => { const [client, room, events] = setupTestData(); const props = getProps(room, events); const paginateSpy = jest.spyOn(TimelineWindow.prototype, "paginate").mockClear(); render(, { wrapper: TooltipProvider }); const event = new MatrixEvent({ type: RoomEvent.Timeline, origin_server_ts: 0 }); const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: false }; const toStartOfTimeline = true; client.emit(RoomEvent.Timeline, event, room, toStartOfTimeline, false, data); expect(paginateSpy).not.toHaveBeenCalled(); }); it("advances the timeline window", () => { const [client, room, events] = setupTestData(); const props = getProps(room, events); const paginateSpy = jest.spyOn(TimelineWindow.prototype, "paginate").mockClear(); render(, { wrapper: TooltipProvider }); const event = new MatrixEvent({ type: RoomEvent.Timeline, origin_server_ts: 0 }); const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: true }; client.emit(RoomEvent.Timeline, event, room, false, false, data); expect(paginateSpy).toHaveBeenCalledWith(EventTimeline.FORWARDS, 1, false); }); it("advances the overlay timeline window", async () => { const [client, room, events] = setupTestData(); const virtualRoom = mkRoom(client, "virtualRoomId"); const virtualEvents = mockEvents(virtualRoom); const { timelineSet: overlayTimelineSet } = getProps(virtualRoom, virtualEvents); const props = { ...getProps(room, events), overlayTimelineSet, }; const paginateSpy = jest.spyOn(TimelineWindow.prototype, "paginate").mockClear(); render(, { wrapper: TooltipProvider }); await flushPromises(); const event = new MatrixEvent({ type: RoomEvent.Timeline, origin_server_ts: 0 }); const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: true }; client.emit(RoomEvent.Timeline, event, room, false, false, data); await flushPromises(); expect(paginateSpy).toHaveBeenCalledTimes(2); }); }); describe("with overlayTimeline", () => { it("renders merged timeline", async () => { const [client, room, events] = setupTestData(); const virtualRoom = mkRoom(client, "virtualRoomId"); const virtualCallInvite = new MatrixEvent({ type: "m.call.invite", room_id: virtualRoom.roomId, event_id: `virtualCallEvent1`, origin_server_ts: 0, }); virtualCallInvite.localTimestamp = 2; const virtualCallMetaEvent = new MatrixEvent({ type: "org.matrix.call.sdp_stream_metadata_changed", room_id: virtualRoom.roomId, event_id: `virtualCallEvent2`, origin_server_ts: 0, }); virtualCallMetaEvent.localTimestamp = 2; const virtualEvents = [virtualCallInvite, ...mockEvents(virtualRoom), virtualCallMetaEvent]; const { timelineSet: overlayTimelineSet } = getProps(virtualRoom, virtualEvents); const { container } = render( , { wrapper: TooltipProvider }, ); 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( , { wrapper: TooltipProvider }, ); 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( , { wrapper: TooltipProvider }, ); 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( , { wrapper: TooltipProvider }, ); 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( , { wrapper: TooltipProvider }, ); 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( , { wrapper: TooltipProvider }, ); 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)); }); }); }); describe("when a thread updates", () => { let client: MatrixClient; let room: Room; let allThreads: EventTimelineSet; let root: MatrixEvent; let reply1: MatrixEvent; let reply2: MatrixEvent; beforeEach(() => { client = MatrixClientPeg.safeGet(); Thread.hasServerSideSupport = FeatureSupport.Stable; room = new Room("roomId", client, "userId", { pendingEventOrdering: PendingEventOrdering.Detached }); allThreads = new EventTimelineSet( room, { pendingEvents: false, }, undefined, undefined, ThreadFilterType.All, ); const timeline = new EventTimeline(allThreads); allThreads.getLiveTimeline = () => timeline; allThreads.getTimelineForEvent = () => timeline; reply1 = new MatrixEvent({ room_id: room.roomId, event_id: "event_reply_1", type: EventType.RoomMessage, sender: "userId", content: createMessageEventContent("ReplyEvent1"), origin_server_ts: 0, }); reply2 = new MatrixEvent({ room_id: room.roomId, event_id: "event_reply_2", type: EventType.RoomMessage, sender: "userId", content: createMessageEventContent("ReplyEvent2"), origin_server_ts: 0, }); root = new MatrixEvent({ room_id: room.roomId, event_id: "event_root_1", type: EventType.RoomMessage, sender: "userId", content: createMessageEventContent("RootEvent"), origin_server_ts: 0, }); const eventMap: { [key: string]: MatrixEvent } = { [root.getId()!]: root, [reply1.getId()!]: reply1, [reply2.getId()!]: reply2, }; room.findEventById = (eventId: string) => eventMap[eventId]; client.fetchRoomEvent = async (roomId: string, eventId: string) => roomId === room.roomId ? eventMap[eventId]?.event : {}; }); it("updates thread previews", async () => { mocked(client.supportsThreads).mockReturnValue(true); reply1.getContent()["m.relates_to"] = { rel_type: RelationType.Thread, event_id: root.getId(), }; reply2.getContent()["m.relates_to"] = { rel_type: RelationType.Thread, event_id: root.getId(), }; const thread = room.createThread(root.getId()!, root, [], true); // So that we do not have to mock the thread loading thread.initialEventsFetched = true; // @ts-ignore thread.fetchEditsWhereNeeded = () => Promise.resolve(); await thread.addEvent(reply1, false, true); await allThreads.getLiveTimeline().addEvent(thread.rootEvent!, { toStartOfTimeline: true }); const replyToEvent = jest.spyOn(thread, "replyToEvent", "get"); const dom = render( , { wrapper: TooltipProvider }, ); await dom.findByText("RootEvent"); await dom.findByText("ReplyEvent1"); expect(replyToEvent).toHaveBeenCalled(); replyToEvent.mockClear(); await thread.addEvent(reply2, false, true); await dom.findByText("RootEvent"); await dom.findByText("ReplyEvent2"); expect(replyToEvent).toHaveBeenCalled(); }); it("ignores thread updates for unknown threads", async () => { root.setUnsigned({ "m.relations": { [THREAD_RELATION_TYPE.name]: { latest_event: reply1.event, count: 1, current_user_participated: true, }, }, }); const realThread = room.createThread(root.getId()!, root, [], true); // So that we do not have to mock the thread loading realThread.initialEventsFetched = true; // @ts-ignore realThread.fetchEditsWhereNeeded = () => Promise.resolve(); await realThread.addEvent(reply1, true); await allThreads.getLiveTimeline().addEvent(realThread.rootEvent!, { toStartOfTimeline: true }); const replyToEvent = jest.spyOn(realThread, "replyToEvent", "get"); // @ts-ignore const fakeThread1: Thread = { id: undefined!, get roomId(): string { return room.roomId; }, }; const fakeRoom = new Room("thisroomdoesnotexist", client, "userId"); // @ts-ignore const fakeThread2: Thread = { id: root.getId()!, get roomId(): string { return fakeRoom.roomId; }, }; const dom = render( , { wrapper: TooltipProvider }, ); await dom.findByText("RootEvent"); await dom.findByText("ReplyEvent1"); expect(replyToEvent).toHaveBeenCalled(); replyToEvent.mockClear(); room.emit(ThreadEvent.Update, fakeThread1); room.emit(ThreadEvent.Update, fakeThread2); await dom.findByText("ReplyEvent1"); expect(replyToEvent).not.toHaveBeenCalled(); replyToEvent.mockClear(); }); }); it("renders when the last message is an undecryptable thread root", async () => { const client = MatrixClientPeg.safeGet(); client.isRoomEncrypted = () => true; client.supportsThreads = () => true; client.decryptEventIfNeeded = () => Promise.resolve(); const authorId = client.getUserId()!; const room = new Room("roomId", client, authorId, { lazyLoadMembers: false, pendingEventOrdering: PendingEventOrdering.Detached, }); const events = mockEvents(room); const timelineSet = room.getUnfilteredTimelineSet(); const { rootEvent } = mkThread({ room, client, authorId, participantUserIds: [authorId], }); events.push(rootEvent); events.forEach((event) => timelineSet.getLiveTimeline().addEvent(event, { toStartOfTimeline: true })); const roomMembership = mkMembership({ mship: "join", prevMship: "join", user: authorId, room: room.roomId, event: true, skey: "123", }); events.push(roomMembership); const member = new RoomMember(room.roomId, authorId); member.membership = "join"; const roomState = new RoomState(room.roomId); jest.spyOn(roomState, "getMember").mockReturnValue(member); jest.spyOn(timelineSet.getLiveTimeline(), "getState").mockReturnValue(roomState); timelineSet.addEventToTimeline(roomMembership, timelineSet.getLiveTimeline(), { toStartOfTimeline: false }); for (const event of events) { jest.spyOn(event, "isDecryptionFailure").mockReturnValue(true); jest.spyOn(event, "shouldAttemptDecryption").mockReturnValue(false); } const { container } = render( , { wrapper: TooltipProvider }, ); await waitFor(() => expect(screen.queryByRole("progressbar")).toBeNull()); await waitFor(() => expect(container.querySelector(".mx_RoomView_MessageList")).not.toBeEmptyDOMElement()); }); });