From e6e6f87d01c9d51931c27ca573bc151070ad1195 Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 20 Oct 2025 08:13:20 +0200 Subject: [PATCH] MVVM userinfo basic component (#30305) * feat: mvvm userinfo basic component * test: mvvm userinfobasic component * chore: apply review. rename views, add comment and move some codes * chore(review): move openDM method into viewmodel --- .../UserInfoBasicOptionsViewModel.tsx | 156 ++++++ .../user_info/UserInfoBasicViewModel.tsx | 197 +++++++ .../UserInfoIgnoreButtonViewModel.tsx | 85 +++ src/components/views/right_panel/UserInfo.tsx | 489 +----------------- .../user_info/UserInfoBasicOptionsView.tsx | 127 +++++ .../user_info/UserInfoBasicView.tsx | 93 ++++ .../user_info/UserInfoIgnoreButtonView.tsx | 30 ++ .../UserInfoBasicOptionsViewModel-test.tsx | 220 ++++++++ .../user_info/UserInfoBasicViewModel-test.tsx | 149 ++++++ .../views/right_panel/UserInfo-test.tsx | 222 +------- .../UserInfoAdminToolsContainer-test.tsx | 63 ++- .../user_info/UserInfoBasic-test.tsx | 112 ++++ .../UserInfoBasicOptionsView-test.tsx | 208 ++++++++ .../UserInfoHeaderVerificationView-test.tsx | 8 +- .../UserInfoHeaderView-test.tsx | 12 +- .../__snapshots__/UserInfoBasic-test.tsx.snap | 315 +++++++++++ ...erInfoHeaderVerificationView-test.tsx.snap | 0 .../UserInfoHeaderView-test.tsx.snap | 0 18 files changed, 1745 insertions(+), 741 deletions(-) create mode 100644 src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx create mode 100644 src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel.tsx create mode 100644 src/components/viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel.tsx create mode 100644 src/components/views/right_panel/user_info/UserInfoBasicOptionsView.tsx create mode 100644 src/components/views/right_panel/user_info/UserInfoBasicView.tsx create mode 100644 src/components/views/right_panel/user_info/UserInfoIgnoreButtonView.tsx create mode 100644 test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel-test.tsx create mode 100644 test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel-test.tsx rename test/unit-tests/components/views/right_panel/{ => user_info}/UserInfoAdminToolsContainer-test.tsx (80%) create mode 100644 test/unit-tests/components/views/right_panel/user_info/UserInfoBasic-test.tsx create mode 100644 test/unit-tests/components/views/right_panel/user_info/UserInfoBasicOptionsView-test.tsx rename test/unit-tests/components/views/right_panel/{ => user_info}/UserInfoHeaderVerificationView-test.tsx (91%) rename test/unit-tests/components/views/right_panel/{ => user_info}/UserInfoHeaderView-test.tsx (91%) create mode 100644 test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoBasic-test.tsx.snap rename test/unit-tests/components/views/right_panel/{ => user_info}/__snapshots__/UserInfoHeaderVerificationView-test.tsx.snap (100%) rename test/unit-tests/components/views/right_panel/{ => user_info}/__snapshots__/UserInfoHeaderView-test.tsx.snap (100%) diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx new file mode 100644 index 0000000000..6af49cbe6a --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx @@ -0,0 +1,156 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { useContext } from "react"; +import { RoomMember, User, type Room, KnownMembership } from "matrix-js-sdk/src/matrix"; + +import Modal from "../../../../Modal"; +import ErrorDialog from "../../../views/dialogs/ErrorDialog"; +import { _t, UserFriendlyError } from "../../../../languageHandler"; +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; +import dis from "../../../../dispatcher/dispatcher"; +import PosthogTrackers from "../../../../PosthogTrackers"; +import { ShareDialog } from "../../../views/dialogs/ShareDialog"; +import { type ComposerInsertPayload } from "../../../../dispatcher/payloads/ComposerInsertPayload"; +import { Action } from "../../../../dispatcher/actions"; +import { SdkContextClass } from "../../../../contexts/SDKContext"; +import { TimelineRenderingType } from "../../../../contexts/RoomContext"; +import MultiInviter from "../../../../utils/MultiInviter"; +import { type ViewRoomPayload } from "../../../../dispatcher/payloads/ViewRoomPayload"; +import { useRoomPermissions } from "./UserInfoBasicViewModel"; +import { DirectoryMember, startDmOnFirstMessage } from "../../../../utils/direct-messages"; +import { type Member } from "../../../views/right_panel/UserInfo"; + +export interface UserInfoBasicOptionsState { + // boolean to know if selected user is current user + isMe: boolean; + // boolean to display/hide invite button + showInviteButton: boolean; + // boolean to display/hide insert pill button + showInsertPillButton: boolean | ""; + // boolean to display/hide read receipt button + readReceiptButtonDisabled: boolean; + // Method called when a insert pill button is clicked + onInsertPillButton: () => void; + // Method called when a read receipt button is clicked, will add a pill in the input message field + onReadReceiptButton: () => void; + // Method called when a share user button is clicked, will display modal with profile to share + onShareUserClick: () => void; + // Method called when a invite button is clicked, will display modal to invite user + onInviteUserButton: (evt: Event) => Promise; + // Method called when the DM button is clicked, will open a DM with the selected member + onOpenDmForUser: (member: Member) => Promise; +} + +export const useUserInfoBasicOptionsViewModel = (room: Room, member: User | RoomMember): UserInfoBasicOptionsState => { + const cli = useContext(MatrixClientContext); + + // selected member is current user + const isMe = member.userId === cli.getUserId(); + + // Those permissions are updated when a change is done on the room current state and the selected user + const roomPermissions = useRoomPermissions(cli, room, member as RoomMember); + + const isSpace = room?.isSpaceRoom(); + + // read receipt button stay disable for a room space or if all events where read (null) + const readReceiptButtonDisabled = isSpace || !room?.getEventReadUpTo(member.userId); + + // always show exempt when room is a space + const showInsertPillButton = member instanceof RoomMember && member.roomId && !isSpace; + + // show invite button only if current user has the permission to invite and the selected user membership is LEAVE + const showInviteButton = + member instanceof RoomMember && + roomPermissions.canInvite && + (member?.membership ?? KnownMembership.Leave) === KnownMembership.Leave; + + const onReadReceiptButton = function (): void { + const room = member instanceof RoomMember ? cli.getRoom(member.roomId) : null; + if (!room || readReceiptButtonDisabled) return; + + dis.dispatch({ + action: Action.ViewRoom, + highlighted: true, + // this could return null, the default prevents a type error + event_id: room.getEventReadUpTo(member.userId) || undefined, + room_id: room.roomId, + metricsTrigger: undefined, // room doesn't change + }); + }; + + const onInsertPillButton = function (): void { + dis.dispatch({ + action: Action.ComposerInsert, + userId: member.userId, + timelineRenderingType: TimelineRenderingType.Room, + }); + }; + + const onInviteUserButton = async (ev: Event): Promise => { + try { + const roomId = + member instanceof RoomMember && member.roomId + ? member.roomId + : SdkContextClass.instance.roomViewStore.getRoomId(); + + // We use a MultiInviter to re-use the invite logic, even though we're only inviting one user. + const inviter = new MultiInviter(cli, roomId || ""); + await inviter.invite([member.userId]).then(() => { + if (inviter.getCompletionState(member.userId) !== "invited") { + const errorStringFromInviterUtility = inviter.getErrorText(member.userId); + if (errorStringFromInviterUtility) { + throw new Error(errorStringFromInviterUtility); + } else { + throw new UserFriendlyError("slash_command|invite_failed", { + user: member.userId, + roomId, + cause: undefined, + }); + } + } + }); + } catch (err) { + const description = err instanceof Error ? err.message : _t("invite|failed_generic"); + + Modal.createDialog(ErrorDialog, { + title: _t("invite|failed_title"), + description, + }); + } + + PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoInviteButton", ev); + }; + + const onShareUserClick = (): void => { + Modal.createDialog(ShareDialog, { + target: member, + }); + }; + + const onOpenDmForUser = async (user: Member): Promise => { + const avatarUrl = user instanceof User ? user.avatarUrl : user.getMxcAvatarUrl(); + const startDmUser = new DirectoryMember({ + user_id: user.userId, + display_name: user.rawDisplayName, + avatar_url: avatarUrl, + }); + await startDmOnFirstMessage(cli, [startDmUser]); + }; + + return { + isMe, + showInviteButton, + showInsertPillButton, + readReceiptButtonDisabled, + onReadReceiptButton, + onInsertPillButton, + onInviteUserButton, + onShareUserClick, + onOpenDmForUser, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel.tsx new file mode 100644 index 0000000000..0773ca3cb7 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel.tsx @@ -0,0 +1,197 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useCallback, useEffect, useState } from "react"; +import { + EventType, + type RoomMember, + type IPowerLevelsContent, + type Room, + RoomStateEvent, + type MatrixClient, + type User, + type MatrixEvent, +} from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; +import { useTypedEventEmitter } from "../../../../hooks/useEventEmitter"; +import Modal from "../../../../Modal"; +import ErrorDialog from "../../../views/dialogs/ErrorDialog"; +import { _t } from "../../../../languageHandler"; +import { type IRoomPermissions } from "../../../views/right_panel/UserInfo"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import QuestionDialog from "../../../views/dialogs/QuestionDialog"; +import DMRoomMap from "../../../../utils/DMRoomMap"; + +export interface UserInfoBasicState { + // current room powerlevels + powerLevels: IPowerLevelsContent; + // getting user permissions in this room + roomPermissions: IRoomPermissions; + // numbers of operation in progress > 0 + pendingUpdateCount: number; + // true if user is me + isMe: boolean; + // true if room is a DM for the user + isRoomDMForMember: boolean; + // Boolean to hide or show the deactivate button + showDeactivateButton: boolean; + // Method called when a deactivate user action is triggered + onSynapseDeactivate: () => void; + startUpdating: () => void; + stopUpdating: () => void; +} + +export const getPowerLevels = (room: Room): IPowerLevelsContent => + room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {}; + +export const useRoomPermissions = (cli: MatrixClient, room: Room, user: RoomMember): IRoomPermissions => { + const [roomPermissions, setRoomPermissions] = useState({ + // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL + modifyLevelMax: -1, + canEdit: false, + canInvite: false, + }); + + const updateRoomPermissions = useCallback(() => { + const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); + if (!powerLevels) return; + + const me = room.getMember(cli.getUserId() || ""); + if (!me) return; + + const them = user; + const isMe = me.userId === them.userId; + const canAffectUser = them.powerLevel < me.powerLevel || isMe; + + let modifyLevelMax = -1; + if (canAffectUser) { + const editPowerLevel = powerLevels.events?.[EventType.RoomPowerLevels] ?? powerLevels.state_default ?? 50; + if (me.powerLevel >= editPowerLevel) { + modifyLevelMax = me.powerLevel; + } + } + + setRoomPermissions({ + canInvite: me.powerLevel >= (powerLevels.invite ?? 0), + canEdit: modifyLevelMax >= 0, + modifyLevelMax, + }); + }, [cli, user, room]); + + useTypedEventEmitter(cli, RoomStateEvent.Update, updateRoomPermissions); + useEffect(() => { + updateRoomPermissions(); + return () => { + setRoomPermissions({ + modifyLevelMax: -1, + canEdit: false, + canInvite: false, + }); + }; + }, [updateRoomPermissions]); + + return roomPermissions; +}; + +const useIsSynapseAdmin = (cli?: MatrixClient): boolean => { + return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false); +}; + +export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsContent => { + const [powerLevels, setPowerLevels] = useState(getPowerLevels(room)); + + const update = useCallback( + (ev?: MatrixEvent) => { + if (!room) return; + if (ev && ev.getType() !== EventType.RoomPowerLevels) return; + setPowerLevels(getPowerLevels(room)); + }, + [room], + ); + + useTypedEventEmitter(cli, RoomStateEvent.Events, update); + useEffect(() => { + update(); + return () => { + setPowerLevels({}); + }; + }, [update]); + return powerLevels; +}; + +export const useUserInfoBasicViewModel = (room: Room, member: User | RoomMember): UserInfoBasicState => { + const cli = useMatrixClientContext(); + + const powerLevels = useRoomPowerLevels(cli, room); + // Load whether or not we are a Synapse Admin + const isSynapseAdmin = useIsSynapseAdmin(cli); + + // Count of how many operations are currently in progress, if > 0 then show a Spinner + const [pendingUpdateCount, setPendingUpdateCount] = useState(0); + + const roomPermissions = useRoomPermissions(cli, room, member as RoomMember); + + // selected member is current user + const isMe = member.userId === cli.getUserId(); + + // is needed to hide the Roles section for DMs as it doesn't make sense there + const isRoomDMForMember = !!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId); + + // used to check if user can deactivate another member + const isMemberSameDomain = member.userId.endsWith(`:${cli.getDomain()}`); + + // We don't need a perfect check here, just something to pass as "probably not our homeserver". If + // someone does figure out how to bypass this check the worst that happens is an error. + const showDeactivateButton = isSynapseAdmin && isMemberSameDomain; + + const startUpdating = useCallback(() => { + setPendingUpdateCount(pendingUpdateCount + 1); + }, [pendingUpdateCount]); + + const stopUpdating = useCallback(() => { + setPendingUpdateCount(pendingUpdateCount - 1); + }, [pendingUpdateCount]); + + const onSynapseDeactivate = useCallback(async () => { + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("user_info|deactivate_confirm_title"), + description:
{_t("user_info|deactivate_confirm_description")}
, + button: _t("user_info|deactivate_confirm_action"), + danger: true, + }); + + const [accepted] = await finished; + if (!accepted) return; + try { + await cli.deactivateSynapseUser(member.userId); + } catch (err) { + logger.error("Failed to deactivate user"); + logger.error(err); + + const description = err instanceof Error ? err.message : _t("invite|failed_generic"); + + Modal.createDialog(ErrorDialog, { + title: _t("user_info|error_deactivate"), + description, + }); + } + }, [cli, member.userId]); + + return { + showDeactivateButton, + powerLevels, + roomPermissions, + pendingUpdateCount, + isMe, + isRoomDMForMember, + onSynapseDeactivate, + startUpdating, + stopUpdating, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel.tsx new file mode 100644 index 0000000000..3c243feaec --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel.tsx @@ -0,0 +1,85 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useContext, useEffect, useState, useCallback } from "react"; +import { type RoomMember, User, ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; +import { _t } from "../../../../languageHandler"; +import Modal from "../../../../Modal"; +import QuestionDialog from "../../../views/dialogs/QuestionDialog"; +import { useTypedEventEmitter } from "../../../../hooks/useEventEmitter"; + +export interface UserInfoPowerLevelState { + /** + * Weither the member is ignored by current user or not + */ + isIgnored: boolean; + /** + * Trigger the method to ignore or unignore a user + * @param ev - The click event + */ + ignoreButtonClick: (ev: Event) => void; +} + +export const useUserInfoIgnoreButtonViewModel = (member: User | RoomMember): UserInfoPowerLevelState => { + const cli = useContext(MatrixClientContext); + + const unignore = useCallback(() => { + const ignoredUsers = cli.getIgnoredUsers(); + const index = ignoredUsers.indexOf(member.userId); + if (index !== -1) ignoredUsers.splice(index, 1); + cli.setIgnoredUsers(ignoredUsers); + }, [cli, member]); + + const ignore = useCallback(async () => { + const name = (member instanceof User ? member.displayName : member.name) || member.userId; + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("user_info|ignore_confirm_title", { user: name }), + description:
{_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: MatrixEvent) => { + if (ev.getType() === "m.ignored_user_list") { + setIsIgnored(cli.isUserIgnored(member.userId)); + } + }, + [cli, member.userId], + ); + useTypedEventEmitter(cli, ClientEvent.AccountData, accountDataHandler); + + const ignoreButtonClick = (ev: Event): void => { + ev.preventDefault(); + if (isIgnored) { + unignore(); + } else { + ignore(); + } + }; + + return { + ignoreButtonClick, + isIgnored, + }; +}; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 4556063303..086777d141 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -9,62 +9,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import React, { type JSX, type ReactNode, useContext, useEffect, useMemo, useState } from "react"; import classNames from "classnames"; -import { - ClientEvent, - type MatrixClient, - RoomMember, - type Room, - RoomStateEvent, - type MatrixEvent, - User, - type Device, - EventType, -} from "matrix-js-sdk/src/matrix"; -import { KnownMembership } from "matrix-js-sdk/src/types"; +import { type MatrixClient, type RoomMember, type Room, type User, type Device } from "matrix-js-sdk/src/matrix"; import { type UserVerificationStatus, type VerificationRequest, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; -import { logger } from "matrix-js-sdk/src/logger"; -import { MenuItem } from "@vector-im/compound-web"; -import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat"; -import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"; -import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share"; -import MentionIcon from "@vector-im/compound-design-tokens/assets/web/icons/mention"; -import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add"; -import BlockIcon from "@vector-im/compound-design-tokens/assets/web/icons/block"; -import DeleteIcon from "@vector-im/compound-design-tokens/assets/web/icons/delete"; -import dis from "../../../dispatcher/dispatcher"; import Modal from "../../../Modal"; -import { _t, UserFriendlyError } from "../../../languageHandler"; -import DMRoomMap from "../../../utils/DMRoomMap"; +import { _t } from "../../../languageHandler"; import { type ButtonEvent } from "../elements/AccessibleButton"; -import MultiInviter from "../../../utils/MultiInviter"; -import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; import EncryptionPanel from "./EncryptionPanel"; -import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; -import { Action } from "../../../dispatcher/actions"; import { useIsEncrypted } from "../../../hooks/useIsEncrypted"; import BaseCard from "./BaseCard"; -import Spinner from "../elements/Spinner"; -import { ShareDialog } from "../dialogs/ShareDialog"; -import ErrorDialog from "../dialogs/ErrorDialog"; import QuestionDialog from "../dialogs/QuestionDialog"; -import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; -import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; -import { UIComponent } from "../../../settings/UIFeature"; -import { TimelineRenderingType } from "../../../contexts/RoomContext"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { type IRightPanelCardState } from "../../../stores/right-panel/RightPanelStoreIPanelState"; import PosthogTrackers from "../../../PosthogTrackers"; -import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages"; -import { SdkContextClass } from "../../../contexts/SDKContext"; -import { UserInfoAdminToolsContainer } from "./user_info/UserInfoAdminToolsContainer"; -import { PowerLevelSection } from "./user_info/UserInfoPowerLevels"; import { UserInfoHeaderView } from "./user_info/UserInfoHeaderView"; +import { UserInfoBasicView } from "./user_info/UserInfoBasicView"; export interface IDevice extends Device { ambiguous?: boolean; @@ -87,190 +50,6 @@ export const disambiguateDevices = (devices: IDevice[]): void => { } }; -/** - * Converts the member to a DirectoryMember and starts a DM with them. - */ -async function openDmForUser(matrixClient: MatrixClient, user: Member): Promise { - const avatarUrl = user instanceof User ? user.avatarUrl : user.getMxcAvatarUrl(); - const startDmUser = new DirectoryMember({ - user_id: user.userId, - display_name: user.rawDisplayName, - avatar_url: avatarUrl, - }); - await startDmOnFirstMessage(matrixClient, [startDmUser]); -} - -const MessageButton = ({ member }: { member: Member }): JSX.Element => { - const cli = useContext(MatrixClientContext); - const [busy, setBusy] = useState(false); - - return ( - { - ev.preventDefault(); - if (busy) return; - setBusy(true); - await openDmForUser(cli, member); - setBusy(false); - }} - disabled={busy} - label={_t("user_info|send_message")} - Icon={ChatIcon} - /> - ); -}; - -export const UserOptionsSection: React.FC<{ - member: Member; - canInvite: boolean; - isSpace?: boolean; - children?: ReactNode; -}> = ({ member, canInvite, isSpace, children }) => { - const cli = useContext(MatrixClientContext); - - let insertPillButton: JSX.Element | undefined; - let inviteUserButton: JSX.Element | undefined; - let readReceiptButton: JSX.Element | undefined; - - const isMe = member.userId === cli.getUserId(); - const onShareUserClick = (): void => { - Modal.createDialog(ShareDialog, { - target: member, - }); - }; - - // Only allow the user to ignore the user if its not ourselves - // same goes for jumping to read receipt - if (!isMe) { - const onReadReceiptButton = function (room: Room): void { - dis.dispatch({ - action: Action.ViewRoom, - highlighted: true, - // this could return null, the default prevents a type error - event_id: room.getEventReadUpTo(member.userId) || undefined, - room_id: room.roomId, - metricsTrigger: undefined, // room doesn't change - }); - }; - - const room = member instanceof RoomMember ? cli.getRoom(member.roomId) : null; - const readReceiptButtonDisabled = isSpace || !room?.getEventReadUpTo(member.userId); - readReceiptButton = ( - { - ev.preventDefault(); - if (room && !readReceiptButtonDisabled) { - onReadReceiptButton(room); - } - }} - label={_t("user_info|jump_to_rr_button")} - disabled={readReceiptButtonDisabled} - Icon={CheckIcon} - /> - ); - - if (member instanceof RoomMember && member.roomId && !isSpace) { - const onInsertPillButton = function (): void { - dis.dispatch({ - action: Action.ComposerInsert, - userId: member.userId, - timelineRenderingType: TimelineRenderingType.Room, - }); - }; - - insertPillButton = ( - { - ev.preventDefault(); - onInsertPillButton(); - }} - label={_t("action|mention")} - Icon={MentionIcon} - /> - ); - } - - if ( - member instanceof RoomMember && - canInvite && - (member?.membership ?? KnownMembership.Leave) === KnownMembership.Leave && - shouldShowComponent(UIComponent.InviteUsers) - ) { - const roomId = member && member.roomId ? member.roomId : SdkContextClass.instance.roomViewStore.getRoomId(); - const onInviteUserButton = async (ev: Event): Promise => { - try { - // We use a MultiInviter to re-use the invite logic, even though we're only inviting one user. - const inviter = new MultiInviter(cli, roomId || ""); - await inviter.invite([member.userId]).then(() => { - if (inviter.getCompletionState(member.userId) !== "invited") { - const errorStringFromInviterUtility = inviter.getErrorText(member.userId); - if (errorStringFromInviterUtility) { - throw new Error(errorStringFromInviterUtility); - } else { - throw new UserFriendlyError("slash_command|invite_failed", { - user: member.userId, - roomId, - cause: undefined, - }); - } - } - }); - } catch (err) { - const description = err instanceof Error ? err.message : _t("invite|failed_generic"); - - Modal.createDialog(ErrorDialog, { - title: _t("invite|failed_title"), - description, - }); - } - - PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoInviteButton", ev); - }; - - inviteUserButton = ( - { - ev.preventDefault(); - onInviteUserButton(ev); - }} - label={_t("action|invite")} - Icon={InviteIcon} - /> - ); - } - } - - const shareUserButton = ( - { - ev.preventDefault(); - onShareUserClick(); - }} - label={_t("user_info|share_button")} - Icon={ShareIcon} - /> - ); - - const directMessageButton = - isMe || !shouldShowComponent(UIComponent.CreateRooms) ? null : ; - - return ( - - {children} - {directMessageButton} - {inviteUserButton} - {readReceiptButton} - {shareUserButton} - {insertPillButton} - - ); -}; - export const warnSelfDemote = async (isSpace: boolean): Promise => { const { finished } = Modal.createDialog(QuestionDialog, { title: _t("user_info|demote_self_confirm_title"), @@ -325,152 +104,12 @@ export const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsConte return member.powerLevel < levelToSend; }; -export const getPowerLevels = (room: Room): IPowerLevelsContent => - room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {}; - -export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsContent => { - const [powerLevels, setPowerLevels] = useState(getPowerLevels(room)); - - const update = useCallback( - (ev?: MatrixEvent) => { - if (!room) return; - if (ev && ev.getType() !== EventType.RoomPowerLevels) return; - setPowerLevels(getPowerLevels(room)); - }, - [room], - ); - - useTypedEventEmitter(cli, RoomStateEvent.Events, update); - useEffect(() => { - update(); - return () => { - setPowerLevels({}); - }; - }, [update]); - return powerLevels; -}; - -const IgnoreToggleButton: React.FC<{ - member: User | RoomMember; -}> = ({ member }) => { - const cli = useContext(MatrixClientContext); - const unignore = useCallback(() => { - const ignoredUsers = cli.getIgnoredUsers(); - const index = ignoredUsers.indexOf(member.userId); - if (index !== -1) ignoredUsers.splice(index, 1); - cli.setIgnoredUsers(ignoredUsers); - }, [cli, member]); - - const ignore = useCallback(async () => { - const name = (member instanceof User ? member.displayName : member.name) || member.userId; - const { finished } = Modal.createDialog(QuestionDialog, { - title: _t("user_info|ignore_confirm_title", { user: name }), - description:
{_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: MatrixEvent) => { - 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} - /> - ); -}; - -const useIsSynapseAdmin = (cli?: MatrixClient): boolean => { - return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false); -}; - export interface IRoomPermissions { modifyLevelMax: number; canEdit: boolean; canInvite: boolean; } -function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IRoomPermissions { - const [roomPermissions, setRoomPermissions] = useState({ - // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL - modifyLevelMax: -1, - canEdit: false, - canInvite: false, - }); - - const updateRoomPermissions = useCallback(() => { - const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); - if (!powerLevels) return; - - const me = room.getMember(cli.getUserId() || ""); - if (!me) return; - - const them = user; - const isMe = me.userId === them.userId; - const canAffectUser = them.powerLevel < me.powerLevel || isMe; - - let modifyLevelMax = -1; - if (canAffectUser) { - const editPowerLevel = powerLevels.events?.[EventType.RoomPowerLevels] ?? powerLevels.state_default ?? 50; - if (me.powerLevel >= editPowerLevel) { - modifyLevelMax = me.powerLevel; - } - } - - setRoomPermissions({ - canInvite: me.powerLevel >= (powerLevels.invite ?? 0), - canEdit: modifyLevelMax >= 0, - modifyLevelMax, - }); - }, [cli, user, room]); - - useTypedEventEmitter(cli, RoomStateEvent.Update, updateRoomPermissions); - useEffect(() => { - updateRoomPermissions(); - return () => { - setRoomPermissions({ - modifyLevelMax: -1, - canEdit: false, - canInvite: false, - }); - }; - }, [updateRoomPermissions]); - - return roomPermissions; -} - async function getUserDeviceInfo( userId: string, cli: MatrixClient, @@ -547,124 +186,6 @@ export const useDevices = (userId: string): IDevice[] | undefined | null => { return devices; }; -const BasicUserInfo: React.FC<{ - room: Room; - member: User | RoomMember; -}> = ({ room, member }) => { - const cli = useContext(MatrixClientContext); - - const powerLevels = useRoomPowerLevels(cli, room); - // Load whether or not we are a Synapse Admin - const isSynapseAdmin = useIsSynapseAdmin(cli); - - // Count of how many operations are currently in progress, if > 0 then show a Spinner - const [pendingUpdateCount, setPendingUpdateCount] = useState(0); - const startUpdating = useCallback(() => { - setPendingUpdateCount(pendingUpdateCount + 1); - }, [pendingUpdateCount]); - const stopUpdating = useCallback(() => { - setPendingUpdateCount(pendingUpdateCount - 1); - }, [pendingUpdateCount]); - - const roomPermissions = useRoomPermissions(cli, room, member as RoomMember); - - const onSynapseDeactivate = useCallback(async () => { - const { finished } = Modal.createDialog(QuestionDialog, { - title: _t("user_info|deactivate_confirm_title"), - description:
{_t("user_info|deactivate_confirm_description")}
, - button: _t("user_info|deactivate_confirm_action"), - danger: true, - }); - - const [accepted] = await finished; - if (!accepted) return; - try { - await cli.deactivateSynapseUser(member.userId); - } catch (err) { - logger.error("Failed to deactivate user"); - logger.error(err); - - const description = err instanceof Error ? err.message : _t("invite|failed_generic"); - - Modal.createDialog(ErrorDialog, { - title: _t("user_info|error_deactivate"), - description, - }); - } - }, [cli, member.userId]); - - let synapseDeactivateButton; - let spinner; - - // We don't need a perfect check here, just something to pass as "probably not our homeserver". If - // someone does figure out how to bypass this check the worst that happens is an error. - if (isSynapseAdmin && member.userId.endsWith(`:${cli.getDomain()}`)) { - synapseDeactivateButton = ( - { - ev.preventDefault(); - onSynapseDeactivate(); - }} - label={_t("user_info|deactivate_confirm_action")} - kind="critical" - Icon={DeleteIcon} - /> - ); - } - - let memberDetails; - let adminToolsContainer; - if (room && (member as RoomMember).roomId) { - // hide the Roles section for DMs as it doesn't make sense there - if (!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId)) { - memberDetails = ( - - ); - } - - adminToolsContainer = ( - 0} - startUpdating={startUpdating} - stopUpdating={stopUpdating} - > - {synapseDeactivateButton} - - ); - } else if (synapseDeactivateButton) { - adminToolsContainer = {synapseDeactivateButton}; - } - - if (pendingUpdateCount > 0) { - spinner = ; - } - - const isMe = member.userId === cli.getUserId(); - - return ( - - - {memberDetails} - - {adminToolsContainer} - {!isMe && ( - - - - )} - {spinner} - - ); -}; - export type Member = User | RoomMember; interface IProps { @@ -700,7 +221,7 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha let content: JSX.Element | undefined; switch (phase) { case RightPanelPhases.MemberInfo: - content = ; + content = ; break; case RightPanelPhases.EncryptionPanel: classes.push("mx_UserInfo_smallAvatar"); diff --git a/src/components/views/right_panel/user_info/UserInfoBasicOptionsView.tsx b/src/components/views/right_panel/user_info/UserInfoBasicOptionsView.tsx new file mode 100644 index 0000000000..b14bd87278 --- /dev/null +++ b/src/components/views/right_panel/user_info/UserInfoBasicOptionsView.tsx @@ -0,0 +1,127 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type RoomMember, type User, type Room } from "matrix-js-sdk/src/matrix"; +import React, { type JSX, type ReactNode, useState } from "react"; +import { MenuItem } from "@vector-im/compound-web"; +import { ChatIcon, CheckIcon, MentionIcon, ShareIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add"; + +import { _t } from "../../../../languageHandler"; +import { useUserInfoBasicOptionsViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel"; +import { Container, type Member } from "../UserInfo"; +import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents"; +import { UIComponent } from "../../../../settings/UIFeature"; + +const MessageButton = ({ + member, + openDMForUser, +}: { + member: Member; + openDMForUser: (user: Member) => Promise; +}): JSX.Element => { + const [busy, setBusy] = useState(false); + + return ( + { + ev.preventDefault(); + if (busy) return; + setBusy(true); + await openDMForUser(member); + setBusy(false); + }} + disabled={busy} + label={_t("user_info|send_message")} + Icon={ChatIcon} + /> + ); +}; + +export const UserInfoBasicOptionsView: React.FC<{ + member: User | RoomMember; + room: Room; + children?: ReactNode; +}> = ({ room, member, children }) => { + const vm = useUserInfoBasicOptionsViewModel(room, member); + + let insertPillButton: JSX.Element | undefined; + let inviteUserButton: JSX.Element | undefined; + let readReceiptButton: JSX.Element | undefined; + + if (!vm.isMe) { + readReceiptButton = ( + { + ev.preventDefault(); + vm.onReadReceiptButton(); + }} + label={_t("user_info|jump_to_rr_button")} + disabled={vm.readReceiptButtonDisabled} + Icon={CheckIcon} + /> + ); + + if (vm.showInsertPillButton) { + insertPillButton = ( + { + ev.preventDefault(); + vm.onInsertPillButton(); + }} + label={_t("action|mention")} + Icon={MentionIcon} + /> + ); + } + + if (vm.showInviteButton && shouldShowComponent(UIComponent.InviteUsers)) { + inviteUserButton = ( + { + ev.preventDefault(); + vm.onInviteUserButton(ev); + }} + label={_t("action|invite")} + Icon={InviteIcon} + /> + ); + } + } + + const shareUserButton = ( + { + ev.preventDefault(); + vm.onShareUserClick(); + }} + label={_t("user_info|share_button")} + Icon={ShareIcon} + /> + ); + + const directMessageButton = + vm.isMe || !shouldShowComponent(UIComponent.CreateRooms) ? null : ( + + ); + + return ( + + {children} + {directMessageButton} + {inviteUserButton} + {readReceiptButton} + {shareUserButton} + {insertPillButton} + + ); +}; diff --git a/src/components/views/right_panel/user_info/UserInfoBasicView.tsx b/src/components/views/right_panel/user_info/UserInfoBasicView.tsx new file mode 100644 index 0000000000..5a2c9158ff --- /dev/null +++ b/src/components/views/right_panel/user_info/UserInfoBasicView.tsx @@ -0,0 +1,93 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { type RoomMember, type User, type Room } from "matrix-js-sdk/src/matrix"; +import { MenuItem } from "@vector-im/compound-web"; +import { DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { _t } from "../../../../languageHandler"; +import { useUserInfoBasicViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoBasicViewModel"; +import { PowerLevelSection } from "./UserInfoPowerLevels"; +import { Container } from "../UserInfo"; +import { IgnoreToggleButton } from "./UserInfoIgnoreButtonView"; +import Spinner from "../../elements/Spinner"; +import { UserInfoAdminToolsContainer } from "./UserInfoAdminToolsContainer"; +import { UserInfoBasicOptionsView } from "./UserInfoBasicOptionsView"; + +/** + * There are two types of components that can be displayed in the right panel concerning userinfo + * Basic info or Encryption Panel + */ +export const UserInfoBasicView: React.FC<{ + room: Room; + member: User | RoomMember; +}> = ({ room, member }) => { + const vm = useUserInfoBasicViewModel(room, member); + let synapseDeactivateButton; + let spinner; + let memberDetails; + let adminToolsContainer; + + if (vm.showDeactivateButton) { + synapseDeactivateButton = ( + { + ev.preventDefault(); + vm.onSynapseDeactivate(); + }} + label={_t("user_info|deactivate_confirm_action")} + kind="critical" + Icon={DeleteIcon} + /> + ); + } + + if (room && (member as RoomMember).roomId) { + // hide the Roles section for DMs as it doesn't make sense there + if (!vm.isRoomDMForMember) { + memberDetails = ( + + ); + } + + adminToolsContainer = ( + 0} + startUpdating={vm.startUpdating} + stopUpdating={vm.stopUpdating} + > + {synapseDeactivateButton} + + ); + } else if (synapseDeactivateButton) { + adminToolsContainer = {synapseDeactivateButton}; + } + + if (vm.pendingUpdateCount > 0) { + spinner = ; + } + + return ( + + + {memberDetails} + + {adminToolsContainer} + {!vm.isMe && ( + + + + )} + {spinner} + + ); +}; diff --git a/src/components/views/right_panel/user_info/UserInfoIgnoreButtonView.tsx b/src/components/views/right_panel/user_info/UserInfoIgnoreButtonView.tsx new file mode 100644 index 0000000000..671b2ce4a7 --- /dev/null +++ b/src/components/views/right_panel/user_info/UserInfoIgnoreButtonView.tsx @@ -0,0 +1,30 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type RoomMember, type User } from "matrix-js-sdk/src/matrix"; +import React from "react"; +import { MenuItem } from "@vector-im/compound-web"; +import { BlockIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { _t } from "../../../../languageHandler"; +import { useUserInfoIgnoreButtonViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel"; + +export const IgnoreToggleButton: React.FC<{ + member: User | RoomMember; +}> = ({ member }) => { + const vm = useUserInfoIgnoreButtonViewModel(member); + + return ( + vm.ignoreButtonClick(ev)} + label={vm.isIgnored ? _t("user_info|unignore_button") : _t("user_info|ignore_button")} + kind="critical" + Icon={BlockIcon} + /> + ); +}; diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel-test.tsx new file mode 100644 index 0000000000..fba632199e --- /dev/null +++ b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel-test.tsx @@ -0,0 +1,220 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { + EventType, + KnownMembership, + type MatrixClient, + MatrixEvent, + type Room, + RoomMember, + type User, +} from "matrix-js-sdk/src/matrix"; +import { renderHook, waitFor } from "jest-matrix-react"; + +import { Action } from "../../../../../../src/dispatcher/actions"; +import Modal from "../../../../../../src/Modal"; +import MultiInviter from "../../../../../../src/utils/MultiInviter"; +import { createTestClient, mkRoom, withClientContextRenderOptions } from "../../../../../test-utils"; +import dis from "../../../../../../src/dispatcher/dispatcher"; +import { useUserInfoBasicOptionsViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel"; +import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; +import ErrorDialog from "../../../../../../src/components/views/dialogs/ErrorDialog"; + +jest.mock("../../../../../../src/dispatcher/dispatcher"); + +describe("", () => { + const defaultRoomId = "!fkfk"; + const defaultUserId = "@user:example.com"; + const meUserId = "@me:example.com"; + + const defaultMember = new RoomMember(defaultRoomId, defaultUserId); + let defaultProps: { room: Room; member: User | RoomMember }; + let mockClient: MatrixClient; + let room: Room; + + beforeEach(() => { + mockClient = createTestClient(); + room = mkRoom(mockClient, defaultRoomId); + defaultProps = { + member: defaultMember, + room, + }; + DMRoomMap.makeShared(mockClient); + }); + + const renderUserInfoBasicOptionsViewModelHook = ( + props: { + member: User | RoomMember; + room: Room; + } = defaultProps, + ) => { + return renderHook( + () => useUserInfoBasicOptionsViewModel(props.room, props.member), + withClientContextRenderOptions(mockClient), + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Mock the current user account id. Which is different to the defaultMember which is the selected one + // When we want to mock the current user, needs to override this value + jest.spyOn(mockClient, "getUserId").mockReturnValue(meUserId); + jest.spyOn(mockClient, "getRoom").mockReturnValue(room); + }); + + it("should showInviteButton if current user can invite and selected user membership is LEAVE", () => { + // cant use mkRoomMember because instanceof check will failed in this case + const member: RoomMember = new RoomMember(defaultMember.userId, defaultMember.roomId); + const me: RoomMember = new RoomMember(meUserId, defaultMember.roomId); + + console.log("member instanceof RoomMember", member instanceof RoomMember); + + member.powerLevel = 1; + member.membership = KnownMembership.Leave; + me.powerLevel = 50; + me.membership = KnownMembership.Join; + const powerLevelEvents = new MatrixEvent({ + type: EventType.RoomPowerLevels, + content: { + invite: 50, + state_default: 0, + }, + }); + jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents); + // used to get the current me user + jest.spyOn(room, "getMember").mockReturnValue(me); + const { result } = renderUserInfoBasicOptionsViewModelHook({ ...defaultProps, member }); + + expect(result.current.showInviteButton).toBeTruthy(); + }); + + it("should not showInviteButton if current cannot invite", () => { + const member: RoomMember = new RoomMember(defaultMember.userId, defaultMember.roomId); + const me: RoomMember = new RoomMember(meUserId, defaultMember.roomId); + member.powerLevel = 50; + member.membership = KnownMembership.Leave; + me.powerLevel = 0; + me.membership = KnownMembership.Join; + const powerLevelEvents = new MatrixEvent({ + type: EventType.RoomPowerLevels, + content: { + invite: 50, + state_default: 0, + }, + }); + jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents); + // used to get the current me user + jest.spyOn(room, "getMember").mockReturnValue(me); + const { result } = renderUserInfoBasicOptionsViewModelHook({ ...defaultProps, member }); + + expect(result.current.showInviteButton).toBeFalsy(); + }); + + it("should not showInviteButton if selected user membership is not LEAVE", () => { + const member: RoomMember = new RoomMember(defaultMember.userId, defaultMember.roomId); + const me: RoomMember = new RoomMember(meUserId, defaultMember.roomId); + member.powerLevel = 50; + member.membership = KnownMembership.Join; + me.powerLevel = 50; + me.membership = KnownMembership.Join; + const powerLevelEvents = new MatrixEvent({ + type: EventType.RoomPowerLevels, + content: { + invite: 50, + state_default: 0, + }, + }); + jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents); + jest.spyOn(room, "getMember").mockReturnValue(me); + const { result } = renderUserInfoBasicOptionsViewModelHook({ ...defaultProps, member }); + + expect(result.current.showInviteButton).toBeFalsy(); + }); + + it("should showInsertPillButton if room is not a space", () => { + jest.spyOn(room, "isSpaceRoom").mockReturnValue(false); + const { result } = renderUserInfoBasicOptionsViewModelHook(); + expect(result.current.showInsertPillButton).toBeTruthy(); + }); + + it("should not showInsertPillButton if room is a space", () => { + jest.spyOn(room, "isSpaceRoom").mockReturnValue(true); + const { result } = renderUserInfoBasicOptionsViewModelHook(); + expect(result.current.showInsertPillButton).toBeFalsy(); + }); + + it("should readReceiptButtonDisabled be true if all messages where read", () => { + jest.spyOn(room, "getEventReadUpTo").mockReturnValue(null); + const { result } = renderUserInfoBasicOptionsViewModelHook(); + expect(result.current.readReceiptButtonDisabled).toBeTruthy(); + }); + + it("should readReceiptButtonDisabled be false if some messages are available", () => { + jest.spyOn(room, "getEventReadUpTo").mockReturnValue("aneventId"); + const { result } = renderUserInfoBasicOptionsViewModelHook(); + expect(result.current.readReceiptButtonDisabled).toBeFalsy(); + }); + + it("should readReceiptButtonDisabled be true if room is a space", () => { + jest.spyOn(room, "getEventReadUpTo").mockReturnValue("aneventId"); + jest.spyOn(room, "isSpaceRoom").mockReturnValue(true); + const { result } = renderUserInfoBasicOptionsViewModelHook(); + expect(result.current.readReceiptButtonDisabled).toBeTruthy(); + }); + + it("firing onReadReceiptButton calls dispatch with correct event_id", () => { + const eventId = "aneventId"; + jest.spyOn(room, "getEventReadUpTo").mockReturnValue(eventId); + jest.spyOn(room, "isSpaceRoom").mockReturnValue(false); + const { result } = renderUserInfoBasicOptionsViewModelHook(); + + result.current.onReadReceiptButton(); + + expect(dis.dispatch).toHaveBeenCalledWith({ + action: "view_room", + event_id: eventId, + highlighted: true, + metricsTrigger: undefined, + room_id: defaultRoomId, + }); + }); + + it("calling onInsertPillButton should calls dispatch", () => { + const { result } = renderUserInfoBasicOptionsViewModelHook(); + + result.current.onInsertPillButton(); + + expect(dis.dispatch).toHaveBeenCalledWith({ + action: Action.ComposerInsert, + userId: defaultMember.userId, + timelineRenderingType: "Room", + }); + }); + + it("calling onInviteUserButton will call MultiInviter.invite", async () => { + // to save mocking, we will reject the call to .invite + const mockErrorMessage = new Error("test error message"); + const spy = jest.spyOn(MultiInviter.prototype, "invite"); + spy.mockRejectedValue(mockErrorMessage); + jest.spyOn(Modal, "createDialog"); + + const { result } = renderUserInfoBasicOptionsViewModelHook(); + result.current.onInviteUserButton(new Event("click")); + + // check that we have called .invite + expect(spy).toHaveBeenCalledWith([defaultMember.userId]); + + await waitFor(() => { + // check that the test error message is displayed + expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, { + description: "test error message", + title: "Failed to invite", + }); + }); + }); +}); diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel-test.tsx new file mode 100644 index 0000000000..17d6500278 --- /dev/null +++ b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel-test.tsx @@ -0,0 +1,149 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ +import React from "react"; +import { EventType, type MatrixClient, MatrixEvent, type Room, RoomMember, type User } from "matrix-js-sdk/src/matrix"; +import { renderHook, waitFor } from "jest-matrix-react"; + +import { createTestClient, mkRoom, withClientContextRenderOptions } from "../../../../../test-utils"; +import { useUserInfoBasicViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel"; +import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; +import Modal from "../../../../../../src/Modal"; +import QuestionDialog from "../../../../../../src/components/views/dialogs/QuestionDialog"; + +jest.mock("../../../../../../src/customisations/UserIdentifier", () => { + return { + getDisplayUserIdentifier: jest.fn().mockReturnValue("customUserIdentifier"), + }; +}); + +describe("useUserInfoHeaderViewModel", () => { + const defaultRoomId = "!fkfk"; + const defaultUserId = "@user:example.com"; + + const defaultMember = new RoomMember(defaultRoomId, defaultUserId); + let mockClient: MatrixClient; + + let defaultProps: { + member: User | RoomMember; + room: Room; + }; + + let room: Room; + + beforeEach(() => { + mockClient = createTestClient(); + mockClient.isSynapseAdministrator = jest.fn().mockResolvedValue(true); + mockClient.deactivateSynapseUser = jest.fn().mockResolvedValue({ + id_server_unbind_result: "success", + }); + + room = mkRoom(mockClient, defaultRoomId); + defaultProps = { + member: defaultMember, + room, + }; + DMRoomMap.makeShared(mockClient); + jest.spyOn(mockClient, "getRoom").mockReturnValue(room); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const renderUserInfoBasicViewModelHook = ( + props: { + member: User | RoomMember; + room: Room; + } = defaultProps, + ) => { + return renderHook( + () => useUserInfoBasicViewModel(props.room, props.member), + withClientContextRenderOptions(mockClient), + ); + }; + + it("should set showDeactivateButton value to true", async () => { + jest.spyOn(mockClient, "getDomain").mockReturnValue("example.com"); + const { result } = renderUserInfoBasicViewModelHook(); + // checking the synpase admin is an async operation, that is why we wait for it + await waitFor(() => { + expect(result.current.showDeactivateButton).toBe(true); + }); + }); + + it("should set showDeactivateButton value to false because domain is not the same", async () => { + jest.spyOn(mockClient, "getDomain").mockReturnValue("toto.com"); + const { result } = renderUserInfoBasicViewModelHook(); + + await waitFor(() => { + expect(result.current.showDeactivateButton).toBe(false); + }); + }); + + it("should give powerlevels values", () => { + const powerLevelEvents = new MatrixEvent({ + type: EventType.RoomPowerLevels, + content: { + invite: 1, + state_default: 1, + }, + }); + jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents); + const { result } = renderUserInfoBasicViewModelHook(); + expect(result.current.powerLevels).toStrictEqual({ + invite: 1, + state_default: 1, + }); + }); + + it("should set isRoomDMForMember to true if found in dmroommap", () => { + jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue("id"); + const { result } = renderUserInfoBasicViewModelHook(); + expect(result.current.isRoomDMForMember).toBeTruthy(); + }); + + it("should set isRoomDMForMember to false if not found in dmroommap", () => { + jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined); + const { result } = renderUserInfoBasicViewModelHook(); + expect(result.current.isRoomDMForMember).toBeFalsy(); + }); + + it("should display modal and call deactivateSynapseUser when calling onSynapaseDeactivate", async () => { + const powerLevelEvents = new MatrixEvent({ + type: EventType.RoomPowerLevels, + content: { + invite: 1, + state_default: 1, + }, + }); + jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents); + jest.spyOn(Modal, "createDialog").mockReturnValue({ + finished: Promise.resolve([true, true, false]), + close: jest.fn(), + }); + + const { result } = renderUserInfoBasicViewModelHook(); + + await waitFor(() => result.current.onSynapseDeactivate()); + + await waitFor(() => { + expect(Modal.createDialog).toHaveBeenLastCalledWith(QuestionDialog, { + button: "Deactivate user", + danger: true, + description: ( +
+ Deactivating this user will log them out and prevent them from logging back in. Additionally, + they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want + to deactivate this user? +
+ ), + title: "Deactivate user?", + }); + }); + expect(mockClient.deactivateSynapseUser).toHaveBeenCalledWith(defaultMember.userId); + }); +}); diff --git a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx index 1b5efc2868..47dc1826b5 100644 --- a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx +++ b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx @@ -28,24 +28,16 @@ import { type CryptoApi, } from "matrix-js-sdk/src/crypto-api"; -import UserInfo, { - disambiguateDevices, - getPowerLevels, - UserOptionsSection, -} from "../../../../../src/components/views/right_panel/UserInfo"; -import dis from "../../../../../src/dispatcher/dispatcher"; +import UserInfo, { disambiguateDevices } from "../../../../../src/components/views/right_panel/UserInfo"; +import { getPowerLevels } from "../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel"; import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import MultiInviter from "../../../../../src/utils/MultiInviter"; import Modal from "../../../../../src/Modal"; -import { DirectoryMember, startDmOnFirstMessage } from "../../../../../src/utils/direct-messages"; import { clearAllModals, flushPromises } from "../../../../test-utils"; import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog"; import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents"; import { UIComponent } from "../../../../../src/settings/UIFeature"; -import { Action } from "../../../../../src/dispatcher/actions"; -import { ShareDialog } from "../../../../../src/components/views/dialogs/ShareDialog"; jest.mock("../../../../../src/utils/direct-messages", () => ({ ...jest.requireActual("../../../../../src/utils/direct-messages"), @@ -449,216 +441,6 @@ describe("", () => { }); }); -describe("", () => { - const member = new RoomMember(defaultRoomId, defaultUserId); - const defaultProps = { member, canInvite: false, isSpace: false }; - - const renderComponent = (props = {}) => { - const Wrapper = (wrapperProps = {}) => { - return ; - }; - - return render(, { - wrapper: Wrapper, - }); - }; - - const inviteSpy = jest.spyOn(MultiInviter.prototype, "invite"); - - beforeEach(() => { - inviteSpy.mockReset(); - mockClient.setIgnoredUsers.mockClear(); - }); - - afterEach(async () => { - await clearAllModals(); - }); - - afterAll(() => { - inviteSpy.mockRestore(); - }); - - it("always shows share user button and clicking it should produce a ShareDialog", async () => { - const spy = jest.spyOn(Modal, "createDialog"); - - renderComponent(); - await userEvent.click(screen.getByRole("button", { name: "Share profile" })); - - expect(spy).toHaveBeenCalledWith(ShareDialog, { target: defaultProps.member }); - }); - - it("does not show ignore or direct message buttons when member userId matches client userId", () => { - mockClient.getSafeUserId.mockReturnValueOnce(member.userId); - mockClient.getUserId.mockReturnValueOnce(member.userId); - renderComponent(); - - expect(screen.queryByRole("button", { name: /ignore/i })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /message/i })).not.toBeInTheDocument(); - }); - - it("shows direct message and mention buttons when member userId does not match client userId", () => { - // call to client.getUserId returns undefined, which will not match member.userId - renderComponent(); - - expect(screen.getByRole("button", { name: "Send message" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Mention" })).toBeInTheDocument(); - }); - - it("mention button fires ComposerInsert Action", async () => { - renderComponent(); - - const button = screen.getByRole("button", { name: "Mention" }); - await userEvent.click(button); - expect(dis.dispatch).toHaveBeenCalledWith({ - action: Action.ComposerInsert, - timelineRenderingType: "Room", - userId: "@user:example.com", - }); - }); - - it("when call to client.getRoom is null, shows disabled read receipt button", () => { - mockClient.getRoom.mockReturnValueOnce(null); - renderComponent(); - - expect(screen.queryByRole("button", { name: "Jump to read receipt" })).toBeDisabled(); - }); - - it("when call to client.getRoom is non-null and room.getEventReadUpTo is null, shows disabled read receipt button", () => { - mockRoom.getEventReadUpTo.mockReturnValueOnce(null); - mockClient.getRoom.mockReturnValueOnce(mockRoom); - renderComponent(); - - expect(screen.queryByRole("button", { name: "Jump to read receipt" })).toBeDisabled(); - }); - - it("when calls to client.getRoom and room.getEventReadUpTo are non-null, shows read receipt button", () => { - mockRoom.getEventReadUpTo.mockReturnValueOnce("1234"); - mockClient.getRoom.mockReturnValueOnce(mockRoom); - renderComponent(); - - expect(screen.getByRole("button", { name: "Jump to read receipt" })).toBeInTheDocument(); - }); - - it("clicking the read receipt button calls dispatch with correct event_id", async () => { - const mockEventId = "1234"; - mockRoom.getEventReadUpTo.mockReturnValue(mockEventId); - mockClient.getRoom.mockReturnValue(mockRoom); - renderComponent(); - - const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" }); - - expect(readReceiptButton).toBeInTheDocument(); - await userEvent.click(readReceiptButton); - expect(dis.dispatch).toHaveBeenCalledWith({ - action: "view_room", - event_id: mockEventId, - highlighted: true, - metricsTrigger: undefined, - room_id: "!fkfk", - }); - - mockRoom.getEventReadUpTo.mockReset(); - mockClient.getRoom.mockReset(); - }); - - it("firing the read receipt event handler with a null event_id calls dispatch with undefined not null", async () => { - const mockEventId = "1234"; - // the first call is the check to see if we should render the button, second call is - // when the button is clicked - mockRoom.getEventReadUpTo.mockReturnValueOnce(mockEventId).mockReturnValueOnce(null); - mockClient.getRoom.mockReturnValue(mockRoom); - renderComponent(); - - const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" }); - - expect(readReceiptButton).toBeInTheDocument(); - await userEvent.click(readReceiptButton); - expect(dis.dispatch).toHaveBeenCalledWith({ - action: "view_room", - event_id: undefined, - highlighted: true, - metricsTrigger: undefined, - room_id: "!fkfk", - }); - - mockClient.getRoom.mockReset(); - }); - - it("does not show the invite button when canInvite is false", () => { - renderComponent(); - expect(screen.queryByRole("button", { name: /invite/i })).not.toBeInTheDocument(); - }); - - it("shows the invite button when canInvite is true", () => { - renderComponent({ canInvite: true }); - expect(screen.getByRole("button", { name: /invite/i })).toBeInTheDocument(); - }); - - it("clicking the invite button will call MultiInviter.invite", async () => { - // to save mocking, we will reject the call to .invite - const mockErrorMessage = new Error("test error message"); - inviteSpy.mockRejectedValue(mockErrorMessage); - - // render the component and click the button - renderComponent({ canInvite: true }); - const inviteButton = screen.getByRole("button", { name: /invite/i }); - expect(inviteButton).toBeInTheDocument(); - await userEvent.click(inviteButton); - - // check that we have called .invite - expect(inviteSpy).toHaveBeenCalledWith([member.userId]); - - // check that the test error message is displayed - await expect(screen.findByText(mockErrorMessage.message)).resolves.toBeInTheDocument(); - }); - - it("if calling .invite throws something strange, show default error message", async () => { - inviteSpy.mockRejectedValue({ this: "could be anything" }); - - // render the component and click the button - renderComponent({ canInvite: true }); - const inviteButton = screen.getByRole("button", { name: /invite/i }); - expect(inviteButton).toBeInTheDocument(); - await userEvent.click(inviteButton); - - // check that the default test error message is displayed - await expect(screen.findByText(/operation failed/i)).resolves.toBeInTheDocument(); - }); - - it.each([ - ["for a RoomMember", member, member.getMxcAvatarUrl()], - ["for a User", defaultUser, defaultUser.avatarUrl], - ])( - "clicking »message« %s should start a DM", - async (test: string, member: RoomMember | User, expectedAvatarUrl: string | undefined) => { - const deferred = Promise.withResolvers(); - mocked(startDmOnFirstMessage).mockReturnValue(deferred.promise); - - renderComponent({ member }); - await userEvent.click(screen.getByRole("button", { name: "Send message" })); - - // Checking the attribute, because the button is a DIV and toBeDisabled() does not work. - expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(); - - expect(startDmOnFirstMessage).toHaveBeenCalledWith(mockClient, [ - new DirectoryMember({ - user_id: member.userId, - display_name: member.rawDisplayName, - avatar_url: expectedAvatarUrl, - }), - ]); - - await act(async () => { - deferred.resolve("!dm:example.com"); - await flushPromises(); - }); - - // Checking the attribute, because the button is a DIV and toBeDisabled() does not work. - expect(screen.getByRole("button", { name: "Send message" })).not.toBeDisabled(); - }, - ); -}); - describe("disambiguateDevices", () => { it("does not add ambiguous key to unique names", () => { const initialDevices = [ diff --git a/test/unit-tests/components/views/right_panel/UserInfoAdminToolsContainer-test.tsx b/test/unit-tests/components/views/right_panel/user_info/UserInfoAdminToolsContainer-test.tsx similarity index 80% rename from test/unit-tests/components/views/right_panel/UserInfoAdminToolsContainer-test.tsx rename to test/unit-tests/components/views/right_panel/user_info/UserInfoAdminToolsContainer-test.tsx index 30a4f78842..524c76a515 100644 --- a/test/unit-tests/components/views/right_panel/UserInfoAdminToolsContainer-test.tsx +++ b/test/unit-tests/components/views/right_panel/user_info/UserInfoAdminToolsContainer-test.tsx @@ -10,16 +10,16 @@ import { render, screen, fireEvent } from "jest-matrix-react"; import { type Room, type RoomMember } from "matrix-js-sdk/src/matrix"; import { mocked } from "jest-mock"; -import { UserInfoAdminToolsContainer } from "../../../../../src/components/views/right_panel/user_info/UserInfoAdminToolsContainer"; -import { useUserInfoAdminToolsContainerViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel"; -import { useRoomKickButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel"; -import { useBanButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel"; -import { useMuteButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel"; -import { useRedactMessagesButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel"; -import { stubClient } from "../../../../test-utils"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; +import { UserInfoAdminToolsContainer } from "../../../../../../src/components/views/right_panel/user_info/UserInfoAdminToolsContainer"; +import { useUserInfoAdminToolsContainerViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel"; +import { useRoomKickButtonViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel"; +import { useBanButtonViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel"; +import { useMuteButtonViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel"; +import { useRedactMessagesButtonViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel"; +import { stubClient } from "../../../../../test-utils"; +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; -jest.mock("../../../../../src/utils/DMRoomMap", () => { +jest.mock("../../../../../../src/utils/DMRoomMap", () => { const mock = { getUserIdForRoomId: jest.fn(), getDMRoomsForUserId: jest.fn(), @@ -32,7 +32,7 @@ jest.mock("../../../../../src/utils/DMRoomMap", () => { }); jest.mock( - "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel", + "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel", () => ({ useUserInfoAdminToolsContainerViewModel: jest.fn().mockReturnValue({ isCurrentUserInTheRoom: true, @@ -44,34 +44,43 @@ jest.mock( }), ); -jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel", () => ({ - useRoomKickButtonViewModel: jest.fn().mockReturnValue({ - canUserBeKicked: true, - kickLabel: "Kick", - onKickClick: jest.fn(), +jest.mock( + "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel", + () => ({ + useRoomKickButtonViewModel: jest.fn().mockReturnValue({ + canUserBeKicked: true, + kickLabel: "Kick", + onKickClick: jest.fn(), + }), }), -})); +); -jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel", () => ({ +jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel", () => ({ useBanButtonViewModel: jest.fn().mockReturnValue({ banLabel: "Ban", onBanOrUnbanClick: jest.fn(), }), })); -jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel", () => ({ - useMuteButtonViewModel: jest.fn().mockReturnValue({ - isMemberInTheRoom: true, - muteLabel: "Mute", - onMuteButtonClick: jest.fn(), +jest.mock( + "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel", + () => ({ + useMuteButtonViewModel: jest.fn().mockReturnValue({ + isMemberInTheRoom: true, + muteLabel: "Mute", + onMuteButtonClick: jest.fn(), + }), }), -})); +); -jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel", () => ({ - useRedactMessagesButtonViewModel: jest.fn().mockReturnValue({ - onRedactAllMessagesClick: jest.fn(), +jest.mock( + "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel", + () => ({ + useRedactMessagesButtonViewModel: jest.fn().mockReturnValue({ + onRedactAllMessagesClick: jest.fn(), + }), }), -})); +); const defaultRoomId = "!fkfk"; diff --git a/test/unit-tests/components/views/right_panel/user_info/UserInfoBasic-test.tsx b/test/unit-tests/components/views/right_panel/user_info/UserInfoBasic-test.tsx new file mode 100644 index 0000000000..3fb67c6876 --- /dev/null +++ b/test/unit-tests/components/views/right_panel/user_info/UserInfoBasic-test.tsx @@ -0,0 +1,112 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { mocked } from "jest-mock"; +import { type MatrixClient, type Room, RoomMember, type User } from "matrix-js-sdk/src/matrix"; +import { logRoles, render, screen } from "jest-matrix-react"; + +import { createTestClient, mkStubRoom } from "../../../../../test-utils"; +import { + type UserInfoBasicState, + useUserInfoBasicViewModel, +} from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel"; +import { UserInfoBasicView } from "../../../../../../src/components/views/right_panel/user_info/UserInfoBasicView"; +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; + +const defaultRoomPermissions = { + canEdit: true, + canInvite: true, + modifyLevelMax: -1, +}; +jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel", () => ({ + useUserInfoBasicViewModel: jest.fn(), + useRoomPermissions: () => defaultRoomPermissions, +})); + +describe("", () => { + const defaultValue: UserInfoBasicState = { + powerLevels: {}, + roomPermissions: defaultRoomPermissions, + pendingUpdateCount: 0, + isMe: false, + isRoomDMForMember: false, + showDeactivateButton: true, + onSynapseDeactivate: jest.fn(), + startUpdating: jest.fn(), + stopUpdating: jest.fn(), + }; + + const defaultRoomId = "!fkfk"; + const defaultUserId = "@user:example.com"; + + const defaultMember = new RoomMember(defaultRoomId, defaultUserId); + let defaultRoom: Room; + + let defaultProps: { member: User | RoomMember; room: Room }; + let matrixClient: MatrixClient; + + const renderComponent = (props = defaultProps) => { + return render( + + + , + ); + }; + beforeEach(() => { + matrixClient = createTestClient(); + defaultRoom = mkStubRoom(defaultRoomId, defaultRoomId, matrixClient); + defaultProps = { + member: defaultMember, + room: defaultRoom, + }; + }); + + it("should display the defaut values", () => { + mocked(useUserInfoBasicViewModel).mockReturnValue(defaultValue); + const { container } = renderComponent(); + logRoles(container); + expect(container).toMatchSnapshot(); + }); + + it("should not show ignore button if user is me", () => { + const state: UserInfoBasicState = { ...defaultValue, isMe: true }; + mocked(useUserInfoBasicViewModel).mockReturnValue(state); + renderComponent(); + + const ignoreButton = screen.queryByRole("button", { name: "Ignore" }); + expect(ignoreButton).not.toBeInTheDocument(); + }); + + it("should not show deactivate button", () => { + const state: UserInfoBasicState = { ...defaultValue, showDeactivateButton: false }; + mocked(useUserInfoBasicViewModel).mockReturnValue(state); + renderComponent(); + + const deactivateButton = screen.queryByRole("button", { name: "Deactivate user" }); + expect(deactivateButton).not.toBeInTheDocument(); + }); + + it("should not show powerlevels selector for dm", () => { + const state: UserInfoBasicState = { ...defaultValue, isRoomDMForMember: true }; + mocked(useUserInfoBasicViewModel).mockReturnValue(state); + const { container } = renderComponent(); + + logRoles(container); + const powserlevel = screen.queryByRole("option", { name: "Default" }); + expect(powserlevel).not.toBeInTheDocument(); + }); + + it("should show spinner if pending update is > 0", () => { + const state: UserInfoBasicState = { ...defaultValue, pendingUpdateCount: 2 }; + mocked(useUserInfoBasicViewModel).mockReturnValue(state); + renderComponent(); + + const spinner = screen.getByTestId("spinner"); + expect(spinner).toBeInTheDocument(); + }); +}); diff --git a/test/unit-tests/components/views/right_panel/user_info/UserInfoBasicOptionsView-test.tsx b/test/unit-tests/components/views/right_panel/user_info/UserInfoBasicOptionsView-test.tsx new file mode 100644 index 0000000000..3047541e65 --- /dev/null +++ b/test/unit-tests/components/views/right_panel/user_info/UserInfoBasicOptionsView-test.tsx @@ -0,0 +1,208 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { mocked } from "jest-mock"; +import { type Room, RoomMember, type User } from "matrix-js-sdk/src/matrix"; +import { fireEvent, render, screen } from "jest-matrix-react"; + +import { mkStubRoom, stubClient } from "../../../../../test-utils"; +import { + useUserInfoBasicOptionsViewModel, + type UserInfoBasicOptionsState, +} from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel"; +import { UserInfoBasicOptionsView } from "../../../../../../src/components/views/right_panel/user_info/UserInfoBasicOptionsView"; +import { UIComponent } from "../../../../../../src/settings/UIFeature"; +import { shouldShowComponent } from "../../../../../../src/customisations/helpers/UIComponents"; +import { type Member } from "../../../../../../src/components/views/right_panel/UserInfo"; + +jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel", () => ({ + useUserInfoBasicOptionsViewModel: jest.fn(), +})); + +jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => { + const original = jest.requireActual("../../../../../../src/customisations/helpers/UIComponents"); + return { + shouldShowComponent: jest.fn().mockImplementation(original.shouldShowComponent), + }; +}); + +describe("", () => { + const defaultValue: UserInfoBasicOptionsState = { + isMe: false, + showInviteButton: false, + showInsertPillButton: false, + readReceiptButtonDisabled: false, + onInsertPillButton: () => jest.fn(), + onReadReceiptButton: () => jest.fn(), + onShareUserClick: () => jest.fn(), + onInviteUserButton: (evt: Event) => Promise.resolve(), + onOpenDmForUser: (member: Member) => Promise.resolve(), + }; + + const defaultRoomId = "!fkfk"; + const defaultUserId = "@user:example.com"; + + const defaultMember = new RoomMember(defaultRoomId, defaultUserId); + let defaultRoom: Room; + + let defaultProps: { member: User | RoomMember; room: Room }; + + beforeEach(() => { + const matrixClient = stubClient(); + defaultRoom = mkStubRoom(defaultRoomId, defaultRoomId, matrixClient); + defaultProps = { + member: defaultMember, + room: defaultRoom, + }; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should always display sharedButton when user is not me", () => { + // User is not me by default + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue }); + render(); + const sharedButton = screen.getByRole("button", { name: "Share profile" }); + expect(sharedButton).toBeInTheDocument(); + }); + + it("should always display sharedButton when user is me", () => { + const propsWithMe = { ...defaultProps }; + const onShareUserClick = jest.fn(); + const state = { ...defaultValue, isMe: true, onShareUserClick }; + + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(state); + render(); + + const sharedButton2 = screen.getByRole("button", { name: "Share profile" }); + expect(sharedButton2).toBeInTheDocument(); + + // clicking on the share profile button + fireEvent.click(sharedButton2); + + expect(onShareUserClick).toHaveBeenCalled(); + }); + + it("should show insert pill button when user is not me and showinsertpill is true", () => { + const onInsertPillButton = jest.fn(); + const state = { ...defaultValue, showInsertPillButton: true, onInsertPillButton }; + // User is not me and showInsertpill is true + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(state); + render(); + + const insertPillButton = screen.getByRole("button", { name: "Mention" }); + expect(insertPillButton).toBeInTheDocument(); + + // clicking on the insert pill button + fireEvent.click(insertPillButton); + + expect(onInsertPillButton).toHaveBeenCalled(); + }); + + it("should not show insert pill button when user is not me and showinsertpill is false", () => { + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue, showInsertPillButton: false }); + render(); + const insertPillButton = screen.queryByRole("button", { name: "Mention" }); + expect(insertPillButton).not.toBeInTheDocument(); + }); + + it("should not show insert pill button when user is me", () => { + // User is me, should not see the insert button even when show insertpill is true + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ + ...defaultValue, + showInsertPillButton: true, + isMe: true, + }); + const propsWithMe = { ...defaultProps }; + render(); + const insertPillButton = screen.queryByRole("button", { name: "Mention" }); + expect(insertPillButton).not.toBeInTheDocument(); + }); + + it("should not show readreceiptbutton when user is me", () => { + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ + ...defaultValue, + readReceiptButtonDisabled: true, + isMe: true, + }); + const propsWithMe = { ...defaultProps }; + render(); + + const readReceiptButton = screen.queryByRole("button", { name: "Jump to read receipt" }); + expect(readReceiptButton).not.toBeInTheDocument(); + }); + + it("should show disable readreceiptbutton when readReceiptButtonDisabled is true", () => { + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue, readReceiptButtonDisabled: true }); + render(); + + const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" }); + expect(readReceiptButton).toBeDisabled(); + }); + + it("should not show disable readreceiptbutton when readReceiptButtonDisabled is false", () => { + const onReadReceiptButton = jest.fn(); + const state = { ...defaultValue, readReceiptButtonDisabled: false, onReadReceiptButton }; + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(state); + render(); + + const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" }); + expect(readReceiptButton).not.toBeDisabled(); + + // clicking on the read receipt button + fireEvent.click(readReceiptButton); + + expect(onReadReceiptButton).toHaveBeenCalled(); + }); + + it("should show not show invite button if shouldShowComponent is false", () => { + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue, showInviteButton: true }); + mocked(shouldShowComponent).mockReturnValue(false); + render(); + + const inviteButton = screen.queryByRole("button", { name: "Invite" }); + expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.InviteUsers); + expect(inviteButton).not.toBeInTheDocument(); + }); + + it("should show show invite button if shouldShowComponent is true", () => { + const onInviteUserButton = jest.fn(); + const state = { ...defaultValue, showInviteButton: true, onInviteUserButton }; + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(state); + mocked(shouldShowComponent).mockReturnValue(true); + render(); + + const inviteButton = screen.getByRole("button", { name: "Invite" }); + expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.InviteUsers); + expect(inviteButton).toBeInTheDocument(); + + // clicking on the invite button + fireEvent.click(inviteButton); + expect(onInviteUserButton).toHaveBeenCalled(); + }); + + it("should show directMessageButton when user is not me", () => { + // User is not me, direct message button should display + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(defaultValue); + mocked(shouldShowComponent).mockReturnValue(true); + render(); + const dmButton = screen.getByRole("button", { name: "Send message" }); + expect(dmButton).toBeInTheDocument(); + }); + + it("should not show directMessageButton when user is me", () => { + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue, isMe: true }); + mocked(shouldShowComponent).mockReturnValue(true); + const propsWithMe = { ...defaultProps }; + render(); + const dmButton = screen.queryByRole("button", { name: "Send message" }); + expect(dmButton).not.toBeInTheDocument(); + }); +}); diff --git a/test/unit-tests/components/views/right_panel/UserInfoHeaderVerificationView-test.tsx b/test/unit-tests/components/views/right_panel/user_info/UserInfoHeaderVerificationView-test.tsx similarity index 91% rename from test/unit-tests/components/views/right_panel/UserInfoHeaderVerificationView-test.tsx rename to test/unit-tests/components/views/right_panel/user_info/UserInfoHeaderVerificationView-test.tsx index 65db069d05..7c54d167c3 100644 --- a/test/unit-tests/components/views/right_panel/UserInfoHeaderVerificationView-test.tsx +++ b/test/unit-tests/components/views/right_panel/user_info/UserInfoHeaderVerificationView-test.tsx @@ -12,10 +12,10 @@ import { Device, RoomMember } from "matrix-js-sdk/src/matrix"; import { render, waitFor, screen } from "jest-matrix-react"; import React from "react"; -import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import { UserInfoHeaderVerificationView } from "../../../../../src/components/views/right_panel/user_info/UserInfoHeaderVerificationView"; -import { createTestClient } from "../../../../test-utils"; +import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; +import { UserInfoHeaderVerificationView } from "../../../../../../src/components/views/right_panel/user_info/UserInfoHeaderVerificationView"; +import { createTestClient } from "../../../../../test-utils"; describe("", () => { const defaultRoomId = "!fkfk"; diff --git a/test/unit-tests/components/views/right_panel/UserInfoHeaderView-test.tsx b/test/unit-tests/components/views/right_panel/user_info/UserInfoHeaderView-test.tsx similarity index 91% rename from test/unit-tests/components/views/right_panel/UserInfoHeaderView-test.tsx rename to test/unit-tests/components/views/right_panel/user_info/UserInfoHeaderView-test.tsx index 04f59f16f6..31da372b2a 100644 --- a/test/unit-tests/components/views/right_panel/UserInfoHeaderView-test.tsx +++ b/test/unit-tests/components/views/right_panel/user_info/UserInfoHeaderView-test.tsx @@ -12,14 +12,14 @@ import { Device, RoomMember } from "matrix-js-sdk/src/matrix"; import { fireEvent, render, screen } from "jest-matrix-react"; import React from "react"; -import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import { UserInfoHeaderView } from "../../../../../src/components/views/right_panel/user_info/UserInfoHeaderView"; -import { createTestClient } from "../../../../test-utils"; -import { useUserfoHeaderViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel"; +import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; +import { UserInfoHeaderView } from "../../../../../../src/components/views/right_panel/user_info/UserInfoHeaderView"; +import { createTestClient } from "../../../../../test-utils"; +import { useUserfoHeaderViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel"; // Mock the viewmodel hooks -jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel", () => ({ +jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel", () => ({ useUserfoHeaderViewModel: jest.fn().mockReturnValue({ onMemberAvatarClick: jest.fn(), precenseInfo: { diff --git a/test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoBasic-test.tsx.snap b/test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoBasic-test.tsx.snap new file mode 100644 index 0000000000..70bb7465a3 --- /dev/null +++ b/test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoBasic-test.tsx.snap @@ -0,0 +1,315 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should display the defaut values 1`] = ` +
+
+
+
+
+ + +
+
+
+ + + + + +
+
+ +
+
+ +
+
+`; diff --git a/test/unit-tests/components/views/right_panel/__snapshots__/UserInfoHeaderVerificationView-test.tsx.snap b/test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoHeaderVerificationView-test.tsx.snap similarity index 100% rename from test/unit-tests/components/views/right_panel/__snapshots__/UserInfoHeaderVerificationView-test.tsx.snap rename to test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoHeaderVerificationView-test.tsx.snap diff --git a/test/unit-tests/components/views/right_panel/__snapshots__/UserInfoHeaderView-test.tsx.snap b/test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoHeaderView-test.tsx.snap similarity index 100% rename from test/unit-tests/components/views/right_panel/__snapshots__/UserInfoHeaderView-test.tsx.snap rename to test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoHeaderView-test.tsx.snap