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:
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { mocked } from "jest-mock";
|
||||
import { type MatrixClient, type Room, RoomMember, type User } from "matrix-js-sdk/src/matrix";
|
||||
import { logRoles, render, screen } from "jest-matrix-react";
|
||||
|
||||
import { createTestClient, mkStubRoom } from "../../../../../test-utils";
|
||||
import {
|
||||
type UserInfoBasicState,
|
||||
useUserInfoBasicViewModel,
|
||||
} from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel";
|
||||
import { UserInfoBasicView } from "../../../../../../src/components/views/right_panel/user_info/UserInfoBasicView";
|
||||
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
|
||||
|
||||
const defaultRoomPermissions = {
|
||||
canEdit: true,
|
||||
canInvite: true,
|
||||
modifyLevelMax: -1,
|
||||
};
|
||||
jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel", () => ({
|
||||
useUserInfoBasicViewModel: jest.fn(),
|
||||
useRoomPermissions: () => defaultRoomPermissions,
|
||||
}));
|
||||
|
||||
describe("<UserInfoBasic />", () => {
|
||||
const defaultValue: UserInfoBasicState = {
|
||||
powerLevels: {},
|
||||
roomPermissions: defaultRoomPermissions,
|
||||
pendingUpdateCount: 0,
|
||||
isMe: false,
|
||||
isRoomDMForMember: false,
|
||||
showDeactivateButton: true,
|
||||
onSynapseDeactivate: jest.fn(),
|
||||
startUpdating: jest.fn(),
|
||||
stopUpdating: jest.fn(),
|
||||
};
|
||||
|
||||
const defaultRoomId = "!fkfk";
|
||||
const defaultUserId = "@user:example.com";
|
||||
|
||||
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
|
||||
let defaultRoom: Room;
|
||||
|
||||
let defaultProps: { member: User | RoomMember; room: Room };
|
||||
let matrixClient: MatrixClient;
|
||||
|
||||
const renderComponent = (props = defaultProps) => {
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={matrixClient}>
|
||||
<UserInfoBasicView {...props} />
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
beforeEach(() => {
|
||||
matrixClient = createTestClient();
|
||||
defaultRoom = mkStubRoom(defaultRoomId, defaultRoomId, matrixClient);
|
||||
defaultProps = {
|
||||
member: defaultMember,
|
||||
room: defaultRoom,
|
||||
};
|
||||
});
|
||||
|
||||
it("should display the defaut values", () => {
|
||||
mocked(useUserInfoBasicViewModel).mockReturnValue(defaultValue);
|
||||
const { container } = renderComponent();
|
||||
logRoles(container);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should not show ignore button if user is me", () => {
|
||||
const state: UserInfoBasicState = { ...defaultValue, isMe: true };
|
||||
mocked(useUserInfoBasicViewModel).mockReturnValue(state);
|
||||
renderComponent();
|
||||
|
||||
const ignoreButton = screen.queryByRole("button", { name: "Ignore" });
|
||||
expect(ignoreButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show deactivate button", () => {
|
||||
const state: UserInfoBasicState = { ...defaultValue, showDeactivateButton: false };
|
||||
mocked(useUserInfoBasicViewModel).mockReturnValue(state);
|
||||
renderComponent();
|
||||
|
||||
const deactivateButton = screen.queryByRole("button", { name: "Deactivate user" });
|
||||
expect(deactivateButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show powerlevels selector for dm", () => {
|
||||
const state: UserInfoBasicState = { ...defaultValue, isRoomDMForMember: true };
|
||||
mocked(useUserInfoBasicViewModel).mockReturnValue(state);
|
||||
const { container } = renderComponent();
|
||||
|
||||
logRoles(container);
|
||||
const powserlevel = screen.queryByRole("option", { name: "Default" });
|
||||
expect(powserlevel).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show spinner if pending update is > 0", () => {
|
||||
const state: UserInfoBasicState = { ...defaultValue, pendingUpdateCount: 2 };
|
||||
mocked(useUserInfoBasicViewModel).mockReturnValue(state);
|
||||
renderComponent();
|
||||
|
||||
const spinner = screen.getByTestId("spinner");
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { mocked } from "jest-mock";
|
||||
import { type Room, RoomMember, type User } from "matrix-js-sdk/src/matrix";
|
||||
import { fireEvent, render, screen } from "jest-matrix-react";
|
||||
|
||||
import { mkStubRoom, stubClient } from "../../../../../test-utils";
|
||||
import {
|
||||
useUserInfoBasicOptionsViewModel,
|
||||
type UserInfoBasicOptionsState,
|
||||
} from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel";
|
||||
import { UserInfoBasicOptionsView } from "../../../../../../src/components/views/right_panel/user_info/UserInfoBasicOptionsView";
|
||||
import { UIComponent } from "../../../../../../src/settings/UIFeature";
|
||||
import { shouldShowComponent } from "../../../../../../src/customisations/helpers/UIComponents";
|
||||
import { type Member } from "../../../../../../src/components/views/right_panel/UserInfo";
|
||||
|
||||
jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel", () => ({
|
||||
useUserInfoBasicOptionsViewModel: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => {
|
||||
const original = jest.requireActual("../../../../../../src/customisations/helpers/UIComponents");
|
||||
return {
|
||||
shouldShowComponent: jest.fn().mockImplementation(original.shouldShowComponent),
|
||||
};
|
||||
});
|
||||
|
||||
describe("<UserOptionsSection />", () => {
|
||||
const defaultValue: UserInfoBasicOptionsState = {
|
||||
isMe: false,
|
||||
showInviteButton: false,
|
||||
showInsertPillButton: false,
|
||||
readReceiptButtonDisabled: false,
|
||||
onInsertPillButton: () => jest.fn(),
|
||||
onReadReceiptButton: () => jest.fn(),
|
||||
onShareUserClick: () => jest.fn(),
|
||||
onInviteUserButton: (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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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: {
|
||||
@@ -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>
|
||||
`;
|
||||
Reference in New Issue
Block a user