MVVM userinfo basic component (#30305)

* feat: mvvm userinfo basic component

* test: mvvm userinfobasic component

* chore: apply review. rename views, add comment and move some codes

* chore(review): move openDM method into viewmodel
This commit is contained in:
Marc
2025-10-20 08:13:20 +02:00
committed by GitHub
parent cf51b256ce
commit e6e6f87d01
18 changed files with 1745 additions and 741 deletions

View File

@@ -0,0 +1,220 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
EventType,
KnownMembership,
type MatrixClient,
MatrixEvent,
type Room,
RoomMember,
type User,
} from "matrix-js-sdk/src/matrix";
import { renderHook, waitFor } from "jest-matrix-react";
import { Action } from "../../../../../../src/dispatcher/actions";
import Modal from "../../../../../../src/Modal";
import MultiInviter from "../../../../../../src/utils/MultiInviter";
import { createTestClient, mkRoom, withClientContextRenderOptions } from "../../../../../test-utils";
import dis from "../../../../../../src/dispatcher/dispatcher";
import { useUserInfoBasicOptionsViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel";
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
import ErrorDialog from "../../../../../../src/components/views/dialogs/ErrorDialog";
jest.mock("../../../../../../src/dispatcher/dispatcher");
describe("<UserOptionsSection />", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const meUserId = "@me:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let defaultProps: { room: Room; member: User | RoomMember };
let mockClient: MatrixClient;
let room: Room;
beforeEach(() => {
mockClient = createTestClient();
room = mkRoom(mockClient, defaultRoomId);
defaultProps = {
member: defaultMember,
room,
};
DMRoomMap.makeShared(mockClient);
});
const renderUserInfoBasicOptionsViewModelHook = (
props: {
member: User | RoomMember;
room: Room;
} = defaultProps,
) => {
return renderHook(
() => useUserInfoBasicOptionsViewModel(props.room, props.member),
withClientContextRenderOptions(mockClient),
);
};
beforeEach(() => {
jest.clearAllMocks();
// Mock the current user account id. Which is different to the defaultMember which is the selected one
// When we want to mock the current user, needs to override this value
jest.spyOn(mockClient, "getUserId").mockReturnValue(meUserId);
jest.spyOn(mockClient, "getRoom").mockReturnValue(room);
});
it("should showInviteButton if current user can invite and selected user membership is LEAVE", () => {
// cant use mkRoomMember because instanceof check will failed in this case
const member: RoomMember = new RoomMember(defaultMember.userId, defaultMember.roomId);
const me: RoomMember = new RoomMember(meUserId, defaultMember.roomId);
console.log("member instanceof RoomMember", member instanceof RoomMember);
member.powerLevel = 1;
member.membership = KnownMembership.Leave;
me.powerLevel = 50;
me.membership = KnownMembership.Join;
const powerLevelEvents = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
invite: 50,
state_default: 0,
},
});
jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents);
// used to get the current me user
jest.spyOn(room, "getMember").mockReturnValue(me);
const { result } = renderUserInfoBasicOptionsViewModelHook({ ...defaultProps, member });
expect(result.current.showInviteButton).toBeTruthy();
});
it("should not showInviteButton if current cannot invite", () => {
const member: RoomMember = new RoomMember(defaultMember.userId, defaultMember.roomId);
const me: RoomMember = new RoomMember(meUserId, defaultMember.roomId);
member.powerLevel = 50;
member.membership = KnownMembership.Leave;
me.powerLevel = 0;
me.membership = KnownMembership.Join;
const powerLevelEvents = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
invite: 50,
state_default: 0,
},
});
jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents);
// used to get the current me user
jest.spyOn(room, "getMember").mockReturnValue(me);
const { result } = renderUserInfoBasicOptionsViewModelHook({ ...defaultProps, member });
expect(result.current.showInviteButton).toBeFalsy();
});
it("should not showInviteButton if selected user membership is not LEAVE", () => {
const member: RoomMember = new RoomMember(defaultMember.userId, defaultMember.roomId);
const me: RoomMember = new RoomMember(meUserId, defaultMember.roomId);
member.powerLevel = 50;
member.membership = KnownMembership.Join;
me.powerLevel = 50;
me.membership = KnownMembership.Join;
const powerLevelEvents = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
invite: 50,
state_default: 0,
},
});
jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents);
jest.spyOn(room, "getMember").mockReturnValue(me);
const { result } = renderUserInfoBasicOptionsViewModelHook({ ...defaultProps, member });
expect(result.current.showInviteButton).toBeFalsy();
});
it("should showInsertPillButton if room is not a space", () => {
jest.spyOn(room, "isSpaceRoom").mockReturnValue(false);
const { result } = renderUserInfoBasicOptionsViewModelHook();
expect(result.current.showInsertPillButton).toBeTruthy();
});
it("should not showInsertPillButton if room is a space", () => {
jest.spyOn(room, "isSpaceRoom").mockReturnValue(true);
const { result } = renderUserInfoBasicOptionsViewModelHook();
expect(result.current.showInsertPillButton).toBeFalsy();
});
it("should readReceiptButtonDisabled be true if all messages where read", () => {
jest.spyOn(room, "getEventReadUpTo").mockReturnValue(null);
const { result } = renderUserInfoBasicOptionsViewModelHook();
expect(result.current.readReceiptButtonDisabled).toBeTruthy();
});
it("should readReceiptButtonDisabled be false if some messages are available", () => {
jest.spyOn(room, "getEventReadUpTo").mockReturnValue("aneventId");
const { result } = renderUserInfoBasicOptionsViewModelHook();
expect(result.current.readReceiptButtonDisabled).toBeFalsy();
});
it("should readReceiptButtonDisabled be true if room is a space", () => {
jest.spyOn(room, "getEventReadUpTo").mockReturnValue("aneventId");
jest.spyOn(room, "isSpaceRoom").mockReturnValue(true);
const { result } = renderUserInfoBasicOptionsViewModelHook();
expect(result.current.readReceiptButtonDisabled).toBeTruthy();
});
it("firing onReadReceiptButton calls dispatch with correct event_id", () => {
const eventId = "aneventId";
jest.spyOn(room, "getEventReadUpTo").mockReturnValue(eventId);
jest.spyOn(room, "isSpaceRoom").mockReturnValue(false);
const { result } = renderUserInfoBasicOptionsViewModelHook();
result.current.onReadReceiptButton();
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_room",
event_id: eventId,
highlighted: true,
metricsTrigger: undefined,
room_id: defaultRoomId,
});
});
it("calling onInsertPillButton should calls dispatch", () => {
const { result } = renderUserInfoBasicOptionsViewModelHook();
result.current.onInsertPillButton();
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ComposerInsert,
userId: defaultMember.userId,
timelineRenderingType: "Room",
});
});
it("calling onInviteUserButton will call MultiInviter.invite", async () => {
// to save mocking, we will reject the call to .invite
const mockErrorMessage = new Error("test error message");
const spy = jest.spyOn(MultiInviter.prototype, "invite");
spy.mockRejectedValue(mockErrorMessage);
jest.spyOn(Modal, "createDialog");
const { result } = renderUserInfoBasicOptionsViewModelHook();
result.current.onInviteUserButton(new Event("click"));
// check that we have called .invite
expect(spy).toHaveBeenCalledWith([defaultMember.userId]);
await waitFor(() => {
// check that the test error message is displayed
expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, {
description: "test error message",
title: "Failed to invite",
});
});
});
});

