/* 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 React from "react"; import { mocked } from "jest-mock"; import { ConnectionError, IProtocol, IPublicRoomsChunkRoom, JoinRule, MatrixClient, Room, RoomMember, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import sanitizeHtml from "sanitize-html"; import { fireEvent, render, screen } from "jest-matrix-react"; import SpotlightDialog from "../../../../src/components/views/dialogs/spotlight/SpotlightDialog"; import { Filter } from "../../../../src/components/views/dialogs/spotlight/Filter"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../../src/models/LocalRoom"; import { DirectoryMember, startDmOnFirstMessage } from "../../../../src/utils/direct-messages"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { flushPromisesWithFakeTimers, mkRoom, stubClient } from "../../../test-utils"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../../src/settings/SettingLevel"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import SdkConfig from "../../../../src/SdkConfig"; import { Action } from "../../../../src/dispatcher/actions"; jest.useFakeTimers(); jest.mock("../../../../src/utils/Feedback"); jest.mock("../../../../src/utils/direct-messages", () => ({ // @ts-ignore ...jest.requireActual("../../../../src/utils/direct-messages"), startDmOnFirstMessage: jest.fn(), })); jest.mock("../../../../src/dispatcher/dispatcher", () => ({ register: jest.fn(), dispatch: jest.fn(), })); interface IUserChunkMember { user_id: string; display_name?: string; avatar_url?: string; } interface MockClientOptions { userId?: string; homeserver?: string; thirdPartyProtocols?: Record; rooms?: IPublicRoomsChunkRoom[]; members?: RoomMember[]; users?: IUserChunkMember[]; } function mockClient({ userId = "testuser", homeserver = "example.tld", thirdPartyProtocols = {}, rooms = [], members = [], users = [], }: MockClientOptions = {}): MatrixClient { stubClient(); const cli = MatrixClientPeg.safeGet(); cli.getUserId = jest.fn(() => userId); cli.getDomain = jest.fn(() => homeserver); cli.getHomeserverUrl = jest.fn(() => homeserver); cli.getThirdpartyProtocols = jest.fn(() => Promise.resolve(thirdPartyProtocols)); cli.publicRooms = jest.fn((options) => { const searchTerm = options?.filter?.generic_search_term?.toLowerCase(); const chunk = rooms.filter( (it) => !searchTerm || it.room_id.toLowerCase().includes(searchTerm) || it.name?.toLowerCase().includes(searchTerm) || sanitizeHtml(it?.topic || "", { allowedTags: [] }) .toLowerCase() .includes(searchTerm) || it.canonical_alias?.toLowerCase().includes(searchTerm) || it.aliases?.find((alias) => alias.toLowerCase().includes(searchTerm)), ); return Promise.resolve({ chunk, total_room_count_estimate: chunk.length, }); }); cli.searchUserDirectory = jest.fn(({ term, limit }) => { const searchTerm = term?.toLowerCase(); const results = users.filter( (it) => !searchTerm || it.user_id.toLowerCase().includes(searchTerm) || it.display_name?.toLowerCase().includes(searchTerm), ); return Promise.resolve({ results: results.slice(0, limit ?? +Infinity), limited: !!limit && limit < results.length, }); }); cli.getProfileInfo = jest.fn(async (userId) => { const member = members.find((it) => it.userId === userId); if (member) { return Promise.resolve({ displayname: member.rawDisplayName, avatar_url: member.getMxcAvatarUrl(), }); } else { return Promise.reject(); } }); return cli; } describe("Spotlight Dialog", () => { const testPerson: IUserChunkMember = { user_id: "@janedoe:matrix.org", display_name: "Jane Doe", avatar_url: undefined, }; const testPublicRoom: IPublicRoomsChunkRoom = { room_id: "!room247:matrix.org", name: "Room #247", topic: "We hope you'll have a shining experience!", world_readable: false, num_joined_members: 1, guest_can_join: false, }; const testDMRoomId = "!testDM:example.com"; const testDMUserId = "@alice:matrix.org"; let testRoom: Room; let testDM: Room; let testLocalRoom: LocalRoom; let mockedClient: MatrixClient; beforeEach(() => { mockedClient = mockClient({ rooms: [testPublicRoom], users: [testPerson] }); testRoom = mkRoom(mockedClient, "!test23:example.com"); mocked(testRoom.getMyMembership).mockReturnValue(KnownMembership.Join); testLocalRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test23", mockedClient, mockedClient.getUserId()!); testLocalRoom.updateMyMembership(KnownMembership.Join); mocked(mockedClient.getVisibleRooms).mockReturnValue([testRoom, testLocalRoom]); jest.spyOn(DMRoomMap, "shared").mockReturnValue({ getUserIdForRoomId: jest.fn(), } as unknown as DMRoomMap); testDM = mkRoom(mockedClient, testDMRoomId); testDM.name = "Chat with Alice"; mocked(testDM.getMyMembership).mockReturnValue(KnownMembership.Join); mocked(DMRoomMap.shared().getUserIdForRoomId).mockImplementation((roomId: string) => { if (roomId === testDMRoomId) { return testDMUserId; } return undefined; }); mocked(mockedClient.getVisibleRooms).mockReturnValue([testRoom, testLocalRoom, testDM]); }); describe("should apply filters supplied via props", () => { it("without filter", async () => { render( null} />); const filterChip = document.querySelector("div.mx_SpotlightDialog_filter"); expect(filterChip).not.toBeInTheDocument(); }); it("with public room filter", async () => { render( null} />); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); const filterChip = document.querySelector("div.mx_SpotlightDialog_filter")!; expect(filterChip).toBeInTheDocument(); expect(filterChip.innerHTML).toContain("Public rooms"); const content = document.querySelector("#mx_SpotlightDialog_content")!; const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBe(1); expect(options[0].innerHTML).toContain(testPublicRoom.name); }); it("with people filter", async () => { render( null} />, ); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); const filterChip = document.querySelector("div.mx_SpotlightDialog_filter")!; expect(filterChip).toBeInTheDocument(); expect(filterChip.innerHTML).toContain("People"); const content = document.querySelector("#mx_SpotlightDialog_content")!; const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBeGreaterThanOrEqual(1); expect(options[0]!.innerHTML).toContain(testPerson.display_name); }); }); describe("when MSC3946 dynamic room predecessors is enabled", () => { beforeEach(() => { jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName, roomId, excludeDefault) => { if (settingName === "feature_dynamic_room_predecessors") { return true; } else { return []; // SpotlightSearch.recentSearches } }); }); afterEach(() => { jest.restoreAllMocks(); }); it("should call getVisibleRooms with MSC3946 dynamic room predecessors", async () => { render( null} />); jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); expect(mockedClient.getVisibleRooms).toHaveBeenCalledWith(true); }); }); describe("should apply manually selected filter", () => { it("with public rooms", async () => { render( null} />); jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); fireEvent.click(screen.getByText("Public rooms")); // wrapper.find("#mx_SpotlightDialog_button_explorePublicRooms").first().simulate("click"); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); const filterChip = document.querySelector("div.mx_SpotlightDialog_filter")!; expect(filterChip).toBeInTheDocument(); expect(filterChip.innerHTML).toContain("Public rooms"); const content = document.querySelector("#mx_SpotlightDialog_content")!; const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBe(1); expect(options[0]!.innerHTML).toContain(testPublicRoom.name); // assert that getVisibleRooms is called without MSC3946 dynamic room predecessors expect(mockedClient.getVisibleRooms).toHaveBeenCalledWith(false); }); it("with people", async () => { render( null} />); jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); fireEvent.click(screen.getByText("People")); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); const filterChip = document.querySelector("div.mx_SpotlightDialog_filter")!; expect(filterChip).toBeInTheDocument(); expect(filterChip.innerHTML).toContain("People"); const content = document.querySelector("#mx_SpotlightDialog_content")!; const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBeGreaterThanOrEqual(1); expect(options[0]!.innerHTML).toContain(testPerson.display_name); }); }); describe("should allow clearing filter manually", () => { it("with public room filter", async () => { render( null} />); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); let filterChip = document.querySelector("div.mx_SpotlightDialog_filter")!; expect(filterChip).toBeInTheDocument(); expect(filterChip.innerHTML).toContain("Public rooms"); fireEvent.click(filterChip.querySelector("div.mx_SpotlightDialog_filter--close")!); jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); filterChip = document.querySelector("div.mx_SpotlightDialog_filter")!; expect(filterChip).not.toBeInTheDocument(); }); it("with people filter", async () => { render( null} />, ); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); let filterChip = document.querySelector("div.mx_SpotlightDialog_filter"); expect(filterChip).toBeInTheDocument(); expect(filterChip!.innerHTML).toContain("People"); fireEvent.click(filterChip!.querySelector("div.mx_SpotlightDialog_filter--close")!); jest.advanceTimersByTime(1); await flushPromisesWithFakeTimers(); filterChip = document.querySelector("div.mx_SpotlightDialog_filter"); expect(filterChip).not.toBeInTheDocument(); }); }); describe("searching for rooms", () => { let options: NodeListOf; beforeAll(async () => { render( null} />); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); const content = document.querySelector("#mx_SpotlightDialog_content")!; options = content.querySelectorAll("li.mx_SpotlightDialog_option"); }); it("should find Rooms", () => { expect(options).toHaveLength(4); expect(options[0]!.innerHTML).toContain(testRoom.name); }); it("should not find LocalRooms", () => { expect(options).toHaveLength(4); expect(options[0]!.innerHTML).not.toContain(testLocalRoom.name); }); }); it("should not filter out users sent by the server", async () => { mocked(mockedClient.searchUserDirectory).mockResolvedValue({ results: [ { user_id: "@user1:server", display_name: "User Alpha", avatar_url: "mxc://1/avatar" }, { user_id: "@user2:server", display_name: "User Beta", avatar_url: "mxc://2/avatar" }, ], limited: false, }); render( null} />); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); const content = document.querySelector("#mx_SpotlightDialog_content")!; const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBeGreaterThanOrEqual(2); expect(options[0]).toHaveTextContent("User Alpha"); expect(options[1]).toHaveTextContent("User Beta"); }); it("should not filter out users sent by the server even if a local suggestion gets filtered out", async () => { const member = new RoomMember(testRoom.roomId, testPerson.user_id); member.name = member.rawDisplayName = testPerson.display_name!; member.getMxcAvatarUrl = jest.fn().mockReturnValue("mxc://0/avatar"); mocked(testRoom.getJoinedMembers).mockReturnValue([member]); mocked(mockedClient.searchUserDirectory).mockResolvedValue({ results: [ { user_id: "@janedoe:matrix.org", display_name: "User Alpha", avatar_url: "mxc://1/avatar" }, { user_id: "@johndoe:matrix.org", display_name: "User Beta", avatar_url: "mxc://2/avatar" }, ], limited: false, }); render( null} />); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); const content = document.querySelector("#mx_SpotlightDialog_content")!; const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBeGreaterThanOrEqual(2); expect(options[0]).toHaveTextContent(testPerson.display_name!); expect(options[1]).toHaveTextContent("User Beta"); }); it("show non-matching query members with DMs if they are present in the server search results", async () => { mocked(mockedClient.searchUserDirectory).mockResolvedValue({ results: [ { user_id: testDMUserId, display_name: "Alice Wonder", avatar_url: "mxc://1/avatar" }, { user_id: "@bob:matrix.org", display_name: "Bob Wonder", avatar_url: "mxc://2/avatar" }, ], limited: false, }); render( null} />, ); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); const content = document.querySelector("#mx_SpotlightDialog_content")!; const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBeGreaterThanOrEqual(2); expect(options[0]).toHaveTextContent(testDMUserId); expect(options[1]).toHaveTextContent("Bob Wonder"); }); it("don't sort the order of users sent by the server", async () => { const serverList = [ { user_id: "@user2:server", display_name: "User Beta", avatar_url: "mxc://2/avatar" }, { user_id: "@user1:server", display_name: "User Alpha", avatar_url: "mxc://1/avatar" }, ]; mocked(mockedClient.searchUserDirectory).mockResolvedValue({ results: serverList, limited: false, }); render( null} />); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); const content = document.querySelector("#mx_SpotlightDialog_content")!; const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBeGreaterThanOrEqual(2); expect(options[0]).toHaveTextContent("User Beta"); expect(options[1]).toHaveTextContent("User Alpha"); }); it("should start a DM when clicking a person", async () => { render( null} />, ); jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); const options = document.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBeGreaterThanOrEqual(1); expect(options[0]!.innerHTML).toContain(testPerson.display_name); fireEvent.click(options[0]!); expect(startDmOnFirstMessage).toHaveBeenCalledWith(mockedClient, [new DirectoryMember(testPerson)]); }); it("should pass via of the server being explored when joining room from directory", async () => { SdkConfig.put({ room_directory: { servers: ["example.tld"], }, }); localStorage.setItem("mx_last_room_directory_server", "example.tld"); render( null} />); jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); const content = document.querySelector("#mx_SpotlightDialog_content")!; const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBe(1); expect(options[0].innerHTML).toContain(testPublicRoom.name); fireEvent.click(options[0].querySelector("[role='button']")!); expect(defaultDispatcher.dispatch).toHaveBeenCalledTimes(1); expect(defaultDispatcher.dispatch).toHaveBeenCalledWith( expect.objectContaining({ action: "view_room", room_id: testPublicRoom.room_id, via_servers: ["example.tld"], }), ); }); describe("nsfw public rooms filter", () => { const nsfwNameRoom: IPublicRoomsChunkRoom = { room_id: "@room1:matrix.org", name: "Room 1 [NSFW]", topic: undefined, world_readable: false, num_joined_members: 1, guest_can_join: false, }; const nsfwTopicRoom: IPublicRoomsChunkRoom = { room_id: "@room2:matrix.org", name: "Room 2", topic: "A room with a topic that includes nsfw", world_readable: false, num_joined_members: 1, guest_can_join: false, }; const potatoRoom: IPublicRoomsChunkRoom = { room_id: "@room3:matrix.org", name: "Potato Room 3", topic: "Room where we discuss potatoes", world_readable: false, num_joined_members: 1, guest_can_join: false, }; beforeEach(() => { mockedClient = mockClient({ rooms: [nsfwNameRoom, nsfwTopicRoom, potatoRoom], users: [testPerson] }); SettingsStore.setValue("SpotlightSearch.showNsfwPublicRooms", null, SettingLevel.DEVICE, false); }); afterAll(() => { SettingsStore.setValue("SpotlightSearch.showNsfwPublicRooms", null, SettingLevel.DEVICE, false); }); it("does not display rooms with nsfw keywords in results when showNsfwPublicRooms is falsy", async () => { render( null} />); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); expect(screen.getByText(potatoRoom.name!)).toBeInTheDocument(); expect(screen.queryByText(nsfwTopicRoom.name!)).not.toBeInTheDocument(); expect(screen.queryByText(nsfwTopicRoom.name!)).not.toBeInTheDocument(); }); it("displays rooms with nsfw keywords in results when showNsfwPublicRooms is truthy", async () => { SettingsStore.setValue("SpotlightSearch.showNsfwPublicRooms", null, SettingLevel.DEVICE, true); render( null} />); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); expect(screen.getByText(nsfwTopicRoom.name!)).toBeInTheDocument(); expect(screen.getByText(nsfwNameRoom.name!)).toBeInTheDocument(); expect(screen.getByText(potatoRoom.name!)).toBeInTheDocument(); }); }); it("should show error if /publicRooms API failed", async () => { mocked(mockedClient.publicRooms).mockRejectedValue(new ConnectionError("Failed to fetch")); render( null} />); jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); expect(screen.getByText("Failed to query public rooms")).toBeInTheDocument(); }); describe("knock rooms", () => { const knockRoom: IPublicRoomsChunkRoom = { guest_can_join: false, join_rule: JoinRule.Knock, num_joined_members: 0, room_id: "some-room-id", world_readable: false, }; const viewRoomParams = { action: Action.ViewRoom, metricsTrigger: "WebUnifiedSearch", metricsViaKeyboard: false, room_alias: undefined, room_id: knockRoom.room_id, should_peek: false, via_servers: ["example.tld"], }; beforeEach(() => (mockedClient = mockClient({ rooms: [knockRoom] }))); describe("when disabling feature", () => { beforeEach(async () => { jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_ask_to_join" ? false : [], ); render( {}} />); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); fireEvent.click(screen.getByRole("button", { name: "View" })); }); it("should not skip to auto join", async () => { expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ ...viewRoomParams, auto_join: true }); }); it("should not prompt ask to join", async () => { expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ action: Action.PromptAskToJoin }); }); }); describe("when enabling feature", () => { beforeEach(async () => { jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_ask_to_join" ? true : [], ); jest.spyOn(mockedClient, "getRoom").mockReturnValue(null); render( {}} />); // search is debounced jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); fireEvent.click(screen.getByRole("button", { name: "Ask to join" })); }); it("should skip to auto join", async () => { expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ ...viewRoomParams, auto_join: false }); }); it("should prompt ask to join", async () => { expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.PromptAskToJoin }); }); }); }); });