diff --git a/test/unit-tests/components/views/rooms/MemberListView-test.tsx b/test/unit-tests/components/views/rooms/MemberListView-test.tsx deleted file mode 100644 index 72c0354df6..0000000000 --- a/test/unit-tests/components/views/rooms/MemberListView-test.tsx +++ /dev/null @@ -1,439 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. -Copyright 2021 Šimon Brandner - -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, { act } from "react"; -import { fireEvent, render, RenderResult, screen, waitFor } from "jest-matrix-react"; -import { - Room, - MatrixClient, - RoomState, - RoomMember, - User, - MatrixEvent, - EventType, - RoomStateEvent, -} from "matrix-js-sdk/src/matrix"; -import { KnownMembership } from "matrix-js-sdk/src/types"; -import { mocked } from "jest-mock"; - -import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import * as TestUtils from "../../../../test-utils"; -import { SDKContext } from "../../../../../src/contexts/SDKContext"; -import { TestSdkContext } from "../../../TestSdkContext"; -import { filterConsole } from "../../../../test-utils"; -import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents"; -import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; -import MemberListView from "../../../../../src/components/views/rooms/MemberListView"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; - -jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({ - shouldShowComponent: jest.fn(), -})); - -type Children = (args: { height: number; width: number }) => React.JSX.Element; -jest.mock("react-virtualized", () => { - const ReactVirtualized = jest.requireActual("react-virtualized"); - return { - ...ReactVirtualized, - AutoSizer: ({ children }: { children: Children }) => children({ height: 1000, width: 1000 }), - }; -}); -jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(1500); -jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(1500); - -function generateRoomId() { - return "!" + Math.random().toString().slice(2, 10) + ":domain"; -} - -describe("MemberListView and MemberlistHeaderView", () => { - filterConsole( - "Age for event was not available, using `now - origin_server_ts` as a fallback. If the device clock is not correct issues might occur.", - ); - function createRoom(opts = {}) { - const room = new Room(generateRoomId(), client, client.getUserId()!); - if (opts) { - Object.assign(room, opts); - } - return room; - } - - let client: MatrixClient; - let root: RenderResult; - let memberListRoom: Room; - - let adminUsers: RoomMember[] = []; - let moderatorUsers: RoomMember[] = []; - let defaultUsers: RoomMember[] = []; - - function memberString(member: RoomMember): string { - if (!member) { - return "(null)"; - } else { - const u = member.user; - return ( - "(" + - member.name + - ", " + - member.powerLevel + - ", " + - (u ? u.lastActiveAgo : "") + - ", " + - (u ? u.getLastActiveTs() : "") + - ", " + - (u ? u.currentlyActive : "") + - ", " + - (u ? u.presence : "") + - ")" - ); - } - } - - function expectOrderedByPresenceAndPowerLevel(memberTiles: NodeListOf, isPresenceEnabled: boolean) { - let prevMember: RoomMember | undefined; - for (const tile of memberTiles) { - const memberA = prevMember; - // console.log("aria-label is ", tile.getAttribute("aria-label")); - const memberB = memberListRoom.currentState.members[tile.getAttribute("aria-label")!.split(" ")[0]]; - prevMember = memberB; // just in case an expect fails, set this early - if (!memberA) { - continue; - } - - console.log("COMPARING A VS B:", memberString(memberA), memberString(memberB)); - - const userA = memberA.user!; - const userB = memberB.user!; - - let groupChange = false; - - if (isPresenceEnabled) { - const convertPresence = (p: string) => (p === "unavailable" ? "online" : p); - const presenceIndex = (p: string) => { - const order = ["active", "online", "offline"]; - const idx = order.indexOf(convertPresence(p)); - return idx === -1 ? order.length : idx; // unknown states at the end - }; - - const idxA = presenceIndex(userA.currentlyActive ? "active" : userA.presence); - const idxB = presenceIndex(userB.currentlyActive ? "active" : userB.presence); - console.log("Comparing presence groups..."); - expect(idxB).toBeGreaterThanOrEqual(idxA); - groupChange = idxA !== idxB; - } else { - console.log("Skipped presence groups"); - } - - if (!groupChange) { - console.log("Comparing power levels..."); - expect(memberA.powerLevel).toBeGreaterThanOrEqual(memberB.powerLevel); - groupChange = memberA.powerLevel !== memberB.powerLevel; - } else { - console.log("Skipping power level check due to group change"); - } - - if (!groupChange) { - if (isPresenceEnabled) { - console.log("Comparing last active timestamp..."); - expect(userB.getLastActiveTs()).toBeLessThanOrEqual(userA.getLastActiveTs()); - groupChange = userA.getLastActiveTs() !== userB.getLastActiveTs(); - } else { - console.log("Skipping last active timestamp"); - } - } else { - console.log("Skipping last active timestamp check due to group change"); - } - - if (!groupChange) { - const nameA = memberA.name[0] === "@" ? memberA.name.slice(1) : memberA.name; - const nameB = memberB.name[0] === "@" ? memberB.name.slice(1) : memberB.name; - const collator = new Intl.Collator(); - const nameCompare = collator.compare(nameB, nameA); - console.log("Comparing name"); - expect(nameCompare).toBeGreaterThanOrEqual(0); - } else { - console.log("Skipping name check due to group change"); - } - } - } - - async function renderMemberList(enablePresence: boolean, usersPerLevel: number = 2): Promise { - TestUtils.stubClient(); - client = MatrixClientPeg.safeGet(); - client.hasLazyLoadMembersEnabled = () => false; - - // Make room - memberListRoom = createRoom(); - expect(memberListRoom.roomId).toBeTruthy(); - - // Make users - adminUsers = []; - moderatorUsers = []; - defaultUsers = []; - for (let i = 0; i < usersPerLevel; i++) { - const adminUser = new RoomMember(memberListRoom.roomId, `@admin${i}:localhost`); - adminUser.membership = KnownMembership.Join; - adminUser.powerLevel = 100; - adminUser.user = User.createUser(adminUser.userId, client); - adminUser.user.currentlyActive = true; - adminUser.user.presence = "online"; - adminUser.user.lastPresenceTs = 1000; - adminUser.user.lastActiveAgo = 10; - adminUsers.push(adminUser); - - const moderatorUser = new RoomMember(memberListRoom.roomId, `@moderator${i}:localhost`); - moderatorUser.membership = KnownMembership.Join; - moderatorUser.powerLevel = 50; - moderatorUser.user = User.createUser(moderatorUser.userId, client); - moderatorUser.user.currentlyActive = true; - moderatorUser.user.presence = "online"; - moderatorUser.user.lastPresenceTs = 1000; - moderatorUser.user.lastActiveAgo = 10; - moderatorUsers.push(moderatorUser); - - const defaultUser = new RoomMember(memberListRoom.roomId, `@default${i}:localhost`); - defaultUser.membership = KnownMembership.Join; - defaultUser.powerLevel = 0; - defaultUser.user = User.createUser(defaultUser.userId, client); - defaultUser.user.currentlyActive = true; - defaultUser.user.presence = "online"; - defaultUser.user.lastPresenceTs = 1000; - defaultUser.user.lastActiveAgo = 10; - defaultUsers.push(defaultUser); - } - - client.getRoom = (roomId) => { - if (roomId === memberListRoom.roomId) return memberListRoom; - else return null; - }; - memberListRoom.currentState = { - members: {}, - getMember: jest.fn(), - getStateEvents: ((eventType, stateKey) => - stateKey === undefined ? [] : null) as RoomState["getStateEvents"], // ignore 3pid invites - } as unknown as RoomState; - for (const member of [...adminUsers, ...moderatorUsers, ...defaultUsers]) { - memberListRoom.currentState.members[member.userId] = member; - } - - const context = new TestSdkContext(); - context.client = client; - context.memberListStore.isPresenceEnabled = jest.fn().mockReturnValue(enablePresence); - root = render( - - - {}} /> - - , - ); - await waitFor(async () => { - expect(root.container.querySelectorAll(".mx_MemberTileView")).toHaveLength(usersPerLevel * 3); - }); - } - - async function reRenderMemberList(): Promise { - await act(async () => { - //@ts-ignore - client.emit(RoomStateEvent.Events, { - //@ts-ignore - getType: () => EventType.RoomThirdPartyInvite, - getRoomId: () => memberListRoom.roomId, - }); - }); - // todo: is this going to flake? - await new Promise((r) => setTimeout(r, 1000)); - } - - describe("MemberListView", () => { - beforeEach(async function () { - await renderMemberList(true); - }); - - it("Memberlist is re-rendered on unreachable presence event", async () => { - await act(async () => { - defaultUsers[0].user?.setPresenceEvent( - new MatrixEvent({ - type: "m.presence", - sender: defaultUsers[0].userId, - content: { - presence: "io.element.unreachable", - currently_active: false, - }, - }), - ); - }); - await waitFor(() => { - expect(root.container.querySelector(".mx_PresenceIconView_unavailable")).not.toBeNull(); - }); - }); - }); - - describe.each([true, false])("does order members correctly (presence %s)", (enablePresence) => { - beforeEach(async function () { - await renderMemberList(enablePresence); - }); - - describe("does order members correctly", () => { - // Note: even if presence is disabled, we still expect that the presence - // tests will pass. All expectOrderedByPresenceAndPowerLevel does is ensure - // the order is perceived correctly, regardless of what we did to the members. - - // Each of the 4 tests here is done to prove that the member list can meet - // all 4 criteria independently. Together, they should work. - - it("by presence state", async () => { - // Intentionally pick users that will confuse the power level sorting - const activeUsers = [defaultUsers[0]]; - const onlineUsers = [adminUsers[0]]; - const offlineUsers = [...moderatorUsers, ...adminUsers.slice(1), ...defaultUsers.slice(1)]; - activeUsers.forEach((u) => { - u.user!.currentlyActive = true; - u.user!.presence = "online"; - }); - onlineUsers.forEach((u) => { - u.user!.currentlyActive = false; - u.user!.presence = "online"; - }); - offlineUsers.forEach((u) => { - u.user!.currentlyActive = false; - u.user!.presence = "offline"; - }); - - await reRenderMemberList(); - - const tiles = root.container.querySelectorAll(".mx_MemberTileView"); - expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); - }); - - it("by power level", async () => { - // We already have admin, moderator, and default users so leave them alone - - await reRenderMemberList(); - - const tiles = root.container.querySelectorAll(".mx_EntityTile"); - expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); - }); - - it("by last active timestamp", async () => { - // Intentionally pick users that will confuse the power level sorting - // lastActiveAgoTs == lastPresenceTs - lastActiveAgo - const activeUsers = [defaultUsers[0]]; - const semiActiveUsers = [adminUsers[0]]; - const inactiveUsers = [...moderatorUsers, ...adminUsers.slice(1), ...defaultUsers.slice(1)]; - activeUsers.forEach((u) => { - u.powerLevel = 100; // set everyone to the same PL to avoid running that check - u.user!.lastPresenceTs = 1000; - u.user!.lastActiveAgo = 0; - }); - semiActiveUsers.forEach((u) => { - u.powerLevel = 100; - u.user!.lastPresenceTs = 1000; - u.user!.lastActiveAgo = 50; - }); - inactiveUsers.forEach((u) => { - u.powerLevel = 100; - u.user!.lastPresenceTs = 1000; - u.user!.lastActiveAgo = 100; - }); - - await reRenderMemberList(); - - const tiles = root.container.querySelectorAll(".mx_EntityTile"); - expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); - }); - - it("by name", async () => { - // Intentionally put everyone on the same level to force a name comparison - const allUsers = [...adminUsers, ...moderatorUsers, ...defaultUsers]; - allUsers.forEach((u) => { - u.user!.currentlyActive = true; - u.user!.presence = "online"; - u.user!.lastPresenceTs = 1000; - u.user!.lastActiveAgo = 0; - u.powerLevel = 100; - }); - - await reRenderMemberList(); - - const tiles = root.container.querySelectorAll(".mx_EntityTile"); - expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); - }); - }); - }); - - describe("MemberListHeaderView", () => { - beforeEach(async function () { - await renderMemberList(true); - }); - - it("Shows the correct member count", async () => { - expect(await screen.findByText("6 Members")).toBeVisible(); - }); - - it("Does not show search box when there's less than 20 members", async () => { - expect(screen.queryByPlaceholderText("Filter People...")).toBeNull(); - }); - - it("Shows search box when there's more than 20 members", async () => { - // Memberlist already has 6 members, add 14 more to make the total 20 - for (let i = 0; i < 14; ++i) { - const newMember = new RoomMember(memberListRoom.roomId, `@new${i}:localhost`); - newMember.membership = KnownMembership.Join; - newMember.powerLevel = 0; - newMember.user = User.createUser(newMember.userId, client); - newMember.user.currentlyActive = true; - newMember.user.presence = "online"; - newMember.user.lastPresenceTs = 1000; - newMember.user.lastActiveAgo = 10; - memberListRoom.currentState.members[newMember.userId] = newMember; - } - await reRenderMemberList(); - expect(screen.queryByPlaceholderText("Filter People...")).toBeVisible(); - }); - - describe("Invite button functionality", () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("Does not render invite button when user is not a member", async () => {}); - - it("does not render invite button UI customisation hides invites", async () => {}); - - it("Renders disabled invite button when current user is a member but does not have rights to invite", async () => { - jest.spyOn(memberListRoom, "getMyMembership").mockReturnValue(KnownMembership.Join); - jest.spyOn(memberListRoom, "canInvite").mockReturnValue(false); - mocked(shouldShowComponent).mockReturnValue(true); - await reRenderMemberList(); - expect(screen.getByRole("button", { name: "Invite" })).toHaveAttribute("aria-disabled", "true"); - }); - - it("Renders enabled invite button when current user is a member and has rights to invite", async () => { - jest.spyOn(memberListRoom, "getMyMembership").mockReturnValue(KnownMembership.Join); - jest.spyOn(memberListRoom, "canInvite").mockReturnValue(true); - mocked(shouldShowComponent).mockReturnValue(true); - await reRenderMemberList(); - expect(screen.getByRole("button", { name: "Invite" })).not.toHaveAttribute("aria-disabled", "true"); - }); - - it("Opens room inviter on button click", async () => { - jest.spyOn(defaultDispatcher, "dispatch"); - jest.spyOn(memberListRoom, "getMyMembership").mockReturnValue(KnownMembership.Join); - jest.spyOn(memberListRoom, "canInvite").mockReturnValue(true); - mocked(shouldShowComponent).mockReturnValue(true); - await reRenderMemberList(); - - fireEvent.click(screen.getByRole("button", { name: "Invite" })); - expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ - action: "view_invite", - roomId: memberListRoom.roomId, - }); - }); - }); - }); -}); diff --git a/test/unit-tests/components/views/rooms/memberlist/MemberListHeaderView-test.tsx b/test/unit-tests/components/views/rooms/memberlist/MemberListHeaderView-test.tsx new file mode 100644 index 0000000000..f7a6c2e3a6 --- /dev/null +++ b/test/unit-tests/components/views/rooms/memberlist/MemberListHeaderView-test.tsx @@ -0,0 +1,110 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2021 Šimon Brandner + +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 { fireEvent, screen } from "jest-matrix-react"; +import { RoomMember, User } from "matrix-js-sdk/src/matrix"; +import { KnownMembership } from "matrix-js-sdk/src/types"; +import { mocked } from "jest-mock"; + +import { shouldShowComponent } from "../../../../../../src/customisations/helpers/UIComponents"; +import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher"; +import { Rendered, renderMemberList } from "./common"; + +jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => ({ + shouldShowComponent: jest.fn(), +})); + +type Children = (args: { height: number; width: number }) => React.JSX.Element; +jest.mock("react-virtualized", () => { + const ReactVirtualized = jest.requireActual("react-virtualized"); + return { + ...ReactVirtualized, + AutoSizer: ({ children }: { children: Children }) => children({ height: 1000, width: 1000 }), + }; +}); +jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(1500); +jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(1500); + +describe("MemberListHeaderView", () => { + let rendered: Rendered; + + beforeEach(async function () { + rendered = await renderMemberList(true); + }); + + it("Shows the correct member count", async () => { + expect(await screen.findByText("6 Members")).toBeVisible(); + }); + + it("Does not show search box when there's less than 20 members", async () => { + expect(screen.queryByPlaceholderText("Filter People...")).toBeNull(); + }); + + it("Shows search box when there's more than 20 members", async () => { + const { memberListRoom, client, reRender } = rendered; + // Memberlist already has 6 members, add 14 more to make the total 20 + for (let i = 0; i < 14; ++i) { + const newMember = new RoomMember(memberListRoom.roomId, `@new${i}:localhost`); + newMember.membership = KnownMembership.Join; + newMember.powerLevel = 0; + newMember.user = User.createUser(newMember.userId, client); + newMember.user.currentlyActive = true; + newMember.user.presence = "online"; + newMember.user.lastPresenceTs = 1000; + newMember.user.lastActiveAgo = 10; + memberListRoom.currentState.members[newMember.userId] = newMember; + } + await reRender(); + expect(screen.queryByPlaceholderText("Filter People...")).toBeVisible(); + }); + + describe("Invite button functionality", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("Does not render invite button when user is not a member", async () => {}); + + it("does not render invite button UI customisation hides invites", async () => {}); + + it("Renders disabled invite button when current user is a member but does not have rights to invite", async () => { + const { memberListRoom, reRender } = rendered; + jest.spyOn(memberListRoom, "getMyMembership").mockReturnValue(KnownMembership.Join); + jest.spyOn(memberListRoom, "canInvite").mockReturnValue(false); + mocked(shouldShowComponent).mockReturnValue(true); + await reRender(); + expect(screen.getByRole("button", { name: "Invite" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("Renders enabled invite button when current user is a member and has rights to invite", async () => { + const { memberListRoom, reRender } = rendered; + jest.spyOn(memberListRoom, "getMyMembership").mockReturnValue(KnownMembership.Join); + jest.spyOn(memberListRoom, "canInvite").mockReturnValue(true); + mocked(shouldShowComponent).mockReturnValue(true); + await reRender(); + expect(screen.getByRole("button", { name: "Invite" })).not.toHaveAttribute("aria-disabled", "true"); + }); + + it("Opens room inviter on button click", async () => { + const { memberListRoom, reRender } = rendered; + jest.spyOn(defaultDispatcher, "dispatch"); + jest.spyOn(memberListRoom, "getMyMembership").mockReturnValue(KnownMembership.Join); + jest.spyOn(memberListRoom, "canInvite").mockReturnValue(true); + mocked(shouldShowComponent).mockReturnValue(true); + await reRender(); + + fireEvent.click(screen.getByRole("button", { name: "Invite" })); + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: "view_invite", + roomId: memberListRoom.roomId, + }); + }); + }); +}); diff --git a/test/unit-tests/components/views/rooms/memberlist/MemberListView-test.tsx b/test/unit-tests/components/views/rooms/memberlist/MemberListView-test.tsx new file mode 100644 index 0000000000..e49b642b6e --- /dev/null +++ b/test/unit-tests/components/views/rooms/memberlist/MemberListView-test.tsx @@ -0,0 +1,256 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2021 Šimon Brandner + +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, { act } from "react"; +import { waitFor } from "jest-matrix-react"; +import { Room, RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { filterConsole } from "../../../../../test-utils"; +import { Rendered, renderMemberList } from "./common"; + +jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => ({ + shouldShowComponent: jest.fn(), +})); + +type Children = (args: { height: number; width: number }) => React.JSX.Element; +jest.mock("react-virtualized", () => { + const ReactVirtualized = jest.requireActual("react-virtualized"); + return { + ...ReactVirtualized, + AutoSizer: ({ children }: { children: Children }) => children({ height: 1000, width: 1000 }), + }; +}); +jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(1500); +jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(1500); + +describe("MemberListView and MemberlistHeaderView", () => { + filterConsole( + "Age for event was not available, using `now - origin_server_ts` as a fallback. If the device clock is not correct issues might occur.", + ); + + function memberString(member: RoomMember): string { + if (!member) { + return "(null)"; + } else { + const u = member.user; + return ( + "(" + + member.name + + ", " + + member.powerLevel + + ", " + + (u ? u.lastActiveAgo : "") + + ", " + + (u ? u.getLastActiveTs() : "") + + ", " + + (u ? u.currentlyActive : "") + + ", " + + (u ? u.presence : "") + + ")" + ); + } + } + + function expectOrderedByPresenceAndPowerLevel( + memberListRoom: Room, + memberTiles: NodeListOf, + isPresenceEnabled: boolean, + ) { + let prevMember: RoomMember | undefined; + for (const tile of memberTiles) { + const memberA = prevMember; + const memberB = memberListRoom.currentState.members[tile.getAttribute("aria-label")!.split(" ")[0]]; + prevMember = memberB; // just in case an expect fails, set this early + if (!memberA) { + continue; + } + + console.log("COMPARING A VS B:", memberString(memberA), memberString(memberB)); + + const userA = memberA.user!; + const userB = memberB.user!; + + let groupChange = false; + + if (isPresenceEnabled) { + const convertPresence = (p: string) => (p === "unavailable" ? "online" : p); + const presenceIndex = (p: string) => { + const order = ["active", "online", "offline"]; + const idx = order.indexOf(convertPresence(p)); + return idx === -1 ? order.length : idx; // unknown states at the end + }; + + const idxA = presenceIndex(userA.currentlyActive ? "active" : userA.presence); + const idxB = presenceIndex(userB.currentlyActive ? "active" : userB.presence); + console.log("Comparing presence groups..."); + expect(idxB).toBeGreaterThanOrEqual(idxA); + groupChange = idxA !== idxB; + } else { + console.log("Skipped presence groups"); + } + + if (!groupChange) { + console.log("Comparing power levels..."); + expect(memberA.powerLevel).toBeGreaterThanOrEqual(memberB.powerLevel); + groupChange = memberA.powerLevel !== memberB.powerLevel; + } else { + console.log("Skipping power level check due to group change"); + } + + if (!groupChange) { + if (isPresenceEnabled) { + console.log("Comparing last active timestamp..."); + expect(userB.getLastActiveTs()).toBeLessThanOrEqual(userA.getLastActiveTs()); + groupChange = userA.getLastActiveTs() !== userB.getLastActiveTs(); + } else { + console.log("Skipping last active timestamp"); + } + } else { + console.log("Skipping last active timestamp check due to group change"); + } + + if (!groupChange) { + const nameA = memberA.name[0] === "@" ? memberA.name.slice(1) : memberA.name; + const nameB = memberB.name[0] === "@" ? memberB.name.slice(1) : memberB.name; + const collator = new Intl.Collator(); + const nameCompare = collator.compare(nameB, nameA); + console.log("Comparing name"); + expect(nameCompare).toBeGreaterThanOrEqual(0); + } else { + console.log("Skipping name check due to group change"); + } + } + } + + describe("MemberListView", () => { + let rendered: Rendered; + + beforeEach(async function () { + rendered = await renderMemberList(true); + }); + + it("Memberlist is re-rendered on unreachable presence event", async () => { + const { root, defaultUsers } = rendered; + await act(async () => { + defaultUsers[0].user?.setPresenceEvent( + new MatrixEvent({ + type: "m.presence", + sender: defaultUsers[0].userId, + content: { + presence: "io.element.unreachable", + currently_active: false, + }, + }), + ); + }); + await waitFor(() => { + expect(root.container.querySelector(".mx_PresenceIconView_unavailable")).not.toBeNull(); + }); + }); + }); + + describe.each([true, false])("does order members correctly (presence %s)", (enablePresence) => { + let rendered: Rendered; + + beforeEach(async function () { + rendered = await renderMemberList(enablePresence); + }); + + describe("does order members correctly", () => { + // Note: even if presence is disabled, we still expect that the presence + // tests will pass. All expectOrderedByPresenceAndPowerLevel does is ensure + // the order is perceived correctly, regardless of what we did to the members. + + // Each of the 4 tests here is done to prove that the member list can meet + // all 4 criteria independently. Together, they should work. + + it("by presence state", async () => { + const { adminUsers, defaultUsers, moderatorUsers, reRender, root, memberListRoom } = rendered; + // Intentionally pick users that will confuse the power level sorting + const activeUsers = [defaultUsers[0]]; + const onlineUsers = [adminUsers[0]]; + const offlineUsers = [...moderatorUsers, ...adminUsers.slice(1), ...defaultUsers.slice(1)]; + activeUsers.forEach((u) => { + u.user!.currentlyActive = true; + u.user!.presence = "online"; + }); + onlineUsers.forEach((u) => { + u.user!.currentlyActive = false; + u.user!.presence = "online"; + }); + offlineUsers.forEach((u) => { + u.user!.currentlyActive = false; + u.user!.presence = "offline"; + }); + + await reRender(); + + const tiles = root.container.querySelectorAll(".mx_MemberTileView"); + expectOrderedByPresenceAndPowerLevel(memberListRoom, tiles, enablePresence); + }); + + it("by power level", async () => { + const { reRender, root, memberListRoom } = rendered; + // We already have admin, moderator, and default users so leave them alone + + await reRender(); + + const tiles = root.container.querySelectorAll(".mx_EntityTile"); + expectOrderedByPresenceAndPowerLevel(memberListRoom, tiles, enablePresence); + }); + + it("by last active timestamp", async () => { + const { adminUsers, defaultUsers, moderatorUsers, reRender, root, memberListRoom } = rendered; + // Intentionally pick users that will confuse the power level sorting + // lastActiveAgoTs == lastPresenceTs - lastActiveAgo + const activeUsers = [defaultUsers[0]]; + const semiActiveUsers = [adminUsers[0]]; + const inactiveUsers = [...moderatorUsers, ...adminUsers.slice(1), ...defaultUsers.slice(1)]; + activeUsers.forEach((u) => { + u.powerLevel = 100; // set everyone to the same PL to avoid running that check + u.user!.lastPresenceTs = 1000; + u.user!.lastActiveAgo = 0; + }); + semiActiveUsers.forEach((u) => { + u.powerLevel = 100; + u.user!.lastPresenceTs = 1000; + u.user!.lastActiveAgo = 50; + }); + inactiveUsers.forEach((u) => { + u.powerLevel = 100; + u.user!.lastPresenceTs = 1000; + u.user!.lastActiveAgo = 100; + }); + + await reRender(); + + const tiles = root.container.querySelectorAll(".mx_EntityTile"); + expectOrderedByPresenceAndPowerLevel(memberListRoom, tiles, enablePresence); + }); + + it("by name", async () => { + const { adminUsers, defaultUsers, moderatorUsers, reRender, root, memberListRoom } = rendered; + // Intentionally put everyone on the same level to force a name comparison + const allUsers = [...adminUsers, ...moderatorUsers, ...defaultUsers]; + allUsers.forEach((u) => { + u.user!.currentlyActive = true; + u.user!.presence = "online"; + u.user!.lastPresenceTs = 1000; + u.user!.lastActiveAgo = 0; + u.powerLevel = 100; + }); + + await reRender(); + + const tiles = root.container.querySelectorAll(".mx_EntityTile"); + expectOrderedByPresenceAndPowerLevel(memberListRoom, tiles, enablePresence); + }); + }); + }); +}); diff --git a/test/unit-tests/components/views/rooms/MemberTileView-test.tsx b/test/unit-tests/components/views/rooms/memberlist/MemberTileView-test.tsx similarity index 92% rename from test/unit-tests/components/views/rooms/MemberTileView-test.tsx rename to test/unit-tests/components/views/rooms/memberlist/MemberTileView-test.tsx index 70d62510a0..f246fef4af 100644 --- a/test/unit-tests/components/views/rooms/MemberTileView-test.tsx +++ b/test/unit-tests/components/views/rooms/memberlist/MemberTileView-test.tsx @@ -13,13 +13,16 @@ import { UserVerificationStatus, DeviceVerificationStatus } from "matrix-js-sdk/ import { mocked } from "jest-mock"; import userEvent from "@testing-library/user-event"; -import * as TestUtils from "../../../../test-utils"; -import { RoomMember } from "../../../../../src/models/rooms/RoomMember"; +import * as TestUtils from "../../../../../test-utils"; +import { RoomMember } from "../../../../../../src/models/rooms/RoomMember"; import { getPending3PidInvites, sdkRoomMemberToRoomMember, -} from "../../../../../src/components/viewmodels/MemberListViewModel"; -import { RoomMemberTileView, ThreePidInviteTileView } from "../../../../../src/components/views/rooms/MemberTileView"; +} from "../../../../../../src/components/viewmodels/MemberListViewModel"; +import { + RoomMemberTileView, + ThreePidInviteTileView, +} from "../../../../../../src/components/views/rooms/MemberTileView"; describe("MemberTileView", () => { describe("RoomMemberTileView", () => { @@ -102,6 +105,7 @@ describe("MemberTileView", () => { room = new Room("!mytestroom:foo.org", cli, cli.getSafeUserId()); room.getLiveTimeline().addEvent( TestUtils.mkThirdPartyInviteEvent(cli.getSafeUserId(), "Foobar", room.roomId), + { toStartOfTimeline: false, addToState: true }, ); }); diff --git a/test/unit-tests/components/views/rooms/PresenceIconView-test.tsx b/test/unit-tests/components/views/rooms/memberlist/PresenceIconView-test.tsx similarity index 94% rename from test/unit-tests/components/views/rooms/PresenceIconView-test.tsx rename to test/unit-tests/components/views/rooms/memberlist/PresenceIconView-test.tsx index f5c363fa67..cb441df6dc 100644 --- a/test/unit-tests/components/views/rooms/PresenceIconView-test.tsx +++ b/test/unit-tests/components/views/rooms/memberlist/PresenceIconView-test.tsx @@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { render } from "jest-matrix-react"; -import AvatarPresenceIconView from "../../../../../src/components/views/rooms/PresenceIconView"; +import AvatarPresenceIconView from "../../../../../../src/components/views/rooms/PresenceIconView"; describe("", () => { it("renders correctly for presence=online", () => { diff --git a/test/unit-tests/components/views/rooms/__snapshots__/MemberTileView-test.tsx.snap b/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap similarity index 99% rename from test/unit-tests/components/views/rooms/__snapshots__/MemberTileView-test.tsx.snap rename to test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap index c086ae5c2a..4245e0f091 100644 --- a/test/unit-tests/components/views/rooms/__snapshots__/MemberTileView-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap @@ -218,7 +218,7 @@ exports[`MemberTileView ThreePidInviteTileView renders ThreePidInvite correctly data-testid="avatar-img" data-type="round" role="presentation" - style="--cpd-avatar-size: 36px;" + style="--cpd-avatar-size: 32px;" > F diff --git a/test/unit-tests/components/views/rooms/__snapshots__/PresenceIconView-test.tsx.snap b/test/unit-tests/components/views/rooms/memberlist/__snapshots__/PresenceIconView-test.tsx.snap similarity index 100% rename from test/unit-tests/components/views/rooms/__snapshots__/PresenceIconView-test.tsx.snap rename to test/unit-tests/components/views/rooms/memberlist/__snapshots__/PresenceIconView-test.tsx.snap diff --git a/test/unit-tests/components/views/rooms/memberlist/common.tsx b/test/unit-tests/components/views/rooms/memberlist/common.tsx new file mode 100644 index 0000000000..ded7227017 --- /dev/null +++ b/test/unit-tests/components/views/rooms/memberlist/common.tsx @@ -0,0 +1,139 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2021 Šimon Brandner + +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, { act } from "react"; +import { render, RenderResult, waitFor } from "jest-matrix-react"; +import { Room, MatrixClient, RoomState, RoomMember, User, EventType, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { KnownMembership } from "matrix-js-sdk/src/types"; + +import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; +import * as TestUtils from "../../../../../test-utils"; +import { SDKContext } from "../../../../../../src/contexts/SDKContext"; +import { TestSdkContext } from "../../../../TestSdkContext"; +import MemberListView from "../../../../../../src/components/views/rooms/MemberListView"; +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; + +export function createRoom(client: MatrixClient, opts = {}) { + const roomId = "!" + Math.random().toString().slice(2, 10) + ":domain"; + const room = new Room(roomId, client, client.getUserId()!); + if (opts) { + Object.assign(room, opts); + } + return room; +} + +export type Rendered = { + client: MatrixClient; + root: RenderResult; + memberListRoom: Room; + adminUsers: RoomMember[]; + moderatorUsers: RoomMember[]; + defaultUsers: RoomMember[]; + reRender: () => Promise; +}; + +export async function renderMemberList(enablePresence: boolean, usersPerLevel: number = 2): Promise { + TestUtils.stubClient(); + const client = MatrixClientPeg.safeGet(); + client.hasLazyLoadMembersEnabled = () => false; + + // Make room + const memberListRoom = createRoom(client); + expect(memberListRoom.roomId).toBeTruthy(); + + // Make users + const adminUsers = []; + const moderatorUsers = []; + const defaultUsers = []; + for (let i = 0; i < usersPerLevel; i++) { + const adminUser = new RoomMember(memberListRoom.roomId, `@admin${i}:localhost`); + adminUser.membership = KnownMembership.Join; + adminUser.powerLevel = 100; + adminUser.user = User.createUser(adminUser.userId, client); + adminUser.user.currentlyActive = true; + adminUser.user.presence = "online"; + adminUser.user.lastPresenceTs = 1000; + adminUser.user.lastActiveAgo = 10; + adminUsers.push(adminUser); + + const moderatorUser = new RoomMember(memberListRoom.roomId, `@moderator${i}:localhost`); + moderatorUser.membership = KnownMembership.Join; + moderatorUser.powerLevel = 50; + moderatorUser.user = User.createUser(moderatorUser.userId, client); + moderatorUser.user.currentlyActive = true; + moderatorUser.user.presence = "online"; + moderatorUser.user.lastPresenceTs = 1000; + moderatorUser.user.lastActiveAgo = 10; + moderatorUsers.push(moderatorUser); + + const defaultUser = new RoomMember(memberListRoom.roomId, `@default${i}:localhost`); + defaultUser.membership = KnownMembership.Join; + defaultUser.powerLevel = 0; + defaultUser.user = User.createUser(defaultUser.userId, client); + defaultUser.user.currentlyActive = true; + defaultUser.user.presence = "online"; + defaultUser.user.lastPresenceTs = 1000; + defaultUser.user.lastActiveAgo = 10; + defaultUsers.push(defaultUser); + } + + client.getRoom = (roomId) => { + if (roomId === memberListRoom.roomId) return memberListRoom; + else return null; + }; + memberListRoom.currentState = { + members: {}, + getMember: jest.fn(), + getStateEvents: ((eventType, stateKey) => (stateKey === undefined ? [] : null)) as RoomState["getStateEvents"], // ignore 3pid invites + } as unknown as RoomState; + for (const member of [...adminUsers, ...moderatorUsers, ...defaultUsers]) { + memberListRoom.currentState.members[member.userId] = member; + } + + const context = new TestSdkContext(); + context.client = client; + context.memberListStore.isPresenceEnabled = jest.fn().mockReturnValue(enablePresence); + const root = render( + + + {}} /> + + , + ); + await waitFor(async () => { + expect(root.container.querySelectorAll(".mx_MemberTileView")).toHaveLength(usersPerLevel * 3); + }); + + const reRender = createReRenderFunction(client, memberListRoom); + + return { + client, + root, + memberListRoom, + adminUsers, + moderatorUsers, + defaultUsers, + reRender, + }; +} + +function createReRenderFunction(client: MatrixClient, memberListRoom: Room): Rendered["reRender"] { + return async function (): Promise { + await act(async () => { + //@ts-ignore + client.emit(RoomStateEvent.Events, { + //@ts-ignore + getType: () => EventType.RoomThirdPartyInvite, + getRoomId: () => memberListRoom.roomId, + }); + }); + // todo: is this going to flake? + await new Promise((r) => setTimeout(r, 1000)); + }; +}