From a6817052612fac1fa95439fa3ca331ab6916b1da Mon Sep 17 00:00:00 2001 From: "marc.sirisak" Date: Thu, 10 Jul 2025 17:18:42 +0200 Subject: [PATCH] feat: mvvm userinfo basic component --- .../user_info/UserInfoBasicViewModel.tsx | 288 ++++++++++ .../UserInfoIgnoreButtonViewModel.tsx | 80 +++ src/components/views/right_panel/UserInfo.tsx | 491 +----------------- .../right_panel/user_info/UserInfoBasic.tsx | 221 ++++++++ .../user_info/UserInfoIgnoreButton.tsx | 31 ++ 5 files changed, 627 insertions(+), 484 deletions(-) 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/UserInfoBasic.tsx create mode 100644 src/components/views/right_panel/user_info/UserInfoIgnoreButton.tsx 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 00000000000..c8469e7bbea --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel.tsx @@ -0,0 +1,288 @@ +/* +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, useContext, useEffect, useState } from "react"; +import { + EventType, + RoomMember, + type IPowerLevelsContent, + type Room, + RoomStateEvent, + type MatrixClient, + type User, + type MatrixEvent, + KnownMembership, +} 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, UserFriendlyError } from "../../../../languageHandler"; +import { type IRoomPermissions } from "../../../views/right_panel/UserInfo"; +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; +import QuestionDialog from "../../../views/dialogs/QuestionDialog"; +import DMRoomMap from "../../../../utils/DMRoomMap"; +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"; + +export interface UserInfoBasicState { + powerLevels: IPowerLevelsContent; + roomPermissions: IRoomPermissions; + pendingUpdateCount: number; + isMe: boolean; + isRoomDMForMember: boolean; + showDeactivateButton: boolean; + showInviteButton: boolean; + readReceiptButtonDisabled: boolean; + showInsertPillButton: boolean | ""; + onSynapseDeactivate: () => void; + startUpdating: () => void; + stopUpdating: () => void; + onShareUserClick: () => void; + onInviteUserButton: (evt: Event) => void; + onInsertPillButton: () => void; + onReadReceiptButton: () => void; +} + +export const getPowerLevels = (room: Room): IPowerLevelsContent => + room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {}; + +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 = 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 roomPermissions = useRoomPermissions(cli, room, member as RoomMember); + + const isSpace = room?.isSpaceRoom(); + + // selected member is current user + const isMe = member.userId === cli.getUserId(); + + // 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 showInsertPillButton = member instanceof RoomMember && member.roomId && !isSpace; + + const readReceiptButtonDisabled = isSpace || !room?.getEventReadUpTo(member.userId); + + const roomId = + member instanceof RoomMember && member.roomId + ? member.roomId + : SdkContextClass.instance.roomViewStore.getRoomId(); + + const showInviteButton = + member instanceof RoomMember && + roomPermissions.canInvite && + (member?.membership ?? KnownMembership.Leave) === KnownMembership.Leave; + + 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]); + + 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 { + // 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, + }); + }; + + return { + showDeactivateButton, + powerLevels, + roomPermissions, + pendingUpdateCount, + isMe, + isRoomDMForMember, + showInviteButton, + readReceiptButtonDisabled, + showInsertPillButton, + onSynapseDeactivate, + startUpdating, + stopUpdating, + onShareUserClick, + onInviteUserButton, + onInsertPillButton, + onReadReceiptButton, + }; +}; 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 00000000000..87f82834be9 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel.tsx @@ -0,0 +1,80 @@ +/* +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 { + ignoreButtonClick: (ev: Event) => void; + isIgnored: boolean; +} + +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 970f8a22786..93d9b88b0b7 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -11,70 +11,35 @@ 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 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, 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 { Badge, Button, Heading, InlineSpinner, MenuItem, Text, Tooltip } from "@vector-im/compound-web"; +import { Badge, Button, Heading, InlineSpinner, Text, Tooltip } from "@vector-im/compound-web"; import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified"; -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 SdkConfig from "../../../SdkConfig"; -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 { verifyUser } from "../../../verification"; -import { Action } from "../../../dispatcher/actions"; import { useIsEncrypted } from "../../../hooks/useIsEncrypted"; import BaseCard from "./BaseCard"; import ImageView from "../elements/ImageView"; -import Spinner from "../elements/Spinner"; import MemberAvatar from "../avatars/MemberAvatar"; import PresenceLabel from "../rooms/PresenceLabel"; -import { ShareDialog } from "../dialogs/ShareDialog"; -import ErrorDialog from "../dialogs/ErrorDialog"; import QuestionDialog from "../dialogs/QuestionDialog"; import { mediaFromMxc } from "../../../customisations/Media"; -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 UserIdentifierCustomisations from "../../../customisations/UserIdentifier"; import PosthogTrackers from "../../../PosthogTrackers"; -import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages"; -import { SdkContextClass } from "../../../contexts/SDKContext"; import { Flex } from "../../utils/Flex"; import CopyableText from "../elements/CopyableText"; import { useUserTimezone } from "../../../hooks/useUserTimezone"; -import { UserInfoAdminToolsContainer } from "./user_info/UserInfoAdminToolsContainer"; -import { PowerLevelSection } from "./user_info/UserInfoPowerLevels"; +import { UserInfoBasic } from "./user_info/UserInfoBasic"; export interface IDevice extends Device { ambiguous?: boolean; @@ -97,190 +62,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"), @@ -298,7 +79,7 @@ export const warnSelfDemote = async (isSpace: boolean): Promise => { return !!confirmed; }; -const Container: React.FC<{ +export const Container: React.FC<{ children: ReactNode; className?: string; }> = ({ children, className }) => { @@ -335,97 +116,6 @@ 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); -}; - const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => { return useAsyncMemo( async () => { @@ -442,55 +132,6 @@ export interface IRoomPermissions { 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, @@ -641,124 +282,6 @@ const VerificationSection: React.FC<{ ); }; -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; export const UserInfoHeader: React.FC<{ @@ -902,7 +425,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/UserInfoBasic.tsx b/src/components/views/right_panel/user_info/UserInfoBasic.tsx new file mode 100644 index 00000000000..ef3b85a2a1c --- /dev/null +++ b/src/components/views/right_panel/user_info/UserInfoBasic.tsx @@ -0,0 +1,221 @@ +/* +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 MatrixClient, type RoomMember, User, type Room } from "matrix-js-sdk/src/matrix"; +import React, { type JSX, type ReactNode, useContext, useState } from "react"; +import { MenuItem } from "@vector-im/compound-web"; +import { + ChatIcon, + CheckIcon, + DeleteIcon, + 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 { + type UserInfoBasicState, + useUserInfoBasicViewModel, +} from "../../../viewmodels/right_panel/user_info/UserInfoBasicViewModel"; +import { PowerLevelSection } from "./UserInfoPowerLevels"; +import { Container, type Member } from "../UserInfo"; +import { IgnoreToggleButton } from "./UserInfoIgnoreButton"; +import Spinner from "../../elements/Spinner"; +import { UserInfoAdminToolsContainer } from "./UserInfoAdminToolsContainer"; +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; +import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents"; +import { UIComponent } from "../../../../settings/UIFeature"; +import { DirectoryMember, startDmOnFirstMessage } from "../../../../utils/direct-messages"; + +/** + * 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} + /> + ); +}; + +const UserOptionsSection: React.FC<{ + vm: UserInfoBasicState; + member: User | RoomMember; + children?: ReactNode; +}> = ({ vm, member, children }) => { + let insertPillButton: JSX.Element | undefined; + let inviteUserButton: JSX.Element | undefined; + let readReceiptButton: JSX.Element | undefined; + + // Only allow the user to ignore the user if its not ourselves + // same goes for jumping to read receipt + 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} + + ); +}; + +/** + * There are two types of components that can be displayed in the right panel concerning userinfo + * Basic info or Encryption Panel + */ +export const UserInfoBasic: 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/UserInfoIgnoreButton.tsx b/src/components/views/right_panel/user_info/UserInfoIgnoreButton.tsx new file mode 100644 index 00000000000..eb52603669e --- /dev/null +++ b/src/components/views/right_panel/user_info/UserInfoIgnoreButton.tsx @@ -0,0 +1,31 @@ +/* +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} + /> + ); +};