diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index b313a0948e..2827ba76be 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { Optional } from "matrix-events-sdk"; import React, { useContext, useEffect, useRef, useState } from "react"; import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; import { Thread } from "matrix-js-sdk/src/models/thread"; @@ -215,31 +216,22 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => const [filterOption, setFilterOption] = useState(ThreadFilterType.All); const [room, setRoom] = useState(null); - const [timelineSet, setTimelineSet] = useState(null); const [narrow, setNarrow] = useState(false); + const timelineSet: Optional = + filterOption === ThreadFilterType.My ? room?.threadsTimelineSets[1] : room?.threadsTimelineSets[0]; + const hasThreads = Boolean(room?.threadsTimelineSets?.[0]?.getLiveTimeline()?.getEvents()?.length); + useEffect(() => { const room = mxClient.getRoom(roomId); - room.createThreadsTimelineSets() - .then(() => { - return room.fetchRoomThreads(); - }) + room?.createThreadsTimelineSets() + .then(() => room.fetchRoomThreads()) .then(() => { setFilterOption(ThreadFilterType.All); setRoom(room); }); }, [mxClient, roomId]); - useEffect(() => { - if (room) { - if (filterOption === ThreadFilterType.My) { - setTimelineSet(room.threadsTimelineSets[1]); - } else { - setTimelineSet(room.threadsTimelineSets[0]); - } - } - }, [room, filterOption]); - useEffect(() => { if (timelineSet && !Thread.hasServerSideSupport) { timelinePanel.current.refreshTimeline(); @@ -268,7 +260,7 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => } footer={ @@ -315,7 +307,7 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => showUrlPreview={false} // No URL previews at the threads list level empty={ 0} + hasThreads={hasThreads} filterOption={filterOption} showAllThreadsCallback={() => setFilterOption(ThreadFilterType.All)} /> diff --git a/test/components/structures/ThreadPanel-test.tsx b/test/components/structures/ThreadPanel-test.tsx index 483e8a9b38..b868549da4 100644 --- a/test/components/structures/ThreadPanel-test.tsx +++ b/test/components/structures/ThreadPanel-test.tsx @@ -14,18 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { mocked } from "jest-mock"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import "focus-visible"; // to fix context menus +import { mocked } from "jest-mock"; +import { MatrixClient, MatrixEvent, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix"; +import { FeatureSupport, Thread } from "matrix-js-sdk/src/models/thread"; +import React from "react"; import ThreadPanel, { ThreadFilterType, ThreadPanelHeader } from "../../../src/components/structures/ThreadPanel"; -import { _t } from "../../../src/languageHandler"; -import ResizeNotifier from "../../../src/utils/ResizeNotifier"; -import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks"; -import { createTestClient, mkStubRoom } from "../../test-utils"; -import { shouldShowFeedback } from "../../../src/utils/Feedback"; import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; +import RoomContext from "../../../src/contexts/RoomContext"; +import { _t } from "../../../src/languageHandler"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import { shouldShowFeedback } from "../../../src/utils/Feedback"; +import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks"; +import ResizeNotifier from "../../../src/utils/ResizeNotifier"; +import { createTestClient, getRoomContext, mkStubRoom, mockPlatformPeg, stubClient } from "../../test-utils"; +import { mkThread } from "../../test-utils/threads"; jest.mock("../../../src/utils/Feedback"); @@ -122,4 +127,185 @@ describe("ThreadPanel", () => { expect(foundButton).toMatchSnapshot(); }); }); + + describe("Filtering", () => { + const ROOM_ID = "!roomId:example.org"; + const SENDER = "@alice:example.org"; + + let mockClient: MatrixClient; + let room: Room; + + const TestThreadPanel = () => ( + + + + + + ); + + beforeEach(async () => { + jest.clearAllMocks(); + + stubClient(); + mockPlatformPeg(); + mockClient = mocked(MatrixClientPeg.get()); + Thread.setServerSideSupport(FeatureSupport.Stable); + Thread.setServerSideListSupport(FeatureSupport.Stable); + Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable); + jest.spyOn(mockClient, "supportsExperimentalThreads").mockReturnValue(true); + + room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + jest.spyOn(room, "fetchRoomThreads").mockReturnValue(Promise.resolve()); + jest.spyOn(mockClient, "getRoom").mockReturnValue(room); + await room.createThreadsTimelineSets(); + const [allThreads, myThreads] = room.threadsTimelineSets; + jest.spyOn(room, "createThreadsTimelineSets").mockReturnValue(Promise.resolve([allThreads, myThreads])); + }); + + function toggleThreadFilter(container: HTMLElement, newFilter: ThreadFilterType) { + fireEvent.click(container.querySelector(".mx_ThreadPanel_dropdown")!); + const found = screen.queryAllByRole("menuitemradio"); + expect(found).toHaveLength(2); + + const allThreadsContent = `${_t("All threads")}${_t("Shows all threads from current room")}`; + const myThreadsContent = `${_t("My threads")}${_t("Shows all threads you've participated in")}`; + + const allThreadsOption = found.find((it) => it.textContent === allThreadsContent); + const myThreadsOption = found.find((it) => it.textContent === myThreadsContent); + expect(allThreadsOption).toBeTruthy(); + expect(myThreadsOption).toBeTruthy(); + + const toSelect = newFilter === ThreadFilterType.My ? myThreadsOption : allThreadsOption; + fireEvent.click(toSelect!); + } + + type EventData = { sender: string | null; content: string | null }; + + function findEvents(container: HTMLElement): EventData[] { + return Array.from(container.querySelectorAll(".mx_EventTile")).map((el) => { + const sender = el.querySelector(".mx_DisambiguatedProfile_displayName")?.textContent ?? null; + const content = el.querySelector(".mx_EventTile_body")?.textContent ?? null; + return { sender, content }; + }); + } + + function toEventData(event: MatrixEvent): EventData { + return { sender: event.event.sender ?? null, content: event.event.content?.body ?? null }; + } + + it("correctly filters Thread List with multiple threads", async () => { + const otherThread = mkThread({ + room, + client: mockClient, + authorId: SENDER, + participantUserIds: [mockClient.getUserId()!], + }); + + const mixedThread = mkThread({ + room, + client: mockClient, + authorId: SENDER, + participantUserIds: [SENDER, mockClient.getUserId()!], + }); + + const ownThread = mkThread({ + room, + client: mockClient, + authorId: mockClient.getUserId()!, + participantUserIds: [mockClient.getUserId()!], + }); + + const threadRoots = [otherThread.rootEvent, mixedThread.rootEvent, ownThread.rootEvent]; + jest.spyOn(mockClient, "fetchRoomEvent").mockImplementation((_, eventId) => { + const event = threadRoots.find((it) => it.getId() === eventId)?.event; + return event ? Promise.resolve(event) : Promise.reject(); + }); + const [allThreads, myThreads] = room.threadsTimelineSets; + allThreads.addLiveEvent(otherThread.rootEvent); + allThreads.addLiveEvent(mixedThread.rootEvent); + allThreads.addLiveEvent(ownThread.rootEvent); + myThreads.addLiveEvent(mixedThread.rootEvent); + myThreads.addLiveEvent(ownThread.rootEvent); + + let events: EventData[] = []; + const renderResult = render(); + await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); + await waitFor(() => { + events = findEvents(renderResult.container); + expect(findEvents(renderResult.container)).toHaveLength(3); + }); + expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); + expect(events[1]).toEqual(toEventData(mixedThread.rootEvent)); + expect(events[2]).toEqual(toEventData(ownThread.rootEvent)); + await waitFor(() => expect(renderResult.container.querySelector(".mx_ThreadPanel_dropdown")).toBeTruthy()); + toggleThreadFilter(renderResult.container, ThreadFilterType.My); + await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); + await waitFor(() => { + events = findEvents(renderResult.container); + expect(findEvents(renderResult.container)).toHaveLength(2); + }); + expect(events[0]).toEqual(toEventData(mixedThread.rootEvent)); + expect(events[1]).toEqual(toEventData(ownThread.rootEvent)); + toggleThreadFilter(renderResult.container, ThreadFilterType.All); + await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); + await waitFor(() => { + events = findEvents(renderResult.container); + expect(findEvents(renderResult.container)).toHaveLength(3); + }); + expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); + expect(events[1]).toEqual(toEventData(mixedThread.rootEvent)); + expect(events[2]).toEqual(toEventData(ownThread.rootEvent)); + }); + + it("correctly filters Thread List with a single, unparticipated thread", async () => { + const otherThread = mkThread({ + room, + client: mockClient, + authorId: SENDER, + participantUserIds: [mockClient.getUserId()!], + }); + + const threadRoots = [otherThread.rootEvent]; + jest.spyOn(mockClient, "fetchRoomEvent").mockImplementation((_, eventId) => { + const event = threadRoots.find((it) => it.getId() === eventId)?.event; + return event ? Promise.resolve(event) : Promise.reject(); + }); + const [allThreads] = room.threadsTimelineSets; + allThreads.addLiveEvent(otherThread.rootEvent); + + let events: EventData[] = []; + const renderResult = render(); + await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); + await waitFor(() => { + events = findEvents(renderResult.container); + expect(findEvents(renderResult.container)).toHaveLength(1); + }); + expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); + await waitFor(() => expect(renderResult.container.querySelector(".mx_ThreadPanel_dropdown")).toBeTruthy()); + toggleThreadFilter(renderResult.container, ThreadFilterType.My); + await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); + await waitFor(() => { + events = findEvents(renderResult.container); + expect(findEvents(renderResult.container)).toHaveLength(0); + }); + toggleThreadFilter(renderResult.container, ThreadFilterType.All); + await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); + await waitFor(() => { + events = findEvents(renderResult.container); + expect(findEvents(renderResult.container)).toHaveLength(1); + }); + expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); + }); + }); });