Update styling of UserInfo right panel card (#12788)

* Add colour to PresenceLabel in UserInfo

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update button positions & styles in UserInfo

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update UserInfo styles

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Revert Ignore->Block copy change

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-07-18 15:24:44 +01:00 committed by GitHub
parent 2920e76b64
commit f706ac4fa1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 670 additions and 419 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -41,35 +41,17 @@ limitations under the License.
}
}
h2 {
font-size: $font-18px;
font-weight: var(--cpd-font-weight-semibold);
margin: 18px 0 0 0;
}
.mx_UserInfo_container {
padding: $spacing-8 $spacing-16;
&:not(.mx_UserInfo_separator) {
padding-top: $spacing-16;
padding-bottom: 0;
> :not(h3) {
margin-inline-start: $spacing-8;
display: flex;
flex-flow: column;
align-items: flex-start;
row-gap: $spacing-8;
}
}
padding: var(--cpd-space-4x) 0;
margin: 0 var(--cpd-space-4x);
.mx_UserInfo_container_verifyButton {
margin-top: $spacing-8;
}
}
.mx_UserInfo_separator {
border-bottom: 1px solid $separator;
& + .mx_UserInfo_container {
border-top: 1px solid $separator;
}
}
.mx_UserInfo_memberDetailsContainer {
@ -94,7 +76,7 @@ limitations under the License.
margin: $spacing-24 $spacing-32 0 $spacing-32;
.mx_UserInfo_avatar_transition {
max-width: 30vh;
max-width: 120px;
aspect-ratio: 1 / 1;
margin: 0 auto;
transition: 0.5s;
@ -112,7 +94,7 @@ limitations under the License.
}
}
h3 {
h2 {
text-transform: uppercase;
color: $tertiary-content;
font: var(--cpd-font-heading-sm-semibold);
@ -125,41 +107,36 @@ limitations under the License.
}
.mx_UserInfo_profile {
text-align: center;
h2 {
display: flex;
font-size: $font-17px;
h1 {
font-size: $font-20px;
line-height: $font-25px;
flex: 1;
justify-content: center;
/* We reverse things here so for accessible technologies the name comes before the e2e shield */
flex-direction: row-reverse;
span {
/* limit to 2 lines, show an ellipsis if it overflows */
/* this looks webkit specific but is supported by Firefox 68+ */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
/* limit to 2 lines, show an ellipsis if it overflows */
/* this looks webkit specific but is supported by Firefox 68+ */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
word-break: break-all;
text-overflow: ellipsis;
}
overflow: hidden;
word-break: break-all;
text-overflow: ellipsis;
.mx_E2EIcon {
margin-top: 3px; /* visual vertical centering to the top line of text. */
margin-inline-end: $spacing-4; /* margin from displayName */
min-width: 18px; /* convince flexbox to not collapse it */
/* E2E icon wrapper */
.mx_Flex > span {
display: inline-block;
}
}
.mx_UserInfo_profileStatus {
margin-top: $spacing-12;
margin: var(--cpd-space-1x) 0;
}
}
.mx_PresenceLabel {
font: var(--cpd-font-body-sm-regular);
opacity: 1;
}
.mx_UserInfo_memberDetails {
.mx_UserInfo_profileField {
display: flex;
@ -184,10 +161,6 @@ limitations under the License.
.mx_UserInfo_field {
line-height: $font-16px;
&.mx_UserInfo_destructive {
color: $alert;
}
}
.mx_UserInfo_statusMessage {

View file

@ -18,3 +18,7 @@ limitations under the License.
font-size: $font-11px;
opacity: 0.5;
}
.mx_PresenceLabel_online {
color: var(--cpd-color-text-success-primary);
}

View file

@ -34,6 +34,18 @@ import { KnownMembership } from "matrix-js-sdk/src/types";
import { UserVerificationStatus, VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { Heading, MenuItem, Text } from "@vector-im/compound-web";
import { Icon as ChatIcon } from "@vector-im/compound-design-tokens/icons/chat.svg";
import { Icon as CheckIcon } from "@vector-im/compound-design-tokens/icons/check.svg";
import { Icon as ShareIcon } from "@vector-im/compound-design-tokens/icons/share.svg";
import { Icon as MentionIcon } from "@vector-im/compound-design-tokens/icons/mention.svg";
import { Icon as InviteIcon } from "@vector-im/compound-design-tokens/icons/user-add.svg";
import { Icon as BlockIcon } from "@vector-im/compound-design-tokens/icons/block.svg";
import { Icon as DeleteIcon } from "@vector-im/compound-design-tokens/icons/delete.svg";
import { Icon as CloseIcon } from "@vector-im/compound-design-tokens/icons/close.svg";
import { Icon as ChatProblemIcon } from "@vector-im/compound-design-tokens/icons/chat-problem.svg";
import { Icon as VisibilityOffIcon } from "@vector-im/compound-design-tokens/icons/visibility-off.svg";
import { Icon as LeaveIcon } from "@vector-im/compound-design-tokens/icons/leave.svg";
import dis from "../../../dispatcher/dispatcher";
import Modal from "../../../Modal";
@ -79,7 +91,8 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { asyncSome } from "../../../utils/arrays";
import UIStore from "../../../stores/UIStore";
import { Flex } from "../../utils/Flex";
import CopyableText from "../elements/CopyableText";
export interface IDevice extends Device {
ambiguous?: boolean;
@ -391,31 +404,29 @@ const MessageButton = ({ member }: { member: Member }): JSX.Element => {
const [busy, setBusy] = useState(false);
return (
<AccessibleButton
kind="link"
onClick={async () => {
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
if (busy) return;
setBusy(true);
await openDmForUser(cli, member);
setBusy(false);
}}
className="mx_UserInfo_field"
disabled={busy}
>
{_t("common|message")}
</AccessibleButton>
label={_t("user_info|send_message")}
Icon={ChatIcon}
/>
);
};
export const UserOptionsSection: React.FC<{
member: Member;
isIgnored: boolean;
canInvite: boolean;
isSpace?: boolean;
}> = ({ member, isIgnored, canInvite, isSpace }) => {
}> = ({ member, canInvite, isSpace, children }) => {
const cli = useContext(MatrixClientContext);
let ignoreButton: JSX.Element | undefined;
let insertPillButton: JSX.Element | undefined;
let inviteUserButton: JSX.Element | undefined;
let readReceiptButton: JSX.Element | undefined;
@ -427,42 +438,9 @@ export const UserOptionsSection: React.FC<{
});
};
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]);
// Only allow the user to ignore the user if its not ourselves
// same goes for jumping to read receipt
if (!isMe) {
ignoreButton = (
<AccessibleButton
onClick={isIgnored ? unignore : ignore}
kind="link"
className={classNames("mx_UserInfo_field", { mx_UserInfo_destructive: !isIgnored })}
>
{isIgnored ? _t("action|unignore") : _t("action|ignore")}
</AccessibleButton>
);
if (member instanceof RoomMember && member.roomId && !isSpace) {
const onReadReceiptButton = function (): void {
const room = cli.getRoom(member.roomId);
@ -487,16 +465,28 @@ export const UserOptionsSection: React.FC<{
const room = member instanceof RoomMember ? cli.getRoom(member.roomId) : undefined;
if (room?.getEventReadUpTo(member.userId)) {
readReceiptButton = (
<AccessibleButton kind="link" onClick={onReadReceiptButton} className="mx_UserInfo_field">
{_t("user_info|jump_to_rr_button")}
</AccessibleButton>
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onReadReceiptButton();
}}
label={_t("user_info|jump_to_rr_button")}
Icon={CheckIcon}
/>
);
}
insertPillButton = (
<AccessibleButton kind="link" onClick={onInsertPillButton} className="mx_UserInfo_field">
{_t("action|mention")}
</AccessibleButton>
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onInsertPillButton();
}}
label={_t("action|mention")}
Icon={MentionIcon}
/>
);
}
@ -507,7 +497,7 @@ export const UserOptionsSection: React.FC<{
shouldShowComponent(UIComponent.InviteUsers)
) {
const roomId = member && member.roomId ? member.roomId : SdkContextClass.instance.roomViewStore.getRoomId();
const onInviteUserButton = async (ev: ButtonEvent): Promise<void> => {
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 || "");
@ -538,34 +528,43 @@ export const UserOptionsSection: React.FC<{
};
inviteUserButton = (
<AccessibleButton kind="link" onClick={onInviteUserButton} className="mx_UserInfo_field">
{_t("action|invite")}
</AccessibleButton>
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onInviteUserButton(ev);
}}
label={_t("action|invite")}
Icon={InviteIcon}
/>
);
}
}
const shareUserButton = (
<AccessibleButton kind="link" onClick={onShareUserClick} className="mx_UserInfo_field">
{_t("user_info|share_button")}
</AccessibleButton>
<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 (
<div className="mx_UserInfo_container">
<h3>{_t("common|options")}</h3>
<div>
{directMessageButton}
{readReceiptButton}
{shareUserButton}
{insertPillButton}
{inviteUserButton}
{ignoreButton}
</div>
</div>
<Container>
{children}
{directMessageButton}
{inviteUserButton}
{readReceiptButton}
{shareUserButton}
{insertPillButton}
</Container>
);
};
@ -586,15 +585,10 @@ export const warnSelfDemote = async (isSpace: boolean): Promise<boolean> => {
return !!confirmed;
};
const GenericAdminToolsContainer: React.FC<{
const Container: React.FC<{
children: ReactNode;
}> = ({ children }) => {
return (
<div className="mx_UserInfo_container">
<h3>{_t("user_info|admin_tools_section")}</h3>
<div className="mx_UserInfo_buttons">{children}</div>
</div>
);
return <div className="mx_UserInfo_container">{children}</div>;
};
interface IPowerLevelsContent {
@ -756,14 +750,17 @@ export const RoomKickButton = ({
: _t("user_info|kick_button_room");
return (
<AccessibleButton
kind="link"
className="mx_UserInfo_field mx_UserInfo_destructive"
onClick={onKick}
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onKick();
}}
disabled={isUpdating}
>
{kickLabel}
</AccessibleButton>
label={kickLabel}
kind="critical"
Icon={LeaveIcon}
/>
);
};
@ -782,13 +779,16 @@ const RedactMessagesButton: React.FC<IBaseProps> = ({ member }) => {
};
return (
<AccessibleButton
kind="link"
className="mx_UserInfo_field mx_UserInfo_destructive"
onClick={onRedactAllMessages}
>
{_t("user_info|redact_button")}
</AccessibleButton>
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onRedactAllMessages();
}}
label={_t("user_info|redact_button")}
kind="critical"
Icon={CloseIcon}
/>
);
};
@ -904,14 +904,18 @@ export const BanToggleButton = ({
label = room.isSpaceRoom() ? _t("user_info|unban_button_space") : _t("user_info|unban_button_room");
}
const classes = classNames("mx_UserInfo_field", {
mx_UserInfo_destructive: !isBanned,
});
return (
<AccessibleButton kind="link" className={classes} onClick={onBanOrUnban} disabled={isUpdating}>
{label}
</AccessibleButton>
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onBanOrUnban();
}}
disabled={isUpdating}
label={label}
kind="critical"
Icon={ChatProblemIcon}
/>
);
};
@ -981,15 +985,81 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({
});
};
const classes = classNames("mx_UserInfo_field", {
mx_UserInfo_destructive: !muted,
});
const muteLabel = muted ? _t("common|unmute") : _t("common|mute");
return (
<AccessibleButton kind="link" className={classes} onClick={onMuteToggle} disabled={isUpdating}>
{muteLabel}
</AccessibleButton>
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onMuteToggle();
}}
disabled={isUpdating}
label={muteLabel}
kind="critical"
Icon={VisibilityOffIcon}
/>
);
};
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) => {
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}
/>
);
};
@ -1070,13 +1140,13 @@ export const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
if (kickButton || banButton || muteButton || redactButton || children) {
return (
<GenericAdminToolsContainer>
<Container>
{muteButton}
{redactButton}
{kickButton}
{banButton}
{redactButton}
{children}
</GenericAdminToolsContainer>
</Container>
);
}
@ -1352,23 +1422,6 @@ const BasicUserInfo: React.FC<{
// Load whether or not we are a Synapse Admin
const isSynapseAdmin = useIsSynapseAdmin(cli);
// 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) => {
if (ev.getType() === "m.ignored_user_list") {
setIsIgnored(cli.isUserIgnored(member.userId));
}
},
[cli, member.userId],
);
useTypedEventEmitter(cli, ClientEvent.AccountData, accountDataHandler);
// Count of how many operations are currently in progress, if > 0 then show a Spinner
const [pendingUpdateCount, setPendingUpdateCount] = useState(0);
const startUpdating = useCallback(() => {
@ -1412,13 +1465,16 @@ const BasicUserInfo: React.FC<{
// someone does figure out how to bypass this check the worst that happens is an error.
if (isSynapseAdmin && member.userId.endsWith(`:${cli.getDomain()}`)) {
synapseDeactivateButton = (
<AccessibleButton
kind="link"
className="mx_UserInfo_field mx_UserInfo_destructive"
onClick={onSynapseDeactivate}
>
{_t("user_info|deactivate_confirm_action")}
</AccessibleButton>
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onSynapseDeactivate();
}}
label={_t("user_info|deactivate_confirm_action")}
kind="critical"
Icon={DeleteIcon}
/>
);
}
@ -1428,23 +1484,12 @@ const BasicUserInfo: React.FC<{
// hide the Roles section for DMs as it doesn't make sense there
if (!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId)) {
memberDetails = (
<div className="mx_UserInfo_container">
<h3>
{_t(
"user_info|role_label",
{},
{
RoomName: () => <b>{room.name}</b>,
},
)}
</h3>
<PowerLevelSection
powerLevels={powerLevels}
user={member as RoomMember}
room={room}
roomPermissions={roomPermissions}
/>
</div>
<PowerLevelSection
powerLevels={powerLevels}
user={member as RoomMember}
room={room}
roomPermissions={roomPermissions}
/>
);
}
@ -1461,7 +1506,7 @@ const BasicUserInfo: React.FC<{
</RoomAdminToolsContainer>
);
} else if (synapseDeactivateButton) {
adminToolsContainer = <GenericAdminToolsContainer>{synapseDeactivateButton}</GenericAdminToolsContainer>;
adminToolsContainer = <Container>{synapseDeactivateButton}</Container>;
}
if (pendingUpdateCount > 0) {
@ -1559,8 +1604,8 @@ const BasicUserInfo: React.FC<{
}
const securitySection = (
<div className="mx_UserInfo_container">
<h3>{_t("common|security")}</h3>
<Container>
<h2>{_t("common|security")}</h2>
<p>{text}</p>
{verifyButton}
{cryptoEnabled && (
@ -1572,23 +1617,29 @@ const BasicUserInfo: React.FC<{
/>
)}
{editDevices}
</div>
</Container>
);
return (
<React.Fragment>
{memberDetails}
{securitySection}
<UserOptionsSection
canInvite={roomPermissions.canInvite}
isIgnored={isIgnored}
member={member as RoomMember}
isSpace={room?.isSpaceRoom()}
/>
>
{memberDetails}
</UserOptionsSection>
{adminToolsContainer}
{!isMe && (
<Container>
<IgnoreToggleButton member={member} />
</Container>
)}
{spinner}
</React.Fragment>
);
@ -1621,24 +1672,6 @@ export const UserInfoHeader: React.FC<{
const avatarUrl = (member as User).avatarUrl;
const avatarElement = (
<div className="mx_UserInfo_avatar">
<div className="mx_UserInfo_avatar_transition">
<div className="mx_UserInfo_avatar_transition_child">
<MemberAvatar
key={member.userId} // to instantly blank the avatar when UserInfo changes members
member={member as RoomMember}
size={UIStore.instance.windowHeight * 0.3 + "px"}
resizeMethod="scale"
fallbackUserId={member.userId}
onClick={onMemberAvatarClick}
urls={avatarUrl ? [avatarUrl] : undefined}
/>
</div>
</div>
</div>
);
let presenceState: string | undefined;
let presenceLastActiveAgo: number | undefined;
let presenceCurrentlyActive: boolean | undefined;
@ -1661,36 +1694,52 @@ export const UserInfoHeader: React.FC<{
activeAgo={presenceLastActiveAgo}
currentlyActive={presenceCurrentlyActive}
presenceState={presenceState}
className="mx_UserInfo_profileStatus"
coloured
/>
);
}
const e2eIcon = e2eStatus ? <E2EIcon size={18} status={e2eStatus} isUser={true} /> : null;
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, {
roomId,
withDisplayName: true,
});
const displayName = (member as RoomMember).rawDisplayName;
return (
<React.Fragment>
{avatarElement}
<div className="mx_UserInfo_container mx_UserInfo_separator">
<div className="mx_UserInfo_profile">
<div>
<h2>
<span title={displayName} aria-label={displayName} dir="auto">
{displayName}
</span>
{e2eIcon}
</h2>
<div className="mx_UserInfo_avatar">
<div className="mx_UserInfo_avatar_transition">
<div className="mx_UserInfo_avatar_transition_child">
<MemberAvatar
key={member.userId} // to instantly blank the avatar when UserInfo changes members
member={member as RoomMember}
size="120px"
resizeMethod="scale"
fallbackUserId={member.userId}
onClick={onMemberAvatarClick}
urls={avatarUrl ? [avatarUrl] : undefined}
/>
</div>
<div className="mx_UserInfo_profile_mxid">
{UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, {
roomId,
withDisplayName: true,
})}
</div>
<div className="mx_UserInfo_profileStatus">{presenceLabel}</div>
</div>
</div>
<Container>
<Flex direction="column" align="center" className="mx_UserInfo_profile">
<Heading size="sm" weight="semibold" as="h1" dir="auto">
<Flex direction="row-reverse" align="center">
{displayName}
{e2eIcon}
</Flex>
</Heading>
{presenceLabel}
<Text size="sm" weight="semibold" className="mx_UserInfo_profile_mxid">
<CopyableText getTextToCopy={() => userIdentifier} border={false}>
{userIdentifier}
</CopyableText>
</Text>
</Flex>
</Container>
</React.Fragment>
);
};

