mv element.io @types __mocks__/ debian docker module_system/ playwright res src test webapp Dockerfile .dockerignore .eslintignore .stylelintrc.cjs babel.config.cjs recorder-worklet-loader.cjs .modernizr.json components.json config.json config.sample.json package.json project.json tsconfig.json tsconfig.module_system.json jest.config.ts playwright.config.ts webpack.config.ts build_config.sample.yaml apps/web/

mkdir apps/web/scripts
mv scripts/{cleanup.sh,ci_package.sh,copy-res.ts,deploy.py,package.sh} apps/web/scripts

And a couple of gitignore tweaks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2026-02-24 15:43:58 +00:00
parent e7509c92a1
commit 91a3cb03c1
3408 changed files with 28 additions and 32 deletions
@@ -0,0 +1,35 @@
/*
Copyright 2024 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 { fireEvent, render, screen } from "jest-matrix-react";
import BaseCard from "../../../../../src/components/views/right_panel/BaseCard.tsx";
import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore.ts";
jest.mock("../../../../../src/stores/right-panel/RightPanelStore", () => ({
instance: {
popCard: jest.fn(),
roomPhaseHistory: [],
},
}));
describe("<BaseCard />", () => {
it("should close when clicking X button", async () => {
const { asFragment } = render(
<BaseCard header="Heading text">
<div>Content</div>
</BaseCard>,
);
expect(screen.getByRole("heading")).toHaveTextContent("Heading text");
expect(asFragment()).toMatchSnapshot();
fireEvent.click(screen.getByTestId("base-card-close-button"));
expect(RightPanelStore.instance.popCard).toHaveBeenCalled();
});
});
@@ -0,0 +1,151 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
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 { mocked, type Mocked } from "jest-mock";
import { render, screen } from "jest-matrix-react";
import { type MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { MatrixWidgetType } from "matrix-widget-api";
import userEvent from "@testing-library/user-event";
import ExtensionsCard from "../../../../../src/components/views/right_panel/ExtensionsCard";
import { stubClient } from "../../../../test-utils";
import { type IApp } from "../../../../../src/stores/WidgetStore";
import WidgetUtils, { useWidgets } from "../../../../../src/utils/WidgetUtils";
import { WidgetLayoutStore } from "../../../../../src/stores/widgets/WidgetLayoutStore";
import { IntegrationManagers } from "../../../../../src/integrations/IntegrationManagers";
jest.mock("../../../../../src/utils/WidgetUtils");
describe("<ExtensionsCard />", () => {
let client: Mocked<MatrixClient>;
let room: Room;
beforeEach(() => {
client = mocked(stubClient());
room = new Room("!room:server", client, client.getSafeUserId());
mocked(WidgetUtils.getWidgetName).mockImplementation((app) => app?.name ?? "No Name");
});
it("should render empty state", () => {
mocked(useWidgets).mockReturnValue([]);
const { asFragment } = render(<ExtensionsCard room={room} onClose={jest.fn()} />);
expect(screen.getByText("Boost productivity with more tools, widgets and bots")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should render widgets", async () => {
mocked(useWidgets).mockReturnValue([
{
id: "id",
roomId: room.roomId,
eventId: "$event1",
creatorUserId: client.getSafeUserId(),
type: MatrixWidgetType.Custom,
name: "Custom Widget",
url: "http://url1",
},
{
id: "jitsi",
roomId: room.roomId,
eventId: "$event2",
creatorUserId: client.getSafeUserId(),
type: MatrixWidgetType.JitsiMeet,
name: "Jitsi",
url: "http://jitsi",
},
] satisfies IApp[]);
const { asFragment } = render(<ExtensionsCard room={room} onClose={jest.fn()} />);
expect(screen.getByText("Custom Widget")).toBeInTheDocument();
expect(screen.getByText("Jitsi")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should show context menu on widget row", async () => {
jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true);
mocked(useWidgets).mockReturnValue([
{
id: "id",
roomId: room.roomId,
eventId: "$event1",
creatorUserId: client.getSafeUserId(),
type: MatrixWidgetType.Custom,
name: "Custom Widget",
url: "http://url1",
},
] satisfies IApp[]);
const { container } = render(<ExtensionsCard room={room} onClose={jest.fn()} />);
await userEvent.click(container.querySelector(".mx_ExtensionsCard_app_options")!);
expect(document.querySelector(".mx_IconizedContextMenu")).toMatchSnapshot();
});
it("should show set room layout button", async () => {
jest.spyOn(WidgetLayoutStore.instance, "canCopyLayoutToRoom").mockReturnValue(true);
mocked(useWidgets).mockReturnValue([
{
id: "id",
roomId: room.roomId,
eventId: "$event1",
creatorUserId: client.getSafeUserId(),
type: MatrixWidgetType.Custom,
name: "Custom Widget",
url: "http://url1",
},
] satisfies IApp[]);
render(<ExtensionsCard room={room} onClose={jest.fn()} />);
expect(screen.getByText("Set layout for everyone")).toBeInTheDocument();
});
it("should show widget as pinned", async () => {
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true);
mocked(useWidgets).mockReturnValue([
{
id: "id",
roomId: room.roomId,
eventId: "$event1",
creatorUserId: client.getSafeUserId(),
type: MatrixWidgetType.Custom,
name: "Custom Widget",
url: "http://url1",
},
] satisfies IApp[]);
render(<ExtensionsCard room={room} onClose={jest.fn()} />);
expect(screen.getByText("Custom Widget").closest(".mx_ExtensionsCard_Button_pinned")).toBeInTheDocument();
});
it("should show cannot pin warning", async () => {
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false);
jest.spyOn(WidgetLayoutStore.instance, "canAddToContainer").mockReturnValue(false);
mocked(useWidgets).mockReturnValue([
{
id: "id",
roomId: room.roomId,
eventId: "$event1",
creatorUserId: client.getSafeUserId(),
type: MatrixWidgetType.Custom,
name: "Custom Widget",
url: "http://url1",
},
] satisfies IApp[]);
render(<ExtensionsCard room={room} onClose={jest.fn()} />);
expect(screen.getByLabelText("You can only pin up to 3 widgets")).toBeInTheDocument();
});
it("should should open integration manager on click", async () => {
jest.spyOn(IntegrationManagers.sharedInstance(), "hasManager").mockReturnValue(false);
const spy = jest.spyOn(IntegrationManagers.sharedInstance(), "openNoManagerDialog");
render(<ExtensionsCard room={room} onClose={jest.fn()} />);
await userEvent.click(screen.getByText("Add extensions"));
expect(spy).toHaveBeenCalled();
});
});
@@ -0,0 +1,396 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 { render, act, type RenderResult, waitForElementToBeRemoved, screen, waitFor } from "jest-matrix-react";
import { mocked, type MockedObject } from "jest-mock";
import {
MatrixEvent,
RoomStateEvent,
Room,
type IMinimalEvent,
EventType,
RelationType,
MsgType,
M_POLL_KIND_DISCLOSED,
EventTimeline,
type MatrixClient,
} from "matrix-js-sdk/src/matrix";
import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent";
import { PollEndEvent } from "matrix-js-sdk/src/extensible_events_v1/PollEndEvent";
import { sleep } from "matrix-js-sdk/src/utils";
import userEvent from "@testing-library/user-event";
import { stubClient, mkEvent, mkMessage, flushPromises } from "../../../../test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import { PinnedMessagesCard } from "../../../../../src/components/views/right_panel/PinnedMessagesCard";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import Modal from "../../../../../src/Modal";
import { UnpinAllDialog } from "../../../../../src/components/views/dialogs/UnpinAllDialog";
describe("<PinnedMessagesCard />", () => {
let cli: MockedObject<MatrixClient>;
beforeEach(() => {
stubClient();
cli = mocked(MatrixClientPeg.safeGet());
cli.getUserId.mockReturnValue("@alice:example.org");
cli.setRoomAccountData.mockResolvedValue({});
cli.relations.mockResolvedValue({ originalEvent: {} as unknown as MatrixEvent, events: [] });
});
const mkRoom = (localPins: MatrixEvent[], nonLocalPins: MatrixEvent[]): Room => {
const room = new Room("!room:example.org", cli, "@me:example.org");
// Deferred since we may be adding or removing pins later
const pins = () => [...localPins, ...nonLocalPins];
// Insert pin IDs into room state
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "getStateEvents").mockImplementation(
(): any =>
mkEvent({
event: true,
type: EventType.RoomPinnedEvents,
content: {
pinned: pins().map((e) => e.getId()),
},
user: "@user:example.org",
room: "!room:example.org",
}),
);
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue(
true,
);
// poll end event validates against this
jest.spyOn(
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"maySendRedactionForEvent",
).mockReturnValue(true);
// Return all pins over fetchRoomEvent
cli.fetchRoomEvent.mockImplementation((roomId, eventId) => {
const event = pins().find((e) => e.getId() === eventId)?.event;
return Promise.resolve(event as IMinimalEvent);
});
cli.getRoom.mockReturnValue(room);
return room;
};
async function renderMessagePinList(room: Room): Promise<RenderResult> {
const renderResult = render(
<MatrixClientContext.Provider value={cli}>
<PinnedMessagesCard
room={room}
onClose={jest.fn()}
permalinkCreator={new RoomPermalinkCreator(room, room.roomId)}
/>
</MatrixClientContext.Provider>,
);
// Wait a tick for state updates
await act(() => sleep(0));
return renderResult;
}
/**
*
* @param room
*/
async function emitPinUpdate(room: Room) {
await act(async () => {
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
roomState.emit(
RoomStateEvent.Events,
new MatrixEvent({ type: EventType.RoomPinnedEvents }),
roomState,
null,
);
});
}
/**
* Initialize the pinned messages card with the given pinned messages.
* Return the room, testing library helpers and functions to add and remove pinned messages.
* @param localPins
* @param nonLocalPins
*/
async function initPinnedMessagesCard(localPins: MatrixEvent[], nonLocalPins: MatrixEvent[]) {
const room = mkRoom(localPins, nonLocalPins);
const addLocalPinEvent = async (event: MatrixEvent) => {
localPins.push(event);
await emitPinUpdate(room);
};
const removeLastLocalPinEvent = async () => {
localPins.pop();
await emitPinUpdate(room);
};
const addNonLocalPinEvent = async (event: MatrixEvent) => {
nonLocalPins.push(event);
await emitPinUpdate(room);
};
const removeLastNonLocalPinEvent = async () => {
nonLocalPins.pop();
await emitPinUpdate(room);
};
const renderResult = await renderMessagePinList(room);
return {
...renderResult,
addLocalPinEvent,
removeLastLocalPinEvent,
addNonLocalPinEvent,
removeLastNonLocalPinEvent,
room,
};
}
const pin1 = mkMessage({
event: true,
room: "!room:example.org",
user: "@alice:example.org",
msg: "First pinned message",
ts: 2,
});
const pin2 = mkMessage({
event: true,
room: "!room:example.org",
user: "@alice:example.org",
msg: "The second one",
ts: 1,
});
it("should show spinner whilst loading", async () => {
const room = mkRoom([], [pin1]);
render(
<MatrixClientContext.Provider value={cli}>
<PinnedMessagesCard
room={room}
onClose={jest.fn()}
permalinkCreator={new RoomPermalinkCreator(room, room.roomId)}
/>
</MatrixClientContext.Provider>,
);
await waitForElementToBeRemoved(() => screen.queryAllByRole("progressbar"));
});
it("should show the empty state when there are no pins", async () => {
const { asFragment } = await initPinnedMessagesCard([], []);
expect(screen.getByText("Pin important messages so that they can be easily discovered")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should show two pinned messages", async () => {
const { asFragment } = await initPinnedMessagesCard([pin1], [pin2]);
await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(2));
expect(asFragment()).toMatchSnapshot();
});
it("should not show more than 100 messages", async () => {
const events = Array.from({ length: 120 }, (_, i) =>
mkMessage({
event: true,
room: "!room:example.org",
user: "@alice:example.org",
msg: `The message ${i}`,
ts: i,
}),
);
await initPinnedMessagesCard(events, []);
await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(100));
}, 15000);
it("should updates when messages are pinned", async () => {
// Start with nothing pinned
const { addLocalPinEvent, addNonLocalPinEvent } = await initPinnedMessagesCard([], []);
await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(0));
// Pin the first message
await addLocalPinEvent(pin1);
await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(1));
// Pin the second message
await addNonLocalPinEvent(pin2);
await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(2));
});
it("should updates when messages are unpinned", async () => {
// Start with two pins
const { removeLastLocalPinEvent, removeLastNonLocalPinEvent } = await initPinnedMessagesCard([pin1], [pin2]);
await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(2));
// Unpin the first message
await removeLastLocalPinEvent();
await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(1));
// Unpin the second message
await removeLastNonLocalPinEvent();
await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(0));
});
it("should display an edited pinned event", async () => {
const messageEvent = mkEvent({
event: true,
type: EventType.RoomMessage,
room: "!room:example.org",
user: "@alice:example.org",
content: {
"msgtype": MsgType.Text,
"body": " * First pinned message, edited",
"m.new_content": {
msgtype: MsgType.Text,
body: "First pinned message, edited",
},
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: pin1.getId(),
},
},
});
cli.relations.mockResolvedValue({
originalEvent: pin1,
events: [messageEvent],
});
await initPinnedMessagesCard([], [pin1]);
expect(screen.getByText("First pinned message, edited")).toBeInTheDocument();
});
describe("unpinnable event", () => {
it("should hide unpinnable events found in local timeline", async () => {
// Redacted messages are unpinnable
const pin = mkEvent({
event: true,
type: EventType.RoomCreate,
content: {},
room: "!room:example.org",
user: "@alice:example.org",
});
await initPinnedMessagesCard([pin], []);
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
});
it("hides unpinnable events not found in local timeline", async () => {
// Redacted messages are unpinnable
const pin = mkEvent({
event: true,
type: EventType.RoomCreate,
content: {},
room: "!room:example.org",
user: "@alice:example.org",
});
await initPinnedMessagesCard([], [pin]);
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
});
});
describe("unpin all", () => {
it("should not allow to unpinall", async () => {
const room = mkRoom([pin1], [pin2]);
jest.spyOn(
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"mayClientSendStateEvent",
).mockReturnValue(false);
const { asFragment } = render(
<MatrixClientContext.Provider value={cli}>
<PinnedMessagesCard
room={room}
onClose={jest.fn()}
permalinkCreator={new RoomPermalinkCreator(room, room.roomId)}
/>
</MatrixClientContext.Provider>,
);
// Wait a tick for state updates
await act(() => sleep(0));
expect(screen.queryByText("Unpin all messages")).toBeNull();
expect(asFragment()).toMatchSnapshot();
});
it("should allow unpinning all messages", async () => {
jest.spyOn(Modal, "createDialog");
const { room } = await initPinnedMessagesCard([pin1], [pin2]);
expect(screen.getByText("Unpin all messages")).toBeInTheDocument();
await userEvent.click(screen.getByText("Unpin all messages"));
// Should open the UnpinAllDialog dialog
expect(Modal.createDialog).toHaveBeenCalledWith(UnpinAllDialog, { roomId: room.roomId, matrixClient: cli });
});
});
it("should displays votes on polls not found in local timeline", async () => {
const poll = mkEvent({
...PollStartEvent.from("A poll", ["Option 1", "Option 2"], M_POLL_KIND_DISCLOSED).serialize(),
event: true,
room: "!room:example.org",
user: "@alice:example.org",
});
const answers = (poll.unstableExtensibleEvent as PollStartEvent).answers;
const responses = [
["@alice:example.org", 0] as [string, number],
["@bob:example.org", 0] as [string, number],
["@eve:example.org", 1] as [string, number],
].map(([user, option], i) =>
mkEvent({
...PollResponseEvent.from([answers[option as number].id], poll.getId()!).serialize(),
event: true,
room: "!room:example.org",
user,
}),
);
const end = mkEvent({
...PollEndEvent.from(poll.getId()!, "Closing the poll").serialize(),
event: true,
room: "!room:example.org",
user: "@alice:example.org",
});
// Make the responses available
cli.relations.mockImplementation(async (roomId, eventId, relationType, eventType, opts) => {
if (eventId === poll.getId() && relationType === RelationType.Reference) {
// Paginate the results, for added challenge
return opts?.from === "page2"
? { originalEvent: poll, events: responses.slice(2) }
: { originalEvent: poll, events: [...responses.slice(0, 2), end], nextBatch: "page2" };
}
// type does not allow originalEvent to be falsy
// but code seems to
// so still test that
return { originalEvent: undefined as unknown as MatrixEvent, events: [] };
});
const { room } = await initPinnedMessagesCard([], [poll]);
// two pages of results
await flushPromises();
await flushPromises();
const pollInstance = room.polls.get(poll.getId()!);
expect(pollInstance).toBeTruthy();
expect(screen.getByText("A poll")).toBeInTheDocument();
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.getByText("2 votes")).toBeInTheDocument();
expect(screen.getByText("Option 2")).toBeInTheDocument();
expect(screen.getByText("1 vote")).toBeInTheDocument();
});
});
@@ -0,0 +1,325 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
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 { render, fireEvent, screen } from "jest-matrix-react";
import { Room, type MatrixClient, JoinRule, MatrixEvent, HistoryVisibility } from "matrix-js-sdk/src/matrix";
import { mocked, type MockedObject } from "jest-mock";
import userEvent from "@testing-library/user-event";
import RoomSummaryCardView from "../../../../../src/components/views/right_panel/RoomSummaryCardView";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { flushPromises, stubClient } from "../../../../test-utils";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import { _t } from "../../../../../src/languageHandler";
import {
type RoomSummaryCardState,
useRoomSummaryCardViewModel,
} from "../../../../../src/components/viewmodels/right_panel/RoomSummaryCardViewModel";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
// Mock the viewmodel hooks
jest.mock("../../../../../src/components/viewmodels/right_panel/RoomSummaryCardViewModel", () => ({
useRoomSummaryCardViewModel: jest.fn(),
}));
describe("<RoomSummaryCard />", () => {
const userId = "@alice:domain.org";
const roomId = "!room:domain.org";
let mockClient!: MockedObject<MatrixClient>;
let room!: Room;
const getComponent = (props = {}) => {
const defaultProps = {
room,
onClose: jest.fn(),
permalinkCreator: new RoomPermalinkCreator(room),
};
return render(<RoomSummaryCardView {...defaultProps} {...props} />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={mockClient}>{children}</MatrixClientContext.Provider>
),
});
};
// Setup mock view models
const vmDefaultValues: RoomSummaryCardState = {
isDirectMessage: false,
isRoomEncrypted: false,
e2eStatus: undefined,
isVideoRoom: false,
roomJoinRule: JoinRule.Public,
historyVisibility: HistoryVisibility.Shared,
alias: "",
isFavorite: false,
canInviteToState: true,
pinCount: 0,
searchInputRef: { current: null },
onUpdateSearchInput: jest.fn(),
onRoomMembersClick: jest.fn(),
onRoomThreadsClick: jest.fn(),
onRoomFilesClick: jest.fn(),
onRoomExtensionsClick: jest.fn(),
onRoomPinsClick: jest.fn(),
onRoomSettingsClick: jest.fn(),
onLeaveRoomClick: jest.fn(),
onShareRoomClick: jest.fn(),
onRoomExportClick: jest.fn(),
onRoomPollHistoryClick: jest.fn(),
onReportRoomClick: jest.fn(),
onFavoriteToggleClick: jest.fn(),
onInviteToRoomClick: jest.fn(),
};
beforeEach(() => {
mockClient = mocked(stubClient());
room = new Room(roomId, mockClient, userId);
mocked(useRoomSummaryCardViewModel).mockReturnValue(vmDefaultValues);
DMRoomMap.makeShared(mockClient);
mockClient.getRoom.mockReturnValue(room);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("renders the room summary", () => {
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
it("renders the room topic in the summary", () => {
room.currentState.setStateEvents([
new MatrixEvent({
type: "m.room.topic",
room_id: roomId,
sender: userId,
content: {
topic: "This is the room's topic.",
},
state_key: "",
}),
]);
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
it("has button to edit topic", () => {
room.currentState.setStateEvents([
new MatrixEvent({
type: "m.room.topic",
room_id: roomId,
sender: userId,
content: {
topic: "This is the room's topic.",
},
state_key: "",
}),
]);
const { container, getByText } = getComponent();
expect(getByText("Edit")).toBeInTheDocument();
expect(container).toMatchSnapshot();
});
describe("search", () => {
it("has the search field", async () => {
const onSearchChange = jest.fn();
const { getByPlaceholderText } = getComponent({
onSearchChange,
});
expect(getByPlaceholderText("Search messages…")).toBeVisible();
});
it("should focus the search field if focusRoomSearch=true", () => {
const onSearchChange = jest.fn();
const { getByPlaceholderText } = getComponent({
onSearchChange,
focusRoomSearch: true,
});
expect(getByPlaceholderText("Search messages…")).toHaveFocus();
});
it("should cancel search on escape", () => {
const onSearchChange = jest.fn();
const onSearchCancel = jest.fn();
const { getByPlaceholderText } = getComponent({
onSearchChange,
onSearchCancel,
focusRoomSearch: true,
});
expect(getByPlaceholderText("Search messages…")).toHaveFocus();
fireEvent.keyDown(getByPlaceholderText("Search messages…"), { key: "Escape" });
expect(vmDefaultValues.onUpdateSearchInput).toHaveBeenCalled();
});
it("should update the search field value correctly", async () => {
const user = userEvent.setup();
const onSearchChange = jest.fn();
const { getByPlaceholderText } = getComponent({
onSearchChange,
});
const searchInput = getByPlaceholderText("Search messages…");
await user.type(searchInput, "test query");
expect(onSearchChange).toHaveBeenCalledWith("test query");
expect(searchInput).toHaveValue("test query");
});
});
it("opens room file panel on button click", () => {
const { getByText } = getComponent();
fireEvent.click(getByText("Files"));
expect(vmDefaultValues.onRoomFilesClick).toHaveBeenCalled();
});
it("opens room export dialog on button click", () => {
const { getByText } = getComponent();
fireEvent.click(getByText(_t("export_chat|title")));
expect(vmDefaultValues.onRoomExportClick).toHaveBeenCalled();
});
it("opens share room dialog on button click", () => {
const { getByText } = getComponent();
fireEvent.click(getByText(_t("action|copy_link")));
expect(vmDefaultValues.onShareRoomClick).toHaveBeenCalled();
});
it("opens invite dialog on button click", () => {
const { getByText } = getComponent();
fireEvent.click(getByText(_t("action|invite")));
expect(vmDefaultValues.onInviteToRoomClick).toHaveBeenCalled();
});
it("fires favourite dispatch on button click", () => {
const { getByText } = getComponent();
fireEvent.click(getByText(_t("room|context_menu|favourite")));
expect(vmDefaultValues.onFavoriteToggleClick).toHaveBeenCalled();
});
it("opens room settings on button click", () => {
const { getByText } = getComponent();
fireEvent.click(getByText(_t("common|settings")));
expect(vmDefaultValues.onRoomSettingsClick).toHaveBeenCalled();
});
it("opens room member list on button click", () => {
const { getByText } = getComponent();
fireEvent.click(getByText("People"));
expect(vmDefaultValues.onRoomMembersClick).toHaveBeenCalled();
});
it("opens room threads list on button click", () => {
const { getByText } = getComponent();
fireEvent.click(getByText("Threads"));
expect(vmDefaultValues.onRoomThreadsClick).toHaveBeenCalled();
});
it("opens room pinned messages on button click", () => {
const { getByText } = getComponent();
fireEvent.click(getByText("Pinned messages"));
expect(vmDefaultValues.onRoomPinsClick).toHaveBeenCalled();
});
it("does not render irrelevant options if video room", () => {
mocked(useRoomSummaryCardViewModel).mockReturnValue({
...vmDefaultValues,
isVideoRoom: true,
});
const { queryByText } = getComponent();
// options not rendered
expect(queryByText("Files")).not.toBeInTheDocument();
expect(queryByText("Pinned")).not.toBeInTheDocument();
expect(queryByText("Export chat")).not.toBeInTheDocument();
});
describe("pinning", () => {
it("renders pins options", () => {
const { getByText } = getComponent();
expect(getByText("Pinned messages")).toBeInTheDocument();
});
});
describe("poll history", () => {
it("renders poll history option", () => {
const { getByText } = getComponent();
expect(getByText("Polls")).toBeInTheDocument();
});
it("opens poll history dialog on button click", () => {
const permalinkCreator = new RoomPermalinkCreator(room);
const { getByText } = getComponent({ permalinkCreator });
fireEvent.click(getByText("Polls"));
expect(vmDefaultValues.onRoomPollHistoryClick).toHaveBeenCalled();
});
});
describe("public room label", () => {
it("does not show public room label for a DM", async () => {
mocked(useRoomSummaryCardViewModel).mockReturnValue({
...vmDefaultValues,
isDirectMessage: true,
});
getComponent();
await flushPromises();
expect(screen.queryByText("Public room")).not.toBeInTheDocument();
});
it("does not show public room label for non public room", async () => {
mocked(useRoomSummaryCardViewModel).mockReturnValue({
...vmDefaultValues,
isDirectMessage: false,
roomJoinRule: JoinRule.Invite,
});
getComponent();
await flushPromises();
expect(screen.queryByText("Public room")).not.toBeInTheDocument();
});
it("shows a public room label for a public room", async () => {
getComponent();
await flushPromises();
expect(screen.queryByText("Public room")).toBeInTheDocument();
});
});
});
@@ -0,0 +1,488 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 { render, screen, act, waitForElementToBeRemoved } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { type Mocked, mocked } from "jest-mock";
import {
type Room,
User,
type MatrixClient,
RoomMember,
Device,
ProfileKeyTimezone,
ProfileKeyMSC4175Timezone,
} from "matrix-js-sdk/src/matrix";
import { EventEmitter } from "events";
import {
UserVerificationStatus,
type VerificationRequest,
VerificationPhase as Phase,
VerificationRequestEvent,
type CryptoApi,
} from "matrix-js-sdk/src/crypto-api";
import UserInfo, { disambiguateDevices } from "../../../../../src/components/views/right_panel/UserInfo";
import { getPowerLevels } from "../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel";
import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import Modal from "../../../../../src/Modal";
import { clearAllModals, flushPromises } from "../../../../test-utils";
import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../../src/settings/UIFeature";
jest.mock("../../../../../src/utils/direct-messages", () => ({
...jest.requireActual("../../../../../src/utils/direct-messages"),
startDmOnFirstMessage: jest.fn(),
}));
jest.mock("../../../../../src/dispatcher/dispatcher");
jest.mock("../../../../../src/customisations/UserIdentifier", () => {
return {
getDisplayUserIdentifier: jest.fn().mockReturnValue("customUserIdentifier"),
};
});
jest.mock("../../../../../src/utils/DMRoomMap", () => {
const mock = {
getUserIdForRoomId: jest.fn(),
getDMRoomsForUserId: jest.fn(),
};
return {
shared: jest.fn().mockReturnValue(mock),
sharedInstance: mock,
};
});
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => {
const original = jest.requireActual("../../../../../src/customisations/helpers/UIComponents");
return {
shouldShowComponent: jest.fn().mockImplementation(original.shouldShowComponent),
};
});
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultUser = new User(defaultUserId);
let mockRoom: Mocked<Room>;
let mockClient: Mocked<MatrixClient>;
let mockCrypto: Mocked<CryptoApi>;
const origDate = global.Date.prototype.toLocaleString;
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);
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),
getExtendedProfile: 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(),
getCrypto: jest.fn().mockReturnValue(mockCrypto),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
});
describe("<UserInfo />", () => {
class MockVerificationRequest extends EventEmitter {
pending = true;
phase: Phase = Phase.Ready;
cancellationCode: string | null = null;
constructor(opts: Partial<VerificationRequest>) {
super();
Object.assign(this, {
channel: { transactionId: 1 },
otherPartySupportsMethod: jest.fn(),
generateQRCode: jest.fn().mockReturnValue(new Promise(() => {})),
...opts,
});
}
}
let verificationRequest: MockVerificationRequest;
const defaultProps = {
user: defaultUser,
// idk what is wrong with this type
phase: RightPanelPhases.MemberInfo as RightPanelPhases.MemberInfo,
onClose: jest.fn(),
};
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<UserInfo {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
beforeEach(() => {
verificationRequest = new MockVerificationRequest({});
});
afterEach(async () => {
await clearAllModals();
jest.clearAllMocks();
});
it("closes on close button click", async () => {
renderComponent();
await userEvent.click(screen.getByTestId("base-card-close-button"));
expect(defaultProps.onClose).toHaveBeenCalled();
});
describe("without a room", () => {
it("does not render space header", () => {
renderComponent();
expect(screen.queryByTestId("space-header")).not.toBeInTheDocument();
});
it("renders user info", () => {
renderComponent();
expect(screen.getByRole("heading", { name: defaultUserId })).toBeInTheDocument();
});
describe.each([[ProfileKeyTimezone], [ProfileKeyMSC4175Timezone]])("timezone rendering (%s)", (profileKey) => {
it("renders user timezone if set", async () => {
// For timezone, force a consistent locale.
jest.spyOn(global.Date.prototype, "toLocaleString").mockImplementation(function (
this: Date,
_locale,
opts,
) {
return origDate.call(this, "en-US", {
...opts,
hourCycle: "h12",
});
});
mockClient.doesServerSupportExtendedProfiles.mockResolvedValue(true);
mockClient.getExtendedProfile.mockResolvedValue({ [profileKey]: "Europe/London" });
renderComponent();
await expect(screen.findByText(/\d\d:\d\d (AM|PM)/)).resolves.toBeInTheDocument();
});
it("does not renders user timezone if timezone is invalid", async () => {
mockClient.doesServerSupportExtendedProfiles.mockResolvedValue(true);
mockClient.getExtendedProfile.mockResolvedValue({ [profileKey]: "invalid-tz" });
renderComponent();
expect(screen.queryByText(/\d\d:\d\d (AM|PM)/)).not.toBeInTheDocument();
});
});
it("renders encryption info panel without pending verification", () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel });
expect(screen.getByRole("heading", { name: /encryption/i })).toBeInTheDocument();
});
it("renders encryption verification panel with pending verification", () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel, verificationRequest });
expect(screen.queryByRole("heading", { name: /encryption/i })).not.toBeInTheDocument();
// the verificationRequest has phase of Phase.Ready but .otherPartySupportsMethod
// will not return true, so we expect to see the noCommonMethod error from VerificationPanel
expect(screen.getByText(/try with a different client/i)).toBeInTheDocument();
});
it("should show error modal when the verification request is cancelled with a mismatch", () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel, verificationRequest });
const spy = jest.spyOn(Modal, "createDialog");
act(() => {
verificationRequest.phase = Phase.Cancelled;
verificationRequest.cancellationCode = "m.key_mismatch";
verificationRequest.emit(VerificationRequestEvent.Change);
});
expect(spy).toHaveBeenCalledWith(
ErrorDialog,
expect.objectContaining({ title: "Your messages are not secure" }),
);
});
it("should not show error modal when the verification request is changed for some other reason", () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel, verificationRequest });
const spy = jest.spyOn(Modal, "createDialog");
// change to "started"
act(() => {
verificationRequest.phase = Phase.Started;
verificationRequest.emit(VerificationRequestEvent.Change);
});
// cancelled for some other reason
act(() => {
verificationRequest.phase = Phase.Cancelled;
verificationRequest.cancellationCode = "changed my mind";
verificationRequest.emit(VerificationRequestEvent.Change);
});
expect(spy).not.toHaveBeenCalled();
});
it("renders close button correctly when encryption panel with a pending verification request", async () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel, verificationRequest });
screen.getByTestId("base-card-close-button").focus();
expect(screen.getByText("Cancel")).toBeInTheDocument();
});
});
describe("with a room", () => {
it("renders user info", () => {
renderComponent({ room: mockRoom });
expect(screen.getByRole("heading", { name: defaultUserId })).toBeInTheDocument();
});
it("does not render space header when room is not a space room", () => {
renderComponent({ room: mockRoom });
expect(screen.queryByTestId("space-header")).not.toBeInTheDocument();
});
it("renders encryption info panel without pending verification", () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel, room: mockRoom });
expect(screen.getByRole("heading", { name: /encryption/i })).toBeInTheDocument();
});
it("renders encryption verification panel with pending verification", () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel, verificationRequest, room: mockRoom });
expect(screen.queryByRole("heading", { name: /encryption/i })).not.toBeInTheDocument();
// the verificationRequest has phase of Phase.Ready but .otherPartySupportsMethod
// will not return true, so we expect to see the noCommonMethod error from VerificationPanel
expect(screen.getByText(/try with a different client/i)).toBeInTheDocument();
});
it("renders the message button", () => {
render(
<MatrixClientContext.Provider value={mockClient}>
<UserInfo {...defaultProps} />
</MatrixClientContext.Provider>,
);
screen.getByRole("button", { name: "Send message" });
});
it("hides the message button if the visibility customisation hides all create room features", () => {
mocked(shouldShowComponent).withImplementation(
(component) => {
return component !== UIComponent.CreateRooms;
},
() => {
render(
<MatrixClientContext.Provider value={mockClient}>
<UserInfo {...defaultProps} />
</MatrixClientContext.Provider>,
);
expect(screen.queryByRole("button", { name: "Message" })).toBeNull();
},
);
});
describe("Ignore", () => {
const member = new RoomMember(defaultRoomId, defaultUserId);
it("shows block button when member userId does not match client userId", () => {
// call to client.getUserId returns undefined, which will not match member.userId
renderComponent();
expect(screen.getByRole("button", { name: "Ignore" })).toBeInTheDocument();
});
it("shows a modal before ignoring the user", async () => {
const originalCreateDialog = Modal.createDialog;
const modalSpy = (Modal.createDialog = jest.fn().mockReturnValue({
finished: Promise.resolve([true]),
close: () => {},
}));
try {
mockClient.getIgnoredUsers.mockReturnValue([]);
renderComponent();
await userEvent.click(screen.getByRole("button", { name: "Ignore" }));
expect(modalSpy).toHaveBeenCalled();
expect(mockClient.setIgnoredUsers).toHaveBeenLastCalledWith([member.userId]);
} finally {
Modal.createDialog = originalCreateDialog;
}
});
it("cancels ignoring the user", async () => {
const originalCreateDialog = Modal.createDialog;
const modalSpy = (Modal.createDialog = jest.fn().mockReturnValue({
finished: Promise.resolve([false]),
close: () => {},
}));
try {
mockClient.getIgnoredUsers.mockReturnValue([]);
renderComponent();
await userEvent.click(screen.getByRole("button", { name: "Ignore" }));
expect(modalSpy).toHaveBeenCalled();
expect(mockClient.setIgnoredUsers).not.toHaveBeenCalled();
} finally {
Modal.createDialog = originalCreateDialog;
}
});
it("unignores the user", async () => {
mockClient.isUserIgnored.mockReturnValue(true);
mockClient.getIgnoredUsers.mockReturnValue([member.userId]);
renderComponent();
await userEvent.click(screen.getByRole("button", { name: "Unignore" }));
expect(mockClient.setIgnoredUsers).toHaveBeenCalledWith([]);
});
});
});
describe("with crypto enabled", () => {
beforeEach(() => {
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(true);
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
const device = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const devicesMap = new Map<string, Device>([[device.deviceId, device]]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
});
it("renders <BasicUserInfo />", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
const { container } = renderComponent({
phase: RightPanelPhases.MemberInfo,
verificationRequest,
room: mockRoom,
});
await flushPromises();
expect(container).toMatchSnapshot();
});
it("should render a deactivate button for users of the same server if we are a server admin", async () => {
mockClient.isSynapseAdministrator.mockResolvedValue(true);
mockClient.getDomain.mockReturnValue("example.com");
const { container } = renderComponent({
phase: RightPanelPhases.MemberInfo,
room: mockRoom,
});
await expect(screen.findByRole("button", { name: "Deactivate user" })).resolves.toBeInTheDocument();
if (screen.queryAllByRole("progressbar").length) {
await act(() => waitForElementToBeRemoved(() => screen.queryAllByRole("progressbar")));
}
expect(container).toMatchSnapshot();
});
});
});
describe("disambiguateDevices", () => {
it("does not add ambiguous key to unique names", () => {
const initialDevices = [
{ deviceId: "id1", displayName: "name1" } as Device,
{ deviceId: "id2", displayName: "name2" } as Device,
{ deviceId: "id3", displayName: "name3" } as Device,
];
disambiguateDevices(initialDevices);
// mutates input so assert against initialDevices
initialDevices.forEach((device) => {
expect(device).not.toHaveProperty("ambiguous");
});
});
it("adds ambiguous key to all ids with non-unique names", () => {
const uniqueNameDevices = [
{ deviceId: "id3", displayName: "name3" } as Device,
{ deviceId: "id4", displayName: "name4" } as Device,
{ deviceId: "id6", displayName: "name6" } as Device,
];
const nonUniqueNameDevices = [
{ deviceId: "id1", displayName: "nonUnique" } as Device,
{ deviceId: "id2", displayName: "nonUnique" } as Device,
{ deviceId: "id5", displayName: "nonUnique" } as Device,
];
const initialDevices = [...uniqueNameDevices, ...nonUniqueNameDevices];
disambiguateDevices(initialDevices);
// mutates input so assert against initialDevices
uniqueNameDevices.forEach((device) => {
expect(device).not.toHaveProperty("ambiguous");
});
nonUniqueNameDevices.forEach((device) => {
expect(device).toHaveProperty("ambiguous", true);
});
});
});
describe("getPowerLevels", () => {
it("returns an empty object when room.currentState.getStateEvents return null", () => {
mockRoom.currentState.getStateEvents.mockReturnValueOnce(null);
expect(getPowerLevels(mockRoom)).toEqual({});
});
});
@@ -0,0 +1,215 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
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, render, waitFor } from "jest-matrix-react";
import React, { type ComponentProps } from "react";
import { User, TypedEventEmitter, Device, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { mocked, type Mocked } from "jest-mock";
import {
type EmojiMapping,
type ShowSasCallbacks,
VerificationPhase as Phase,
type VerificationRequest,
type VerificationRequestEvent,
type Verifier,
VerifierEvent,
type VerifierEventHandlerMap,
} from "matrix-js-sdk/src/crypto-api";
import VerificationPanel from "../../../../../src/components/views/right_panel/VerificationPanel";
import { flushPromises, stubClient } from "../../../../test-utils";
describe("<VerificationPanel />", () => {
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
});
describe("'Ready' phase (dialog mode)", () => {
it("should show a 'Start' button", () => {
const container = renderComponent({
request: makeMockVerificationRequest({
phase: Phase.Ready,
}),
layout: "dialog",
});
container.getByRole("button", { name: "Start" });
});
it("should show a QR code if the other side can scan and QR bytes are calculated", async () => {
const request = makeMockVerificationRequest({
phase: Phase.Ready,
});
request.generateQRCode.mockResolvedValue(new Uint8ClampedArray(Buffer.from("test", "utf-8")));
const container = renderComponent({
request: request,
layout: "dialog",
});
container.getByText("Scan this unique code");
// it shows a spinner at first; wait for the update which makes it show the QR code
await waitFor(() => {
container.getByAltText("QR Code");
});
});
});
describe("'Ready' phase (regular mode)", () => {
it("should show a 'Verify by emoji' button", () => {
const container = renderComponent({
request: makeMockVerificationRequest({ phase: Phase.Ready }),
});
container.getByRole("button", { name: "Verify by emoji" });
});
it("should show a QR code if the other side can scan and QR bytes are calculated", async () => {
const request = makeMockVerificationRequest({
phase: Phase.Ready,
});
request.generateQRCode.mockResolvedValue(new Uint8ClampedArray(Buffer.from("test", "utf-8")));
const container = renderComponent({
request: request,
member: new User("@other:user"),
});
container.getByText("Ask @other:user to scan your code:");
// it shows a spinner at first; wait for the update which makes it show the QR code
await waitFor(() => {
container.getByAltText("QR Code");
});
});
});
describe("'Verify by emoji' flow", () => {
let mockVerifier: Mocked<Verifier>;
let mockRequest: Mocked<VerificationRequest>;
beforeEach(() => {
mockVerifier = makeMockVerifier();
mockRequest = makeMockVerificationRequest({
verifier: mockVerifier as unknown as VerificationRequest["verifier"],
chosenMethod: "m.sas.v1",
});
});
it("shows a spinner initially", () => {
const { container } = renderComponent({
request: mockRequest,
phase: Phase.Started,
});
expect(container.getElementsByClassName("mx_Spinner").length).toBeTruthy();
});
it("should show some emojis once keys are exchanged", () => {
const { getAllByText } = renderComponent({
request: mockRequest,
phase: Phase.Started,
});
// fire the ShowSas event
const sasEvent = makeMockSasCallbacks();
mockVerifier.getShowSasCallbacks.mockReturnValue(sasEvent);
act(() => {
mockVerifier.emit(VerifierEvent.ShowSas, sasEvent);
});
expect(getAllByText("🦄")).toHaveLength(7);
expect(getAllByText("Unicorn")).toHaveLength(7);
});
describe("'Verify own device' flow", () => {
beforeEach(() => {
Object.defineProperty(mockRequest, "isSelfVerification", { get: () => true });
Object.defineProperty(mockRequest, "otherDeviceId", { get: () => "other_device" });
const otherDeviceDetails = new Device({
algorithms: [],
deviceId: "other_device",
keys: new Map(),
userId: "",
displayName: "my other device",
});
mocked(client.getCrypto()!).getUserDeviceInfo.mockResolvedValue(
new Map([[client.getSafeUserId(), new Map([["other_device", otherDeviceDetails]])]]),
);
});
it("should show 'Waiting for you to verify' after confirming", async () => {
const rendered = renderComponent({
request: mockRequest,
phase: Phase.Started,
});
// wait for the device to be looked up
await act(() => flushPromises());
// fire the ShowSas event
const sasEvent = makeMockSasCallbacks();
mockVerifier.getShowSasCallbacks.mockReturnValue(sasEvent);
act(() => {
mockVerifier.emit(VerifierEvent.ShowSas, sasEvent);
});
// confirm
act(() => {
rendered.getByRole("button", { name: "They match" }).click();
});
expect(rendered.container).toHaveTextContent(
"Waiting for you to verify on your other device, my other device (other_device)…",
);
});
});
});
});
function renderComponent(props: Partial<ComponentProps<typeof VerificationPanel>> & { request: VerificationRequest }) {
const defaultProps = {
layout: "",
member: {} as User,
onClose: () => {},
isRoomEncrypted: false,
inDialog: false,
phase: props.request.phase,
};
return render(<VerificationPanel {...defaultProps} {...props} />);
}
function makeMockVerificationRequest(props: Partial<VerificationRequest> = {}): Mocked<VerificationRequest> {
const request = new TypedEventEmitter<VerificationRequestEvent, any>();
Object.assign(request, {
cancel: jest.fn(),
otherPartySupportsMethod: jest.fn().mockReturnValue(true),
generateQRCode: jest.fn().mockResolvedValue(undefined),
...props,
});
return request as unknown as Mocked<VerificationRequest>;
}
function makeMockVerifier(): Mocked<Verifier> {
const verifier = new TypedEventEmitter<VerifierEvent, VerifierEventHandlerMap>();
Object.assign(verifier, {
cancel: jest.fn(),
verify: jest.fn(),
getShowSasCallbacks: jest.fn(),
getReciprocateQrCodeCallbacks: jest.fn(),
});
return verifier as unknown as Mocked<Verifier>;
}
function makeMockSasCallbacks(): ShowSasCallbacks {
const unicorn: EmojiMapping = ["🦄", "unicorn"];
return {
sas: {
emoji: new Array<EmojiMapping>(7).fill(unicorn),
},
cancel: jest.fn(),
confirm: jest.fn(),
mismatch: jest.fn(),
};
}
@@ -0,0 +1,58 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<BaseCard /> should close when clicking X button 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard"
>
<div
class="mx_BaseCard_header"
>
<div
class="mx_BaseCard_header_title"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 mx_BaseCard_header_title_heading"
role="heading"
>
Heading text
</p>
</div>
<button
aria-labelledby="_r_0_"
class="_icon-button_1215g_8"
data-kind="secondary"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_AutoHideScrollbar"
tabindex="-1"
>
<div>
Content
</div>
</div>
</div>
</DocumentFragment>
`;
@@ -0,0 +1,291 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<ExtensionsCard /> should render empty state 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard mx_ExtensionsCard"
>
<div
class="mx_BaseCard_header"
>
<div
class="mx_BaseCard_header_title"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 mx_BaseCard_header_title_heading"
role="heading"
>
Extensions
</p>
</div>
<button
aria-labelledby="_r_0_"
class="_icon-button_1215g_8"
data-kind="secondary"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_AutoHideScrollbar"
tabindex="-1"
>
<button
class="_button_13vu4_8 _has-icon_13vu4_60"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 13H6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 5 12q0-.424.287-.713A.97.97 0 0 1 6 11h5V6q0-.424.287-.713A.97.97 0 0 1 12 5q.424 0 .713.287Q13 5.576 13 6v5h5q.424 0 .712.287.288.288.288.713 0 .424-.288.713A.97.97 0 0 1 18 13h-5v5q0 .424-.287.712A.97.97 0 0 1 12 19a.97.97 0 0 1-.713-.288A.97.97 0 0 1 11 18z"
/>
</svg>
Add extensions
</button>
<div
class="_flex_4dswl_9 mx_EmptyState"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<svg
fill="currentColor"
height="32px"
viewBox="0 0 24 24"
width="32px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.25 11.672a.9.9 0 0 1-.663-.282L12.61 7.413a.9.9 0 0 1-.282-.663q0-.381.282-.663l3.977-3.977a.9.9 0 0 1 .663-.282q.381 0 .663.282l3.977 3.977a.9.9 0 0 1 .282.663.9.9 0 0 1-.282.663l-3.977 3.977a.9.9 0 0 1-.663.282m2.475-4.922L17.25 4.275 14.775 6.75l2.475 2.475zM4 11a.97.97 0 0 1-.712-.287A.97.97 0 0 1 3 10V4q0-.424.288-.712A.97.97 0 0 1 4 3h6q.424 0 .713.288Q11 3.575 11 4v6q0 .424-.287.713A.97.97 0 0 1 10 11zm5-2V5H5v4zm5 12a.97.97 0 0 1-.713-.288A.97.97 0 0 1 13 20v-6q0-.424.287-.713A.97.97 0 0 1 14 13h6q.424 0 .712.287.288.288.288.713v6q0 .424-.288.712A.97.97 0 0 1 20 21zm5-2v-4h-4v4zM4 21a.97.97 0 0 1-.712-.288A.97.97 0 0 1 3 20v-6q0-.424.288-.713A.97.97 0 0 1 4 13h6q.424 0 .713.287.287.288.287.713v6q0 .424-.287.712A.97.97 0 0 1 10 21zm5-2v-4H5v4z"
/>
</svg>
<p
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74"
>
Boost productivity with more tools, widgets and bots
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
>
Select “Add extensions” to browse and add extensions to this room
</p>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<ExtensionsCard /> should render widgets 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard mx_ExtensionsCard"
>
<div
class="mx_BaseCard_header"
>
<div
class="mx_BaseCard_header_title"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 mx_BaseCard_header_title_heading"
role="heading"
>
Extensions
</p>
</div>
<button
aria-labelledby="_r_6_"
class="_icon-button_1215g_8"
data-kind="secondary"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_AutoHideScrollbar"
tabindex="-1"
>
<button
class="_button_13vu4_8 _has-icon_13vu4_60"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 13H6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 5 12q0-.424.287-.713A.97.97 0 0 1 6 11h5V6q0-.424.287-.713A.97.97 0 0 1 12 5q.424 0 .713.287Q13 5.576 13 6v5h5q.424 0 .712.287.288.288.288.713 0 .424-.288.713A.97.97 0 0 1 18 13h-5v5q0 .424-.287.712A.97.97 0 0 1 12 19a.97.97 0 0 1-.713-.288A.97.97 0 0 1 11 18z"
/>
</svg>
Add extensions
</button>
<div
class="_separator_cqpyv_8"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<div
class="mx_BaseCard_Button mx_ExtensionsCard_Button"
>
<div
class="mx_AccessibleButton mx_ExtensionsCard_icon_app"
role="button"
tabindex="0"
>
<span
aria-label="Avatar"
class="_avatar_zysgz_8 mx_BaseAvatar mx_WidgetAvatar"
data-color="1"
data-testid="avatar-img"
data-type="round"
role="img"
style="--cpd-avatar-size: 24px;"
>
<img
alt=""
class="_image_zysgz_43"
data-type="round"
height="24px"
loading="lazy"
referrerpolicy="no-referrer"
src="image-file-stub"
width="24px"
/>
</span>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 mx_lineClamp"
>
Custom Widget
</p>
</div>
<div
aria-label="Pin"
class="mx_AccessibleButton mx_ExtensionsCard_app_pinToggle"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.769 2.857A.5.5 0 0 1 6.119 2h11.762a.5.5 0 0 1 .35.857L16.15 4.9a.5.5 0 0 0-.15.357v4.487a.5.5 0 0 0 .15.356l3.7 3.644a.5.5 0 0 1 .15.356v1.4a.5.5 0 0 1-.5.5H13v6a1 1 0 1 1-2 0v-6H4.5a.5.5 0 0 1-.5-.5v-1.4a.5.5 0 0 1 .15-.356l3.7-3.644A.5.5 0 0 0 8 9.744V5.257a.5.5 0 0 0-.15-.357z"
/>
</svg>
</div>
</div>
<div
class="mx_BaseCard_Button mx_ExtensionsCard_Button"
>
<div
class="mx_AccessibleButton mx_ExtensionsCard_icon_app"
role="button"
tabindex="0"
>
<span
aria-label="Avatar"
class="_avatar_zysgz_8 mx_BaseAvatar mx_WidgetAvatar"
data-color="1"
data-testid="avatar-img"
data-type="round"
role="img"
style="--cpd-avatar-size: 24px;"
>
<img
alt=""
class="_image_zysgz_43"
data-type="round"
height="24px"
loading="lazy"
referrerpolicy="no-referrer"
src="image-file-stub"
width="24px"
/>
</span>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 mx_lineClamp"
>
Jitsi
</p>
</div>
<div
aria-label="Pin"
class="mx_AccessibleButton mx_ExtensionsCard_app_pinToggle"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.769 2.857A.5.5 0 0 1 6.119 2h11.762a.5.5 0 0 1 .35.857L16.15 4.9a.5.5 0 0 0-.15.357v4.487a.5.5 0 0 0 .15.356l3.7 3.644a.5.5 0 0 1 .15.356v1.4a.5.5 0 0 1-.5.5H13v6a1 1 0 1 1-2 0v-6H4.5a.5.5 0 0 1-.5-.5v-1.4a.5.5 0 0 1 .15-.356l3.7-3.644A.5.5 0 0 0 8 9.744V5.257a.5.5 0 0 0-.15-.357z"
/>
</svg>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<ExtensionsCard /> should show context menu on widget row 1`] = `null`;
@@ -0,0 +1,526 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<PinnedMessagesCard /> should show the empty state when there are no pins 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard mx_PinnedMessagesCard"
>
<div
class="mx_BaseCard_header"
>
<div
class="mx_BaseCard_header_title"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 mx_BaseCard_header_title_heading"
role="heading"
>
Pinned messages
</p>
</div>
<button
aria-labelledby="_r_f_"
class="_icon-button_1215g_8"
data-kind="secondary"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_AutoHideScrollbar"
tabindex="-1"
>
<div
class="_flex_4dswl_9 mx_EmptyState"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<svg
fill="currentColor"
height="32px"
viewBox="0 0 24 24"
width="32px"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M6.119 2a.5.5 0 0 0-.35.857L7.85 4.9a.5.5 0 0 1 .15.357v4.487a.5.5 0 0 1-.15.356l-3.7 3.644A.5.5 0 0 0 4 14.1v1.4a.5.5 0 0 0 .5.5H11v6a1 1 0 1 0 2 0v-6h6.5a.5.5 0 0 0 .5-.5v-1.4a.5.5 0 0 0-.15-.356l-3.7-3.644a.5.5 0 0 1-.15-.356V5.257a.5.5 0 0 1 .15-.357l2.081-2.043a.5.5 0 0 0-.35-.857zM10 4h4v5.744a2.5 2.5 0 0 0 .746 1.781L17.26 14H6.74l2.514-2.475A2.5 2.5 0 0 0 10 9.744z"
fill-rule="evenodd"
/>
</svg>
<p
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74"
>
Pin important messages so that they can be easily discovered
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
>
Select a message and choose “Pin” to it include here.
</p>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<PinnedMessagesCard /> should show two pinned messages 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard mx_PinnedMessagesCard"
>
<div
class="mx_BaseCard_header"
>
<div
class="mx_BaseCard_header_title"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 mx_BaseCard_header_title_heading"
role="heading"
>
2 Pinned messages
</p>
</div>
<button
aria-labelledby="_r_l_"
class="_icon-button_1215g_8"
data-kind="secondary"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_AutoHideScrollbar"
tabindex="-1"
>
<div
class="mx_PinnedMessagesCard_wrapper mx_PinnedMessagesCard_wrapper_unpin_all"
role="list"
>
<div
class="mx_PinnedEventTile"
role="listitem"
>
<div>
<span
class="_avatar_zysgz_8 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_zysgz_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
title="@alice:example.org"
>
a
</span>
</div>
<div
class="mx_PinnedEventTile_wrapper"
>
<div
class="mx_PinnedEventTile_top"
>
<span
aria-labelledby="_r_s_"
class="mx_PinnedEventTile_sender mx_Username_color3"
>
@alice:example.org
</span>
<button
aria-describedby="_r_r_"
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open menu"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_11_"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
id="_r_r_"
>
<div
class="mx_EventTile_body translate"
dir="auto"
>
The second one
</div>
</div>
</div>
</div>
<div
class="_separator_cqpyv_8 mx_PinnedMessagesCard_Separator"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<div
class="mx_PinnedEventTile"
role="listitem"
>
<div>
<span
class="_avatar_zysgz_8 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_zysgz_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
title="@alice:example.org"
>
a
</span>
</div>
<div
class="mx_PinnedEventTile_wrapper"
>
<div
class="mx_PinnedEventTile_top"
>
<span
aria-labelledby="_r_14_"
class="mx_PinnedEventTile_sender mx_Username_color3"
>
@alice:example.org
</span>
<button
aria-describedby="_r_13_"
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open menu"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_19_"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
id="_r_13_"
>
<div
class="mx_EventTile_body translate"
dir="auto"
>
First pinned message
</div>
</div>
</div>
</div>
</div>
<div
class="mx_PinnedMessagesCard_unpin"
>
<button
class="_button_13vu4_8"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
Unpin all messages
</button>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<PinnedMessagesCard /> unpin all should not allow to unpinall 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard mx_PinnedMessagesCard"
>
<div
class="mx_BaseCard_header"
>
<div
class="mx_BaseCard_header_title"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 mx_BaseCard_header_title_heading"
role="heading"
>
2 Pinned messages
</p>
</div>
<button
aria-labelledby="_r_10f_"
class="_icon-button_1215g_8"
data-kind="secondary"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_AutoHideScrollbar"
tabindex="-1"
>
<div
class="mx_PinnedMessagesCard_wrapper"
role="list"
>
<div
class="mx_PinnedEventTile"
role="listitem"
>
<div>
<span
class="_avatar_zysgz_8 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_zysgz_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
title="@alice:example.org"
>
a
</span>
</div>
<div
class="mx_PinnedEventTile_wrapper"
>
<div
class="mx_PinnedEventTile_top"
>
<span
aria-labelledby="_r_10m_"
class="mx_PinnedEventTile_sender mx_Username_color3"
>
@alice:example.org
</span>
<button
aria-describedby="_r_10l_"
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open menu"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_10r_"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
id="_r_10l_"
>
<div
class="mx_EventTile_body translate"
dir="auto"
>
The second one
</div>
</div>
</div>
</div>
<div
class="_separator_cqpyv_8 mx_PinnedMessagesCard_Separator"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<div
class="mx_PinnedEventTile"
role="listitem"
>
<div>
<span
class="_avatar_zysgz_8 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_zysgz_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
title="@alice:example.org"
>
a
</span>
</div>
<div
class="mx_PinnedEventTile_wrapper"
>
<div
class="mx_PinnedEventTile_top"
>
<span
aria-labelledby="_r_10u_"
class="mx_PinnedEventTile_sender mx_Username_color3"
>
@alice:example.org
</span>
<button
aria-describedby="_r_10t_"
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open menu"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_113_"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
id="_r_10t_"
>
<div
class="mx_EventTile_body translate"
dir="auto"
>
First pinned message
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;
@@ -0,0 +1,646 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<UserInfo /> with crypto enabled renders <BasicUserInfo /> 1`] = `
<div>
<div
class="mx_BaseCard mx_UserInfo"
>
<div
class="mx_BaseCard_header"
>
<div
class="mx_BaseCard_header_title"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 mx_BaseCard_header_title_heading"
role="heading"
>
Profile
</p>
</div>
<button
aria-labelledby="_r_7c_"
class="_icon-button_1215g_8"
data-kind="secondary"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_AutoHideScrollbar"
tabindex="-1"
>
<div
class="mx_UserInfo_avatar"
>
<div
class="mx_UserInfo_avatar_transition"
>
<div
class="mx_UserInfo_avatar_transition_child"
>
<button
aria-label="Profile picture"
aria-live="off"
class="_avatar_zysgz_8 mx_BaseAvatar _avatar-imageless_zysgz_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 120px;"
>
u
</button>
</div>
</div>
</div>
<div
class="mx_UserInfo_container mx_UserInfo_header"
>
<div
class="_flex_4dswl_9 mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h1
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
dir="auto"
>
<div
class="_flex_4dswl_9 mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
@user:example.com
</div>
</h1>
<div
class="mx_PresenceLabel mx_UserInfo_profileStatus"
>
Unknown
</div>
<p
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36 mx_UserInfo_profile_mxid"
>
<div
class="mx_CopyableText"
>
customUserIdentifier
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 5H5v9h1a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1a1 1 0 1 1-2 0z"
/>
<path
d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z"
/>
</svg>
</div>
</div>
</p>
</div>
<div
class="_flex_4dswl_9 mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31 mx_UserInfo_verification_unavailable"
>
(
User verification unavailable
)
</p>
</div>
</div>
<div
class="mx_UserInfo_container"
>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m1.5 21.25 1.45-4.95a10.2 10.2 0 0 1-.712-2.1A10.2 10.2 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22q-1.125 0-2.2-.238a10.2 10.2 0 0 1-2.1-.712L2.75 22.5a.94.94 0 0 1-1-.25.94.94 0 0 1-.25-1m2.45-1.2 3.2-.95a1 1 0 0 1 .275-.062q.15-.013.275-.013.225 0 .438.038.212.036.412.137a7.4 7.4 0 0 0 1.675.6Q11.1 20 12 20q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12q0 .9.2 1.775t.6 1.675q.176.325.188.688t-.088.712z"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Send message
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26 _disabled_lqfwq_118"
data-kind="primary"
disabled=""
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Jump to read receipt
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 16a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 15V7.85L9.125 9.725q-.3.3-.7.3T7.7 9.7a.93.93 0 0 1-.288-.713A.98.98 0 0 1 7.7 8.3l3.6-3.6q.15-.15.325-.213.175-.062.375-.062t.375.062a.9.9 0 0 1 .325.213l3.6 3.6q.3.3.287.712a.98.98 0 0 1-.287.688q-.3.3-.713.313a.93.93 0 0 1-.712-.288L13 7.85V15q0 .424-.287.713A.97.97 0 0 1 12 16m-6 4q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 18v-2q0-.424.287-.713A.97.97 0 0 1 5 15q.424 0 .713.287Q6 15.576 6 16v2h12v-2q0-.424.288-.713A.97.97 0 0 1 19 15q.424 0 .712.287.288.288.288.713v2q0 .824-.587 1.413A1.93 1.93 0 0 1 18 20z"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Share profile
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
</div>
<div
class="mx_UserInfo_container"
>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26"
data-kind="critical"
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 22a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22m0-2q3.35 0 5.675-2.325T20 12q0-1.35-.437-2.6A8 8 0 0 0 18.3 7.1L7.1 18.3q1.05.825 2.3 1.262T12 20m-6.3-3.1L16.9 5.7a8 8 0 0 0-2.3-1.263A7.8 7.8 0 0 0 12 4Q8.65 4 6.325 6.325T4 12q0 1.35.438 2.6A8 8 0 0 0 5.7 16.9"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Ignore
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
`;
exports[`<UserInfo /> with crypto enabled should render a deactivate button for users of the same server if we are a server admin 1`] = `
<div>
<div
class="mx_BaseCard mx_UserInfo"
>
<div
class="mx_BaseCard_header"
>
<div
class="mx_BaseCard_header_title"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 mx_BaseCard_header_title_heading"
role="heading"
>
Profile
</p>
</div>
<button
aria-labelledby="_r_7m_"
class="_icon-button_1215g_8"
data-kind="secondary"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_AutoHideScrollbar"
tabindex="-1"
>
<div
class="mx_UserInfo_avatar"
>
<div
class="mx_UserInfo_avatar_transition"
>
<div
class="mx_UserInfo_avatar_transition_child"
>
<button
aria-label="Profile picture"
aria-live="off"
class="_avatar_zysgz_8 mx_BaseAvatar _avatar-imageless_zysgz_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 120px;"
>
u
</button>
</div>
</div>
</div>
<div
class="mx_UserInfo_container mx_UserInfo_header"
>
<div
class="_flex_4dswl_9 mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h1
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
dir="auto"
>
<div
class="_flex_4dswl_9 mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
@user:example.com
</div>
</h1>
<div
class="mx_PresenceLabel mx_UserInfo_profileStatus"
>
Unknown
</div>
<p
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36 mx_UserInfo_profile_mxid"
>
<div
class="mx_CopyableText"
>
customUserIdentifier
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 5H5v9h1a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1a1 1 0 1 1-2 0z"
/>
<path
d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z"
/>
</svg>
</div>
</div>
</p>
</div>
<div
class="_flex_4dswl_9 mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31 mx_UserInfo_verification_unavailable"
>
(
User verification unavailable
)
</p>
</div>
</div>
<div
class="mx_UserInfo_container"
>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m1.5 21.25 1.45-4.95a10.2 10.2 0 0 1-.712-2.1A10.2 10.2 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22q-1.125 0-2.2-.238a10.2 10.2 0 0 1-2.1-.712L2.75 22.5a.94.94 0 0 1-1-.25.94.94 0 0 1-.25-1m2.45-1.2 3.2-.95a1 1 0 0 1 .275-.062q.15-.013.275-.013.225 0 .438.038.212.036.412.137a7.4 7.4 0 0 0 1.675.6Q11.1 20 12 20q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12q0 .9.2 1.775t.6 1.675q.176.325.188.688t-.088.712z"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Send message
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26 _disabled_lqfwq_118"
data-kind="primary"
disabled=""
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Jump to read receipt
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 16a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 15V7.85L9.125 9.725q-.3.3-.7.3T7.7 9.7a.93.93 0 0 1-.288-.713A.98.98 0 0 1 7.7 8.3l3.6-3.6q.15-.15.325-.213.175-.062.375-.062t.375.062a.9.9 0 0 1 .325.213l3.6 3.6q.3.3.287.712a.98.98 0 0 1-.287.688q-.3.3-.713.313a.93.93 0 0 1-.712-.288L13 7.85V15q0 .424-.287.713A.97.97 0 0 1 12 16m-6 4q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 18v-2q0-.424.287-.713A.97.97 0 0 1 5 15q.424 0 .713.287Q6 15.576 6 16v2h12v-2q0-.424.288-.713A.97.97 0 0 1 19 15q.424 0 .712.287.288.288.288.713v2q0 .824-.587 1.413A1.93 1.93 0 0 1 18 20z"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Share profile
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
</div>
<div
class="mx_UserInfo_container"
>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26"
data-kind="critical"
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Deactivate user
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
</div>
<div
class="mx_UserInfo_container"
>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26"
data-kind="critical"
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 22a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22m0-2q3.35 0 5.675-2.325T20 12q0-1.35-.437-2.6A8 8 0 0 0 18.3 7.1L7.1 18.3q1.05.825 2.3 1.262T12 20m-6.3-3.1L16.9 5.7a8 8 0 0 0-2.3-1.263A7.8 7.8 0 0 0 12 4Q8.65 4 6.325 6.325T4 12q0 1.35.438 2.6A8 8 0 0 0 5.7 16.9"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Ignore
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
`;
@@ -0,0 +1,315 @@
/*
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 { render, screen, fireEvent } from "jest-matrix-react";
import { type Room, type RoomMember } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { UserInfoAdminToolsContainer } from "../../../../../../src/components/views/right_panel/user_info/UserInfoAdminToolsContainer";
import { useUserInfoAdminToolsContainerViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
import { useRoomKickButtonViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel";
import { useBanButtonViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel";
import { useMuteButtonViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel";
import { useRedactMessagesButtonViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel";
import { stubClient } from "../../../../../test-utils";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
jest.mock("../../../../../../src/utils/DMRoomMap", () => {
const mock = {
getUserIdForRoomId: jest.fn(),
getDMRoomsForUserId: jest.fn(),
};
return {
shared: jest.fn().mockReturnValue(mock),
sharedInstance: mock,
};
});
jest.mock(
"../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel",
() => ({
useUserInfoAdminToolsContainerViewModel: jest.fn().mockReturnValue({
isCurrentUserInTheRoom: true,
shouldShowKickButton: true,
shouldShowBanButton: true,
shouldShowMuteButton: true,
shouldShowRedactButton: true,
}),
}),
);
jest.mock(
"../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel",
() => ({
useRoomKickButtonViewModel: jest.fn().mockReturnValue({
canUserBeKicked: true,
kickLabel: "Kick",
onKickClick: jest.fn(),
}),
}),
);
jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel", () => ({
useBanButtonViewModel: jest.fn().mockReturnValue({
banLabel: "Ban",
onBanOrUnbanClick: jest.fn(),
}),
}));
jest.mock(
"../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel",
() => ({
useMuteButtonViewModel: jest.fn().mockReturnValue({
isMemberInTheRoom: true,
muteLabel: "Mute",
onMuteButtonClick: jest.fn(),
}),
}),
);
jest.mock(
"../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel",
() => ({
useRedactMessagesButtonViewModel: jest.fn().mockReturnValue({
onRedactAllMessagesClick: jest.fn(),
}),
}),
);
const defaultRoomId = "!fkfk";
describe("UserInfoAdminToolsContainer", () => {
// Setup it data
const 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);
const mockMember = {
userId: "@user:example.com",
membership: "join",
powerLevel: 0,
} as unknown as RoomMember;
const mockPowerLevels = {
users: {
"@currentuser:example.com": 100,
},
events: {},
state_default: 50,
ban: 50,
kick: 50,
redact: 50,
};
const defaultProps = {
room: mockRoom,
member: mockMember,
powerLevels: mockPowerLevels,
isUpdating: false,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
};
const mockMatrixClient = stubClient();
const renderComponent = (props = defaultProps) => {
return render(
<MatrixClientContext.Provider value={mockMatrixClient}>
<UserInfoAdminToolsContainer {...props} />
</MatrixClientContext.Provider>,
);
};
beforeEach(() => {
mocked(useUserInfoAdminToolsContainerViewModel).mockReturnValue({
isCurrentUserInTheRoom: true,
shouldShowKickButton: true,
shouldShowBanButton: true,
shouldShowMuteButton: true,
shouldShowRedactButton: true,
});
jest.clearAllMocks();
});
it("renders all admin tools when user has permissions", () => {
renderComponent();
// Check that all buttons are rendered
expect(screen.getByText("Mute")).toBeInTheDocument();
expect(screen.getByText("Kick")).toBeInTheDocument();
expect(screen.getByText("Ban")).toBeInTheDocument();
expect(screen.getByText("Remove messages")).toBeInTheDocument();
});
it("renders no admin tools when current user is not in the room", () => {
mocked(useUserInfoAdminToolsContainerViewModel).mockReturnValue({
isCurrentUserInTheRoom: false,
shouldShowKickButton: false,
shouldShowBanButton: false,
shouldShowMuteButton: false,
shouldShowRedactButton: false,
});
const { container } = renderComponent();
// Should render an empty div
expect(container.firstChild).toBeEmptyDOMElement();
});
it("renders children when provided", () => {
render(
<UserInfoAdminToolsContainer {...defaultProps}>
<div data-testid="child-element">Custom Child</div>
</UserInfoAdminToolsContainer>,
);
expect(screen.getByTestId("child-element")).toBeInTheDocument();
expect(screen.getByText("Custom Child")).toBeInTheDocument();
});
describe("Kick behavior", () => {
it("clicking kick button calls the appropriate handler", () => {
const mockedOnKickClick = jest.fn();
mocked(useRoomKickButtonViewModel).mockReturnValue({
canUserBeKicked: true,
kickLabel: "Kick",
onKickClick: mockedOnKickClick,
});
renderComponent();
const kickButton = screen.getByText("Kick");
fireEvent.click(kickButton);
expect(mockedOnKickClick).toHaveBeenCalled();
});
it("should not display kick buttun if user can't be kicked", () => {
mocked(useRoomKickButtonViewModel).mockReturnValue({
canUserBeKicked: false,
kickLabel: "Kick",
onKickClick: jest.fn(),
});
renderComponent();
expect(screen.queryByText("Kick")).not.toBeInTheDocument();
});
it("should display the correct label when user can be disinvited", () => {
mocked(useRoomKickButtonViewModel).mockReturnValue({
canUserBeKicked: true,
kickLabel: "Disinvite",
onKickClick: jest.fn(),
});
renderComponent({
...defaultProps,
member: mockMember,
});
expect(screen.getByText("Disinvite")).toBeInTheDocument();
});
});
describe("Ban behavior", () => {
it("clicking ban button calls the appropriate handler", () => {
const mockedOnBanOrUnbanClick = jest.fn();
mocked(useBanButtonViewModel).mockReturnValue({
banLabel: "Ban",
onBanOrUnbanClick: mockedOnBanOrUnbanClick,
});
renderComponent();
const banButton = screen.getByText("Ban");
fireEvent.click(banButton);
expect(mockedOnBanOrUnbanClick).toHaveBeenCalled();
});
it("should display the correct label", () => {
const mockedOnBanOrUnbanClick = jest.fn();
mocked(useBanButtonViewModel).mockReturnValue({
banLabel: "Unban",
onBanOrUnbanClick: mockedOnBanOrUnbanClick,
});
renderComponent();
// The label should be "Unban"
expect(screen.getByText("Unban")).toBeInTheDocument();
});
});
describe("Mute behavior", () => {
it("clicking mute button calls the appropriate handler", () => {
const mockedOnMuteButtonClick = jest.fn();
mocked(useMuteButtonViewModel).mockReturnValue({
isMemberInTheRoom: true,
muteLabel: "Mute",
onMuteButtonClick: mockedOnMuteButtonClick,
});
renderComponent();
const muteButton = screen.getByText("Mute");
fireEvent.click(muteButton);
expect(mockedOnMuteButtonClick).toHaveBeenCalled();
});
it("should not display mute button if user is not in the room", () => {
mocked(useMuteButtonViewModel).mockReturnValue({
isMemberInTheRoom: false,
muteLabel: "Mute",
onMuteButtonClick: jest.fn(),
});
renderComponent();
expect(screen.queryByText("Mute")).not.toBeInTheDocument();
});
it("should display the correct label", () => {
mocked(useMuteButtonViewModel).mockReturnValue({
isMemberInTheRoom: true,
muteLabel: "Mute",
onMuteButtonClick: jest.fn(),
});
renderComponent();
expect(screen.getByText("Mute")).toBeInTheDocument();
});
});
describe("Redact behavior", () => {
it("clicking redact button calls the appropriate handler", () => {
const mockedOnRedactAllMessagesClick = jest.fn();
mocked(useRedactMessagesButtonViewModel).mockReturnValue({
onRedactAllMessagesClick: mockedOnRedactAllMessagesClick,
});
renderComponent();
const redactButton = screen.getByText("Remove messages");
fireEvent.click(redactButton);
expect(mockedOnRedactAllMessagesClick).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,112 @@
/*
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 { mocked } from "jest-mock";
import { type MatrixClient, type Room, RoomMember, type User } from "matrix-js-sdk/src/matrix";
import { logRoles, render, screen } from "jest-matrix-react";
import { createTestClient, mkStubRoom } from "../../../../../test-utils";
import {
type UserInfoBasicState,
useUserInfoBasicViewModel,
} from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel";
import { UserInfoBasicView } from "../../../../../../src/components/views/right_panel/user_info/UserInfoBasicView";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
const defaultRoomPermissions = {
canEdit: true,
canInvite: true,
modifyLevelMax: -1,
};
jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel", () => ({
useUserInfoBasicViewModel: jest.fn(),
useRoomPermissions: () => defaultRoomPermissions,
}));
describe("<UserInfoBasic />", () => {
const defaultValue: UserInfoBasicState = {
powerLevels: {},
roomPermissions: defaultRoomPermissions,
pendingUpdateCount: 0,
isMe: false,
isRoomDMForMember: false,
showDeactivateButton: true,
onSynapseDeactivate: jest.fn(),
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
};
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let defaultRoom: Room;
let defaultProps: { member: User | RoomMember; room: Room };
let matrixClient: MatrixClient;
const renderComponent = (props = defaultProps) => {
return render(
<MatrixClientContext.Provider value={matrixClient}>
<UserInfoBasicView {...props} />
</MatrixClientContext.Provider>,
);
};
beforeEach(() => {
matrixClient = createTestClient();
defaultRoom = mkStubRoom(defaultRoomId, defaultRoomId, matrixClient);
defaultProps = {
member: defaultMember,
room: defaultRoom,
};
});
it("should display the defaut values", () => {
mocked(useUserInfoBasicViewModel).mockReturnValue(defaultValue);
const { container } = renderComponent();
logRoles(container);
expect(container).toMatchSnapshot();
});
it("should not show ignore button if user is me", () => {
const state: UserInfoBasicState = { ...defaultValue, isMe: true };
mocked(useUserInfoBasicViewModel).mockReturnValue(state);
renderComponent();
const ignoreButton = screen.queryByRole("button", { name: "Ignore" });
expect(ignoreButton).not.toBeInTheDocument();
});
it("should not show deactivate button", () => {
const state: UserInfoBasicState = { ...defaultValue, showDeactivateButton: false };
mocked(useUserInfoBasicViewModel).mockReturnValue(state);
renderComponent();
const deactivateButton = screen.queryByRole("button", { name: "Deactivate user" });
expect(deactivateButton).not.toBeInTheDocument();
});
it("should not show powerlevels selector for dm", () => {
const state: UserInfoBasicState = { ...defaultValue, isRoomDMForMember: true };
mocked(useUserInfoBasicViewModel).mockReturnValue(state);
const { container } = renderComponent();
logRoles(container);
const powserlevel = screen.queryByRole("option", { name: "Default" });
expect(powserlevel).not.toBeInTheDocument();
});
it("should show spinner if pending update is > 0", () => {
const state: UserInfoBasicState = { ...defaultValue, pendingUpdateCount: 2 };
mocked(useUserInfoBasicViewModel).mockReturnValue(state);
renderComponent();
const spinner = screen.getByTestId("spinner");
expect(spinner).toBeInTheDocument();
});
});
@@ -0,0 +1,208 @@
/*
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 { mocked } from "jest-mock";
import { type Room, RoomMember, type User } from "matrix-js-sdk/src/matrix";
import { fireEvent, render, screen } from "jest-matrix-react";
import { mkStubRoom, stubClient } from "../../../../../test-utils";
import {
useUserInfoBasicOptionsViewModel,
type UserInfoBasicOptionsState,
} from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel";
import { UserInfoBasicOptionsView } from "../../../../../../src/components/views/right_panel/user_info/UserInfoBasicOptionsView";
import { UIComponent } from "../../../../../../src/settings/UIFeature";
import { shouldShowComponent } from "../../../../../../src/customisations/helpers/UIComponents";
import { type Member } from "../../../../../../src/components/views/right_panel/UserInfo";
jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel", () => ({
useUserInfoBasicOptionsViewModel: jest.fn(),
}));
jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => {
const original = jest.requireActual("../../../../../../src/customisations/helpers/UIComponents");
return {
shouldShowComponent: jest.fn().mockImplementation(original.shouldShowComponent),
};
});
describe("<UserOptionsSection />", () => {
const defaultValue: UserInfoBasicOptionsState = {
isMe: false,
showInviteButton: false,
showInsertPillButton: false,
readReceiptButtonDisabled: false,
onInsertPillButton: () => jest.fn(),
onReadReceiptButton: () => jest.fn(),
onShareUserClick: () => jest.fn(),
onInviteUserButton: (fallbackRoomId: string, evt: Event) => Promise.resolve(),
onOpenDmForUser: (member: Member) => Promise.resolve(),
};
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let defaultRoom: Room;
let defaultProps: { member: User | RoomMember; room: Room };
beforeEach(() => {
const matrixClient = stubClient();
defaultRoom = mkStubRoom(defaultRoomId, defaultRoomId, matrixClient);
defaultProps = {
member: defaultMember,
room: defaultRoom,
};
});
afterEach(() => {
jest.resetAllMocks();
});
it("should always display sharedButton when user is not me", () => {
// User is not me by default
mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue });
render(<UserInfoBasicOptionsView {...defaultProps} />);
const sharedButton = screen.getByRole("button", { name: "Share profile" });
expect(sharedButton).toBeInTheDocument();
});
it("should always display sharedButton when user is me", () => {
const propsWithMe = { ...defaultProps };
const onShareUserClick = jest.fn();
const state = { ...defaultValue, isMe: true, onShareUserClick };
mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(state);
render(<UserInfoBasicOptionsView {...propsWithMe} />);
const sharedButton2 = screen.getByRole("button", { name: "Share profile" });
expect(sharedButton2).toBeInTheDocument();
// clicking on the share profile button
fireEvent.click(sharedButton2);
expect(onShareUserClick).toHaveBeenCalled();
});
it("should show insert pill button when user is not me and showinsertpill is true", () => {
const onInsertPillButton = jest.fn();
const state = { ...defaultValue, showInsertPillButton: true, onInsertPillButton };
// User is not me and showInsertpill is true
mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(state);
render(<UserInfoBasicOptionsView {...defaultProps} />);
const insertPillButton = screen.getByRole("button", { name: "Mention" });
expect(insertPillButton).toBeInTheDocument();
// clicking on the insert pill button
fireEvent.click(insertPillButton);
expect(onInsertPillButton).toHaveBeenCalled();
});
it("should not show insert pill button when user is not me and showinsertpill is false", () => {
mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue, showInsertPillButton: false });
render(<UserInfoBasicOptionsView {...defaultProps} />);
const insertPillButton = screen.queryByRole("button", { name: "Mention" });
expect(insertPillButton).not.toBeInTheDocument();
});
it("should not show insert pill button when user is me", () => {
// User is me, should not see the insert button even when show insertpill is true
mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({
...defaultValue,
showInsertPillButton: true,
isMe: true,
});
const propsWithMe = { ...defaultProps };
render(<UserInfoBasicOptionsView {...propsWithMe} />);
const insertPillButton = screen.queryByRole("button", { name: "Mention" });
expect(insertPillButton).not.toBeInTheDocument();
});
it("should not show readreceiptbutton when user is me", () => {
mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({
...defaultValue,
readReceiptButtonDisabled: true,
isMe: true,
});
const propsWithMe = { ...defaultProps };
render(<UserInfoBasicOptionsView {...propsWithMe} />);
const readReceiptButton = screen.queryByRole("button", { name: "Jump to read receipt" });
expect(readReceiptButton).not.toBeInTheDocument();
});
it("should show disable readreceiptbutton when readReceiptButtonDisabled is true", () => {
mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue, readReceiptButtonDisabled: true });
render(<UserInfoBasicOptionsView {...defaultProps} />);
const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" });
expect(readReceiptButton).toBeDisabled();
});
it("should not show disable readreceiptbutton when readReceiptButtonDisabled is false", () => {
const onReadReceiptButton = jest.fn();
const state = { ...defaultValue, readReceiptButtonDisabled: false, onReadReceiptButton };
mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(state);
render(<UserInfoBasicOptionsView {...defaultProps} />);
const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" });
expect(readReceiptButton).not.toBeDisabled();
// clicking on the read receipt button
fireEvent.click(readReceiptButton);
expect(onReadReceiptButton).toHaveBeenCalled();
});
it("should show not show invite button if shouldShowComponent is false", () => {
mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue, showInviteButton: true });
mocked(shouldShowComponent).mockReturnValue(false);
render(<UserInfoBasicOptionsView {...defaultProps} />);
const inviteButton = screen.queryByRole("button", { name: "Invite" });
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.InviteUsers);
expect(inviteButton).not.toBeInTheDocument();
});
it("should show show invite button if shouldShowComponent is true", () => {
const onInviteUserButton = jest.fn();
const state = { ...defaultValue, showInviteButton: true, onInviteUserButton };
mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(state);
mocked(shouldShowComponent).mockReturnValue(true);
render(<UserInfoBasicOptionsView {...defaultProps} />);
const inviteButton = screen.getByRole("button", { name: "Invite" });
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.InviteUsers);
expect(inviteButton).toBeInTheDocument();
// clicking on the invite button
fireEvent.click(inviteButton);
expect(onInviteUserButton).toHaveBeenCalled();
});
it("should show directMessageButton when user is not me", () => {
// User is not me, direct message button should display
mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(defaultValue);
mocked(shouldShowComponent).mockReturnValue(true);
render(<UserInfoBasicOptionsView {...defaultProps} />);
const dmButton = screen.getByRole("button", { name: "Send message" });
expect(dmButton).toBeInTheDocument();
});
it("should not show directMessageButton when user is me", () => {
mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue, isMe: true });
mocked(shouldShowComponent).mockReturnValue(true);
const propsWithMe = { ...defaultProps };
render(<UserInfoBasicOptionsView {...propsWithMe} />);
const dmButton = screen.queryByRole("button", { name: "Send message" });
expect(dmButton).not.toBeInTheDocument();
});
});
@@ -0,0 +1,96 @@
/*
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 { mocked, type Mocked } from "jest-mock";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { UserVerificationStatus, type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { Device, RoomMember } from "matrix-js-sdk/src/matrix";
import { render, waitFor, screen } from "jest-matrix-react";
import React from "react";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import { UserInfoHeaderVerificationView } from "../../../../../../src/components/views/right_panel/user_info/UserInfoHeaderVerificationView";
import { createTestClient } from "../../../../../test-utils";
describe("<UserInfoHeaderVerificationView />", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let mockClient: MatrixClient;
let mockCrypto: Mocked<CryptoApi>;
beforeEach(() => {
mockCrypto = mocked({
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 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);
});
const renderComponent = () => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const devicesMap = new Map<string, Device>([[device1.deviceId, device1]]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
jest.spyOn(mockClient, "doesServerSupportUnstableFeature").mockResolvedValue(true);
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<UserInfoHeaderVerificationView member={defaultMember} devices={[device1]} />, {
wrapper: Wrapper,
});
};
it("renders verified badge when user is verified", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, false));
const { container } = renderComponent();
await waitFor(() => expect(screen.getByText("Verified")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
it("renders verify button", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
mockCrypto.userHasCrossSigningKeys.mockResolvedValue(true);
const { container } = renderComponent();
await waitFor(() => expect(screen.getByText("Verify User")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
it("renders verification unavailable message", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
mockCrypto.userHasCrossSigningKeys.mockResolvedValue(false);
const { container } = renderComponent();
await waitFor(() => expect(screen.getByText("(User verification unavailable)")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,195 @@
/*
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 { mocked, type Mocked } from "jest-mock";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { Device, RoomMember } from "matrix-js-sdk/src/matrix";
import { fireEvent, render, screen } from "jest-matrix-react";
import React from "react";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import { UserInfoHeaderView } from "../../../../../../src/components/views/right_panel/user_info/UserInfoHeaderView";
import { createTestClient } from "../../../../../test-utils";
import { useUserfoHeaderViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel";
// Mock the viewmodel hooks
jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel", () => ({
useUserfoHeaderViewModel: jest.fn().mockReturnValue({
onMemberAvatarClick: jest.fn(),
precenseInfo: {
lastActiveAgo: undefined,
currentlyActive: undefined,
state: undefined,
},
showPresence: false,
timezoneInfo: null,
userIdentifier: "customUserIdentifier",
}),
}));
describe("<UserInfoHeaderView />", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const defaultProps = {
member: defaultMember,
roomId: defaultRoomId,
};
let mockClient: MatrixClient;
let mockCrypto: Mocked<CryptoApi>;
beforeEach(() => {
mockCrypto = mocked({
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 CryptoApi);
mockClient = createTestClient();
mockClient.doesServerSupportExtendedProfiles = () => Promise.resolve(false);
jest.spyOn(mockClient, "doesServerSupportUnstableFeature").mockResolvedValue(true);
jest.spyOn(mockClient.secretStorage, "hasKey").mockResolvedValue(true);
jest.spyOn(mockClient, "getCrypto").mockReturnValue(mockCrypto);
jest.spyOn(mockClient, "doesServerSupportUnstableFeature").mockResolvedValue(true);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
});
const renderComponent = (
props = {
hideVerificationSection: false,
},
) => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const devicesMap = new Map<string, Device>([[device1.deviceId, device1]]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(
<UserInfoHeaderView
{...defaultProps}
{...props}
devices={[device1]}
hideVerificationSection={props.hideVerificationSection}
/>,
{
wrapper: Wrapper,
},
);
};
it("renders custom user identifiers in the header", () => {
const { container } = renderComponent();
expect(screen.getByText("customUserIdentifier")).toBeInTheDocument();
expect(container).toMatchSnapshot();
});
it("should not render verification view if hideVerificationSection is true", () => {
mocked(useUserfoHeaderViewModel).mockReturnValue({
onMemberAvatarClick: jest.fn(),
precenseInfo: {
lastActiveAgo: undefined,
currentlyActive: undefined,
state: undefined,
},
showPresence: false,
timezoneInfo: null,
userIdentifier: "null",
});
const { container } = renderComponent({ hideVerificationSection: true });
const verificationClass = container.getElementsByClassName("mx_UserInfo_verification").length;
expect(verificationClass).toEqual(0);
});
it("should render timezone if it exist", () => {
mocked(useUserfoHeaderViewModel).mockReturnValue({
onMemberAvatarClick: jest.fn(),
precenseInfo: {
lastActiveAgo: undefined,
currentlyActive: undefined,
state: undefined,
},
showPresence: false,
timezoneInfo: {
timezone: "FR",
friendly: "paris",
},
userIdentifier: null,
});
renderComponent({ hideVerificationSection: false });
expect(screen.getByText("paris")).toBeInTheDocument();
});
it("should render correct presence label", () => {
mocked(useUserfoHeaderViewModel).mockReturnValue({
onMemberAvatarClick: jest.fn(),
precenseInfo: {
lastActiveAgo: 0,
currentlyActive: true,
state: "online",
},
showPresence: true,
timezoneInfo: null,
userIdentifier: null,
});
renderComponent({ hideVerificationSection: false });
expect(screen.getByText("Online")).toBeInTheDocument();
});
it("should be able to click on member avatar", () => {
const onMemberAvatarClick = jest.fn();
mocked(useUserfoHeaderViewModel).mockReturnValue({
onMemberAvatarClick,
precenseInfo: {
lastActiveAgo: undefined,
currentlyActive: undefined,
state: undefined,
},
showPresence: false,
timezoneInfo: {
timezone: "FR",
friendly: "paris",
},
userIdentifier: null,
});
renderComponent();
const avatar = screen.getByRole("button", { name: "Profile picture" });
fireEvent.click(avatar);
expect(onMemberAvatarClick).toHaveBeenCalled();
});
});
@@ -0,0 +1,164 @@
/*
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 React from "react";
import userEvent from "@testing-library/user-event";
import { fireEvent, render, screen } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import { MatrixEvent, type MatrixClient, RoomMember, type Room, EventType } from "matrix-js-sdk/src/matrix";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import { type IRoomPermissions } from "../../../../../../src/components/views/right_panel/UserInfo";
import { PowerLevelSection } from "../../../../../../src/components/views/right_panel/user_info/UserInfoPowerLevels";
describe("<PowerLevelEditor />", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
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);
});
afterAll(() => {
defaultProps = {
user: defaultMember,
room: mockRoom,
roomPermissions: {
modifyLevelMax: 100,
canEdit: false,
canInvite: false,
},
};
jest.clearAllMocks();
});
const renderComponent = (props = defaultProps) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<PowerLevelSection {...props} />, {
wrapper: Wrapper,
});
};
it("renders a power level combobox if can edit is true", () => {
const startPowerLevel = 999;
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: { users: { [defaultUserId]: startPowerLevel }, users_default: 1 },
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
renderComponent({
...defaultProps,
room: mockRoom,
roomPermissions: { ...defaultProps.roomPermissions, canEdit: true },
});
expect(screen.getByRole("combobox", { name: "Power level" })).toBeInTheDocument();
});
it("renders a user role if can edit is false", () => {
const member = new RoomMember(defaultRoomId, defaultUserId);
member.powerLevel = 100;
renderComponent({ ...defaultProps, user: member });
expect(screen.getByText("Admin")).toBeInTheDocument();
});
it("renders a combobox and attempts to change power level on change of the combobox", async () => {
const startPowerLevel = 999;
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);
renderComponent({
...defaultProps,
room: mockRoom,
roomPermissions: { ...defaultProps.roomPermissions, canEdit: true },
});
const changedPowerLevel = 100;
fireEvent.change(screen.getByRole("combobox", { name: "Power level" }), {
target: { value: changedPowerLevel },
});
await screen.findByText("Demote", { exact: true });
// firing the event will raise a dialog warning about self demotion, wait for this to appear then click on it
await userEvent.click(await screen.findByText("Demote", { exact: true }));
expect(mockClient.setPowerLevel).toHaveBeenCalledTimes(1);
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, changedPowerLevel);
});
});
@@ -0,0 +1,327 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<UserInfoBasic /> should display the defaut values 1`] = `
<div>
<div
class="mx_UserInfo_container"
>
<div
class="mx_UserInfo_profileField"
>
<div
class="mx_PowerSelector"
>
<div
class="mx_Field mx_Field_select"
>
<select
data-testid="power-level-select-element"
id="mx_Field_1"
label="Power level"
placeholder="Power level"
type="text"
>
<option
data-testid="power-level-option-0"
value="0"
>
Default
</option>
<option
data-testid="power-level-option-SELECT_VALUE_CUSTOM"
value="SELECT_VALUE_CUSTOM"
>
Custom level
</option>
</select>
<label
for="mx_Field_1"
>
Power level
</label>
<svg
class="mx_Field_select_chevron"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</div>
</div>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m1.5 21.25 1.45-4.95a10.2 10.2 0 0 1-.712-2.1A10.2 10.2 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22q-1.125 0-2.2-.238a10.2 10.2 0 0 1-2.1-.712L2.75 22.5a.94.94 0 0 1-1-.25.94.94 0 0 1-.25-1m2.45-1.2 3.2-.95a1 1 0 0 1 .275-.062q.15-.013.275-.013.225 0 .438.038.212.036.412.137a7.4 7.4 0 0 0 1.675.6Q11.1 20 12 20q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12q0 .9.2 1.775t.6 1.675q.176.325.188.688t-.088.712z"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Send message
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 12q-1.65 0-2.825-1.175T6 8t1.175-2.825T10 4t2.825 1.175T14 8t-1.175 2.825T10 12m-8 6v-.8q0-.85.438-1.562.437-.713 1.162-1.088a14.8 14.8 0 0 1 3.15-1.163A13.8 13.8 0 0 1 10 13q1.65 0 3.25.387 1.6.388 3.15 1.163.724.375 1.163 1.087Q18 16.35 18 17.2v.8q0 .824-.587 1.413A1.93 1.93 0 0 1 16 20H4q-.824 0-1.412-.587A1.93 1.93 0 0 1 2 18m2 0h12v-.8a.97.97 0 0 0-.5-.85q-1.35-.675-2.725-1.012a11.6 11.6 0 0 0-5.55 0Q5.85 15.675 4.5 16.35a.97.97 0 0 0-.5.85zm6-8q.825 0 1.412-.588Q12 8.826 12 8q0-.824-.588-1.412A1.93 1.93 0 0 0 10 6q-.825 0-1.412.588A1.93 1.93 0 0 0 8 8q0 .825.588 1.412Q9.175 10 10 10m7 1h2v2q0 .424.288.713.287.287.712.287.424 0 .712-.287A.97.97 0 0 0 21 13v-2h2q.424 0 .712-.287A.97.97 0 0 0 24 10a.97.97 0 0 0-.288-.713A.97.97 0 0 0 23 9h-2V7a.97.97 0 0 0-.288-.713A.97.97 0 0 0 20 6a.97.97 0 0 0-.712.287A.97.97 0 0 0 19 7v2h-2a.97.97 0 0 0-.712.287A.97.97 0 0 0 16 10q0 .424.288.713.287.287.712.287"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Invite
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26 _disabled_lqfwq_118"
data-kind="primary"
disabled=""
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Jump to read receipt
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 16a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 15V7.85L9.125 9.725q-.3.3-.7.3T7.7 9.7a.93.93 0 0 1-.288-.713A.98.98 0 0 1 7.7 8.3l3.6-3.6q.15-.15.325-.213.175-.062.375-.062t.375.062a.9.9 0 0 1 .325.213l3.6 3.6q.3.3.287.712a.98.98 0 0 1-.287.688q-.3.3-.713.313a.93.93 0 0 1-.712-.288L13 7.85V15q0 .424-.287.713A.97.97 0 0 1 12 16m-6 4q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 18v-2q0-.424.287-.713A.97.97 0 0 1 5 15q.424 0 .713.287Q6 15.576 6 16v2h12v-2q0-.424.288-.713A.97.97 0 0 1 19 15q.424 0 .712.287.288.288.288.713v2q0 .824-.587 1.413A1.93 1.93 0 0 1 18 20z"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Share profile
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 4a8 8 0 1 0 0 16 1 1 0 1 1 0 2C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10v1.5a3.5 3.5 0 0 1-6.396 1.966A5 5 0 1 1 17 12v1.5a1.5 1.5 0 0 0 3 0V12a8 8 0 0 0-8-8m3 8a3 3 0 1 0-6 0 3 3 0 0 0 6 0"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Mention
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
</div>
<div
class="mx_UserInfo_container"
>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26"
data-kind="critical"
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Deactivate user
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
</div>
<div
class="mx_UserInfo_container"
>
<button
class="_item_lqfwq_8 _interactive_lqfwq_26"
data-kind="critical"
role="button"
>
<svg
aria-hidden="true"
class="_icon_lqfwq_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 22a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22m0-2q3.35 0 5.675-2.325T20 12q0-1.35-.437-2.6A8 8 0 0 0 18.3 7.1L7.1 18.3q1.05.825 2.3 1.262T12 20m-6.3-3.1L16.9 5.7a8 8 0 0 0-2.3-1.263A7.8 7.8 0 0 0 12 4Q8.65 4 6.325 6.325T4 12q0 1.35.438 2.6A8 8 0 0 0 5.7 16.9"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_lqfwq_34"
>
Ignore
</span>
<svg
aria-hidden="true"
class="_nav-hint_lqfwq_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
</div>
</div>
`;
@@ -0,0 +1,73 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<UserInfoHeaderVerificationView /> renders verification unavailable message 1`] = `
<div>
<div
class="_flex_4dswl_9 mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31 mx_UserInfo_verification_unavailable"
>
(
User verification unavailable
)
</p>
</div>
</div>
`;
exports[`<UserInfoHeaderVerificationView /> renders verified badge when user is verified 1`] = `
<div>
<div
class="_flex_4dswl_9 mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _badge_18gm1_8 mx_UserInfo_verified_badge"
data-kind="green"
>
<svg
class="mx_UserInfo_verified_icon"
fill="currentColor"
height="16px"
viewBox="0 0 24 24"
width="16px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.15 21.75 6.7 19.3l-2.75-.6a.94.94 0 0 1-.6-.387.93.93 0 0 1-.175-.688L3.45 14.8l-1.875-2.15a.93.93 0 0 1-.25-.65q0-.375.25-.65L3.45 9.2l-.275-2.825a.93.93 0 0 1 .175-.687.94.94 0 0 1 .6-.388l2.75-.6 1.45-2.45a.98.98 0 0 1 .55-.437.97.97 0 0 1 .7.037l2.6 1.1 2.6-1.1a.97.97 0 0 1 .7-.038q.35.112.55.438L17.3 4.7l2.75.6q.375.075.6.388.225.312.175.687L20.55 9.2l1.875 2.15q.25.275.25.65t-.25.65L20.55 14.8l.275 2.825a.93.93 0 0 1-.175.688.94.94 0 0 1-.6.387l-2.75.6-1.45 2.45a.98.98 0 0 1-.55.438.97.97 0 0 1-.7-.038l-2.6-1.1-2.6 1.1a.97.97 0 0 1-.7.038.98.98 0 0 1-.55-.438m2.8-9.05L9.5 11.275A.93.93 0 0 0 8.812 11q-.412 0-.712.3a.95.95 0 0 0-.275.7q0 .425.275.7l2.15 2.15q.3.3.7.3t.7-.3l4.25-4.25q.3-.3.287-.7a1.06 1.06 0 0 0-.287-.7 1.02 1.02 0 0 0-.713-.312.93.93 0 0 0-.712.287z"
/>
</svg>
<p
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 mx_UserInfo_verified_label"
>
Verified
</p>
</span>
</div>
</div>
`;
exports[`<UserInfoHeaderVerificationView /> renders verify button 1`] = `
<div>
<div
class="_flex_4dswl_9 mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_UserInfo_container_verifyButton"
>
<button
class="_button_13vu4_8 mx_UserInfo_verify_button"
data-kind="tertiary"
data-size="sm"
role="button"
tabindex="0"
>
Verify User
</button>
</div>
</div>
</div>
`;
@@ -0,0 +1,101 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<UserInfoHeaderView /> renders custom user identifiers in the header 1`] = `
<div>
<div
class="mx_UserInfo_avatar"
>
<div
class="mx_UserInfo_avatar_transition"
>
<div
class="mx_UserInfo_avatar_transition_child"
>
<button
aria-label="Profile picture"
aria-live="off"
class="_avatar_zysgz_8 mx_BaseAvatar _avatar-imageless_zysgz_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 120px;"
title="@user:example.com"
>
u
</button>
</div>
</div>
</div>
<div
class="mx_UserInfo_container mx_UserInfo_header"
>
<div
class="_flex_4dswl_9 mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h1
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
dir="auto"
>
<div
class="_flex_4dswl_9 mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
@user:example.com
</div>
</h1>
<p
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36 mx_UserInfo_profile_mxid"
>
<div
class="mx_CopyableText"
>
customUserIdentifier
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 5H5v9h1a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1a1 1 0 1 1-2 0z"
/>
<path
d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z"
/>
</svg>
</div>
</div>
</p>
</div>
<div
class="_flex_4dswl_9 mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
class="_icon_11k6c_18"
fill="currentColor"
height="1em"
style="width: 24px; height: 24px;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
fill-rule="evenodd"
/>
</svg>
</div>
</div>
</div>
`;