View File

@@ -0,0 +1,149 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { EventType, type MatrixClient, MatrixEvent, type Room, RoomMember, type User } from "matrix-js-sdk/src/matrix";
import { renderHook, waitFor } from "jest-matrix-react";
import { createTestClient, mkRoom, withClientContextRenderOptions } from "../../../../../test-utils";
import { useUserInfoBasicViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel";
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
import Modal from "../../../../../../src/Modal";
import QuestionDialog from "../../../../../../src/components/views/dialogs/QuestionDialog";
jest.mock("../../../../../../src/customisations/UserIdentifier", () => {
return {
getDisplayUserIdentifier: jest.fn().mockReturnValue("customUserIdentifier"),
};
});
describe("useUserInfoHeaderViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let mockClient: MatrixClient;
let defaultProps: {
member: User | RoomMember;
room: Room;
};
let room: Room;
beforeEach(() => {
mockClient = createTestClient();
mockClient.isSynapseAdministrator = jest.fn().mockResolvedValue(true);
mockClient.deactivateSynapseUser = jest.fn().mockResolvedValue({
id_server_unbind_result: "success",
});
room = mkRoom(mockClient, defaultRoomId);
defaultProps = {
member: defaultMember,
room,
};
DMRoomMap.makeShared(mockClient);
jest.spyOn(mockClient, "getRoom").mockReturnValue(room);
});
afterEach(() => {
jest.clearAllMocks();
});
const renderUserInfoBasicViewModelHook = (
props: {
member: User | RoomMember;
room: Room;
} = defaultProps,
) => {
return renderHook(
() => useUserInfoBasicViewModel(props.room, props.member),
withClientContextRenderOptions(mockClient),
);
};
it("should set showDeactivateButton value to true", async () => {
jest.spyOn(mockClient, "getDomain").mockReturnValue("example.com");
const { result } = renderUserInfoBasicViewModelHook();
// checking the synpase admin is an async operation, that is why we wait for it
await waitFor(() => {
expect(result.current.showDeactivateButton).toBe(true);
});
});
it("should set showDeactivateButton value to false because domain is not the same", async () => {
jest.spyOn(mockClient, "getDomain").mockReturnValue("toto.com");
const { result } = renderUserInfoBasicViewModelHook();
await waitFor(() => {
expect(result.current.showDeactivateButton).toBe(false);
});
});
it("should give powerlevels values", () => {
const powerLevelEvents = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
invite: 1,
state_default: 1,
},
});
jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents);
const { result } = renderUserInfoBasicViewModelHook();
expect(result.current.powerLevels).toStrictEqual({
invite: 1,
state_default: 1,
});
});
it("should set isRoomDMForMember to true if found in dmroommap", () => {
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue("id");
const { result } = renderUserInfoBasicViewModelHook();
expect(result.current.isRoomDMForMember).toBeTruthy();
});
it("should set isRoomDMForMember to false if not found in dmroommap", () => {
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined);
const { result } = renderUserInfoBasicViewModelHook();
expect(result.current.isRoomDMForMember).toBeFalsy();
});
it("should display modal and call deactivateSynapseUser when calling onSynapaseDeactivate", async () => {
const powerLevelEvents = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
invite: 1,
state_default: 1,
},
});
jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents);
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true, true, false]),
close: jest.fn(),
});
const { result } = renderUserInfoBasicViewModelHook();
await waitFor(() => result.current.onSynapseDeactivate());
await waitFor(() => {
expect(Modal.createDialog).toHaveBeenLastCalledWith(QuestionDialog, {
button: "Deactivate user",
danger: true,
description: (
<div>
Deactivating this user will log them out and prevent them from logging back in. Additionally,
they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want
to deactivate this user?
</div>
),
title: "Deactivate user?",
});
});
expect(mockClient.deactivateSynapseUser).toHaveBeenCalledWith(defaultMember.userId);
});
});

