feat: show call participants in room list (Discord-style)
Docker / Docker Buildx (push) Has been cancelled
Build Debian package / Build package (release) Has been cancelled
Build and Deploy / prepare (release) Has been cancelled
Deploy release / Deploy to Cloudflare Pages (release) Has been cancelled
Build and Deploy / Trigger Pro pipeline (release) Has been cancelled
Build and Deploy / Windows arm64 (release) Has been cancelled
Build and Deploy / Windows x64 (release) Has been cancelled
Build and Deploy / macOS (release) Has been cancelled
Build and Deploy / Linux amd64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / Linux arm64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / ${{ needs.prepare.outputs.deploy == 'true' && 'Deploy' || 'Deploy (dry-run)' }} (release) Has been cancelled
Build and Deploy / Deploy builds to ESS (release) Has been cancelled

This commit is contained in:
sorB
2026-05-10 14:25:35 +02:00
parent b797925316
commit 3da363517f
4610 changed files with 827237 additions and 1 deletions
@@ -0,0 +1,116 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { renderHook, waitFor } from "jest-matrix-react";
import {
JoinRule,
type MatrixClient,
MatrixEvent,
type Room,
RoomEvent,
RoomMember,
User,
} from "matrix-js-sdk/src/matrix";
import { act } from "react";
import {
AvatarBadgeDecoration,
useRoomAvatarViewModel,
} from "../../../../../src/components/viewmodels/avatars/RoomAvatarViewModel";
import { createTestClient, mkStubRoom } from "../../../../test-utils";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import * as PresenceIndicatorModule from "../../../../../src/components/views/avatars/WithPresenceIndicator";
import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag";
jest.mock("../../../../../src/utils/room/getJoinedNonFunctionalMembers", () => ({
getJoinedNonFunctionalMembers: jest.fn().mockReturnValue([]),
}));
describe("RoomAvatarViewModel", () => {
let matrixClient: MatrixClient;
let room: Room;
beforeEach(() => {
matrixClient = createTestClient();
room = mkStubRoom("roomId", "roomName", matrixClient);
DMRoomMap.makeShared(matrixClient);
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined);
jest.spyOn(PresenceIndicatorModule, "useDmMember").mockReturnValue(null);
jest.spyOn(PresenceIndicatorModule, "usePresence").mockReturnValue(null);
});
it("should have badgeDecoration set to LowPriority", () => {
room.tags[DefaultTagID.LowPriority] = {};
const { result: vm } = renderHook(() => useRoomAvatarViewModel(room));
expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.LowPriority);
});
it("should have badgeDecoration set to VideoRoom", () => {
jest.spyOn(room, "isCallRoom").mockReturnValue(true);
const { result: vm } = renderHook(() => useRoomAvatarViewModel(room));
expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.VideoRoom);
});
it("should have badgeDecoration set to PublicRoom", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
const { result: vm } = renderHook(() => useRoomAvatarViewModel(room));
expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.PublicRoom);
});
it("should set badgeDecoration based on priority", () => {
// 1. Presence has the least priority
const user = User.createUser("userId", matrixClient);
const roomMember = new RoomMember(room.roomId, "userId");
roomMember.user = user;
jest.spyOn(PresenceIndicatorModule, "useDmMember").mockReturnValue(roomMember);
jest.spyOn(PresenceIndicatorModule, "usePresence").mockReturnValue(PresenceIndicatorModule.Presence.Online);
const { result: vm1 } = renderHook(() => useRoomAvatarViewModel(room));
expect(vm1.current.badgeDecoration).toBe(AvatarBadgeDecoration.Presence);
// 2. With presence and public room, presence takes precedence
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
// Render again, it's easier than mocking the event emitter.
const { result: vm, rerender } = renderHook(() => useRoomAvatarViewModel(room));
expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.PublicRoom);
// 3. With presence, public-room and video room, video room takes precedence
jest.spyOn(room, "isCallRoom").mockReturnValue(true);
rerender(room);
expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.VideoRoom);
// 4. With presence, public room, video room and low priority, low priority takes precedence
room.tags[DefaultTagID.LowPriority] = {};
act(() => room.emit(RoomEvent.Tags, new MatrixEvent(), room));
rerender(room);
expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.LowPriority);
});
it("should recompute isPublic when room changed", async () => {
const { result: vm, rerender } = renderHook((props) => useRoomAvatarViewModel(props), { initialProps: room });
expect(vm.current.badgeDecoration).not.toBe(AvatarBadgeDecoration.PublicRoom);
const publicRoom = mkStubRoom("roomId2", "roomName2", matrixClient);
jest.spyOn(publicRoom, "getJoinRule").mockReturnValue(JoinRule.Public);
rerender(publicRoom);
await waitFor(() => expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.PublicRoom));
});
it("should return presence", async () => {
const user = User.createUser("userId", matrixClient);
const roomMember = new RoomMember(room.roomId, "userId");
roomMember.user = user;
jest.spyOn(PresenceIndicatorModule, "useDmMember").mockReturnValue(roomMember);
jest.spyOn(PresenceIndicatorModule, "usePresence").mockReturnValue(PresenceIndicatorModule.Presence.Online);
const { result: vm } = renderHook(() => useRoomAvatarViewModel(room));
expect(vm.current.presence).toBe(PresenceIndicatorModule.Presence.Online);
});
});
@@ -0,0 +1,147 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { EventType, MsgType, type MatrixClient, type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
import { formatFullDate } from "../../../../../src/DateUtils";
import { _t } from "../../../../../src/languageHandler";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { RedactedBodyViewModel } from "../../../../../src/viewmodels/message-body/RedactedBodyViewModel";
import { mkEvent, mkRoom, stubClient } from "../../../../test-utils";
describe("RedactedBodyViewModel", () => {
let client: MatrixClient;
let room: Room;
let showTwelveHourSettingWatcher: ((...args: any[]) => void) | undefined;
const makeRedactedBecauseEvent = ({ sender, originServerTs }: { sender: string; originServerTs: number }) => ({
content: {},
event_id: "$redaction:example.com",
origin_server_ts: originServerTs,
redacts: "$message:example.com",
room_id: room.roomId,
sender,
type: EventType.RoomRedaction,
unsigned: {},
});
const makeRedactedEvent = ({
sender = "@alice:example.com",
redactedBecauseSender = sender,
originServerTs = Date.UTC(2022, 10, 17, 15, 58, 32),
}: {
sender?: string;
redactedBecauseSender?: string;
originServerTs?: number;
} = {}): MatrixEvent =>
mkEvent({
event: true,
type: EventType.RoomMessage,
user: sender,
room: room.roomId,
content: {
msgtype: MsgType.Text,
body: "Message",
},
unsigned: {
redacted_because: makeRedactedBecauseEvent({
sender: redactedBecauseSender,
originServerTs,
}),
},
});
beforeEach(() => {
client = stubClient();
room = mkRoom(client, "!room:example.com");
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client);
jest.spyOn(client, "getRoom").mockReturnValue(room);
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "showTwelveHourTimestamps",
);
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_settingName, _roomId, callbackFn) => {
showTwelveHourSettingWatcher = callbackFn as (...args: any[]) => void;
return "mock-show-twelve-hour-watcher";
});
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn());
});
it("builds self-redaction text and tooltip from the event", () => {
const event = makeRedactedEvent();
const vm = new RedactedBodyViewModel({ mxEvent: event });
expect(vm.getSnapshot()).toEqual({
text: "Message deleted",
tooltip: _t("timeline|redacted|tooltip", {
date: formatFullDate(new Date(Date.UTC(2022, 10, 17, 15, 58, 32)), true),
}),
});
});
it("uses the redacting member name when another user removed the message", () => {
jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice" } as any);
const event = makeRedactedEvent({
redactedBecauseSender: "@alice-redactor:example.com",
});
const vm = new RedactedBodyViewModel({ mxEvent: event });
expect(vm.getSnapshot().text).toBe("Message deleted by Alice");
});
it("updates the tooltip when showTwelveHourTimestamps changes", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
const event = makeRedactedEvent();
const vm = new RedactedBodyViewModel({ mxEvent: event });
const listener = jest.fn();
vm.subscribe(listener);
showTwelveHourSettingWatcher?.("showTwelveHourTimestamps", null, undefined, false, false);
expect(listener).not.toHaveBeenCalled();
showTwelveHourSettingWatcher?.("showTwelveHourTimestamps", null, undefined, true, true);
expect(listener).toHaveBeenCalledTimes(1);
expect(vm.getSnapshot().tooltip).toBe(
_t("timeline|redacted|tooltip", {
date: formatFullDate(new Date(Date.UTC(2022, 10, 17, 15, 58, 32)), true),
}),
);
});
it("setEvent is a no-op for the same event and updates for a different event", () => {
const originalEvent = makeRedactedEvent();
const updatedEvent = makeRedactedEvent({
redactedBecauseSender: "@moderator:example.com",
originServerTs: Date.UTC(2023, 0, 1, 12, 0, 0),
});
const vm = new RedactedBodyViewModel({ mxEvent: originalEvent });
const listener = jest.fn();
jest.spyOn(room, "getMember").mockReturnValue({ name: "Moderator" } as any);
vm.subscribe(listener);
vm.setEvent(originalEvent);
expect(listener).not.toHaveBeenCalled();
vm.setEvent(updatedEvent);
expect(listener).toHaveBeenCalledTimes(1);
expect(vm.getSnapshot().text).toBe("Message deleted by Moderator");
});
it("unwatches the timestamp setting when disposed", () => {
const vm = new RedactedBodyViewModel({ mxEvent: makeRedactedEvent() });
vm.dispose();
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("mock-show-twelve-hour-watcher");
});
});
@@ -0,0 +1,91 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { renderHook } from "jest-matrix-react";
import { act, type SyntheticEvent } from "react";
import { useRoomTopicViewModel } from "../../../../../src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel";
import { createTestClient, mkStubRoom } from "../../../../test-utils";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
import { onRoomTopicLinkClick } from "../../../../../src/components/views/elements/RoomTopic";
jest.mock("../../../../../src/components/views/elements/RoomTopic");
describe("RoomSummaryCardTopicViewModel", () => {
const client = createTestClient();
const mockRoom = mkStubRoom("!room:example.com", "Test Room", client);
const mockUserId = "@user:example.com";
const mockEvent = { preventDefault: jest.fn(), stopPropagation: jest.fn() } as unknown as SyntheticEvent;
beforeEach(() => {
// Mock room client's getSafeUserId
mockRoom.client.getSafeUserId = jest.fn().mockReturnValue(mockUserId);
jest.spyOn(defaultDispatcher, "dispatch");
(onRoomTopicLinkClick as jest.Mock).mockReset();
});
afterEach(() => {
jest.restoreAllMocks();
});
function render() {
return renderHook(() => useRoomTopicViewModel(mockRoom));
}
it("should initialize with expanded state", () => {
const { result } = render();
expect(result.current.expanded).toBe(true);
});
it("should toggle expanded state on click", async () => {
const { result } = render();
await act(() => {
result.current.onExpandedClick(mockEvent);
});
expect(result.current.expanded).toBe(false);
});
it("should handle edit click", () => {
const { result } = render();
result.current.onEditClick(mockEvent);
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "open_room_settings" });
});
it("should handle topic link clicks when the target is an anchor element", () => {
const { result } = render();
const mockAnchorEvent = { target: document.createElement("a") } as unknown as React.MouseEvent<HTMLElement>;
result.current.onTopicLinkClick(mockAnchorEvent);
expect(onRoomTopicLinkClick).toHaveBeenCalledWith(mockAnchorEvent);
});
it("should handle topic link clicks when the target is not an anchor element", () => {
const { result } = render();
const mockNonAnchorEvent = {
target: document.createElement("div"),
} as unknown as React.MouseEvent<HTMLElement>;
result.current.onTopicLinkClick(mockNonAnchorEvent);
expect(onRoomTopicLinkClick).not.toHaveBeenCalled();
});
describe("Topic editing permissions", () => {
it("should allow editing when user has permission", () => {
mockRoom.currentState.maySendStateEvent = jest.fn().mockReturnValue(true);
const { result } = render();
expect(result.current.canEditTopic).toBe(true);
});
it("should not allow editing when user lacks permission", () => {
mockRoom.currentState.maySendStateEvent = jest.fn().mockReturnValue(false);
const { result } = render();
expect(result.current.canEditTopic).toBe(false);
});
});
});
@@ -0,0 +1,265 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { act, renderHook, waitFor } from "jest-matrix-react";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { useRoomSummaryCardViewModel } from "../../../../../src/components/viewmodels/right_panel/RoomSummaryCardViewModel";
import { mkStubRoom, stubClient, withClientContextRenderOptions } from "../../../../test-utils";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag";
import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore";
import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases";
import Modal from "../../../../../src/Modal";
import { ShareDialog } from "../../../../../src/components/views/dialogs/ShareDialog";
import ExportDialog from "../../../../../src/components/views/dialogs/ExportDialog";
import { PollHistoryDialog } from "../../../../../src/components/views/dialogs/PollHistoryDialog";
import { ReportRoomDialog } from "../../../../../src/components/views/dialogs/ReportRoomDialog";
import { inviteToRoom } from "../../../../../src/utils/room/inviteToRoom";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import * as hooks from "../../../../../src/hooks/useAccountData";
import * as getTagsForRoomUtils from "../../../../../src/utils/room/getTagsForRoom";
jest.mock("../../../../../src/utils/room/inviteToRoom", () => ({
inviteToRoom: jest.fn(),
}));
describe("useRoomSummaryCardViewModel", () => {
let matrixClient: MatrixClient;
let room: Room;
let permalinkCreator: any;
const onSearchCancel = jest.fn();
beforeEach(() => {
matrixClient = stubClient();
room = mkStubRoom("roomId", "roomName", matrixClient);
permalinkCreator = {};
DMRoomMap.setShared({
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap);
jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue([]);
});
afterEach(() => {
jest.resetAllMocks();
});
function render() {
return renderHook(
() => useRoomSummaryCardViewModel(room, permalinkCreator, onSearchCancel),
withClientContextRenderOptions(matrixClient),
);
}
it("should return correct initial state", () => {
const { result } = render();
expect(result.current.isDirectMessage).toBe(false);
expect(result.current.isRoomEncrypted).toBe(false);
expect(result.current.isVideoRoom).toBe(false);
expect(result.current.isFavorite).toBe(false);
expect(result.current.pinCount).toBe(0);
expect(result.current.searchInputRef.current).toBe(null);
});
it("should handle room members click", () => {
const spy = jest.spyOn(RightPanelStore.instance, "pushCard");
const { result } = render();
result.current.onRoomMembersClick();
expect(spy).toHaveBeenCalledWith({ phase: RightPanelPhases.MemberList }, true);
});
it("should handle room settings click", () => {
const spy = jest.spyOn(defaultDispatcher, "dispatch");
const { result } = render();
result.current.onRoomSettingsClick(new Event("click"));
expect(spy).toHaveBeenCalledWith({ action: "open_room_settings" });
});
it("should handle leave room click", () => {
const spy = jest.spyOn(defaultDispatcher, "dispatch");
const { result } = render();
result.current.onLeaveRoomClick();
expect(spy).toHaveBeenCalledWith({
action: "leave_room",
room_id: room.roomId,
});
});
it("should handle room threads click", () => {
const spy = jest.spyOn(RightPanelStore.instance, "pushCard");
const { result } = render();
result.current.onRoomThreadsClick();
expect(spy).toHaveBeenCalledWith({ phase: RightPanelPhases.ThreadPanel }, true);
});
it("should handle room files click", () => {
const spy = jest.spyOn(RightPanelStore.instance, "pushCard");
const { result } = render();
result.current.onRoomFilesClick();
expect(spy).toHaveBeenCalledWith({ phase: RightPanelPhases.FilePanel }, true);
});
it("should handle room extensions click", () => {
const spy = jest.spyOn(RightPanelStore.instance, "pushCard");
const { result } = render();
result.current.onRoomExtensionsClick();
expect(spy).toHaveBeenCalledWith({ phase: RightPanelPhases.Extensions }, true);
});
it("should handle room pins click", () => {
const spy = jest.spyOn(RightPanelStore.instance, "pushCard");
const { result } = render();
result.current.onRoomPinsClick();
expect(spy).toHaveBeenCalledWith({ phase: RightPanelPhases.PinnedMessages }, true);
});
it("should handle room invite click", () => {
const { result } = render();
result.current.onInviteToRoomClick();
expect(inviteToRoom).toHaveBeenCalledWith(room);
});
describe("action that trigger a dialog", () => {
let createDialogSpy: jest.SpyInstance;
beforeEach(() => {
createDialogSpy = jest.spyOn(Modal, "createDialog").mockImplementation(() => ({
finished: Promise.resolve([false]),
close: jest.fn(),
}));
});
afterEach(() => {
createDialogSpy.mockRestore();
});
it("should handle room export click", async () => {
const { result } = render();
await act(async () => {
await result.current.onRoomExportClick();
});
expect(createDialogSpy).toHaveBeenCalledWith(ExportDialog, { room });
});
it("should handle room poll history click", async () => {
const { result } = render();
await act(async () => {
await result.current.onRoomPollHistoryClick();
});
expect(createDialogSpy).toHaveBeenCalledWith(PollHistoryDialog, {
room,
matrixClient,
permalinkCreator,
});
});
it("should handle room report click", async () => {
const { result } = render();
await act(async () => {
await result.current.onReportRoomClick();
});
expect(createDialogSpy).toHaveBeenCalledWith(ReportRoomDialog, { roomId: room.roomId });
});
it("should handle share room click", async () => {
const { result } = render();
await act(async () => {
await result.current.onShareRoomClick();
});
expect(createDialogSpy).toHaveBeenCalledWith(ShareDialog, {
target: room,
});
});
});
describe("favorite room state", () => {
it("should identify favorite rooms", () => {
jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue([DefaultTagID.Favourite]);
const { result } = render();
expect(result.current.isFavorite).toBe(true);
});
it("should identify non-favorite rooms", () => {
jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue([]);
const { result } = render();
expect(result.current.isFavorite).toBe(false);
});
});
describe("direct message state", () => {
it("should identify direct message rooms", async () => {
// Mock the direct rooms account data
const directRoomsList = {
"@user:domain.com": [room.roomId],
};
// Mock the useAccountData hook result
jest.spyOn(hooks, "useAccountData").mockReturnValue(directRoomsList);
const { result } = render();
await waitFor(() => {
expect(result.current.isDirectMessage).toBe(true);
});
});
it("should identify non-direct message rooms", async () => {
// Mock the direct rooms account data
const directRoomsList = {};
// Mock the useAccountData hook result
jest.spyOn(hooks, "useAccountData").mockReturnValue(directRoomsList);
const { result } = render();
await waitFor(() => {
expect(result.current.isDirectMessage).toBe(false);
});
});
});
describe("search input", () => {
it("should handle search input escape key", () => {
const directRoomsList = {};
jest.spyOn(hooks, "useAccountData").mockReturnValue(directRoomsList);
const { result } = render();
// Create a mock input element and set it as the current ref value
const mockInputElement = document.createElement("input");
mockInputElement.value = "some search text";
result.current.searchInputRef.current = mockInputElement;
const event = {
key: "Escape",
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
};
result.current.onUpdateSearchInput(event as any);
expect(onSearchCancel).toHaveBeenCalled();
expect(mockInputElement?.value).toBe("");
});
});
});
@@ -0,0 +1,220 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
EventType,
KnownMembership,
type MatrixClient,
MatrixEvent,
type Room,
RoomMember,
type User,
} from "matrix-js-sdk/src/matrix";
import { renderHook, waitFor } from "jest-matrix-react";
import { Action } from "../../../../../../src/dispatcher/actions";
import Modal from "../../../../../../src/Modal";
import MultiInviter from "../../../../../../src/utils/MultiInviter";
import { createTestClient, mkRoom, withClientContextRenderOptions } from "../../../../../test-utils";
import dis from "../../../../../../src/dispatcher/dispatcher";
import { useUserInfoBasicOptionsViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel";
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
import ErrorDialog from "../../../../../../src/components/views/dialogs/ErrorDialog";
jest.mock("../../../../../../src/dispatcher/dispatcher");
describe("<UserOptionsSection />", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const meUserId = "@me:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let defaultProps: { room: Room; member: User | RoomMember };
let mockClient: MatrixClient;
let room: Room;
beforeEach(() => {
mockClient = createTestClient();
room = mkRoom(mockClient, defaultRoomId);
defaultProps = {
member: defaultMember,
room,
};
DMRoomMap.makeShared(mockClient);
});
const renderUserInfoBasicOptionsViewModelHook = (
props: {
member: User | RoomMember;
room: Room;
} = defaultProps,
) => {
return renderHook(
() => useUserInfoBasicOptionsViewModel(props.room, props.member),
withClientContextRenderOptions(mockClient),
);
};
beforeEach(() => {
jest.clearAllMocks();
// Mock the current user account id. Which is different to the defaultMember which is the selected one
// When we want to mock the current user, needs to override this value
jest.spyOn(mockClient, "getUserId").mockReturnValue(meUserId);
jest.spyOn(mockClient, "getRoom").mockReturnValue(room);
});
it("should showInviteButton if current user can invite and selected user membership is LEAVE", () => {
// cant use mkRoomMember because instanceof check will failed in this case
const member: RoomMember = new RoomMember(defaultMember.userId, defaultMember.roomId);
const me: RoomMember = new RoomMember(meUserId, defaultMember.roomId);
console.log("member instanceof RoomMember", member instanceof RoomMember);
member.powerLevel = 1;
member.membership = KnownMembership.Leave;
me.powerLevel = 50;
me.membership = KnownMembership.Join;
const powerLevelEvents = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
invite: 50,
state_default: 0,
},
});
jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents);
// used to get the current me user
jest.spyOn(room, "getMember").mockReturnValue(me);
const { result } = renderUserInfoBasicOptionsViewModelHook({ ...defaultProps, member });
expect(result.current.showInviteButton).toBeTruthy();
});
it("should not showInviteButton if current cannot invite", () => {
const member: RoomMember = new RoomMember(defaultMember.userId, defaultMember.roomId);
const me: RoomMember = new RoomMember(meUserId, defaultMember.roomId);
member.powerLevel = 50;
member.membership = KnownMembership.Leave;
me.powerLevel = 0;
me.membership = KnownMembership.Join;
const powerLevelEvents = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
invite: 50,
state_default: 0,
},
});
jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents);
// used to get the current me user
jest.spyOn(room, "getMember").mockReturnValue(me);
const { result } = renderUserInfoBasicOptionsViewModelHook({ ...defaultProps, member });
expect(result.current.showInviteButton).toBeFalsy();
});
it("should not showInviteButton if selected user membership is not LEAVE", () => {
const member: RoomMember = new RoomMember(defaultMember.userId, defaultMember.roomId);
const me: RoomMember = new RoomMember(meUserId, defaultMember.roomId);
member.powerLevel = 50;
member.membership = KnownMembership.Join;
me.powerLevel = 50;
me.membership = KnownMembership.Join;
const powerLevelEvents = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
invite: 50,
state_default: 0,
},
});
jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents);
jest.spyOn(room, "getMember").mockReturnValue(me);
const { result } = renderUserInfoBasicOptionsViewModelHook({ ...defaultProps, member });
expect(result.current.showInviteButton).toBeFalsy();
});
it("should showInsertPillButton if room is not a space", () => {
jest.spyOn(room, "isSpaceRoom").mockReturnValue(false);
const { result } = renderUserInfoBasicOptionsViewModelHook();
expect(result.current.showInsertPillButton).toBeTruthy();
});
it("should not showInsertPillButton if room is a space", () => {
jest.spyOn(room, "isSpaceRoom").mockReturnValue(true);
const { result } = renderUserInfoBasicOptionsViewModelHook();
expect(result.current.showInsertPillButton).toBeFalsy();
});
it("should readReceiptButtonDisabled be true if all messages where read", () => {
jest.spyOn(room, "getEventReadUpTo").mockReturnValue(null);
const { result } = renderUserInfoBasicOptionsViewModelHook();
expect(result.current.readReceiptButtonDisabled).toBeTruthy();
});
it("should readReceiptButtonDisabled be false if some messages are available", () => {
jest.spyOn(room, "getEventReadUpTo").mockReturnValue("aneventId");
const { result } = renderUserInfoBasicOptionsViewModelHook();
expect(result.current.readReceiptButtonDisabled).toBeFalsy();
});
it("should readReceiptButtonDisabled be true if room is a space", () => {
jest.spyOn(room, "getEventReadUpTo").mockReturnValue("aneventId");
jest.spyOn(room, "isSpaceRoom").mockReturnValue(true);
const { result } = renderUserInfoBasicOptionsViewModelHook();
expect(result.current.readReceiptButtonDisabled).toBeTruthy();
});
it("firing onReadReceiptButton calls dispatch with correct event_id", () => {
const eventId = "aneventId";
jest.spyOn(room, "getEventReadUpTo").mockReturnValue(eventId);
jest.spyOn(room, "isSpaceRoom").mockReturnValue(false);
const { result } = renderUserInfoBasicOptionsViewModelHook();
result.current.onReadReceiptButton();
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_room",
event_id: eventId,
highlighted: true,
metricsTrigger: undefined,
room_id: defaultRoomId,
});
});
it("calling onInsertPillButton should calls dispatch", () => {
const { result } = renderUserInfoBasicOptionsViewModelHook();
result.current.onInsertPillButton();
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ComposerInsert,
userId: defaultMember.userId,
timelineRenderingType: "Room",
});
});
it("calling onInviteUserButton will call MultiInviter.invite", async () => {
// to save mocking, we will reject the call to .invite
const mockErrorMessage = new Error("test error message");
const spy = jest.spyOn(MultiInviter.prototype, "invite");
spy.mockRejectedValue(mockErrorMessage);
jest.spyOn(Modal, "createDialog");
const { result } = renderUserInfoBasicOptionsViewModelHook();
result.current.onInviteUserButton("roomId", new Event("click"));
// check that we have called .invite
expect(spy).toHaveBeenCalledWith([defaultMember.userId]);
await waitFor(() => {
// check that the test error message is displayed
expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, {
description: "test error message",
title: "Failed to invite",
});
});
});
});
@@ -0,0 +1,149 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { EventType, type MatrixClient, MatrixEvent, type Room, RoomMember, type User } from "matrix-js-sdk/src/matrix";
import { renderHook, waitFor } from "jest-matrix-react";
import { createTestClient, mkRoom, withClientContextRenderOptions } from "../../../../../test-utils";
import { useUserInfoBasicViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel";
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
import Modal from "../../../../../../src/Modal";
import QuestionDialog from "../../../../../../src/components/views/dialogs/QuestionDialog";
jest.mock("../../../../../../src/customisations/UserIdentifier", () => {
return {
getDisplayUserIdentifier: jest.fn().mockReturnValue("customUserIdentifier"),
};
});
describe("useUserInfoHeaderViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let mockClient: MatrixClient;
let defaultProps: {
member: User | RoomMember;
room: Room;
};
let room: Room;
beforeEach(() => {
mockClient = createTestClient();
mockClient.isSynapseAdministrator = jest.fn().mockResolvedValue(true);
mockClient.deactivateSynapseUser = jest.fn().mockResolvedValue({
id_server_unbind_result: "success",
});
room = mkRoom(mockClient, defaultRoomId);
defaultProps = {
member: defaultMember,
room,
};
DMRoomMap.makeShared(mockClient);
jest.spyOn(mockClient, "getRoom").mockReturnValue(room);
});
afterEach(() => {
jest.clearAllMocks();
});
const renderUserInfoBasicViewModelHook = (
props: {
member: User | RoomMember;
room: Room;
} = defaultProps,
) => {
return renderHook(
() => useUserInfoBasicViewModel(props.room, props.member),
withClientContextRenderOptions(mockClient),
);
};
it("should set showDeactivateButton value to true", async () => {
jest.spyOn(mockClient, "getDomain").mockReturnValue("example.com");
const { result } = renderUserInfoBasicViewModelHook();
// checking the synpase admin is an async operation, that is why we wait for it
await waitFor(() => {
expect(result.current.showDeactivateButton).toBe(true);
});
});
it("should set showDeactivateButton value to false because domain is not the same", async () => {
jest.spyOn(mockClient, "getDomain").mockReturnValue("toto.com");
const { result } = renderUserInfoBasicViewModelHook();
await waitFor(() => {
expect(result.current.showDeactivateButton).toBe(false);
});
});
it("should give powerlevels values", () => {
const powerLevelEvents = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
invite: 1,
state_default: 1,
},
});
jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents);
const { result } = renderUserInfoBasicViewModelHook();
expect(result.current.powerLevels).toStrictEqual({
invite: 1,
state_default: 1,
});
});
it("should set isRoomDMForMember to true if found in dmroommap", () => {
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue("id");
const { result } = renderUserInfoBasicViewModelHook();
expect(result.current.isRoomDMForMember).toBeTruthy();
});
it("should set isRoomDMForMember to false if not found in dmroommap", () => {
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined);
const { result } = renderUserInfoBasicViewModelHook();
expect(result.current.isRoomDMForMember).toBeFalsy();
});
it("should display modal and call deactivateSynapseUser when calling onSynapaseDeactivate", async () => {
const powerLevelEvents = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
invite: 1,
state_default: 1,
},
});
jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents);
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true, true, false]),
close: jest.fn(),
});
const { result } = renderUserInfoBasicViewModelHook();
await waitFor(() => result.current.onSynapseDeactivate());
await waitFor(() => {
expect(Modal.createDialog).toHaveBeenLastCalledWith(QuestionDialog, {
button: "Deactivate user",
danger: true,
description: (
<div>
Deactivating this user will remove their devices and prevent them from logging back in.
Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you
sure you want to deactivate this user?
</div>
),
title: "Deactivate user?",
});
});
expect(mockClient.deactivateSynapseUser).toHaveBeenCalledWith(defaultMember.userId);
});
});
@@ -0,0 +1,194 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { Device, type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
import { type Mocked } from "jest-mock";
import { UserVerificationStatus, type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { renderHook, waitFor } from "jest-matrix-react";
import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import { useUserInfoVerificationViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel";
describe("useUserInfoVerificationHeaderViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const defaultProps = {
devices: [] as Device[],
member: defaultMember,
};
let mockClient: MatrixClient;
let mockCrypto: Mocked<CryptoApi>;
beforeEach(() => {
mockCrypto = {
bootstrapSecretStorage: jest.fn(),
bootstrapCrossSigning: jest.fn(),
getCrossSigningKeyId: jest.fn(),
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
getUserDeviceInfo: jest.fn(),
getDeviceVerificationStatus: jest.fn(),
getUserVerificationStatus: jest.fn(),
isDehydrationSupported: jest.fn().mockResolvedValue(false),
startDehydration: jest.fn(),
getKeyBackupInfo: jest.fn().mockResolvedValue(null),
userHasCrossSigningKeys: jest.fn().mockResolvedValue(false),
} as unknown as Mocked<CryptoApi>;
mockClient = createTestClient();
jest.spyOn(mockClient, "doesServerSupportUnstableFeature").mockResolvedValue(true);
jest.spyOn(mockClient.secretStorage, "hasKey").mockResolvedValue(true);
jest.spyOn(mockClient, "getCrypto").mockReturnValue(mockCrypto);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
});
afterEach(() => {
jest.restoreAllMocks();
});
const renderUserInfoHeaderVerificationHook = (props = defaultProps) => {
return renderHook(
() => useUserInfoVerificationViewModel(props.member, props.devices),
withClientContextRenderOptions(mockClient),
);
};
it("should be able to verify user", async () => {
const notMeId = "@notMe";
const notMetMember = new RoomMember(defaultRoomId, notMeId);
const device1 = new Device({
deviceId: "d1",
userId: notMeId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
// mock the user as not verified
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
new UserVerificationStatus(false, false, false),
);
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
// the selected user is not the default user, so he can make user verification
const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [device1] });
await waitFor(() => {
const canVerify = result.current.canVerify;
expect(canVerify).toBeTruthy();
});
});
it("should not be able to verify user if user is not me", async () => {
const device1 = new Device({
deviceId: "d1",
userId: defaultMember.userId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
// mock the user as not verified
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
new UserVerificationStatus(false, false, false),
);
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
const { result } = renderUserInfoHeaderVerificationHook({ member: defaultMember, devices: [device1] });
await waitFor(() => {
const canVerify = result.current.canVerify;
expect(canVerify).toBeFalsy();
// if we cant verify the user the hasCrossSigningKeys value should also be undefined
expect(result.current.hasCrossSigningKeys).toBeUndefined();
});
});
it("should not be able to verify user if im already verified", async () => {
const notMeId = "@notMe";
const notMetMember = new RoomMember(defaultRoomId, notMeId);
const device1 = new Device({
deviceId: "d1",
userId: notMeId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
// mock the user as already verified
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
new UserVerificationStatus(true, true, false),
);
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
// the selected user is not the default user, so he can make user verification
const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [device1] });
await waitFor(() => {
const canVerify = result.current.canVerify;
expect(canVerify).toBeFalsy();
// if we cant verify the user the hasCrossSigningKeys value should also be undefined
expect(result.current.hasCrossSigningKeys).toBeUndefined();
});
});
it("should not be able to verify user there is no devices", async () => {
const notMeId = "@notMe";
const notMetMember = new RoomMember(defaultRoomId, notMeId);
// mock the user as not verified
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
new UserVerificationStatus(false, false, false),
);
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
// the selected user is not the default user, so he can make user verification
const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [] });
await waitFor(() => {
const canVerify = result.current.canVerify;
expect(canVerify).toBeFalsy();
// if we cant verify the user the hasCrossSigningKeys value should also be undefined
expect(result.current.hasCrossSigningKeys).toBeUndefined();
});
});
it("should get correct hasCrossSigningKeys values", async () => {
const notMeId = "@notMe";
const notMetMember = new RoomMember(defaultRoomId, notMeId);
const device1 = new Device({
deviceId: "d1",
userId: notMeId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
// mock the user as not verified
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
new UserVerificationStatus(false, false, false),
);
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
jest.spyOn(mockCrypto, "userHasCrossSigningKeys").mockResolvedValue(true);
const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [device1] });
await waitFor(() => {
const hasCrossSigningKeys = result.current.hasCrossSigningKeys;
expect(hasCrossSigningKeys).toBeTruthy();
});
});
});
@@ -0,0 +1,179 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
import { mocked, type Mocked } from "jest-mock";
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { renderHook } from "jest-matrix-react";
import { withClientContextRenderOptions } from "../../../../../test-utils";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import { useUserfoHeaderViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel";
import * as UseTimezone from "../../../../../../src/hooks/useUserTimezone";
import SdkConfig from "../../../../../../src/SdkConfig";
import Modal from "../../../../../../src/Modal";
import ImageView from "../../../../../../src/components/views/elements/ImageView";
import * as Media from "../../../../../../src/customisations/Media";
import { type IConfigOptions } from "../../../../../../src/IConfigOptions";
jest.mock("../../../../../../src/customisations/UserIdentifier", () => {
return {
getDisplayUserIdentifier: jest.fn().mockReturnValue("customUserIdentifier"),
};
});
describe("useUserInfoHeaderViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const defaultProps = {
member: defaultMember,
roomId: defaultRoomId,
};
let mockClient: Mocked<MatrixClient>;
let mockCrypto: Mocked<CryptoApi>;
const mockAvatarUrl = "mock-avatar-url";
const oldGet = SdkConfig.get;
beforeEach(() => {
mockCrypto = mocked({
getDeviceVerificationStatus: jest.fn(),
getUserDeviceInfo: jest.fn(),
userHasCrossSigningKeys: jest.fn().mockResolvedValue(false),
getUserVerificationStatus: jest.fn(),
isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false),
} as unknown as CryptoApi);
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn(),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue(mockAvatarUrl),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn(),
getCrypto: jest.fn().mockReturnValue(mockCrypto),
baseUrl: "homeserver.url",
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
});
afterEach(() => {
jest.restoreAllMocks();
});
const renderUserInfoHeaderViewModelHook = (props = defaultProps) => {
return renderHook(() => useUserfoHeaderViewModel(props), withClientContextRenderOptions(mockClient));
};
it("should give user timezone info", () => {
const defaultTZ = { timezone: "FR", friendly: "fr" };
jest.spyOn(UseTimezone, "useUserTimezone").mockReturnValue(defaultTZ);
const { result } = renderUserInfoHeaderViewModelHook();
const timezone = result.current.timezoneInfo;
expect(UseTimezone.useUserTimezone).toHaveBeenCalledWith(mockClient, defaultMember.userId);
expect(timezone).toEqual(defaultTZ);
});
it("should give correct showPresence value based on enablePresenceByHsUrl", () => {
jest.spyOn(SdkConfig, "get").mockImplementation((key: string) => {
if (key === "enable_presence_by_hs_url") {
return {
[mockClient.baseUrl]: false,
};
}
return oldGet(key as keyof IConfigOptions);
});
const { result } = renderUserInfoHeaderViewModelHook();
const showPresence = result.current.showPresence;
expect(showPresence).toBeFalsy();
});
it("should have default value true for showPresence", () => {
jest.spyOn(SdkConfig, "get").mockImplementation(() => false);
const { result } = renderUserInfoHeaderViewModelHook();
const showPresence = result.current.showPresence;
expect(showPresence).toBeTruthy();
});
it("should open image dialog when avatar is clicked", () => {
const props = Object.assign({}, defaultProps);
const spyModale = jest.spyOn(Modal, "createDialog");
const spyMedia = jest.spyOn(Media, "mediaFromMxc");
jest.spyOn(props.member, "getMxcAvatarUrl").mockReturnValue(mockAvatarUrl);
const { result } = renderUserInfoHeaderViewModelHook(props);
result.current.onMemberAvatarClick();
expect(spyModale).toHaveBeenCalledWith(
ImageView,
{
src: mockAvatarUrl,
name: defaultMember.name,
},
"mx_Dialog_lightbox",
undefined,
true,
);
expect(spyMedia).toHaveBeenCalledWith(mockAvatarUrl);
});
it("should not open image dialog when avatar url is null", () => {
const props = Object.assign({}, defaultProps);
const spyModale = jest.spyOn(Modal, "createDialog");
jest.spyOn(props.member, "getMxcAvatarUrl").mockReturnValue(mockAvatarUrl);
jest.spyOn(Media, "mediaFromMxc").mockReturnValue({
srcHttp: null,
isEncrypted: false,
srcMxc: "",
thumbnailMxc: undefined,
hasThumbnail: false,
thumbnailHttp: null,
getThumbnailHttp: function (width: number, height: number, mode?: "scale" | "crop"): string | null {
throw new Error("Function not implemented.");
},
getThumbnailOfSourceHttp: function (width: number, height: number, mode?: "scale" | "crop"): string | null {
throw new Error("Function not implemented.");
},
getSquareThumbnailHttp: function (dim: number): string | null {
throw new Error("Function not implemented.");
},
downloadSource: function (): Promise<Response> {
throw new Error("Function not implemented.");
},
});
const { result } = renderUserInfoHeaderViewModelHook(props);
result.current.onMemberAvatarClick();
expect(spyModale).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,222 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { renderHook } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import { RoomMember, MatrixEvent, type Room, EventType, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import { useUserInfoPowerlevelViewModel } from "../../../../../../src/components/viewmodels/right_panel/UserInfoPowerlevelViewModel";
import { withClientContextRenderOptions } from "../../../../../test-utils";
import { type IRoomPermissions } from "../../../../../../src/components/views/right_panel/UserInfo";
import Modal from "../../../../../../src/Modal";
import { warnSelfDemote } from "../../../../../../src/components/views/right_panel/UserInfo";
jest.mock("../../../../../../src/Modal", () => ({
createDialog: jest.fn(),
}));
jest.mock("../../../../../../src/components/views/right_panel/UserInfo", () => ({
warnSelfDemote: jest.fn(),
}));
describe("UserInfoAdminPowerlevelViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMeId = "@me:example.com";
const selfUser = new RoomMember(defaultRoomId, defaultMeId);
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const startPowerLevel = 50;
const changedPowerLevel = 100;
let mockClient: Mocked<MatrixClient>;
let mockRoom: Mocked<Room>;
let defaultProps: {
user: RoomMember;
room: Room;
roomPermissions: IRoomPermissions;
};
beforeEach(() => {
defaultProps = {
user: defaultMember,
room: mockRoom,
roomPermissions: {
modifyLevelMax: 100,
canEdit: false,
canInvite: false,
},
};
mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn(),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn().mockResolvedValueOnce({ event_id: "123" }),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
(Modal.createDialog as jest.Mock).mockImplementation(() => ({
finished: Promise.resolve([true]),
}));
(warnSelfDemote as jest.Mock).mockResolvedValue(true);
});
const renderComponentHook = (props = defaultProps, client = mockClient) => {
return renderHook(
() => useUserInfoPowerlevelViewModel(props.user, props.room),
withClientContextRenderOptions(client),
);
};
afterEach(() => {
jest.clearAllMocks();
});
it("should give default power level", () => {
const defaultPowerLevel = 1;
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: { users: { [defaultUserId]: defaultPowerLevel }, users_default: defaultPowerLevel },
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom });
expect(result.current.powerLevelUsersDefault).toBe(defaultPowerLevel);
});
it("handles successful power level change", async () => {
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: { users: { [defaultUserId]: startPowerLevel }, users_default: 1 },
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
mockClient.getSafeUserId.mockReturnValueOnce(defaultUserId);
mockClient.getUserId.mockReturnValueOnce(defaultUserId);
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
await result.current.onPowerChange(changedPowerLevel);
expect(mockClient.setPowerLevel).toHaveBeenCalledTimes(1);
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, changedPowerLevel);
});
it("shows warning when promoting user to higher power level", async () => {
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
users: {
[defaultUserId]: startPowerLevel,
[defaultMeId]: startPowerLevel,
},
users_default: 1,
},
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
mockClient.getUserId.mockReturnValue(defaultMeId);
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
await result.current.onPowerChange(changedPowerLevel);
expect(Modal.createDialog).toHaveBeenCalled();
expect(mockClient.setPowerLevel).toHaveBeenCalled();
});
it("shows warning when self-demoting", async () => {
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
users: { [defaultMeId]: changedPowerLevel },
users_default: 1,
},
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
mockClient.getUserId.mockReturnValue(defaultMeId);
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom, user: selfUser }, mockClient);
await result.current.onPowerChange(startPowerLevel);
expect(warnSelfDemote).toHaveBeenCalled();
expect(mockClient.setPowerLevel).toHaveBeenCalled();
});
it("cancels power level change when user declines warning", async () => {
(Modal.createDialog as jest.Mock).mockImplementation(() => ({
finished: Promise.resolve([false]),
}));
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
users: {
[defaultUserId]: startPowerLevel,
"@me:example.com": startPowerLevel,
},
users_default: 1,
},
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
mockClient.getUserId.mockReturnValue(defaultMeId);
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
await result.current.onPowerChange(changedPowerLevel);
expect(Modal.createDialog).toHaveBeenCalled();
expect(mockClient.setPowerLevel).not.toHaveBeenCalled();
});
it("handles missing power level event", async () => {
mockRoom.currentState.getStateEvents.mockReturnValue(null);
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
await result.current.onPowerChange(changedPowerLevel);
expect(mockClient.setPowerLevel).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,173 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { renderHook } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import { type Room, type MatrixClient, RoomMember, type IPowerLevelsContent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import {
type RoomAdminToolsContainerProps,
useUserInfoAdminToolsContainerViewModel,
} from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
import { withClientContextRenderOptions } from "../../../../../../test-utils";
describe("UserInfoAdminToolsContainerViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
let mockRoom: Mocked<Room>;
let mockClient: Mocked<MatrixClient>;
let mockPowerLevels: IPowerLevelsContent;
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let defaultContainerProps: RoomAdminToolsContainerProps;
beforeEach(() => {
mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockPowerLevels = {
users: {
"@currentuser:example.com": 100,
},
events: {},
state_default: 50,
ban: 50,
kick: 50,
redact: 50,
};
defaultContainerProps = {
room: mockRoom,
member: defaultMember,
powerLevels: mockPowerLevels,
};
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn().mockReturnValue(defaultUserId),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn(),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
});
const renderAdminToolsContainerHook = (props = defaultContainerProps) => {
return renderHook(
() => useUserInfoAdminToolsContainerViewModel(props),
withClientContextRenderOptions(mockClient),
);
};
describe("useUserInfoAdminToolsContainerViewModel", () => {
it("should return false when user is not in the room", () => {
mockRoom.getMember.mockReturnValue(null);
const { result } = renderAdminToolsContainerHook();
expect(result.current).toEqual({
isCurrentUserInTheRoom: false,
shouldShowKickButton: false,
shouldShowBanButton: false,
shouldShowMuteButton: false,
shouldShowRedactButton: false,
});
});
it("should not show kick, ban and mute buttons if user is me", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
const props = {
...defaultContainerProps,
room: mockRoom,
member: mockMeMember,
powerLevels: mockPowerLevels,
};
const { result } = renderAdminToolsContainerHook(props);
expect(result.current).toEqual({
isCurrentUserInTheRoom: true,
shouldShowKickButton: false,
shouldShowBanButton: false,
shouldShowMuteButton: false,
shouldShowRedactButton: true,
});
});
it("returns mute toggle button if conditions met", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
const defaultMemberWithPowerLevelAndJoinMembership = {
...defaultMember,
powerLevel: 0,
membership: KnownMembership.Join,
} as RoomMember;
const { result } = renderAdminToolsContainerHook({
...defaultContainerProps,
member: defaultMemberWithPowerLevelAndJoinMembership,
powerLevels: { events: { "m.room.power_levels": 1 } },
});
expect(result.current.shouldShowMuteButton).toBe(true);
});
it("should not show mute button for one's own member", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, mockClient.getSafeUserId());
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
mockClient.getUserId.mockReturnValueOnce(mockMeMember.userId);
const { result } = renderAdminToolsContainerHook({
...defaultContainerProps,
member: mockMeMember,
powerLevels: { events: { "m.room.power_levels": 100 } },
});
expect(result.current.shouldShowMuteButton).toBe(false);
});
});
});
@@ -0,0 +1,224 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { cleanup, renderHook } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import { type Room, type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import { type RoomAdminToolsProps } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
import { useBanButtonViewModel } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel";
import Modal from "../../../../../../../src/Modal";
import { withClientContextRenderOptions } from "../../../../../../test-utils";
describe("useBanButtonViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
let mockRoom: Mocked<Room>;
let mockSpace: Mocked<Room>;
let mockClient: Mocked<MatrixClient>;
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const memberWithBanMembership = { ...defaultMember, membership: KnownMembership.Ban } as RoomMember;
let defaultAdminToolsProps: RoomAdminToolsProps;
const createDialogSpy: jest.SpyInstance = jest.spyOn(Modal, "createDialog");
beforeEach(() => {
mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockSpace = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue("m.space"),
isSpaceRoom: jest.fn().mockReturnValue(true),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
defaultAdminToolsProps = {
room: mockRoom,
member: defaultMember,
isUpdating: false,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
};
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn().mockReturnValue(defaultUserId),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn(),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
mockRoom.getMember.mockReturnValue(defaultMember);
});
const renderBanButtonHook = (props = defaultAdminToolsProps) => {
return renderHook(() => useBanButtonViewModel(props), withClientContextRenderOptions(mockClient));
};
it("renders the correct labels for banned and unbanned members", () => {
// test for room
const propsWithBanMembership = {
...defaultAdminToolsProps,
member: memberWithBanMembership,
};
// defaultMember is not banned
const { result } = renderBanButtonHook();
expect(result.current.banLabel).toBe("Ban from room");
cleanup();
const { result: result2 } = renderBanButtonHook(propsWithBanMembership);
expect(result2.current.banLabel).toBe("Unban from room");
cleanup();
// test for space
const { result: result3 } = renderBanButtonHook({ ...defaultAdminToolsProps, room: mockSpace });
expect(result3.current.banLabel).toBe("Ban from space");
cleanup();
const { result: result4 } = renderBanButtonHook({
...propsWithBanMembership,
room: mockSpace,
});
expect(result4.current.banLabel).toBe("Unban from space");
cleanup();
});
it("clicking the ban or unban button calls Modal.createDialog with the correct arguments if user is not banned", async () => {
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
const propsWithSpace = {
...defaultAdminToolsProps,
room: mockSpace,
};
const { result } = renderBanButtonHook(propsWithSpace);
await result.current.onBanOrUnbanClick();
// check the last call arguments and the presence of the spaceChildFilter callback
expect(createDialogSpy).toHaveBeenLastCalledWith(
expect.any(Function),
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
"mx_ConfirmSpaceUserActionDialog_wrapper",
);
// test the spaceChildFilter callback
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
// make dummy values for myMember and theirMember, then we will test
// null vs their member followed by
// truthy my member vs their member
const mockMyMember = { powerLevel: 1 };
const mockTheirMember = { membership: "is not ban", powerLevel: 0 };
const mockRoom = {
getMember: jest
.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(mockTheirMember)
.mockReturnValueOnce(mockMyMember)
.mockReturnValueOnce(mockTheirMember),
currentState: {
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
},
};
expect(callback(mockRoom)).toBe(false);
expect(callback(mockRoom)).toBe(true);
});
it("clicking the ban or unban button calls Modal.createDialog with the correct arguments if user _is_ banned", async () => {
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
const propsWithBanMembership = {
...defaultAdminToolsProps,
member: memberWithBanMembership,
room: mockSpace,
};
const { result } = renderBanButtonHook(propsWithBanMembership);
await result.current.onBanOrUnbanClick();
// check the last call arguments and the presence of the spaceChildFilter callback
expect(createDialogSpy).toHaveBeenLastCalledWith(
expect.any(Function),
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
"mx_ConfirmSpaceUserActionDialog_wrapper",
);
// test the spaceChildFilter callback
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
// make dummy values for myMember and theirMember, then we will test
// null vs their member followed by
// my member vs their member
const mockMyMember = { powerLevel: 1 };
const mockTheirMember = { membership: KnownMembership.Ban, powerLevel: 0 };
const mockRoom = {
getMember: jest
.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(mockTheirMember)
.mockReturnValueOnce(mockMyMember)
.mockReturnValueOnce(mockTheirMember),
currentState: {
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
},
};
expect(callback(mockRoom)).toBe(false);
expect(callback(mockRoom)).toBe(true);
});
});
@@ -0,0 +1,232 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { cleanup, renderHook } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import { type Room, type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import { type RoomAdminToolsProps } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
import { useRoomKickButtonViewModel } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel";
import Modal from "../../../../../../../src/Modal";
import { withClientContextRenderOptions } from "../../../../../../test-utils";
describe("useRoomKickButtonViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
let mockRoom: Mocked<Room>;
let mockSpace: Mocked<Room>;
let mockClient: Mocked<MatrixClient>;
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const memberWithInviteMembership = { ...defaultMember, membership: KnownMembership.Invite } as RoomMember;
const memberWithJoinMembership = { ...defaultMember, membership: KnownMembership.Join } as RoomMember;
const createDialogSpy: jest.SpyInstance = jest.spyOn(Modal, "createDialog");
let defaultAdminToolsProps: RoomAdminToolsProps;
beforeEach(() => {
mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockSpace = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue("m.space"),
isSpaceRoom: jest.fn().mockReturnValue(true),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
defaultAdminToolsProps = {
room: mockRoom,
member: defaultMember,
isUpdating: false,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
};
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn().mockReturnValue(defaultUserId),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn(),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
// mock useContext to return mockClient
// jest.spyOn(React, "useContext").mockReturnValue(mockClient);
mockRoom.getMember.mockReturnValue(defaultMember);
});
afterEach(() => {
createDialogSpy.mockReset();
});
const renderKickButtonHook = (props = defaultAdminToolsProps) => {
return renderHook(() => useRoomKickButtonViewModel(props), withClientContextRenderOptions(mockClient));
};
it("renders nothing if member.membership is undefined", () => {
// .membership is undefined in our member by default
const { result } = renderKickButtonHook();
expect(result.current.canUserBeKicked).toBe(false);
});
it("renders something if member.membership is 'invite' or 'join'", () => {
let props = {
...defaultAdminToolsProps,
member: memberWithInviteMembership,
};
const { result } = renderKickButtonHook(props);
expect(result.current.canUserBeKicked).toBe(true);
cleanup();
props = {
...defaultAdminToolsProps,
member: memberWithJoinMembership,
};
const { result: result2 } = renderKickButtonHook(props);
expect(result2.current.canUserBeKicked).toBe(true);
});
it("renders the correct label", () => {
// test for room
const propsWithJoinMembership = {
...defaultAdminToolsProps,
member: memberWithJoinMembership,
};
const { result } = renderKickButtonHook(propsWithJoinMembership);
expect(result.current.kickLabel).toBe("Remove from room");
cleanup();
const propsWithInviteMembership = {
...defaultAdminToolsProps,
member: memberWithInviteMembership,
};
const { result: result2 } = renderKickButtonHook(propsWithInviteMembership);
expect(result2.current.kickLabel).toBe("Disinvite from room");
cleanup();
});
it("renders the correct label for space", () => {
const propsWithInviteMembership = {
...defaultAdminToolsProps,
room: mockSpace,
member: memberWithInviteMembership,
};
const propsWithJoinMembership = {
...defaultAdminToolsProps,
room: mockSpace,
member: memberWithJoinMembership,
};
const { result: result3 } = renderKickButtonHook(propsWithJoinMembership);
expect(result3.current.kickLabel).toBe("Remove from space");
cleanup();
const { result: result4 } = renderKickButtonHook(propsWithInviteMembership);
expect(result4.current.kickLabel).toBe("Disinvite from space");
cleanup();
});
it("clicking the kick button calls Modal.createDialog with the correct arguments when room is a space", async () => {
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
const propsWithInviteMembership = {
...defaultAdminToolsProps,
room: mockSpace,
member: memberWithInviteMembership,
};
const { result } = renderKickButtonHook(propsWithInviteMembership);
await result.current.onKickClick();
// check the last call arguments and the presence of the spaceChildFilter callback
expect(createDialogSpy).toHaveBeenLastCalledWith(
expect.any(Function),
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
"mx_ConfirmSpaceUserActionDialog_wrapper",
);
// test the spaceChildFilter callback
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
// make dummy values for myMember and theirMember, then we will test
// null vs their member followed by
// my member vs their member
const mockMyMember = { powerLevel: 1 };
const mockTheirMember = { membership: KnownMembership.Invite, powerLevel: 0 };
const mockRoom = {
getMember: jest
.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(mockTheirMember)
.mockReturnValueOnce(mockMyMember)
.mockReturnValueOnce(mockTheirMember),
currentState: {
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
},
};
expect(callback(mockRoom)).toBe(false);
expect(callback(mockRoom)).toBe(true);
});
});
@@ -0,0 +1,230 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { renderHook } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import {
type Room,
type MatrixClient,
RoomMember,
type MatrixEvent,
type ISendEventResponse,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import { type RoomAdminToolsProps } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
import { useMuteButtonViewModel } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel";
import { isMuted } from "../../../../../../../src/components/views/right_panel/UserInfo";
import { withClientContextRenderOptions } from "../../../../../../test-utils";
describe("useMuteButtonViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
let mockRoom: Mocked<Room>;
let mockClient: Mocked<MatrixClient>;
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let defaultAdminToolsProps: RoomAdminToolsProps;
beforeEach(() => {
mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
defaultAdminToolsProps = {
room: mockRoom,
member: defaultMember,
isUpdating: false,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
};
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn().mockReturnValue(defaultUserId),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn(),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
mockClient.setPowerLevel.mockImplementation(() => Promise.resolve({} as ISendEventResponse));
mockRoom.currentState.getStateEvents.mockReturnValueOnce({
getContent: jest.fn().mockReturnValue({
events: {
"m.room.message": 0,
},
events_default: 0,
}),
} as unknown as MatrixEvent);
jest.spyOn(mockClient, "setPowerLevel").mockImplementation(() => Promise.resolve({} as ISendEventResponse));
jest.spyOn(mockRoom.currentState, "getStateEvents").mockReturnValue({
getContent: jest.fn().mockReturnValue({
events: {
"m.room.message": 0,
},
events_default: 0,
}),
} as unknown as MatrixEvent);
});
afterEach(() => {
jest.clearAllMocks();
});
const renderMuteButtonHook = (props = defaultAdminToolsProps) => {
return renderHook(() => useMuteButtonViewModel(props), withClientContextRenderOptions(mockClient));
};
it("should early return when isUpdating=true", async () => {
const defaultMemberWithPowerLevelAndJoinMembership = {
...defaultMember,
powerLevel: 0,
membership: KnownMembership.Join,
} as RoomMember;
const { result } = renderMuteButtonHook({
...defaultAdminToolsProps,
member: defaultMemberWithPowerLevelAndJoinMembership,
isUpdating: true,
});
const resultClick = await result.current.onMuteButtonClick();
expect(resultClick).toBe(undefined);
});
it("should stop updating when level is NaN", async () => {
const { result } = renderMuteButtonHook({
...defaultAdminToolsProps,
member: defaultMember,
isUpdating: false,
});
jest.spyOn(mockRoom.currentState, "getStateEvents").mockReturnValueOnce({
getContent: jest.fn().mockReturnValue({
events: {
"m.room.message": NaN,
},
events_default: NaN,
}),
} as unknown as MatrixEvent);
await result.current.onMuteButtonClick();
expect(defaultAdminToolsProps.stopUpdating).toHaveBeenCalled();
});
it("should set powerlevel to default when user is muted", async () => {
const defaultMutedMember = {
...defaultMember,
powerLevel: -1,
membership: KnownMembership.Join,
} as RoomMember;
const { result } = renderMuteButtonHook({
...defaultAdminToolsProps,
member: defaultMutedMember,
isUpdating: false,
});
await result.current.onMuteButtonClick();
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, 0);
});
it("should set powerlevel - 1 when user is unmuted", async () => {
const defaultUnmutedMember = {
...defaultMember,
powerLevel: 0,
membership: KnownMembership.Join,
} as RoomMember;
const { result } = renderMuteButtonHook({
...defaultAdminToolsProps,
member: defaultUnmutedMember,
isUpdating: false,
});
await result.current.onMuteButtonClick();
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, -1);
});
it("returns false if either argument is falsy", () => {
// @ts-ignore to let us purposely pass incorrect args
expect(isMuted(defaultMember, null)).toBe(false);
// @ts-ignore to let us purposely pass incorrect args
expect(isMuted(null, {})).toBe(false);
});
it("when powerLevelContent.events and .events_default are undefined, returns false", () => {
const powerLevelContents = {};
expect(isMuted(defaultMember, powerLevelContents)).toBe(false);
});
it("when powerLevelContent.events is undefined, uses .events_default", () => {
const higherPowerLevelContents = { events_default: 10 };
expect(isMuted(defaultMember, higherPowerLevelContents)).toBe(true);
const lowerPowerLevelContents = { events_default: -10 };
expect(isMuted(defaultMember, lowerPowerLevelContents)).toBe(false);
});
it("when powerLevelContent.events is defined but '.m.room.message' isn't, uses .events_default", () => {
const higherPowerLevelContents = { events: {}, events_default: 10 };
expect(isMuted(defaultMember, higherPowerLevelContents)).toBe(true);
const lowerPowerLevelContents = { events: {}, events_default: -10 };
expect(isMuted(defaultMember, lowerPowerLevelContents)).toBe(false);
});
it("when powerLevelContent.events and '.m.room.message' are defined, uses the value", () => {
const higherPowerLevelContents = { events: { "m.room.message": -10 }, events_default: 10 };
expect(isMuted(defaultMember, higherPowerLevelContents)).toBe(false);
const lowerPowerLevelContents = { events: { "m.room.message": 10 }, events_default: -10 };
expect(isMuted(defaultMember, lowerPowerLevelContents)).toBe(true);
});
});
@@ -0,0 +1,98 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { renderHook } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import { type Room, type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import { useRedactMessagesButtonViewModel } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel";
import Modal from "../../../../../../../src/Modal";
import BulkRedactDialog from "../../../../../../../src/components/views/dialogs/BulkRedactDialog";
import { withClientContextRenderOptions } from "../../../../../../test-utils";
describe("useRedactMessagesButtonViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
let mockRoom: Mocked<Room>;
let mockClient: Mocked<MatrixClient>;
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
beforeEach(() => {
mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn().mockReturnValue(defaultUserId),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn(),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
});
const renderRedactButtonHook = (props = defaultMember) => {
return renderHook(() => useRedactMessagesButtonViewModel(props), withClientContextRenderOptions(mockClient));
};
it("should show BulkRedactDialog upon clicking the Remove messages button", async () => {
const spy = jest.spyOn(Modal, "createDialog");
mockClient.getRoom.mockReturnValue(mockRoom);
mockClient.getUserId.mockReturnValue("@arbitraryId:server");
const mockMeMember = new RoomMember(mockRoom.roomId, mockClient.getUserId()!);
mockMeMember.powerLevel = 51; // defaults to 50
const defaultMemberWithPowerLevel = { ...defaultMember, powerLevel: 0 } as RoomMember;
mockRoom.getMember.mockImplementation((userId) =>
userId === mockClient.getUserId() ? mockMeMember : defaultMemberWithPowerLevel,
);
const { result } = renderRedactButtonHook();
await result.current.onRedactAllMessagesClick();
expect(spy).toHaveBeenCalledWith(
BulkRedactDialog,
expect.objectContaining({ member: defaultMemberWithPowerLevel }),
);
});
});
@@ -0,0 +1,149 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { renderHook, waitFor } from "jest-matrix-react";
import { act } from "react";
import { mocked } from "jest-mock";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import type { BackupTrustInfo, KeyBackupCheck, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { useKeyStoragePanelViewModel } from "../../../../../../src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel";
import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils";
describe("KeyStoragePanelViewModel", () => {
let matrixClient: MatrixClient;
beforeEach(() => {
matrixClient = createTestClient();
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should update the pending value immediately", async () => {
const { result } = renderHook(
() => useKeyStoragePanelViewModel(),
withClientContextRenderOptions(matrixClient),
);
act(() => {
result.current.setEnabled(true);
});
expect(result.current.isEnabled).toBe(true);
expect(result.current.busy).toBe(true);
});
it("should update if a KeyBackupStatus event is received", async () => {
const { result } = renderHook(
() => useKeyStoragePanelViewModel(),
withClientContextRenderOptions(matrixClient),
);
await waitFor(() => expect(result.current.isEnabled).toBe(false));
const mock = mocked(matrixClient.getCrypto()!.getActiveSessionBackupVersion);
mock.mockResolvedValue("1");
matrixClient.emit(CryptoEvent.KeyBackupStatus, true);
await waitFor(() => expect(result.current.isEnabled).toBe(true));
mock.mockResolvedValue(null);
matrixClient.emit(CryptoEvent.KeyBackupStatus, false);
await waitFor(() => expect(result.current.isEnabled).toBe(false));
});
it("should call resetKeyBackup if there is no backup currently", async () => {
mocked(matrixClient.getCrypto()!.checkKeyBackupAndEnable).mockResolvedValue(null);
const { result } = renderHook(
() => useKeyStoragePanelViewModel(),
withClientContextRenderOptions(matrixClient),
);
await result.current.setEnabled(true);
expect(mocked(matrixClient.getCrypto()!.resetKeyBackup)).toHaveBeenCalled();
});
it.each<BackupTrustInfo>([
{ trusted: true, matchesDecryptionKey: false },
{ trusted: false, matchesDecryptionKey: true },
{ trusted: true, matchesDecryptionKey: true },
])("should not call resetKeyBackup if there is a backup currently and it is trusted", async (trustInfo) => {
mocked(matrixClient.getCrypto()!.checkKeyBackupAndEnable).mockResolvedValue({
backupInfo: {
version: "1",
algorithm: "foobar",
auth_data: {
public_key: "foobar",
},
},
trustInfo,
});
const { result } = renderHook(
() => useKeyStoragePanelViewModel(),
withClientContextRenderOptions(matrixClient),
);
await result.current.setEnabled(true);
expect(mocked(matrixClient.getCrypto()!.resetKeyBackup)).not.toHaveBeenCalled();
});
it("should call resetKeyBackup if there is a backup currently but it is not trusted", async () => {
mocked(matrixClient.getCrypto()!.checkKeyBackupAndEnable).mockResolvedValue({
backupInfo: {
version: "1",
algorithm: "foobar",
auth_data: {
public_key: "foobar",
},
},
trustInfo: {
trusted: false,
matchesDecryptionKey: false,
},
});
const { result } = renderHook(
() => useKeyStoragePanelViewModel(),
withClientContextRenderOptions(matrixClient),
);
await result.current.setEnabled(true);
expect(mocked(matrixClient.getCrypto()!.resetKeyBackup)).toHaveBeenCalled();
});
it("should set account data flag when enabling", async () => {
mocked(matrixClient.getCrypto()!.checkKeyBackupAndEnable).mockResolvedValue(null);
const { result } = renderHook(
() => useKeyStoragePanelViewModel(),
withClientContextRenderOptions(matrixClient),
);
await result.current.setEnabled(true);
expect(mocked(matrixClient.setAccountData)).toHaveBeenCalledWith("m.org.matrix.custom.backup_disabled", {
disabled: false,
});
});
it("should delete key storage when disabling", async () => {
mocked(matrixClient.getCrypto()!.checkKeyBackupAndEnable).mockResolvedValue({} as KeyBackupCheck);
mocked(matrixClient.getCrypto()!.getKeyBackupInfo).mockResolvedValue({ version: "99" } as KeyBackupInfo);
const { result } = renderHook(
() => useKeyStoragePanelViewModel(),
withClientContextRenderOptions(matrixClient),
);
await result.current.setEnabled(false);
expect(mocked(matrixClient.getCrypto()!.disableKeyStorage)).toHaveBeenCalled();
expect(mocked(matrixClient.setAccountData)).toHaveBeenCalledWith("m.org.matrix.custom.backup_disabled", {
disabled: true,
});
});
});