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,156 @@
/*
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 { useContext } from "react";
import { RoomMember, User, type Room, KnownMembership } from "matrix-js-sdk/src/matrix";
import Modal from "../../../../Modal";
import ErrorDialog from "../../../views/dialogs/ErrorDialog";
import { _t, UserFriendlyError } from "../../../../languageHandler";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import dis from "../../../../dispatcher/dispatcher";
import PosthogTrackers from "../../../../PosthogTrackers";
import { ShareDialog } from "../../../views/dialogs/ShareDialog";
import { type ComposerInsertPayload } from "../../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../../dispatcher/actions";
import { SdkContextClass } from "../../../../contexts/SDKContext";
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
import MultiInviter from "../../../../utils/MultiInviter";
import { type ViewRoomPayload } from "../../../../dispatcher/payloads/ViewRoomPayload";
import { useRoomPermissions } from "./UserInfoBasicViewModel";
import { DirectoryMember, startDmOnFirstMessage } from "../../../../utils/direct-messages";
import { type Member } from "../../../views/right_panel/UserInfo";
export interface UserInfoBasicOptionsState {
// boolean to know if selected user is current user
isMe: boolean;
// boolean to display/hide invite button
showInviteButton: boolean;
// boolean to display/hide insert pill button
showInsertPillButton: boolean | "";
// boolean to display/hide read receipt button
readReceiptButtonDisabled: boolean;
// Method called when a insert pill button is clicked
onInsertPillButton: () => void;
// Method called when a read receipt button is clicked, will add a pill in the input message field
onReadReceiptButton: () => void;
// Method called when a share user button is clicked, will display modal with profile to share
onShareUserClick: () => void;
// Method called when a invite button is clicked, will display modal to invite user
onInviteUserButton: (evt: Event) => Promise<void>;
// Method called when the DM button is clicked, will open a DM with the selected member
onOpenDmForUser: (member: Member) => Promise<void>;
}
export const useUserInfoBasicOptionsViewModel = (room: Room, member: User | RoomMember): UserInfoBasicOptionsState => {
const cli = useContext(MatrixClientContext);
// selected member is current user
const isMe = member.userId === cli.getUserId();
// Those permissions are updated when a change is done on the room current state and the selected user
const roomPermissions = useRoomPermissions(cli, room, member as RoomMember);
const isSpace = room?.isSpaceRoom();
// read receipt button stay disable for a room space or if all events where read (null)
const readReceiptButtonDisabled = isSpace || !room?.getEventReadUpTo(member.userId);
// always show exempt when room is a space
const showInsertPillButton = member instanceof RoomMember && member.roomId && !isSpace;
// show invite button only if current user has the permission to invite and the selected user membership is LEAVE
const showInviteButton =
member instanceof RoomMember &&
roomPermissions.canInvite &&
(member?.membership ?? KnownMembership.Leave) === KnownMembership.Leave;
const onReadReceiptButton = function (): void {
const room = member instanceof RoomMember ? cli.getRoom(member.roomId) : null;
if (!room || readReceiptButtonDisabled) return;
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
highlighted: true,
// this could return null, the default prevents a type error
event_id: room.getEventReadUpTo(member.userId) || undefined,
room_id: room.roomId,
metricsTrigger: undefined, // room doesn't change
});
};
const onInsertPillButton = function (): void {
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
userId: member.userId,
timelineRenderingType: TimelineRenderingType.Room,
});
};
const onInviteUserButton = async (ev: Event): Promise<void> => {
try {
const roomId =
member instanceof RoomMember && member.roomId
? member.roomId
: SdkContextClass.instance.roomViewStore.getRoomId();
// We use a MultiInviter to re-use the invite logic, even though we're only inviting one user.
const inviter = new MultiInviter(cli, roomId || "");
await inviter.invite([member.userId]).then(() => {
if (inviter.getCompletionState(member.userId) !== "invited") {
const errorStringFromInviterUtility = inviter.getErrorText(member.userId);
if (errorStringFromInviterUtility) {
throw new Error(errorStringFromInviterUtility);
} else {
throw new UserFriendlyError("slash_command|invite_failed", {
user: member.userId,
roomId,
cause: undefined,
});
}
}
});
} catch (err) {
const description = err instanceof Error ? err.message : _t("invite|failed_generic");
Modal.createDialog(ErrorDialog, {
title: _t("invite|failed_title"),
description,
});
}
PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoInviteButton", ev);
};
const onShareUserClick = (): void => {
Modal.createDialog(ShareDialog, {
target: member,
});
};
const onOpenDmForUser = async (user: Member): Promise<void> => {
const avatarUrl = user instanceof User ? user.avatarUrl : user.getMxcAvatarUrl();
const startDmUser = new DirectoryMember({
user_id: user.userId,
display_name: user.rawDisplayName,
avatar_url: avatarUrl,
});
await startDmOnFirstMessage(cli, [startDmUser]);
};
return {
isMe,
showInviteButton,
showInsertPillButton,
readReceiptButtonDisabled,
onReadReceiptButton,
onInsertPillButton,
onInviteUserButton,
onShareUserClick,
onOpenDmForUser,
};
};

View File

@@ -0,0 +1,197 @@
/*
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, { useCallback, useEffect, useState } from "react";
import {
EventType,
type RoomMember,
type IPowerLevelsContent,
type Room,
RoomStateEvent,
type MatrixClient,
type User,
type MatrixEvent,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
import { useTypedEventEmitter } from "../../../../hooks/useEventEmitter";
import Modal from "../../../../Modal";
import ErrorDialog from "../../../views/dialogs/ErrorDialog";
import { _t } from "../../../../languageHandler";
import { type IRoomPermissions } from "../../../views/right_panel/UserInfo";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import QuestionDialog from "../../../views/dialogs/QuestionDialog";
import DMRoomMap from "../../../../utils/DMRoomMap";
export interface UserInfoBasicState {
// current room powerlevels
powerLevels: IPowerLevelsContent;
// getting user permissions in this room
roomPermissions: IRoomPermissions;
// numbers of operation in progress > 0
pendingUpdateCount: number;
// true if user is me
isMe: boolean;
// true if room is a DM for the user
isRoomDMForMember: boolean;
// Boolean to hide or show the deactivate button
showDeactivateButton: boolean;
// Method called when a deactivate user action is triggered
onSynapseDeactivate: () => void;
startUpdating: () => void;
stopUpdating: () => void;
}
export const getPowerLevels = (room: Room): IPowerLevelsContent =>
room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {};
export const useRoomPermissions = (cli: MatrixClient, room: Room, user: RoomMember): IRoomPermissions => {
const [roomPermissions, setRoomPermissions] = useState<IRoomPermissions>({
// modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL
modifyLevelMax: -1,
canEdit: false,
canInvite: false,
});
const updateRoomPermissions = useCallback(() => {
const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
if (!powerLevels) return;
const me = room.getMember(cli.getUserId() || "");
if (!me) return;
const them = user;
const isMe = me.userId === them.userId;
const canAffectUser = them.powerLevel < me.powerLevel || isMe;
let modifyLevelMax = -1;
if (canAffectUser) {
const editPowerLevel = powerLevels.events?.[EventType.RoomPowerLevels] ?? powerLevels.state_default ?? 50;
if (me.powerLevel >= editPowerLevel) {
modifyLevelMax = me.powerLevel;
}
}
setRoomPermissions({
canInvite: me.powerLevel >= (powerLevels.invite ?? 0),
canEdit: modifyLevelMax >= 0,
modifyLevelMax,
});
}, [cli, user, room]);
useTypedEventEmitter(cli, RoomStateEvent.Update, updateRoomPermissions);
useEffect(() => {
updateRoomPermissions();
return () => {
setRoomPermissions({
modifyLevelMax: -1,
canEdit: false,
canInvite: false,
});
};
}, [updateRoomPermissions]);
return roomPermissions;
};
const useIsSynapseAdmin = (cli?: MatrixClient): boolean => {
return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false);
};
export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsContent => {
const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>(getPowerLevels(room));
const update = useCallback(
(ev?: MatrixEvent) => {
if (!room) return;
if (ev && ev.getType() !== EventType.RoomPowerLevels) return;
setPowerLevels(getPowerLevels(room));
},
[room],
);
useTypedEventEmitter(cli, RoomStateEvent.Events, update);
useEffect(() => {
update();
return () => {
setPowerLevels({});
};
}, [update]);
return powerLevels;
};
export const useUserInfoBasicViewModel = (room: Room, member: User | RoomMember): UserInfoBasicState => {
const cli = useMatrixClientContext();
const powerLevels = useRoomPowerLevels(cli, room);
// Load whether or not we are a Synapse Admin
const isSynapseAdmin = useIsSynapseAdmin(cli);
// Count of how many operations are currently in progress, if > 0 then show a Spinner
const [pendingUpdateCount, setPendingUpdateCount] = useState(0);
const roomPermissions = useRoomPermissions(cli, room, member as RoomMember);
// selected member is current user
const isMe = member.userId === cli.getUserId();
// is needed to hide the Roles section for DMs as it doesn't make sense there
const isRoomDMForMember = !!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId);
// used to check if user can deactivate another member
const isMemberSameDomain = member.userId.endsWith(`:${cli.getDomain()}`);
// We don't need a perfect check here, just something to pass as "probably not our homeserver". If
// someone does figure out how to bypass this check the worst that happens is an error.
const showDeactivateButton = isSynapseAdmin && isMemberSameDomain;
const startUpdating = useCallback(() => {
setPendingUpdateCount(pendingUpdateCount + 1);
}, [pendingUpdateCount]);
const stopUpdating = useCallback(() => {
setPendingUpdateCount(pendingUpdateCount - 1);
}, [pendingUpdateCount]);
const onSynapseDeactivate = useCallback(async () => {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("user_info|deactivate_confirm_title"),
description: <div>{_t("user_info|deactivate_confirm_description")}</div>,
button: _t("user_info|deactivate_confirm_action"),
danger: true,
});
const [accepted] = await finished;
if (!accepted) return;
try {
await cli.deactivateSynapseUser(member.userId);
} catch (err) {
logger.error("Failed to deactivate user");
logger.error(err);
const description = err instanceof Error ? err.message : _t("invite|failed_generic");
Modal.createDialog(ErrorDialog, {
title: _t("user_info|error_deactivate"),
description,
});
}
}, [cli, member.userId]);
return {
showDeactivateButton,
powerLevels,
roomPermissions,
pendingUpdateCount,
isMe,
isRoomDMForMember,
onSynapseDeactivate,
startUpdating,
stopUpdating,
};
};

View File

@@ -0,0 +1,85 @@
/*
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, { useContext, useEffect, useState, useCallback } from "react";
import { type RoomMember, User, ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
import QuestionDialog from "../../../views/dialogs/QuestionDialog";
import { useTypedEventEmitter } from "../../../../hooks/useEventEmitter";
export interface UserInfoPowerLevelState {
/**
* Weither the member is ignored by current user or not
*/
isIgnored: boolean;
/**
* Trigger the method to ignore or unignore a user
* @param ev - The click event
*/
ignoreButtonClick: (ev: Event) => void;
}
export const useUserInfoIgnoreButtonViewModel = (member: User | RoomMember): UserInfoPowerLevelState => {
const cli = useContext(MatrixClientContext);
const unignore = useCallback(() => {
const ignoredUsers = cli.getIgnoredUsers();
const index = ignoredUsers.indexOf(member.userId);
if (index !== -1) ignoredUsers.splice(index, 1);
cli.setIgnoredUsers(ignoredUsers);
}, [cli, member]);
const ignore = useCallback(async () => {
const name = (member instanceof User ? member.displayName : member.name) || member.userId;
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("user_info|ignore_confirm_title", { user: name }),
description: <div>{_t("user_info|ignore_confirm_description")}</div>,
button: _t("action|ignore"),
});
const [confirmed] = await finished;
if (confirmed) {
const ignoredUsers = cli.getIgnoredUsers();
ignoredUsers.push(member.userId);
cli.setIgnoredUsers(ignoredUsers);
}
}, [cli, member]);
// Check whether the user is ignored
const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(member.userId));
// Recheck if the user or client changes
useEffect(() => {
setIsIgnored(cli.isUserIgnored(member.userId));
}, [cli, member.userId]);
// Recheck also if we receive new accountData m.ignored_user_list
const accountDataHandler = useCallback(
(ev: MatrixEvent) => {
if (ev.getType() === "m.ignored_user_list") {
setIsIgnored(cli.isUserIgnored(member.userId));
}
},
[cli, member.userId],
);
useTypedEventEmitter(cli, ClientEvent.AccountData, accountDataHandler);
const ignoreButtonClick = (ev: Event): void => {
ev.preventDefault();
if (isIgnored) {
unignore();
} else {
ignore();
}
};
return {
ignoreButtonClick,
isIgnored,
};
};

