MVVM userinfo: split header and verification components (#30214)

* feat: mvvm userinfo split header and verification

* test: add userinfoheader tests

* fix: userHeaderVerificationView verification method
This commit is contained in:
Marc
2025-07-21 14:04:50 +02:00
committed by GitHub
parent 8a879c7fca
commit 0a97cbaada
13 changed files with 1189 additions and 533 deletions

View File

@@ -0,0 +1,87 @@
/*
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 { type MatrixClient, type RoomMember, type User } from "matrix-js-sdk/src/matrix";
import { useContext } from "react";
import { type UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { type IDevice } from "../../../views/right_panel/UserInfo";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
import { verifyUser } from "../../../../verification";
export interface UserInfoVerificationSectionState {
/**
* variables used to check if we can verify the user and display the verify button
*/
canVerify: boolean;
hasCrossSigningKeys: boolean | undefined;
/**
* used to display correct badge value
*/
isUserVerified: boolean;
/**
* callback function when verifyUser button is clicked
*/
verifySelectedUser: () => Promise<void>;
}
const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => {
return useAsyncMemo<boolean>(
async () => {
return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
},
[cli],
false,
);
};
const useHasCrossSigningKeys = (cli: MatrixClient, member: User, canVerify: boolean): boolean | undefined => {
return useAsyncMemo(async () => {
if (!canVerify) return undefined;
return cli.getCrypto()?.userHasCrossSigningKeys(member.userId, true);
}, [cli, member, canVerify]);
};
/**
* View model for the userInfoVerificationHeaderView
* @see {@link UserInfoVerificationSectionState} for more information about what this view model returns.
*/
export const useUserInfoVerificationViewModel = (
member: User | RoomMember,
devices: IDevice[],
): UserInfoVerificationSectionState => {
const cli = useContext(MatrixClientContext);
const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
const userTrust = useAsyncMemo<UserVerificationStatus | undefined>(
async () => cli.getCrypto()?.getUserVerificationStatus(member.userId),
[member.userId],
// the user verification status is not initialized
undefined,
);
const hasUserVerificationStatus = Boolean(userTrust);
const isUserVerified = Boolean(userTrust?.isVerified());
const isMe = member.userId === cli.getUserId();
const canVerify =
hasUserVerificationStatus &&
homeserverSupportsCrossSigning &&
!isUserVerified &&
!isMe &&
devices &&
devices.length > 0;
const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify);
const verifySelectedUser = (): Promise<void> => verifyUser(cli, member as User);
return {
canVerify,
hasCrossSigningKeys,
isUserVerified,
verifySelectedUser,
};
};

View File

@@ -0,0 +1,115 @@
/*
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 { RoomMember, type User } from "matrix-js-sdk/src/matrix";
import { useCallback, useContext } from "react";
import { mediaFromMxc } from "../../../../customisations/Media";
import Modal from "../../../../Modal";
import ImageView from "../../../views/elements/ImageView";
import SdkConfig from "../../../../SdkConfig";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { type Member } from "../../../views/right_panel/UserInfo";
import { useUserTimezone } from "../../../../hooks/useUserTimezone";
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
export interface PresenceInfo {
lastActiveAgo: number | undefined;
currentlyActive: boolean | undefined;
state: string | undefined;
}
export interface TimezoneInfo {
timezone: string;
friendly: string;
}
export interface UserInfoHeaderState {
/**
* callback function when selected user avatar is clicked in user info
*/
onMemberAvatarClick: () => void;
/**
* Object containing information about the precense of the selected user
*/
precenseInfo: PresenceInfo;
/**
* Boolean that show or hide the precense information
*/
showPresence: boolean;
/**
* Timezone object
*/
timezoneInfo: TimezoneInfo | null;
/**
* Displayed identifier for the selected user
*/
userIdentifier: string | null;
}
interface UserInfoHeaderViewModelProps {
member: Member;
roomId?: string;
}
/**
* View model for the userInfoHeaderView
* props
* @see {@link UserInfoHeaderState} for more information about what this view model returns.
*/
export function useUserfoHeaderViewModel({ member, roomId }: UserInfoHeaderViewModelProps): UserInfoHeaderState {
const cli = useContext(MatrixClientContext);
let showPresence = true;
const precenseInfo: PresenceInfo = {
lastActiveAgo: undefined,
currentlyActive: undefined,
state: undefined,
};
const enablePresenceByHsUrl = SdkConfig.get("enable_presence_by_hs_url");
const timezoneInfo = useUserTimezone(cli, member.userId);
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, {
roomId,
withDisplayName: true,
});
const onMemberAvatarClick = useCallback(() => {
const avatarUrl = (member as RoomMember).getMxcAvatarUrl
? (member as RoomMember).getMxcAvatarUrl()
: (member as User).avatarUrl;
const httpUrl = mediaFromMxc(avatarUrl).srcHttp;
if (!httpUrl) return;
const params = {
src: httpUrl,
name: (member as RoomMember).name || (member as User).displayName,
};
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
}, [member]);
if (member instanceof RoomMember && member.user) {
precenseInfo.state = member.user.presence;
precenseInfo.lastActiveAgo = member.user.lastActiveAgo;
precenseInfo.currentlyActive = member.user.currentlyActive;
}
if (enablePresenceByHsUrl && enablePresenceByHsUrl[cli.baseUrl] !== undefined) {
showPresence = enablePresenceByHsUrl[cli.baseUrl];
}
return {
onMemberAvatarClick,
showPresence,
precenseInfo,
timezoneInfo,
userIdentifier,
};
}