View file

@ -16,6 +16,7 @@ limitations under the License.
import React from "react";
import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { formatDuration } from "../../../DateUtils";
@ -31,6 +32,9 @@ interface IProps {
currentlyActive?: boolean;
// offline, online, etc
presenceState?: string;
// whether to apply colouring to the label
coloured?: boolean;
className?: string;
}
export default class PresenceLabel extends React.Component<IProps> {
@ -62,7 +66,11 @@ export default class PresenceLabel extends React.Component<IProps> {
public render(): React.ReactNode {
return (
<div className="mx_PresenceLabel">
<div
className={classNames("mx_PresenceLabel", this.props.className, {
mx_PresenceLabel_online: this.props.coloured && this.props.presenceState === "online",
})}
>
{this.getPrettyPresence(this.props.presenceState, this.props.activeAgo, this.props.currentlyActive)}
</div>
);

View file

@ -3770,6 +3770,7 @@
"error_revoke_3pid_invite_title": "Failed to revoke invite",
"hide_sessions": "Hide sessions",
"hide_verified_sessions": "Hide verified sessions",
"ignore_button": "Ignore",
"ignore_confirm_description": "All messages and invites from this user will be hidden. Are you sure you want to ignore them?",
"ignore_confirm_title": "Ignore %(user)s",
"invited_by": "Invited by %(sender)s",
@ -3797,20 +3798,21 @@
"no_recent_messages_description": "Try scrolling up in the timeline to see if there are any earlier ones.",
"no_recent_messages_title": "No recent messages by %(user)s found"
},
"redact_button": "Remove recent messages",
"redact_button": "Remove messages",
"revoke_invite": "Revoke invite",
"role_label": "Role in <RoomName/>",
"room_encrypted": "Messages in this room are end-to-end encrypted.",
"room_encrypted_detail": "Your messages are secured and only you and the recipient have the unique keys to unlock them.",
"room_unencrypted": "Messages in this room are not end-to-end encrypted.",
"room_unencrypted_detail": "In encrypted rooms, your messages are secured and only you and the recipient have the unique keys to unlock them.",
"share_button": "Share Link to User",
"send_message": "Send message",
"share_button": "Share profile",
"unban_button_room": "Unban from room",
"unban_button_space": "Unban from space",
"unban_room_confirm_title": "Unban from %(roomName)s",
"unban_space_everything": "Unban them from everything I'm able to",
"unban_space_specific": "Unban them from specific things I'm able to",
"unban_space_warning": "They won't be able to access whatever you're not an admin of.",
"unignore_button": "Unignore",
"verify_button": "Verify User",
"verify_explainer": "For extra security, verify this user by checking a one-time code on both of your devices."
},

View file

@ -56,6 +56,9 @@ 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";
import BulkRedactDialog from "../../../../src/components/views/dialogs/BulkRedactDialog";
jest.mock("../../../../src/utils/direct-messages", () => ({
...jest.requireActual("../../../../src/utils/direct-messages"),
@ -323,7 +326,7 @@ describe("<UserInfo />", () => {
</MatrixClientContext.Provider>,
);
screen.getByRole("button", { name: "Message" });
screen.getByRole("button", { name: "Send message" });
});
it("hides the message button if the visibility customisation hides all create room features", () => {
@ -342,6 +345,64 @@ describe("<UserInfo />", () => {
},
);
});
describe("Ignore", () => {
const member = new RoomMember(defaultRoomId, defaultUserId);
it("shows block button when member userId does not match client userId", () => {
// call to client.getUserId returns undefined, which will not match member.userId
renderComponent();
expect(screen.getByRole("button", { name: "Ignore" })).toBeInTheDocument();
});
it("shows a modal before ignoring the user", async () => {
const originalCreateDialog = Modal.createDialog;
const modalSpy = (Modal.createDialog = jest.fn().mockReturnValue({
finished: Promise.resolve([true]),
close: () => {},
}));
try {
mockClient.getIgnoredUsers.mockReturnValue([]);
renderComponent();
await userEvent.click(screen.getByRole("button", { name: "Ignore" }));
expect(modalSpy).toHaveBeenCalled();
expect(mockClient.setIgnoredUsers).toHaveBeenLastCalledWith([member.userId]);
} finally {
Modal.createDialog = originalCreateDialog;
}
});
it("cancels ignoring the user", async () => {
const originalCreateDialog = Modal.createDialog;
const modalSpy = (Modal.createDialog = jest.fn().mockReturnValue({
finished: Promise.resolve([false]),
close: () => {},
}));
try {
mockClient.getIgnoredUsers.mockReturnValue([]);
renderComponent();
await userEvent.click(screen.getByRole("button", { name: "Ignore" }));
expect(modalSpy).toHaveBeenCalled();
expect(mockClient.setIgnoredUsers).not.toHaveBeenCalled();
} finally {
Modal.createDialog = originalCreateDialog;
}
});
it("unignores the user", async () => {
mockClient.isUserIgnored.mockReturnValue(true);
mockClient.getIgnoredUsers.mockReturnValue([member.userId]);
renderComponent();
await userEvent.click(screen.getByRole("button", { name: "Unignore" }));
expect(mockClient.setIgnoredUsers).toHaveBeenCalledWith([]);
});
});
});
describe("with crypto enabled", () => {
@ -801,7 +862,7 @@ describe("<DeviceItem />", () => {
describe("<UserOptionsSection />", () => {
const member = new RoomMember(defaultRoomId, defaultUserId);
const defaultProps = { member, isIgnored: false, canInvite: false, isSpace: false };
const defaultProps = { member, canInvite: false, isSpace: false };
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
@ -828,9 +889,13 @@ describe("<UserOptionsSection />", () => {
inviteSpy.mockRestore();
});
it("always shows share user button", () => {
it("always shows share user button and clicking it should produce a ShareDialog", async () => {
const spy = jest.spyOn(Modal, "createDialog");
renderComponent();
expect(screen.getByRole("button", { name: /share link to user/i })).toBeInTheDocument();
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", () => {
@ -842,20 +907,31 @@ describe("<UserOptionsSection />", () => {
expect(screen.queryByRole("button", { name: /message/i })).not.toBeInTheDocument();
});
it("shows ignore, direct message and mention buttons when member userId does not match client userId", () => {
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: /ignore/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /message/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /mention/i })).toBeInTheDocument();
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, does not show read receipt button", () => {
mockClient.getRoom.mockReturnValueOnce(null);
renderComponent();
expect(screen.queryByRole("button", { name: /jump to read receipt/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Jump to read receipt" })).not.toBeInTheDocument();
});
it("when call to client.getRoom is non-null and room.getEventReadUpTo is null, does not show read receipt button", () => {
@ -863,7 +939,7 @@ describe("<UserOptionsSection />", () => {
mockClient.getRoom.mockReturnValueOnce(mockRoom);
renderComponent();
expect(screen.queryByRole("button", { name: /jump to read receipt/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Jump to read receipt" })).not.toBeInTheDocument();
});
it("when calls to client.getRoom and room.getEventReadUpTo are non-null, shows read receipt button", () => {
@ -871,7 +947,7 @@ describe("<UserOptionsSection />", () => {
mockClient.getRoom.mockReturnValueOnce(mockRoom);
renderComponent();
expect(screen.getByRole("button", { name: /jump to read receipt/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Jump to read receipt" })).toBeInTheDocument();
});
it("clicking the read receipt button calls dispatch with correct event_id", async () => {
@ -880,7 +956,7 @@ describe("<UserOptionsSection />", () => {
mockClient.getRoom.mockReturnValue(mockRoom);
renderComponent();
const readReceiptButton = screen.getByRole("button", { name: /jump to read receipt/i });
const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" });
expect(readReceiptButton).toBeInTheDocument();
await userEvent.click(readReceiptButton);
@ -904,7 +980,7 @@ describe("<UserOptionsSection />", () => {
mockClient.getRoom.mockReturnValue(mockRoom);
renderComponent();
const readReceiptButton = screen.getByRole("button", { name: /jump to read receipt/i });
const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" });
expect(readReceiptButton).toBeInTheDocument();
await userEvent.click(readReceiptButton);
@ -964,52 +1040,6 @@ describe("<UserOptionsSection />", () => {
});
});
it("shows a modal before ignoring the user", async () => {
const originalCreateDialog = Modal.createDialog;
const modalSpy = (Modal.createDialog = jest.fn().mockReturnValue({
finished: Promise.resolve([true]),
close: () => {},
}));
try {
mockClient.getIgnoredUsers.mockReturnValue([]);
renderComponent({ isIgnored: false });
await userEvent.click(screen.getByRole("button", { name: "Ignore" }));
expect(modalSpy).toHaveBeenCalled();
expect(mockClient.setIgnoredUsers).toHaveBeenLastCalledWith([member.userId]);
} finally {
Modal.createDialog = originalCreateDialog;
}
});
it("cancels ignoring the user", async () => {
const originalCreateDialog = Modal.createDialog;
const modalSpy = (Modal.createDialog = jest.fn().mockReturnValue({
finished: Promise.resolve([false]),
close: () => {},
}));
try {
mockClient.getIgnoredUsers.mockReturnValue([]);
renderComponent({ isIgnored: false });
await userEvent.click(screen.getByRole("button", { name: "Ignore" }));
expect(modalSpy).toHaveBeenCalled();
expect(mockClient.setIgnoredUsers).not.toHaveBeenCalled();
} finally {
Modal.createDialog = originalCreateDialog;
}
});
it("unignores the user", async () => {
mockClient.getIgnoredUsers.mockReturnValue([member.userId]);
renderComponent({ isIgnored: true });
await userEvent.click(screen.getByRole("button", { name: "Unignore" }));
expect(mockClient.setIgnoredUsers).toHaveBeenCalledWith([]);
});
it.each([
["for a RoomMember", member, member.getMxcAvatarUrl()],
["for a User", defaultUser, defaultUser.avatarUrl],
@ -1020,10 +1050,10 @@ describe("<UserOptionsSection />", () => {
mocked(startDmOnFirstMessage).mockReturnValue(deferred.promise);
renderComponent({ member });
await userEvent.click(screen.getByText("Message"));
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.getByText("Message")).toHaveAttribute("disabled");
expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled();
expect(startDmOnFirstMessage).toHaveBeenCalledWith(mockClient, [
new DirectoryMember({
@ -1039,7 +1069,7 @@ describe("<UserOptionsSection />", () => {
});
// Checking the attribute, because the button is a DIV and toBeDisabled() does not work.
expect(screen.getByText("Message")).not.toHaveAttribute("disabled");
expect(screen.getByRole("button", { name: "Send message" })).not.toBeDisabled();
},
);
});
@ -1396,10 +1426,30 @@ describe("<RoomAdminToolsContainer />", () => {
renderComponent({ member: defaultMemberWithPowerLevel });
expect(screen.getByRole("heading", { name: /admin tools/i })).toBeInTheDocument();
expect(screen.getByText(/disinvite from room/i)).toBeInTheDocument();
expect(screen.getByText(/ban from room/i)).toBeInTheDocument();
expect(screen.getByText(/remove recent messages/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Disinvite from room" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ban from room" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove messages" })).toBeInTheDocument();
});
it("should show BulkRedactDialog upon clicking the Remove messages button", async () => {
const spy = jest.spyOn(Modal, "createDialog");
mockClient.getRoom.mockReturnValue(mockRoom);
mockClient.getUserId.mockReturnValue("@arbitraryId:server");
const mockMeMember = new RoomMember(mockRoom.roomId, mockClient.getUserId()!);
mockMeMember.powerLevel = 51; // defaults to 50
const defaultMemberWithPowerLevel = { ...defaultMember, powerLevel: 0 } as RoomMember;
mockRoom.getMember.mockImplementation((userId) =>
userId === mockClient.getUserId() ? mockMeMember : defaultMemberWithPowerLevel,
);
renderComponent({ member: defaultMemberWithPowerLevel });
await userEvent.click(screen.getByRole("button", { name: "Remove messages" }));
expect(spy).toHaveBeenCalledWith(
BulkRedactDialog,
expect.objectContaining({ member: defaultMemberWithPowerLevel }),
);
});
it("returns mute toggle button if conditions met", () => {
@ -1441,10 +1491,9 @@ describe("<RoomAdminToolsContainer />", () => {
isUpdating: true,
});
const button = screen.getByText(/mute/i);
const button = screen.getByRole("button", { name: "Mute" });
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute("disabled");
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toBeDisabled();
});
it("should not show mute button for one's own member", () => {

View file

@ -118,7 +118,7 @@ exports[`<UserInfo /> with crypto enabled renders <BasicUserInfo /> 1`] = `
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 230.39999999999998px;"
style="--cpd-avatar-size: 120px;"
>
u
</button>
@ -126,44 +126,51 @@ exports[`<UserInfo /> with crypto enabled renders <BasicUserInfo /> 1`] = `
</div>
</div>
<div
class="mx_UserInfo_container mx_UserInfo_separator"
class="mx_UserInfo_container"
>
<div
class="mx_UserInfo_profile"
class="mx_Flex mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
>
<div>
<h2>
<span
aria-label="@user:example.com"
dir="auto"
title="@user:example.com"
>
@user:example.com
</span>
</h2>
</div>
<div
class="mx_UserInfo_profile_mxid"
>
customUserIdentifier
</div>
<div
class="mx_UserInfo_profileStatus"
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
dir="auto"
>
<div
class="mx_PresenceLabel"
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
>
Unknown
@user:example.com
</div>
</h1>
<div
class="mx_PresenceLabel mx_UserInfo_profileStatus"
>
Unknown
</div>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 mx_UserInfo_profile_mxid"
>
<div
class="mx_CopyableText"
>
customUserIdentifier
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</p>
</div>
</div>
<div
class="mx_UserInfo_container"
>
<h3>
<h2>
Security
</h3>
</h2>
<p>
Messages in this room are not end-to-end encrypted.
</p>
@ -201,32 +208,100 @@ exports[`<UserInfo /> with crypto enabled renders <BasicUserInfo /> 1`] = `
<div
class="mx_UserInfo_container"
>
<h3>
Options
</h3>
<div>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary"
role="button"
>
<div
class="mx_AccessibleButton mx_UserInfo_field mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
aria-hidden="true"
class="_icon_1gwvj_44"
height="24"
width="24"
/>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
Message
</div>
<div
class="mx_AccessibleButton mx_UserInfo_field mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
Send message
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
Share Link to User
</div>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.876.876 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</button>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary"
role="button"
>
<div
class="mx_AccessibleButton mx_UserInfo_field mx_UserInfo_destructive mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
aria-hidden="true"
class="_icon_1gwvj_44"
height="24"
width="24"
/>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
Share profile
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.876.876 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</button>
</div>
<div
class="mx_UserInfo_container"
>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="critical"
role="button"
>
<div
aria-hidden="true"
class="_icon_1gwvj_44"
height="24"
width="24"
/>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
Ignore
</div>
</div>
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.876.876 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</button>
</div>
</div>
</div>
@ -282,7 +357,7 @@ exports[`<UserInfo /> with crypto enabled should render a deactivate button for
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 230.39999999999998px;"
style="--cpd-avatar-size: 120px;"
>
u
</button>
@ -290,44 +365,51 @@ exports[`<UserInfo /> with crypto enabled should render a deactivate button for
</div>
</div>
<div
class="mx_UserInfo_container mx_UserInfo_separator"
class="mx_UserInfo_container"
>
<div
class="mx_UserInfo_profile"
class="mx_Flex mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
>
<div>
<h2>
<span
aria-label="@user:example.com"
dir="auto"
title="@user:example.com"
>
@user:example.com
</span>
</h2>
</div>
<div
class="mx_UserInfo_profile_mxid"
>
customUserIdentifier
</div>
<div
class="mx_UserInfo_profileStatus"
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
dir="auto"
>
<div
class="mx_PresenceLabel"
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
>
Unknown
@user:example.com
</div>
</h1>
<div
class="mx_PresenceLabel mx_UserInfo_profileStatus"
>
Unknown
</div>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 mx_UserInfo_profile_mxid"
>
<div
class="mx_CopyableText"
>
customUserIdentifier
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</p>
</div>
</div>
<div
class="mx_UserInfo_container"
>
<h3>
<h2>
Security
</h3>
</h2>
<p>
Messages in this room are not end-to-end encrypted.
</p>
@ -365,50 +447,134 @@ exports[`<UserInfo /> with crypto enabled should render a deactivate button for
<div
class="mx_UserInfo_container"
>
<h3>
Options
</h3>
<div>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary"
role="button"
>
<div
class="mx_AccessibleButton mx_UserInfo_field mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
aria-hidden="true"
class="_icon_1gwvj_44"
height="24"
width="24"
/>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
Message
</div>
Send message
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.876.876 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</button>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary"
role="button"
>
<div
class="mx_AccessibleButton mx_UserInfo_field mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
aria-hidden="true"
class="_icon_1gwvj_44"
height="24"
width="24"
/>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
Share Link to User
</div>
<div
class="mx_AccessibleButton mx_UserInfo_field mx_UserInfo_destructive mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
Share profile
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
Ignore
</div>
</div>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.876.876 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</button>
</div>
<div
class="mx_UserInfo_container"
>
<h3>
Admin Tools
</h3>
<div
class="mx_UserInfo_buttons"
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="critical"
role="button"
>
<div
class="mx_AccessibleButton mx_UserInfo_field mx_UserInfo_destructive mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
aria-hidden="true"
class="_icon_1gwvj_44"
height="24"
width="24"
/>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
Deactivate user
</div>
</div>
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.876.876 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</button>
</div>
<div
class="mx_UserInfo_container"
>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="critical"
role="button"
>
<div
aria-hidden="true"
class="_icon_1gwvj_44"
height="24"
width="24"
/>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
Ignore
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.876.876 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</button>
</div>
</div>
</div>