View File

@@ -9,62 +9,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
import React, { type JSX, type ReactNode, useContext, useEffect, useMemo, useState } from "react";
import classNames from "classnames";
import {
ClientEvent,
type MatrixClient,
RoomMember,
type Room,
RoomStateEvent,
type MatrixEvent,
User,
type Device,
EventType,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { type MatrixClient, type RoomMember, type Room, type User, type Device } from "matrix-js-sdk/src/matrix";
import { type UserVerificationStatus, type VerificationRequest, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { MenuItem } from "@vector-im/compound-web";
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
import MentionIcon from "@vector-im/compound-design-tokens/assets/web/icons/mention";
import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
import BlockIcon from "@vector-im/compound-design-tokens/assets/web/icons/block";
import DeleteIcon from "@vector-im/compound-design-tokens/assets/web/icons/delete";
import dis from "../../../dispatcher/dispatcher";
import Modal from "../../../Modal";
import { _t, UserFriendlyError } from "../../../languageHandler";
import DMRoomMap from "../../../utils/DMRoomMap";
import { _t } from "../../../languageHandler";
import { type ButtonEvent } from "../elements/AccessibleButton";
import MultiInviter from "../../../utils/MultiInviter";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import EncryptionPanel from "./EncryptionPanel";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import { Action } from "../../../dispatcher/actions";
import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
import BaseCard from "./BaseCard";
import Spinner from "../elements/Spinner";
import { ShareDialog } from "../dialogs/ShareDialog";
import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { TimelineRenderingType } from "../../../contexts/RoomContext";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { type IRightPanelCardState } from "../../../stores/right-panel/RightPanelStoreIPanelState";
import PosthogTrackers from "../../../PosthogTrackers";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { UserInfoAdminToolsContainer } from "./user_info/UserInfoAdminToolsContainer";
import { PowerLevelSection } from "./user_info/UserInfoPowerLevels";
import { UserInfoHeaderView } from "./user_info/UserInfoHeaderView";
import { UserInfoBasicView } from "./user_info/UserInfoBasicView";
export interface IDevice extends Device {
ambiguous?: boolean;
@@ -87,190 +50,6 @@ export const disambiguateDevices = (devices: IDevice[]): void => {
}
};
/**
* Converts the member to a DirectoryMember and starts a DM with them.
*/
async function openDmForUser(matrixClient: MatrixClient, user: Member): Promise<void> {
const avatarUrl = user instanceof User ? user.avatarUrl : user.getMxcAvatarUrl();
const startDmUser = new DirectoryMember({
user_id: user.userId,
display_name: user.rawDisplayName,
avatar_url: avatarUrl,
});
await startDmOnFirstMessage(matrixClient, [startDmUser]);
}
const MessageButton = ({ member }: { member: Member }): JSX.Element => {
const cli = useContext(MatrixClientContext);
const [busy, setBusy] = useState(false);
return (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
if (busy) return;
setBusy(true);
await openDmForUser(cli, member);
setBusy(false);
}}
disabled={busy}
label={_t("user_info|send_message")}
Icon={ChatIcon}
/>
);
};
export const UserOptionsSection: React.FC<{
member: Member;
canInvite: boolean;
isSpace?: boolean;
children?: ReactNode;
}> = ({ member, canInvite, isSpace, children }) => {
const cli = useContext(MatrixClientContext);
let insertPillButton: JSX.Element | undefined;
let inviteUserButton: JSX.Element | undefined;
let readReceiptButton: JSX.Element | undefined;
const isMe = member.userId === cli.getUserId();
const onShareUserClick = (): void => {
Modal.createDialog(ShareDialog, {
target: member,
});
};
// Only allow the user to ignore the user if its not ourselves
// same goes for jumping to read receipt
if (!isMe) {
const onReadReceiptButton = function (room: Room): void {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
highlighted: true,
// this could return null, the default prevents a type error
event_id: room.getEventReadUpTo(member.userId) || undefined,
room_id: room.roomId,
metricsTrigger: undefined, // room doesn't change
});
};
const room = member instanceof RoomMember ? cli.getRoom(member.roomId) : null;
const readReceiptButtonDisabled = isSpace || !room?.getEventReadUpTo(member.userId);
readReceiptButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
if (room && !readReceiptButtonDisabled) {
onReadReceiptButton(room);
}
}}
label={_t("user_info|jump_to_rr_button")}
disabled={readReceiptButtonDisabled}
Icon={CheckIcon}
/>
);
if (member instanceof RoomMember && member.roomId && !isSpace) {
const onInsertPillButton = function (): void {
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
userId: member.userId,
timelineRenderingType: TimelineRenderingType.Room,
});
};
insertPillButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onInsertPillButton();
}}
label={_t("action|mention")}
Icon={MentionIcon}
/>
);
}
if (
member instanceof RoomMember &&
canInvite &&
(member?.membership ?? KnownMembership.Leave) === KnownMembership.Leave &&
shouldShowComponent(UIComponent.InviteUsers)
) {
const roomId = member && member.roomId ? member.roomId : SdkContextClass.instance.roomViewStore.getRoomId();
const onInviteUserButton = async (ev: Event): Promise<void> => {
try {
// We use a MultiInviter to re-use the invite logic, even though we're only inviting one user.
const inviter = new MultiInviter(cli, roomId || "");
await inviter.invite([member.userId]).then(() => {
if (inviter.getCompletionState(member.userId) !== "invited") {
const errorStringFromInviterUtility = inviter.getErrorText(member.userId);
if (errorStringFromInviterUtility) {
throw new Error(errorStringFromInviterUtility);
} else {
throw new UserFriendlyError("slash_command|invite_failed", {
user: member.userId,
roomId,
cause: undefined,
});
}
}
});
} catch (err) {
const description = err instanceof Error ? err.message : _t("invite|failed_generic");
Modal.createDialog(ErrorDialog, {
title: _t("invite|failed_title"),
description,
});
}
PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoInviteButton", ev);
};
inviteUserButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onInviteUserButton(ev);
}}
label={_t("action|invite")}
Icon={InviteIcon}
/>
);
}
}
const shareUserButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onShareUserClick();
}}
label={_t("user_info|share_button")}
Icon={ShareIcon}
/>
);
const directMessageButton =
isMe || !shouldShowComponent(UIComponent.CreateRooms) ? null : <MessageButton member={member} />;
return (
<Container>
{children}
{directMessageButton}
{inviteUserButton}
{readReceiptButton}
{shareUserButton}
{insertPillButton}
</Container>
);
};
export const warnSelfDemote = async (isSpace: boolean): Promise<boolean> => {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("user_info|demote_self_confirm_title"),
@@ -325,152 +104,12 @@ export const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsConte
return member.powerLevel < levelToSend;
};
export const getPowerLevels = (room: Room): IPowerLevelsContent =>
room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {};
export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsContent => {
const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>(getPowerLevels(room));
const update = useCallback(
(ev?: MatrixEvent) => {
if (!room) return;
if (ev && ev.getType() !== EventType.RoomPowerLevels) return;
setPowerLevels(getPowerLevels(room));
},
[room],
);
useTypedEventEmitter(cli, RoomStateEvent.Events, update);
useEffect(() => {
update();
return () => {
setPowerLevels({});
};
}, [update]);
return powerLevels;
};
const IgnoreToggleButton: React.FC<{
member: User | RoomMember;
}> = ({ member }) => {
const cli = useContext(MatrixClientContext);
const unignore = useCallback(() => {
const ignoredUsers = cli.getIgnoredUsers();
const index = ignoredUsers.indexOf(member.userId);
if (index !== -1) ignoredUsers.splice(index, 1);
cli.setIgnoredUsers(ignoredUsers);
}, [cli, member]);
const ignore = useCallback(async () => {
const name = (member instanceof User ? member.displayName : member.name) || member.userId;
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("user_info|ignore_confirm_title", { user: name }),
description: <div>{_t("user_info|ignore_confirm_description")}</div>,
button: _t("action|ignore"),
});
const [confirmed] = await finished;
if (confirmed) {
const ignoredUsers = cli.getIgnoredUsers();
ignoredUsers.push(member.userId);
cli.setIgnoredUsers(ignoredUsers);
}
}, [cli, member]);
// Check whether the user is ignored
const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(member.userId));
// Recheck if the user or client changes
useEffect(() => {
setIsIgnored(cli.isUserIgnored(member.userId));
}, [cli, member.userId]);
// Recheck also if we receive new accountData m.ignored_user_list
const accountDataHandler = useCallback(
(ev: MatrixEvent) => {
if (ev.getType() === "m.ignored_user_list") {
setIsIgnored(cli.isUserIgnored(member.userId));
}
},
[cli, member.userId],
);
useTypedEventEmitter(cli, ClientEvent.AccountData, accountDataHandler);
return (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
if (isIgnored) {
unignore();
} else {
ignore();
}
}}
label={isIgnored ? _t("user_info|unignore_button") : _t("user_info|ignore_button")}
kind="critical"
Icon={BlockIcon}
/>
);
};
const useIsSynapseAdmin = (cli?: MatrixClient): boolean => {
return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false);
};
export interface IRoomPermissions {
modifyLevelMax: number;
canEdit: boolean;
canInvite: boolean;
}
function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IRoomPermissions {
const [roomPermissions, setRoomPermissions] = useState<IRoomPermissions>({
// modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL
modifyLevelMax: -1,
canEdit: false,
canInvite: false,
});
const updateRoomPermissions = useCallback(() => {
const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
if (!powerLevels) return;
const me = room.getMember(cli.getUserId() || "");
if (!me) return;
const them = user;
const isMe = me.userId === them.userId;
const canAffectUser = them.powerLevel < me.powerLevel || isMe;
let modifyLevelMax = -1;
if (canAffectUser) {
const editPowerLevel = powerLevels.events?.[EventType.RoomPowerLevels] ?? powerLevels.state_default ?? 50;
if (me.powerLevel >= editPowerLevel) {
modifyLevelMax = me.powerLevel;
}
}
setRoomPermissions({
canInvite: me.powerLevel >= (powerLevels.invite ?? 0),
canEdit: modifyLevelMax >= 0,
modifyLevelMax,
});
}, [cli, user, room]);
useTypedEventEmitter(cli, RoomStateEvent.Update, updateRoomPermissions);
useEffect(() => {
updateRoomPermissions();
return () => {
setRoomPermissions({
modifyLevelMax: -1,
canEdit: false,
canInvite: false,
});
};
}, [updateRoomPermissions]);
return roomPermissions;
}
async function getUserDeviceInfo(
userId: string,
cli: MatrixClient,
@@ -547,124 +186,6 @@ export const useDevices = (userId: string): IDevice[] | undefined | null => {
return devices;
};
const BasicUserInfo: React.FC<{
room: Room;
member: User | RoomMember;
}> = ({ room, member }) => {
const cli = useContext(MatrixClientContext);
const powerLevels = useRoomPowerLevels(cli, room);
// Load whether or not we are a Synapse Admin
const isSynapseAdmin = useIsSynapseAdmin(cli);
// Count of how many operations are currently in progress, if > 0 then show a Spinner
const [pendingUpdateCount, setPendingUpdateCount] = useState(0);
const startUpdating = useCallback(() => {
setPendingUpdateCount(pendingUpdateCount + 1);
}, [pendingUpdateCount]);
const stopUpdating = useCallback(() => {
setPendingUpdateCount(pendingUpdateCount - 1);
}, [pendingUpdateCount]);
const roomPermissions = useRoomPermissions(cli, room, member as RoomMember);
const onSynapseDeactivate = useCallback(async () => {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("user_info|deactivate_confirm_title"),
description: <div>{_t("user_info|deactivate_confirm_description")}</div>,
button: _t("user_info|deactivate_confirm_action"),
danger: true,
});
const [accepted] = await finished;
if (!accepted) return;
try {
await cli.deactivateSynapseUser(member.userId);
} catch (err) {
logger.error("Failed to deactivate user");
logger.error(err);
const description = err instanceof Error ? err.message : _t("invite|failed_generic");
Modal.createDialog(ErrorDialog, {
title: _t("user_info|error_deactivate"),
description,
});
}
}, [cli, member.userId]);
let synapseDeactivateButton;
let spinner;
// We don't need a perfect check here, just something to pass as "probably not our homeserver". If
// someone does figure out how to bypass this check the worst that happens is an error.
if (isSynapseAdmin && member.userId.endsWith(`:${cli.getDomain()}`)) {
synapseDeactivateButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onSynapseDeactivate();
}}
label={_t("user_info|deactivate_confirm_action")}
kind="critical"
Icon={DeleteIcon}
/>
);
}
let memberDetails;
let adminToolsContainer;
if (room && (member as RoomMember).roomId) {
// hide the Roles section for DMs as it doesn't make sense there
if (!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId)) {
memberDetails = (
<PowerLevelSection user={member as RoomMember} room={room} roomPermissions={roomPermissions} />
);
}
adminToolsContainer = (
<UserInfoAdminToolsContainer
powerLevels={powerLevels}
member={member as RoomMember}
room={room}
isUpdating={pendingUpdateCount > 0}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
>
{synapseDeactivateButton}
</UserInfoAdminToolsContainer>
);
} else if (synapseDeactivateButton) {
adminToolsContainer = <Container>{synapseDeactivateButton}</Container>;
}
if (pendingUpdateCount > 0) {
spinner = <Spinner />;
}
const isMe = member.userId === cli.getUserId();
return (
<React.Fragment>
<UserOptionsSection
canInvite={roomPermissions.canInvite}
member={member as RoomMember}
isSpace={room?.isSpaceRoom()}
>
{memberDetails}
</UserOptionsSection>
{adminToolsContainer}
{!isMe && (
<Container>
<IgnoreToggleButton member={member} />
</Container>
)}
{spinner}
</React.Fragment>
);
};
export type Member = User | RoomMember;
interface IProps {
@@ -700,7 +221,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
let content: JSX.Element | undefined;
switch (phase) {
case RightPanelPhases.MemberInfo:
content = <BasicUserInfo room={room as Room} member={member as User} />;
content = <UserInfoBasicView room={room as Room} member={member as User} />;
break;
case RightPanelPhases.EncryptionPanel:
classes.push("mx_UserInfo_smallAvatar");

View File

@@ -0,0 +1,127 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type RoomMember, type User, type Room } from "matrix-js-sdk/src/matrix";
import React, { type JSX, type ReactNode, useState } from "react";
import { MenuItem } from "@vector-im/compound-web";
import { ChatIcon, CheckIcon, MentionIcon, ShareIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
import { _t } from "../../../../languageHandler";
import { useUserInfoBasicOptionsViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel";
import { Container, type Member } from "../UserInfo";
import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../../settings/UIFeature";
const MessageButton = ({
member,
openDMForUser,
}: {
member: Member;
openDMForUser: (user: Member) => Promise<void>;
}): JSX.Element => {
const [busy, setBusy] = useState(false);
return (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
if (busy) return;
setBusy(true);
await openDMForUser(member);
setBusy(false);
}}
disabled={busy}
label={_t("user_info|send_message")}
Icon={ChatIcon}
/>
);
};
export const UserInfoBasicOptionsView: React.FC<{
member: User | RoomMember;
room: Room;
children?: ReactNode;
}> = ({ room, member, children }) => {
const vm = useUserInfoBasicOptionsViewModel(room, member);
let insertPillButton: JSX.Element | undefined;
let inviteUserButton: JSX.Element | undefined;
let readReceiptButton: JSX.Element | undefined;
if (!vm.isMe) {
readReceiptButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onReadReceiptButton();
}}
label={_t("user_info|jump_to_rr_button")}
disabled={vm.readReceiptButtonDisabled}
Icon={CheckIcon}
/>
);
if (vm.showInsertPillButton) {
insertPillButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onInsertPillButton();
}}
label={_t("action|mention")}
Icon={MentionIcon}
/>
);
}
if (vm.showInviteButton && shouldShowComponent(UIComponent.InviteUsers)) {
inviteUserButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onInviteUserButton(ev);
}}
label={_t("action|invite")}
Icon={InviteIcon}
/>
);
}
}
const shareUserButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onShareUserClick();
}}
label={_t("user_info|share_button")}
Icon={ShareIcon}
/>
);
const directMessageButton =
vm.isMe || !shouldShowComponent(UIComponent.CreateRooms) ? null : (
<MessageButton member={member} openDMForUser={vm.onOpenDmForUser} />
);
return (
<Container>
{children}
{directMessageButton}
{inviteUserButton}
{readReceiptButton}
{shareUserButton}
{insertPillButton}
</Container>
);
};