View File

@@ -25,8 +25,7 @@ import {
import { KnownMembership } from "matrix-js-sdk/src/types";
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 VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
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";
@@ -40,41 +39,32 @@ import Modal from "../../../Modal";
import { _t, UserFriendlyError } from "../../../languageHandler";
import DMRoomMap from "../../../utils/DMRoomMap";
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 { UserInfoHeaderView } from "./user_info/UserInfoHeaderView";
export interface IDevice extends Device {
ambiguous?: boolean;
@@ -298,7 +288,7 @@ export const warnSelfDemote = async (isSpace: boolean): Promise<boolean> => {
return !!confirmed;
};
const Container: React.FC<{
export const Container: React.FC<{
children: ReactNode;
className?: string;
}> = ({ children, className }) => {
@@ -426,16 +416,6 @@ const useIsSynapseAdmin = (cli?: MatrixClient): boolean => {
return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false);
};
const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => {
return useAsyncMemo<boolean>(
async () => {
return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
},
[cli],
false,
);
};
export interface IRoomPermissions {
modifyLevelMax: number;
canEdit: boolean;
@@ -567,80 +547,6 @@ export const useDevices = (userId: string): IDevice[] | undefined | null => {
return devices;
};
function useHasCrossSigningKeys(cli: MatrixClient, member: User, canVerify: boolean): boolean | undefined {
return useAsyncMemo(async () => {
if (!canVerify) return undefined;
return await cli.getCrypto()?.userHasCrossSigningKeys(member.userId, true);
}, [cli, member, canVerify]);
}
const VerificationSection: React.FC<{
member: User | RoomMember;
devices: IDevice[];
}> = ({ member, devices }) => {
const cli = useContext(MatrixClientContext);
let content;
const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
const userTrust = useAsyncMemo<UserVerificationStatus | undefined>(
async () => cli.getCrypto()?.getUserVerificationStatus(member.userId),
[member.userId],
// the user verification status is not initialized
undefined,
);
const hasUserVerificationStatus = Boolean(userTrust);
const isUserVerified = Boolean(userTrust?.isVerified());
const isMe = member.userId === cli.getUserId();
const canVerify =
hasUserVerificationStatus &&
homeserverSupportsCrossSigning &&
!isUserVerified &&
!isMe &&
devices &&
devices.length > 0;
const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify);
if (isUserVerified) {
content = (
<Badge kind="green" className="mx_UserInfo_verified_badge">
<VerifiedIcon className="mx_UserInfo_verified_icon" height="16px" width="16px" />
<Text size="sm" weight="medium" className="mx_UserInfo_verified_label">
{_t("common|verified")}
</Text>
</Badge>
);
} else if (hasCrossSigningKeys === undefined) {
// We are still fetching the cross-signing keys for the user, show spinner.
content = <InlineSpinner size={24} />;
} else if (canVerify && hasCrossSigningKeys) {
content = (
<div className="mx_UserInfo_container_verifyButton">
<Button
className="mx_UserInfo_verify_button"
kind="tertiary"
size="sm"
onClick={() => verifyUser(cli, member as User)}
>
{_t("user_info|verify_button")}
</Button>
</div>
);
} else {
content = (
<Text className="mx_UserInfo_verification_unavailable" size="sm">
({_t("user_info|verification_unavailable")})
</Text>
);
}
return (
<Flex justify="center" align="center" className="mx_UserInfo_verification">
{content}
</Flex>
);
};
const BasicUserInfo: React.FC<{
room: Room;
member: User | RoomMember;
@@ -761,114 +667,6 @@ const BasicUserInfo: React.FC<{
export type Member = User | RoomMember;
export const UserInfoHeader: React.FC<{
member: Member;
devices: IDevice[];
roomId?: string;
hideVerificationSection?: boolean;
}> = ({ member, devices, roomId, hideVerificationSection }) => {
const cli = useContext(MatrixClientContext);
const onMemberAvatarClick = useCallback(() => {
const avatarUrl = (member as RoomMember).getMxcAvatarUrl
? (member as RoomMember).getMxcAvatarUrl()
: (member as User).avatarUrl;
const httpUrl = mediaFromMxc(avatarUrl).srcHttp;
if (!httpUrl) return;
const params = {
src: httpUrl,
name: (member as RoomMember).name || (member as User).displayName,
};
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
}, [member]);
const avatarUrl = (member as User).avatarUrl;
let presenceState: string | undefined;
let presenceLastActiveAgo: number | undefined;
let presenceCurrentlyActive: boolean | undefined;
if (member instanceof RoomMember && member.user) {
presenceState = member.user.presence;
presenceLastActiveAgo = member.user.lastActiveAgo;
presenceCurrentlyActive = member.user.currentlyActive;
}
const enablePresenceByHsUrl = SdkConfig.get("enable_presence_by_hs_url");
let showPresence = true;
if (enablePresenceByHsUrl && enablePresenceByHsUrl[cli.baseUrl] !== undefined) {
showPresence = enablePresenceByHsUrl[cli.baseUrl];
}
let presenceLabel: JSX.Element | undefined;
if (showPresence) {
presenceLabel = (
<PresenceLabel
activeAgo={presenceLastActiveAgo}
currentlyActive={presenceCurrentlyActive}
presenceState={presenceState}
className="mx_UserInfo_profileStatus"
coloured
/>
);
}
const timezoneInfo = useUserTimezone(cli, member.userId);
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, {
roomId,
withDisplayName: true,
});
const displayName = (member as RoomMember).rawDisplayName;
return (
<React.Fragment>
<div className="mx_UserInfo_avatar">
<div className="mx_UserInfo_avatar_transition">
<div className="mx_UserInfo_avatar_transition_child">
<MemberAvatar
key={member.userId} // to instantly blank the avatar when UserInfo changes members
member={member as RoomMember}
size="120px"
resizeMethod="scale"
fallbackUserId={member.userId}
onClick={onMemberAvatarClick}
urls={avatarUrl ? [avatarUrl] : undefined}
/>
</div>
</div>
</div>
<Container className="mx_UserInfo_header">
<Flex direction="column" align="center" className="mx_UserInfo_profile">
<Heading size="sm" weight="semibold" as="h1" dir="auto">
<Flex className="mx_UserInfo_profile_name" direction="row-reverse" align="center">
{displayName}
</Flex>
</Heading>
{presenceLabel}
{timezoneInfo && (
<Tooltip label={timezoneInfo?.timezone ?? ""}>
<Flex align="center" className="mx_UserInfo_timezone">
<Text size="sm" weight="regular">
{timezoneInfo?.friendly ?? ""}
</Text>
</Flex>
</Tooltip>
)}
<Text size="sm" weight="semibold" className="mx_UserInfo_profile_mxid">
<CopyableText getTextToCopy={() => userIdentifier} border={false}>
{userIdentifier}
</CopyableText>
</Text>
</Flex>
{!hideVerificationSection && <VerificationSection member={member} devices={devices} />}
</Container>
</React.Fragment>
);
};
interface IProps {
user: Member;
room?: Room;
@@ -927,7 +725,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
const header = (
<>
<UserInfoHeader
<UserInfoHeaderView
hideVerificationSection={phase === RightPanelPhases.EncryptionPanel}
member={member}
devices={devices}

View File

@@ -0,0 +1,63 @@
/*
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 User, type RoomMember } from "matrix-js-sdk/src/matrix";
import { Text, Button, InlineSpinner, Badge } from "@vector-im/compound-web";
import { VerifiedIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useUserInfoVerificationViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel";
import { type IDevice } from "../UserInfo";
import { Flex } from "../../../utils/Flex";
import { _t } from "../../../../languageHandler";
export const UserInfoHeaderVerificationView: React.FC<{
member: User | RoomMember;
devices: IDevice[];
}> = ({ member, devices }) => {
let content;
const vm = useUserInfoVerificationViewModel(member, devices);
if (vm.isUserVerified) {
content = (
<Badge kind="green" className="mx_UserInfo_verified_badge">
<VerifiedIcon className="mx_UserInfo_verified_icon" height="16px" width="16px" />
<Text size="sm" weight="medium" className="mx_UserInfo_verified_label">
{_t("common|verified")}
</Text>
</Badge>
);
} else if (vm.hasCrossSigningKeys === undefined) {
// We are still fetching the cross-signing keys for the user, show spinner.
content = <InlineSpinner size={24} />;
} else if (vm.canVerify && vm.hasCrossSigningKeys) {
content = (
<div className="mx_UserInfo_container_verifyButton">
<Button
className="mx_UserInfo_verify_button"
kind="tertiary"
size="sm"
onClick={() => vm.verifySelectedUser()}
>
{_t("user_info|verify_button")}
</Button>
</div>
);
} else {
content = (
<Text className="mx_UserInfo_verification_unavailable" size="sm">
({_t("user_info|verification_unavailable")})
</Text>
);
}
return (
<Flex justify="center" align="center" className="mx_UserInfo_verification">
{content}
</Flex>
);
};

View File

@@ -0,0 +1,96 @@
/*
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, { type JSX } from "react";
import { type User, type RoomMember } from "matrix-js-sdk/src/matrix";
import { Heading, Tooltip, Text } from "@vector-im/compound-web";
import { useUserfoHeaderViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoHeaderViewModel";
import MemberAvatar from "../../avatars/MemberAvatar";
import { Container, type Member, type IDevice } from "../UserInfo";
import { Flex } from "../../../utils/Flex";
import PresenceLabel from "../../rooms/PresenceLabel";
import CopyableText from "../../elements/CopyableText";
import { UserInfoHeaderVerificationView } from "./UserInfoHeaderVerificationView";
export interface UserInfoHeaderViewProps {
member: Member;
roomId?: string;
devices: IDevice[];
hideVerificationSection: boolean;
}
export const UserInfoHeaderView: React.FC<UserInfoHeaderViewProps> = ({
member,
devices,
roomId,
hideVerificationSection,
}) => {
const vm = useUserfoHeaderViewModel({ member, roomId });
const avatarUrl = (member as User).avatarUrl;
const displayName = (member as RoomMember).rawDisplayName;
let presenceLabel: JSX.Element | undefined;
if (vm.showPresence) {
presenceLabel = (
<PresenceLabel
activeAgo={vm.precenseInfo.lastActiveAgo}
currentlyActive={vm.precenseInfo.currentlyActive}
presenceState={vm.precenseInfo.state}
className="mx_UserInfo_profileStatus"
coloured
/>
);
}
return (
<React.Fragment>
<div className="mx_UserInfo_avatar">
<div className="mx_UserInfo_avatar_transition">
<div className="mx_UserInfo_avatar_transition_child">
<MemberAvatar
key={member.userId} // to instantly blank the avatar when UserInfo changes members
member={member as RoomMember}
size="120px"
resizeMethod="scale"
fallbackUserId={member.userId}
onClick={vm.onMemberAvatarClick}
urls={avatarUrl ? [avatarUrl] : undefined}
/>
</div>
</div>
</div>
<Container className="mx_UserInfo_header">
<Flex direction="column" align="center" className="mx_UserInfo_profile">
<Heading size="sm" weight="semibold" as="h1" dir="auto">
<Flex className="mx_UserInfo_profile_name" direction="row-reverse" align="center">
{displayName}
</Flex>
</Heading>
{presenceLabel}
{vm.timezoneInfo && (
<Tooltip label={vm.timezoneInfo?.timezone ?? ""}>
<Flex align="center" className="mx_UserInfo_timezone">
<Text size="sm" weight="regular">
{vm.timezoneInfo?.friendly ?? ""}
</Text>
</Flex>
</Tooltip>
)}
<Text size="sm" weight="semibold" className="mx_UserInfo_profile_mxid">
<CopyableText getTextToCopy={() => vm.userIdentifier} border={false}>
{vm.userIdentifier}
</CopyableText>
</Text>
</Flex>
{!hideVerificationSection && <UserInfoHeaderVerificationView member={member} devices={devices} />}
</Container>
</React.Fragment>
);
};