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
This commit is contained in:
Marc
2025-10-20 08:13:20 +02:00
committed by GitHub
parent cf51b256ce
commit e6e6f87d01
18 changed files with 1745 additions and 741 deletions

View File

@@ -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<void>;
// Method called when the DM button is clicked, will open a DM with the selected member
onOpenDmForUser: (member: Member) => Promise<void>;
}
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<ViewRoomPayload>({
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<ComposerInsertPayload>({
action: Action.ComposerInsert,
userId: member.userId,
timelineRenderingType: TimelineRenderingType.Room,
});
};
const onInviteUserButton = async (ev: Event): Promise<void> => {
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<void> => {
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,
};
};

View File

@@ -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<IRoomPermissions>({
// 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<IPowerLevelsContent>(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: <div>{_t("user_info|deactivate_confirm_description")}</div>,
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,
};
};

View File

@@ -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: <div>{_t("user_info|ignore_confirm_description")}</div>,
button: _t("action|ignore"),
});
const [confirmed] = await finished;
if (confirmed) {
const ignoredUsers = cli.getIgnoredUsers();
ignoredUsers.push(member.userId);
cli.setIgnoredUsers(ignoredUsers);
}
}, [cli, member]);
// Check whether the user is ignored
const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(member.userId));
// Recheck if the user or client changes
useEffect(() => {
setIsIgnored(cli.isUserIgnored(member.userId));
}, [cli, member.userId]);
// Recheck also if we receive new accountData m.ignored_user_list
const accountDataHandler = useCallback(
(ev: 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,
};
};

View File

