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 0d4e64813c..b6d6d2a210 100644 Binary files a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png and b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png differ diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index 1dfd39dd25..a2e156e0e5 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; + /* 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 { diff --git a/res/css/views/rooms/_PresenceLabel.pcss b/res/css/views/rooms/_PresenceLabel.pcss index 5be83c77d7..e775fb08ea 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 493cb06bcf..1f9843d708 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 24e144c8ef..bdbc7e23e2 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 3f71d4319b..03da4e7811 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 6875bf227d..3b36468786 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 c29ab8cee6..c82d72f917 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 - -

-
-
- customUserIdentifier -
-
- Unknown + @user:example.com
+ +
+ 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 - -

-
-
- customUserIdentifier -
-
- Unknown + @user:example.com
+ +
+ 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 -

-
+ +
+ + +
-

- Admin Tools -

-
-
+ + + +
+
+