Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Fix issue where thread dropdown would not show up correctly #9872

Merged
merged 4 commits into from
Jan 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 9 additions & 17 deletions src/components/structures/ThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -215,31 +216,22 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>

const [filterOption, setFilterOption] = useState<ThreadFilterType>(ThreadFilterType.All);
const [room, setRoom] = useState<Room | null>(null);
const [timelineSet, setTimelineSet] = useState<EventTimelineSet | null>(null);
const [narrow, setNarrow] = useState<boolean>(false);

const timelineSet: Optional<EventTimelineSet> =
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();
Expand Down Expand Up @@ -268,7 +260,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
<ThreadPanelHeader
filterOption={filterOption}
setFilterOption={setFilterOption}
empty={!timelineSet?.getLiveTimeline()?.getEvents().length}
empty={!hasThreads}
/>
}
footer={
Expand Down Expand Up @@ -315,7 +307,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
showUrlPreview={false} // No URL previews at the threads list level
empty={
<EmptyThread
hasThreads={room.threadsTimelineSets?.[0]?.getLiveTimeline().getEvents().length > 0}
hasThreads={hasThreads}
filterOption={filterOption}
showAllThreadsCallback={() => setFilterOption(ThreadFilterType.All)}
/>
Expand Down
200 changes: 193 additions & 7 deletions test/components/structures/ThreadPanel-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../src/contexts/RoomContext";
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 { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { shouldShowFeedback } from "../../../src/utils/Feedback";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
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");

Expand Down Expand Up @@ -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 = () => (
<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider
value={getRoomContext(room, {
canSendMessages: true,
})}
>
<ThreadPanel
roomId={ROOM_ID}
onClose={jest.fn()}
resizeNotifier={new ResizeNotifier()}
permalinkCreator={new RoomPermalinkCreator(room)}
/>
</RoomContext.Provider>
</MatrixClientContext.Provider>
);

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(<TestThreadPanel />);
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(<TestThreadPanel />);
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));
});
});
});