From f706ac4fa19980bfb09526271e6ed6758bbe1d5f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 18 Jul 2024 15:24:44 +0100 Subject: [PATCH] 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> --- .../user-view.spec.ts/user-info-linux.png | Bin 16728 -> 17208 bytes res/css/views/right_panel/_UserInfo.pcss | 83 ++-- res/css/views/rooms/_PresenceLabel.pcss | 4 + src/components/views/right_panel/UserInfo.tsx | 433 ++++++++++-------- src/components/views/rooms/PresenceLabel.tsx | 10 +- src/i18n/strings/en_EN.json | 8 +- .../views/right_panel/UserInfo-test.tsx | 187 +++++--- .../__snapshots__/UserInfo-test.tsx.snap | 358 +++++++++++---- 8 files changed, 667 insertions(+), 416 deletions(-) diff --git a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png index 0d4e64813c193d94be6f1092e51b5a160278b432..b6d6d2a210856bdaeae1a9500ce9b2822271075f 100644 GIT binary patch literal 17208 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 MOjfi)`0Mxo1@+W9uK)l5 literal 16728 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 diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index 1dfd39dd250..a2e156e0e58 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -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; - - 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 */ + /* 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; + + /* 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 { diff --git a/res/css/views/rooms/_PresenceLabel.pcss b/res/css/views/rooms/_PresenceLabel.pcss index 5be83c77d7c..e775fb08ea8 100644 --- a/res/css/views/rooms/_PresenceLabel.pcss +++ b/res/css/views/rooms/_PresenceLabel.pcss @@ -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); +} diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 493cb06bcf7..1f9843d708b 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -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 ( - { + { + ev.preventDefault(); if (busy) return; setBusy(true); await openDmForUser(cli, member); setBusy(false); }} - className="mx_UserInfo_field" disabled={busy} - > - {_t("common|message")} - + 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:
{_t("user_info|ignore_confirm_description")}
, - 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 = ( - - {isIgnored ? _t("action|unignore") : _t("action|ignore")} - - ); - 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 = ( - - {_t("user_info|jump_to_rr_button")} - + { + ev.preventDefault(); + onReadReceiptButton(); + }} + label={_t("user_info|jump_to_rr_button")} + Icon={CheckIcon} + /> ); } insertPillButton = ( - - {_t("action|mention")} - + { + 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 => { + const onInviteUserButton = async (ev: Event): Promise => { 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 = ( - - {_t("action|invite")} - + { + ev.preventDefault(); + onInviteUserButton(ev); + }} + label={_t("action|invite")} + Icon={InviteIcon} + /> ); } } const shareUserButton = ( - - {_t("user_info|share_button")} - + { + ev.preventDefault(); + onShareUserClick(); + }} + label={_t("user_info|share_button")} + Icon={ShareIcon} + /> ); const directMessageButton = isMe || !shouldShowComponent(UIComponent.CreateRooms) ? null : ; return ( -
-

{_t("common|options")}

-
- {directMessageButton} - {readReceiptButton} - {shareUserButton} - {insertPillButton} - {inviteUserButton} - {ignoreButton} -
-
+ + {children} + {directMessageButton} + {inviteUserButton} + {readReceiptButton} + {shareUserButton} + {insertPillButton} + ); }; @@ -586,15 +585,10 @@ export const warnSelfDemote = async (isSpace: boolean): Promise => { return !!confirmed; }; -const GenericAdminToolsContainer: React.FC<{ +const Container: React.FC<{ children: ReactNode; }> = ({ children }) => { - return ( -
-

{_t("user_info|admin_tools_section")}

-
{children}
-
- ); + return
{children}
; }; interface IPowerLevelsContent { @@ -756,14 +750,17 @@ export const RoomKickButton = ({ : _t("user_info|kick_button_room"); return ( - { + ev.preventDefault(); + onKick(); + }} disabled={isUpdating} - > - {kickLabel} - + label={kickLabel} + kind="critical" + Icon={LeaveIcon} + /> ); }; @@ -782,13 +779,16 @@ const RedactMessagesButton: React.FC = ({ member }) => { }; return ( - - {_t("user_info|redact_button")} - + { + 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 ( - - {label} - + { + ev.preventDefault(); + onBanOrUnban(); + }} + disabled={isUpdating} + label={label} + kind="critical" + Icon={ChatProblemIcon} + /> ); }; @@ -981,15 +985,81 @@ const MuteToggleButton: React.FC = ({ }); }; - const classes = classNames("mx_UserInfo_field", { - mx_UserInfo_destructive: !muted, - }); - const muteLabel = muted ? _t("common|unmute") : _t("common|mute"); return ( - - {muteLabel} - + { + 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:
{_t("user_info|ignore_confirm_description")}
, + 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 ( + { + 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 = ({ if (kickButton || banButton || muteButton || redactButton || children) { return ( - + {muteButton} + {redactButton} {kickButton} {banButton} - {redactButton} {children} - + ); } @@ -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 = ( - - {_t("user_info|deactivate_confirm_action")} - + { + 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 = ( -
-

- {_t( - "user_info|role_label", - {}, - { - RoomName: () => {room.name}, - }, - )} -

- -
+ ); } @@ -1461,7 +1506,7 @@ const BasicUserInfo: React.FC<{ ); } else if (synapseDeactivateButton) { - adminToolsContainer = {synapseDeactivateButton}; + adminToolsContainer = {synapseDeactivateButton}; } if (pendingUpdateCount > 0) { @@ -1559,8 +1604,8 @@ const BasicUserInfo: React.FC<{ } const securitySection = ( -
-

{_t("common|security")}

+ +

{_t("common|security")}

{text}

{verifyButton} {cryptoEnabled && ( @@ -1572,23 +1617,29 @@ const BasicUserInfo: React.FC<{ /> )} {editDevices} -
+ ); return ( - {memberDetails} - {securitySection} + + > + {memberDetails} + {adminToolsContainer} + {!isMe && ( + + + + )} + {spinner} ); @@ -1621,24 +1672,6 @@ export const UserInfoHeader: React.FC<{ const avatarUrl = (member as User).avatarUrl; - const avatarElement = ( -
-
-
- -
-
-
- ); - 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 ? : null; - + const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, { + roomId, + withDisplayName: true, + }); const displayName = (member as RoomMember).rawDisplayName; return ( - {avatarElement} - -
-
-
-

- - {displayName} - - {e2eIcon} -

-
-
- {UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, { - roomId, - withDisplayName: true, - })} +
+
+
+
-
{presenceLabel}
+ + + + + + {displayName} + {e2eIcon} + + + {presenceLabel} + + userIdentifier} border={false}> + {userIdentifier} + + + + ); }; diff --git a/src/components/views/rooms/PresenceLabel.tsx b/src/components/views/rooms/PresenceLabel.tsx index 24e144c8ef0..bdbc7e23e2a 100644 --- a/src/components/views/rooms/PresenceLabel.tsx +++ b/src/components/views/rooms/PresenceLabel.tsx @@ -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 { @@ -62,7 +66,11 @@ export default class PresenceLabel extends React.Component { public render(): React.ReactNode { return ( -
+
{this.getPrettyPresence(this.props.presenceState, this.props.activeAgo, this.props.currentlyActive)}
); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 3f71d4319b9..03da4e78118 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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 ", "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." }, diff --git a/test/components/views/right_panel/UserInfo-test.tsx b/test/components/views/right_panel/UserInfo-test.tsx index 6875bf227d9..3b364687869 100644 --- a/test/components/views/right_panel/UserInfo-test.tsx +++ b/test/components/views/right_panel/UserInfo-test.tsx @@ -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("", () => { , ); - 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("", () => { }, ); }); + + 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("", () => { describe("", () => { 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("", () => { 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("", () => { 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("", () => { 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("", () => { 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("", () => { 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("", () => { 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("", () => { }); }); - 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("", () => { 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("", () => { }); // 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("", () => { 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("", () => { 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", () => { diff --git a/test/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap b/test/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap index c29ab8cee63..c82d72f917f 100644 --- a/test/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap +++ b/test/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap @@ -118,7 +118,7 @@ exports[` with crypto enabled renders 1`] = ` data-testid="avatar-img" data-type="round" role="button" - style="--cpd-avatar-size: 230.39999999999998px;" + style="--cpd-avatar-size: 120px;" > u @@ -126,44 +126,51 @@ exports[` with crypto enabled renders 1`] = `
-
-

- - @user:example.com - -

-
+

+
+ @user:example.com +
+

- customUserIdentifier + Unknown
-
- Unknown + customUserIdentifier +
-
+

-

+

Security -

+

Messages in this room are not end-to-end encrypted.

@@ -201,32 +208,100 @@ exports[` with crypto enabled renders 1`] = `
-

- Options -

-
+ + +
+
+
+ + +
@@ -282,7 +357,7 @@ exports[` 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 @@ -290,44 +365,51 @@ exports[` with crypto enabled should render a deactivate button for
-
-

- - @user:example.com - -

-
+

+
+ @user:example.com +
+

- customUserIdentifier + Unknown
-
- Unknown + customUserIdentifier +
-
+

-

+

Security -

+

Messages in this room are not end-to-end encrypted.

@@ -365,50 +447,134 @@ exports[` with crypto enabled should render a deactivate button for
-

- Options -

-
+ +
+ Share profile + + +
-

- Admin Tools -

-
-
+ + + +
+
+