@@ -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<void> {
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 (
<MenuItem
role="button"
onSelect={async (ev) => {
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<ViewRoomPayload>({
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 = (
<MenuItem
role="button"
onSelect={async (ev) => {
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<ComposerInsertPayload>({
action: Action.ComposerInsert,
userId: member.userId,
timelineRenderingType: TimelineRenderingType.Room,
});
};
insertPillButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
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<void> => {
try {
// We use a MultiInviter to re-use the invite logic, even though we're only inviting one user.
const inviter = new MultiInviter(cli, roomId || "");
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 = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onInviteUserButton(ev);
}}
label={_t("action|invite")}
Icon={InviteIcon}
/>
);
}
}
const shareUserButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onShareUserClick();
}}
label={_t("user_info|share_button")}
Icon={ShareIcon}
/>
);
const directMessageButton =
isMe || !shouldShowComponent(UIComponent.CreateRooms) ? null : <MessageButton member={member} />;
return (
<Container>
{children}
{directMessageButton}
{inviteUserButton}
{readReceiptButton}
{shareUserButton}
{insertPillButton}
</Container>
);
};
export const warnSelfDemote = async (isSpace: boolean): Promise<boolean> => {
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<IPowerLevelsContent>(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: <div>{_t("user_info|ignore_confirm_description")}</div>,
button: _t("action|ignore"),
});
const [confirmed] = await finished;
if (confirmed) {
const ignoredUsers = cli.getIgnoredUsers();
ignoredUsers.push(member.userId);
cli.setIgnoredUsers(ignoredUsers);
}
}, [cli, member]);
// Check whether the user is ignored
const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(member.userId));
// Recheck if the user or client changes
useEffect(() => {
setIsIgnored(cli.isUserIgnored(member.userId));
}, [cli, member.userId]);
// Recheck also if we receive new accountData m.ignored_user_list
const accountDataHandler = useCallback(
(ev: MatrixEvent) => {
if (ev.getType() === "m.ignored_user_list") {
setIsIgnored(cli.isUserIgnored(member.userId));
}
},
[cli, member.userId],
);
useTypedEventEmitter(cli, ClientEvent.AccountData, accountDataHandler);
return (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
if (isIgnored) {
unignore();
} else {
ignore();
}
}}
label={isIgnored ? _t("user_info|unignore_button") : _t("user_info|ignore_button")}
kind="critical"
Icon={BlockIcon}
/>
);
};
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<IRoomPermissions>({
// 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: <div>{_t("user_info|deactivate_confirm_description")}</div>,
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 = (
<MenuItem
role="button"
onSelect={async (ev) => {
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 = (
<PowerLevelSection user={member as RoomMember} room={room} roomPermissions={roomPermissions} />
);
}
adminToolsContainer = (
<UserInfoAdminToolsContainer
powerLevels={powerLevels}
member={member as RoomMember}
room={room}
isUpdating={pendingUpdateCount > 0}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
>
{synapseDeactivateButton}
</UserInfoAdminToolsContainer>
);
} else if (synapseDeactivateButton) {
adminToolsContainer = <Container>{synapseDeactivateButton}</Container>;
}
if (pendingUpdateCount > 0) {
spinner = <Spinner />;
}
const isMe = member.userId === cli.getUserId();
return (
<React.Fragment>
<UserOptionsSection
canInvite={roomPermissions.canInvite}
member={member as RoomMember}
isSpace={room?.isSpaceRoom()}
>
{memberDetails}
</UserOptionsSection>
{adminToolsContainer}
{!isMe && (
<Container>
<IgnoreToggleButton member={member} />
</Container>
)}
{spinner}
</React.Fragment>
);
};
export type Member = User | RoomMember;
interface IProps {
@@ -700,7 +221,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
let content: JSX.Element | undefined;
switch (phase) {
case RightPanelPhases.MemberInfo:
content = <BasicUserInfo room={room as Room} member={member as User} />;
content = <UserInfoBasicView room={room as Room} member={member as User} />;
break;
case RightPanelPhases.EncryptionPanel:
classes.push("mx_UserInfo_smallAvatar");

View File

@@ -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<void>;
}): JSX.Element => {
const [busy, setBusy] = useState(false);
return (
<MenuItem
role="button"
onSelect={async (ev) => {
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 = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onReadReceiptButton();
}}
label={_t("user_info|jump_to_rr_button")}
disabled={vm.readReceiptButtonDisabled}
Icon={CheckIcon}
/>
);
if (vm.showInsertPillButton) {
insertPillButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onInsertPillButton();
}}
label={_t("action|mention")}
Icon={MentionIcon}
/>
);
}
if (vm.showInviteButton && shouldShowComponent(UIComponent.InviteUsers)) {
inviteUserButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onInviteUserButton(ev);
}}
label={_t("action|invite")}
Icon={InviteIcon}
/>
);
}
}
const shareUserButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onShareUserClick();
}}
label={_t("user_info|share_button")}
Icon={ShareIcon}
/>
);
const directMessageButton =
vm.isMe || !shouldShowComponent(UIComponent.CreateRooms) ? null : (
<MessageButton member={member} openDMForUser={vm.onOpenDmForUser} />
);
return (
<Container>
{children}
{directMessageButton}
{inviteUserButton}
{readReceiptButton}
{shareUserButton}
{insertPillButton}
</Container>
);
};

View File

@@ -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 = (
<MenuItem
role="button"
onSelect={async (ev) => {
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 = (
<PowerLevelSection user={member as RoomMember} room={room} roomPermissions={vm.roomPermissions} />
);
}
adminToolsContainer = (
<UserInfoAdminToolsContainer
powerLevels={vm.powerLevels}
member={member as RoomMember}
room={room}
isUpdating={vm.pendingUpdateCount > 0}
startUpdating={vm.startUpdating}
stopUpdating={vm.stopUpdating}
>
{synapseDeactivateButton}
</UserInfoAdminToolsContainer>
);
} else if (synapseDeactivateButton) {
adminToolsContainer = <Container>{synapseDeactivateButton}</Container>;
}
if (vm.pendingUpdateCount > 0) {
spinner = <Spinner />;
}
return (
<React.Fragment>
<UserInfoBasicOptionsView room={room} member={member}>
{memberDetails}
</UserInfoBasicOptionsView>
{adminToolsContainer}
{!vm.isMe && (
<Container>
<IgnoreToggleButton member={member} />
</Container>
)}
{spinner}
</React.Fragment>
);
};

View File

@@ -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 (
<MenuItem
role="button"
onSelect={async (ev) => vm.ignoreButtonClick(ev)}
label={vm.isIgnored ? _t("user_info|unignore_button") : _t("user_info|ignore_button")}
kind="critical"
Icon={BlockIcon}
/>
);
};