View File

@@ -28,24 +28,16 @@ import {
type CryptoApi,
} from "matrix-js-sdk/src/crypto-api";
import UserInfo, {
disambiguateDevices,
getPowerLevels,
UserOptionsSection,
} from "../../../../../src/components/views/right_panel/UserInfo";
import dis from "../../../../../src/dispatcher/dispatcher";
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 MultiInviter from "../../../../../src/utils/MultiInviter";
import Modal from "../../../../../src/Modal";
import { DirectoryMember, startDmOnFirstMessage } from "../../../../../src/utils/direct-messages";
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";
import { Action } from "../../../../../src/dispatcher/actions";
import { ShareDialog } from "../../../../../src/components/views/dialogs/ShareDialog";
jest.mock("../../../../../src/utils/direct-messages", () => ({
...jest.requireActual("../../../../../src/utils/direct-messages"),
@@ -449,216 +441,6 @@ describe("<UserInfo />", () => {
});
});
describe("<UserOptionsSection />", () => {
const member = new RoomMember(defaultRoomId, defaultUserId);
const defaultProps = { member, canInvite: false, isSpace: false };
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<UserOptionsSection {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
const inviteSpy = jest.spyOn(MultiInviter.prototype, "invite");
beforeEach(() => {
inviteSpy.mockReset();
mockClient.setIgnoredUsers.mockClear();
});
afterEach(async () => {
await clearAllModals();
});
afterAll(() => {
inviteSpy.mockRestore();
});
it("always shows share user button and clicking it should produce a ShareDialog", async () => {
const spy = jest.spyOn(Modal, "createDialog");
renderComponent();
await userEvent.click(screen.getByRole("button", { name: "Share profile" }));
expect(spy).toHaveBeenCalledWith(ShareDialog, { target: defaultProps.member });
});
it("does not show ignore or direct message buttons when member userId matches client userId", () => {
mockClient.getSafeUserId.mockReturnValueOnce(member.userId);
mockClient.getUserId.mockReturnValueOnce(member.userId);
renderComponent();
expect(screen.queryByRole("button", { name: /ignore/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /message/i })).not.toBeInTheDocument();
});
it("shows direct message and mention buttons 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: "Send message" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Mention" })).toBeInTheDocument();
});
it("mention button fires ComposerInsert Action", async () => {
renderComponent();
const button = screen.getByRole("button", { name: "Mention" });
await userEvent.click(button);
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ComposerInsert,
timelineRenderingType: "Room",
userId: "@user:example.com",
});
});
it("when call to client.getRoom is null, shows disabled read receipt button", () => {
mockClient.getRoom.mockReturnValueOnce(null);
renderComponent();
expect(screen.queryByRole("button", { name: "Jump to read receipt" })).toBeDisabled();
});
it("when call to client.getRoom is non-null and room.getEventReadUpTo is null, shows disabled read receipt button", () => {
mockRoom.getEventReadUpTo.mockReturnValueOnce(null);
mockClient.getRoom.mockReturnValueOnce(mockRoom);
renderComponent();
expect(screen.queryByRole("button", { name: "Jump to read receipt" })).toBeDisabled();
});
it("when calls to client.getRoom and room.getEventReadUpTo are non-null, shows read receipt button", () => {
mockRoom.getEventReadUpTo.mockReturnValueOnce("1234");
mockClient.getRoom.mockReturnValueOnce(mockRoom);
renderComponent();
expect(screen.getByRole("button", { name: "Jump to read receipt" })).toBeInTheDocument();
});
it("clicking the read receipt button calls dispatch with correct event_id", async () => {
const mockEventId = "1234";
mockRoom.getEventReadUpTo.mockReturnValue(mockEventId);
mockClient.getRoom.mockReturnValue(mockRoom);
renderComponent();
const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" });
expect(readReceiptButton).toBeInTheDocument();
await userEvent.click(readReceiptButton);
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_room",
event_id: mockEventId,
highlighted: true,
metricsTrigger: undefined,
room_id: "!fkfk",
});
mockRoom.getEventReadUpTo.mockReset();
mockClient.getRoom.mockReset();
});
it("firing the read receipt event handler with a null event_id calls dispatch with undefined not null", async () => {
const mockEventId = "1234";
// the first call is the check to see if we should render the button, second call is
// when the button is clicked
mockRoom.getEventReadUpTo.mockReturnValueOnce(mockEventId).mockReturnValueOnce(null);
mockClient.getRoom.mockReturnValue(mockRoom);
renderComponent();
const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" });
expect(readReceiptButton).toBeInTheDocument();
await userEvent.click(readReceiptButton);
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_room",
event_id: undefined,
highlighted: true,
metricsTrigger: undefined,
room_id: "!fkfk",
});
mockClient.getRoom.mockReset();
});
it("does not show the invite button when canInvite is false", () => {
renderComponent();
expect(screen.queryByRole("button", { name: /invite/i })).not.toBeInTheDocument();
});
it("shows the invite button when canInvite is true", () => {
renderComponent({ canInvite: true });
expect(screen.getByRole("button", { name: /invite/i })).toBeInTheDocument();
});
it("clicking the invite button will call MultiInviter.invite", async () => {
// to save mocking, we will reject the call to .invite
const mockErrorMessage = new Error("test error message");
inviteSpy.mockRejectedValue(mockErrorMessage);
// render the component and click the button
renderComponent({ canInvite: true });
const inviteButton = screen.getByRole("button", { name: /invite/i });
expect(inviteButton).toBeInTheDocument();
await userEvent.click(inviteButton);
// check that we have called .invite
expect(inviteSpy).toHaveBeenCalledWith([member.userId]);
// check that the test error message is displayed
await expect(screen.findByText(mockErrorMessage.message)).resolves.toBeInTheDocument();
});
it("if calling .invite throws something strange, show default error message", async () => {
inviteSpy.mockRejectedValue({ this: "could be anything" });
// render the component and click the button
renderComponent({ canInvite: true });
const inviteButton = screen.getByRole("button", { name: /invite/i });
expect(inviteButton).toBeInTheDocument();
await userEvent.click(inviteButton);
// check that the default test error message is displayed
await expect(screen.findByText(/operation failed/i)).resolves.toBeInTheDocument();
});
it.each([
["for a RoomMember", member, member.getMxcAvatarUrl()],
["for a User", defaultUser, defaultUser.avatarUrl],
])(
"clicking »message« %s should start a DM",
async (test: string, member: RoomMember | User, expectedAvatarUrl: string | undefined) => {
const deferred = Promise.withResolvers<string>();
mocked(startDmOnFirstMessage).mockReturnValue(deferred.promise);
renderComponent({ member });
await userEvent.click(screen.getByRole("button", { name: "Send message" }));
// Checking the attribute, because the button is a DIV and toBeDisabled() does not work.
expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled();
expect(startDmOnFirstMessage).toHaveBeenCalledWith(mockClient, [
new DirectoryMember({
user_id: member.userId,
display_name: member.rawDisplayName,
avatar_url: expectedAvatarUrl,
}),
]);
await act(async () => {
deferred.resolve("!dm:example.com");
await flushPromises();
});
// Checking the attribute, because the button is a DIV and toBeDisabled() does not work.
expect(screen.getByRole("button", { name: "Send message" })).not.toBeDisabled();
},
);
});
describe("disambiguateDevices", () => {
it("does not add ambiguous key to unique names", () => {
const initialDevices = [

View File

@@ -10,16 +10,16 @@ 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";
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", () => {
jest.mock("../../../../../../src/utils/DMRoomMap", () => {
const mock = {
getUserIdForRoomId: jest.fn(),
getDMRoomsForUserId: jest.fn(),
@@ -32,7 +32,7 @@ jest.mock("../../../../../src/utils/DMRoomMap", () => {
});
jest.mock(
"../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel",
"../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel",
() => ({
useUserInfoAdminToolsContainerViewModel: jest.fn().mockReturnValue({
isCurrentUserInTheRoom: true,
@@ -44,34 +44,43 @@ jest.mock(
}),
);
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/UserInfoKickButtonViewModel",
() => ({
useRoomKickButtonViewModel: jest.fn().mockReturnValue({
canUserBeKicked: true,
kickLabel: "Kick",
onKickClick: jest.fn(),
}),
}),
}));
);
jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel", () => ({
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/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(),
jest.mock(
"../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel",
() => ({
useRedactMessagesButtonViewModel: jest.fn().mockReturnValue({
onRedactAllMessagesClick: jest.fn(),
}),
}),
}));
);
const defaultRoomId = "!fkfk";

View File

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

View File

@@ -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: (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();
});
});

View File

@@ -12,10 +12,10 @@ 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";
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";

View File

@@ -12,14 +12,14 @@ 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";
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", () => ({
jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel", () => ({
useUserfoHeaderViewModel: jest.fn().mockReturnValue({
onMemberAvatarClick: jest.fn(),
precenseInfo: {

View File

@@ -0,0 +1,315 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
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>
</div>
</div>
</div>
<button
class="_item_dyt4i_8 _interactive_dyt4i_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_dyt4i_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_dyt4i_34"
>
Send message
</span>
<svg
aria-hidden="true"
class="_nav-hint_dyt4i_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_dyt4i_8 _interactive_dyt4i_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_dyt4i_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_dyt4i_34"
>
Invite
</span>
<svg
aria-hidden="true"
class="_nav-hint_dyt4i_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_dyt4i_8 _interactive_dyt4i_26 _disabled_dyt4i_118"
data-kind="primary"
disabled=""
role="button"
>
<svg
aria-hidden="true"
class="_icon_dyt4i_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_dyt4i_34"
>
Jump to read receipt
</span>
<svg
aria-hidden="true"
class="_nav-hint_dyt4i_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_dyt4i_8 _interactive_dyt4i_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_dyt4i_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_dyt4i_34"
>
Share profile
</span>
<svg
aria-hidden="true"
class="_nav-hint_dyt4i_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_dyt4i_8 _interactive_dyt4i_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_dyt4i_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_dyt4i_34"
>
Mention
</span>
<svg
aria-hidden="true"
class="_nav-hint_dyt4i_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_dyt4i_8 _interactive_dyt4i_26"
data-kind="critical"
role="button"
>
<svg
aria-hidden="true"
class="_icon_dyt4i_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_dyt4i_34"
>
Deactivate user
</span>
<svg
aria-hidden="true"
class="_nav-hint_dyt4i_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_dyt4i_8 _interactive_dyt4i_26"
data-kind="critical"
role="button"
>
<svg
aria-hidden="true"
class="_icon_dyt4i_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_dyt4i_34"
>
Ignore
</span>
<svg
aria-hidden="true"
class="_nav-hint_dyt4i_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>
`;