/* Copyright 2024 New Vector Ltd. Copyright 2022, 2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ 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 { KnownMembership } from "matrix-js-sdk/src/types"; import React, { createRef } from "react"; import { Mocked, mocked } from "jest-mock"; import { forEachRight } from "lodash"; 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( , ); 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(); 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(, {}); await waitFor(() => expectEvents(container, [events[1]])); // ScrollPanel has no chance of working in jsdom, so we've no choice // but to do some shady stuff to trigger the fill callback by hand const scrollPanel = mountSpy.mock.contexts[0] as ScrollPanel; scrollPanel.props.onFillRequest!(true); await waitFor(() => expectEvents(container, [events[0], events[1]])); }); }); it("unpaginates", async () => { const [, room, events] = setupTestData(); await withScrollPanelMountSpy(async (mountSpy) => { const { container } = render(); await waitFor(() => expectEvents(container, [events[0], events[1]])); // ScrollPanel has no chance of working in jsdom, so we've no choice // but to do some shady stuff to trigger the unfill callback by hand const scrollPanel = mountSpy.mock.contexts[0] as ScrollPanel; scrollPanel.props.onUnfillRequest!(true, events[0].getId()!); await waitFor(() => expectEvents(container, [events[1]])); }); }); describe("onRoomTimeline", () => { it("ignores events for other timelines", () => { const [client, room, events] = setupTestData(); 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(); 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(); 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(); 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(); 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(); 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( , ); await waitFor(() => expectEvents(container, [ // main timeline events are included events[0], events[1], // virtual timeline call event is included virtualCallInvite, // virtual call event has no tile renderer => not rendered ]), ); }); it.each([ ["when it starts with no overlay events", true], ["to get enough overlay events", false], ])("expands the initial window %s", async (_s, startWithEmptyOverlayWindow) => { const [client, room, events] = setupTestData(); const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events); let overlayEventsPage1: MatrixEvent[]; let overlayEventsPage2: MatrixEvent[]; let overlayEventsPage3: MatrixEvent[]; if (startWithEmptyOverlayWindow) { overlayEventsPage1 = overlayEvents.slice(0, 3); overlayEventsPage2 = []; overlayEventsPage3 = overlayEvents.slice(3, 5); } else { overlayEventsPage1 = overlayEvents.slice(0, 2); overlayEventsPage2 = overlayEvents.slice(2, 3); overlayEventsPage3 = overlayEvents.slice(3, 5); } // Start with only page 2 of the overlay events in the window const [overlayTimeline, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEventsPage2); setupPagination(client, overlayTimeline, overlayEventsPage1, overlayEventsPage3); const { container } = render( , ); await waitFor(() => expectEvents(container, [ overlayEvents[0], events[0], overlayEvents[1], overlayEvents[2], overlayEvents[3], events[1], overlayEvents[4], ]), ); }); it("extends overlay window beyond main window at the start of the timeline", async () => { const [client, room, events] = setupTestData(); const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events); // Delete event 0 so the TimelinePanel will still leave some stuff // unloaded for us to test with events.shift(); const overlayEventsPage1 = overlayEvents.slice(0, 2); const overlayEventsPage2 = overlayEvents.slice(2, 5); // Start with only page 2 of the overlay events in the window const [overlayTimeline, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEventsPage2); setupPagination(client, overlayTimeline, overlayEventsPage1, null); const { container } = render( , ); await waitFor(() => expectEvents(container, [ // These first two are the newly loaded events overlayEvents[0], overlayEvents[1], overlayEvents[2], overlayEvents[3], events[0], overlayEvents[4], ]), ); }); it("extends overlay window beyond main window at the end of the timeline", async () => { const [client, room, events] = setupTestData(); const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events); // Delete event 1 so the TimelinePanel will still leave some stuff // unloaded for us to test with events.pop(); const overlayEventsPage1 = overlayEvents.slice(0, 2); const overlayEventsPage2 = overlayEvents.slice(2, 5); // Start with only page 1 of the overlay events in the window const [overlayTimeline, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEventsPage1); setupPagination(client, overlayTimeline, null, overlayEventsPage2); const { container } = render( , ); await waitFor(() => expectEvents(container, [ overlayEvents[0], events[0], overlayEvents[1], // These are the newly loaded events overlayEvents[2], overlayEvents[3], overlayEvents[4], ]), ); }); it("paginates", async () => { const [client, room, events] = setupTestData(); const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events); const eventsPage1 = events.slice(0, 1); const eventsPage2 = events.slice(1, 2); // Start with only page 1 of the main events in the window const [timeline, timelineSet] = mkTimeline(room, eventsPage1); const [, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEvents); setupPagination(client, timeline, null, eventsPage2); await withScrollPanelMountSpy(async (mountSpy) => { const { container } = render( , ); await waitFor(() => expectEvents(container, [overlayEvents[0], events[0]])); // ScrollPanel has no chance of working in jsdom, so we've no choice // but to do some shady stuff to trigger the fill callback by hand const scrollPanel = mountSpy.mock.contexts[0] as ScrollPanel; scrollPanel.props.onFillRequest!(false); await waitFor(() => expectEvents(container, [ overlayEvents[0], events[0], overlayEvents[1], overlayEvents[2], overlayEvents[3], events[1], overlayEvents[4], ]), ); }); }); it.each([ ["down", "main", true, false], ["down", "overlay", true, true], ["up", "main", false, false], ["up", "overlay", false, true], ])("unpaginates %s to an event from the %s timeline", async (_s1, _s2, backwards, fromOverlay) => { const [client, room, events] = setupTestData(); const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events); let marker: MatrixEvent; let expectedEvents: MatrixEvent[]; if (backwards) { if (fromOverlay) { marker = overlayEvents[1]; // Overlay events 0−1 and event 0 should be unpaginated // Overlay events 2−3 should be hidden since they're at the edge of the window expectedEvents = [events[1], overlayEvents[4]]; } else { marker = events[0]; // Overlay event 0 and event 0 should be unpaginated // Overlay events 1−3 should be hidden since they're at the edge of the window expectedEvents = [events[1], overlayEvents[4]]; } } else { if (fromOverlay) { marker = overlayEvents[4]; // Only the last overlay event should be unpaginated expectedEvents = [ overlayEvents[0], events[0], overlayEvents[1], overlayEvents[2], overlayEvents[3], events[1], ]; } else { // Get rid of overlay event 4 so we can test the case where no overlay events get unpaginated overlayEvents.pop(); marker = events[1]; // Only event 1 should be unpaginated // Overlay events 1−2 should be hidden since they're at the edge of the window expectedEvents = [overlayEvents[0], events[0]]; } } const [, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEvents); await withScrollPanelMountSpy(async (mountSpy) => { const { container } = render( , ); await waitFor(() => expectEvents(container, [ overlayEvents[0], events[0], overlayEvents[1], overlayEvents[2], overlayEvents[3], events[1], ...(!backwards && !fromOverlay ? [] : [overlayEvents[4]]), ]), ); // ScrollPanel has no chance of working in jsdom, so we've no choice // but to do some shady stuff to trigger the unfill callback by hand const scrollPanel = mountSpy.mock.contexts[0] as ScrollPanel; scrollPanel.props.onUnfillRequest!(backwards, marker.getId()!); await waitFor(() => expectEvents(container, expectedEvents)); }); }); }); 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( , ); 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( , ); 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: KnownMembership.Join, prevMship: KnownMembership.Join, user: authorId, room: room.roomId, event: true, skey: "123", }); events.push(roomMembership); const member = new RoomMember(room.roomId, authorId); member.membership = KnownMembership.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( , ); await waitFor(() => expect(screen.queryByRole("progressbar")).toBeNull()); await waitFor(() => expect(container.querySelector(".mx_RoomView_MessageList")).not.toBeEmptyDOMElement()); }); });