View File

@@ -0,0 +1,93 @@
/*
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 { type RoomMember, type User, type Room } from "matrix-js-sdk/src/matrix";
import { MenuItem } from "@vector-im/compound-web";
import { DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../../languageHandler";
import { useUserInfoBasicViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoBasicViewModel";
import { PowerLevelSection } from "./UserInfoPowerLevels";
import { Container } from "../UserInfo";
import { IgnoreToggleButton } from "./UserInfoIgnoreButtonView";
import Spinner from "../../elements/Spinner";
import { UserInfoAdminToolsContainer } from "./UserInfoAdminToolsContainer";
import { UserInfoBasicOptionsView } from "./UserInfoBasicOptionsView";
/**
* There are two types of components that can be displayed in the right panel concerning userinfo
* Basic info or Encryption Panel
*/
export const UserInfoBasicView: React.FC<{
room: Room;
member: User | RoomMember;
}> = ({ room, member }) => {
const vm = useUserInfoBasicViewModel(room, member);
let synapseDeactivateButton;
let spinner;
let memberDetails;
let adminToolsContainer;
if (vm.showDeactivateButton) {
synapseDeactivateButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onSynapseDeactivate();
}}
label={_t("user_info|deactivate_confirm_action")}
kind="critical"
Icon={DeleteIcon}
/>
);
}
if (room && (member as RoomMember).roomId) {
// hide the Roles section for DMs as it doesn't make sense there
if (!vm.isRoomDMForMember) {
memberDetails = (
<PowerLevelSection user={member as RoomMember} room={room} roomPermissions={vm.roomPermissions} />
);
}
adminToolsContainer = (
<UserInfoAdminToolsContainer
powerLevels={vm.powerLevels}
member={member as RoomMember}
room={room}
isUpdating={vm.pendingUpdateCount > 0}
startUpdating={vm.startUpdating}
stopUpdating={vm.stopUpdating}
>
{synapseDeactivateButton}
</UserInfoAdminToolsContainer>
);
} else if (synapseDeactivateButton) {
adminToolsContainer = <Container>{synapseDeactivateButton}</Container>;
}
if (vm.pendingUpdateCount > 0) {
spinner = <Spinner />;
}
return (
<React.Fragment>
<UserInfoBasicOptionsView room={room} member={member}>
{memberDetails}
</UserInfoBasicOptionsView>
{adminToolsContainer}
{!vm.isMe && (
<Container>
<IgnoreToggleButton member={member} />
</Container>
)}
{spinner}
</React.Fragment>
);
};

View File

@@ -0,0 +1,30 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type RoomMember, type User } from "matrix-js-sdk/src/matrix";
import React from "react";
import { MenuItem } from "@vector-im/compound-web";
import { BlockIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../../languageHandler";
import { useUserInfoIgnoreButtonViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel";
export const IgnoreToggleButton: React.FC<{
member: User | RoomMember;
}> = ({ member }) => {
const vm = useUserInfoIgnoreButtonViewModel(member);
return (
<MenuItem
role="button"
onSelect={async (ev) => vm.ignoreButtonClick(ev)}
label={vm.isIgnored ? _t("user_info|unignore_button") : _t("user_info|ignore_button")}
kind="critical"
Icon={BlockIcon}
/>
);
};

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>
`;