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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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>
);
};

View File

@ -0,0 +1,194 @@
/*
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 { Device, type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
import { type Mocked } from "jest-mock";
import { UserVerificationStatus, type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { renderHook, waitFor } from "jest-matrix-react";
import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import { useUserInfoVerificationViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel";
describe("useUserInfoVerificationHeaderViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const defaultProps = {
devices: [] as Device[],
member: defaultMember,
};
let mockClient: MatrixClient;
let mockCrypto: Mocked<CryptoApi>;
beforeEach(() => {
mockCrypto = {
bootstrapSecretStorage: jest.fn(),
bootstrapCrossSigning: jest.fn(),
getCrossSigningKeyId: jest.fn(),
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
getUserDeviceInfo: jest.fn(),
getDeviceVerificationStatus: jest.fn(),
getUserVerificationStatus: jest.fn(),
isDehydrationSupported: jest.fn().mockResolvedValue(false),
startDehydration: jest.fn(),
getKeyBackupInfo: jest.fn().mockResolvedValue(null),
userHasCrossSigningKeys: jest.fn().mockResolvedValue(false),
} as unknown as Mocked<CryptoApi>;
mockClient = createTestClient();
jest.spyOn(mockClient, "doesServerSupportUnstableFeature").mockResolvedValue(true);
jest.spyOn(mockClient.secretStorage, "hasKey").mockResolvedValue(true);
jest.spyOn(mockClient, "getCrypto").mockReturnValue(mockCrypto);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
});
afterEach(() => {
jest.restoreAllMocks();
});
const renderUserInfoHeaderVerificationHook = (props = defaultProps) => {
return renderHook(
() => useUserInfoVerificationViewModel(props.member, props.devices),
withClientContextRenderOptions(mockClient),
);
};
it("should be able to verify user", async () => {
const notMeId = "@notMe";
const notMetMember = new RoomMember(defaultRoomId, notMeId);
const device1 = new Device({
deviceId: "d1",
userId: notMeId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
// mock the user as not verified
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
new UserVerificationStatus(false, false, false),
);
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
// the selected user is not the default user, so he can make user verification
const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [device1] });
await waitFor(() => {
const canVerify = result.current.canVerify;
expect(canVerify).toBeTruthy();
});
});
it("should not be able to verify user if user is not me", async () => {
const device1 = new Device({
deviceId: "d1",
userId: defaultMember.userId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
// mock the user as not verified
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
new UserVerificationStatus(false, false, false),
);
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
const { result } = renderUserInfoHeaderVerificationHook({ member: defaultMember, devices: [device1] });
await waitFor(() => {
const canVerify = result.current.canVerify;
expect(canVerify).toBeFalsy();
// if we cant verify the user the hasCrossSigningKeys value should also be undefined
expect(result.current.hasCrossSigningKeys).toBeUndefined();
});
});
it("should not be able to verify user if im already verified", async () => {
const notMeId = "@notMe";
const notMetMember = new RoomMember(defaultRoomId, notMeId);
const device1 = new Device({
deviceId: "d1",
userId: notMeId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
// mock the user as already verified
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
new UserVerificationStatus(true, true, false),
);
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
// the selected user is not the default user, so he can make user verification
const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [device1] });
await waitFor(() => {
const canVerify = result.current.canVerify;
expect(canVerify).toBeFalsy();
// if we cant verify the user the hasCrossSigningKeys value should also be undefined
expect(result.current.hasCrossSigningKeys).toBeUndefined();
});
});
it("should not be able to verify user there is no devices", async () => {
const notMeId = "@notMe";
const notMetMember = new RoomMember(defaultRoomId, notMeId);
// mock the user as not verified
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
new UserVerificationStatus(false, false, false),
);
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
// the selected user is not the default user, so he can make user verification
const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [] });
await waitFor(() => {
const canVerify = result.current.canVerify;
expect(canVerify).toBeFalsy();
// if we cant verify the user the hasCrossSigningKeys value should also be undefined
expect(result.current.hasCrossSigningKeys).toBeUndefined();
});
});
it("should get correct hasCrossSigningKeys values", async () => {
const notMeId = "@notMe";
const notMetMember = new RoomMember(defaultRoomId, notMeId);
const device1 = new Device({
deviceId: "d1",
userId: notMeId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
// mock the user as not verified
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
new UserVerificationStatus(false, false, false),
);
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
jest.spyOn(mockCrypto, "userHasCrossSigningKeys").mockResolvedValue(true);
const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [device1] });
await waitFor(() => {
const hasCrossSigningKeys = result.current.hasCrossSigningKeys;
expect(hasCrossSigningKeys).toBeTruthy();
});
});
});

View File

@ -0,0 +1,179 @@
/*
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, RoomMember } from "matrix-js-sdk/src/matrix";
import { mocked, type Mocked } from "jest-mock";
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { renderHook } from "jest-matrix-react";
import { withClientContextRenderOptions } from "../../../../../test-utils";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import { useUserfoHeaderViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel";
import * as UseTimezone from "../../../../../../src/hooks/useUserTimezone";
import SdkConfig from "../../../../../../src/SdkConfig";
import Modal from "../../../../../../src/Modal";
import ImageView from "../../../../../../src/components/views/elements/ImageView";
import * as Media from "../../../../../../src/customisations/Media";
import { type IConfigOptions } from "../../../../../../src/IConfigOptions";
jest.mock("../../../../../../src/customisations/UserIdentifier", () => {
return {
getDisplayUserIdentifier: jest.fn().mockReturnValue("customUserIdentifier"),
};
});
describe("useUserInfoHeaderViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const defaultProps = {
member: defaultMember,
roomId: defaultRoomId,
};
let mockClient: Mocked<MatrixClient>;
let mockCrypto: Mocked<CryptoApi>;
const mockAvatarUrl = "mock-avatar-url";
const oldGet = SdkConfig.get;
beforeEach(() => {
mockCrypto = mocked({
getDeviceVerificationStatus: jest.fn(),
getUserDeviceInfo: jest.fn(),
userHasCrossSigningKeys: jest.fn().mockResolvedValue(false),
getUserVerificationStatus: jest.fn(),
isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false),
} as unknown as CryptoApi);
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn(),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue(mockAvatarUrl),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn(),
getCrypto: jest.fn().mockReturnValue(mockCrypto),
baseUrl: "homeserver.url",
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
});
afterEach(() => {
jest.restoreAllMocks();
});
const renderUserInfoHeaderViewModelHook = (props = defaultProps) => {
return renderHook(() => useUserfoHeaderViewModel(props), withClientContextRenderOptions(mockClient));
};
it("should give user timezone info", () => {
const defaultTZ = { timezone: "FR", friendly: "fr" };
jest.spyOn(UseTimezone, "useUserTimezone").mockReturnValue(defaultTZ);
const { result } = renderUserInfoHeaderViewModelHook();
const timezone = result.current.timezoneInfo;
expect(UseTimezone.useUserTimezone).toHaveBeenCalledWith(mockClient, defaultMember.userId);
expect(timezone).toEqual(defaultTZ);
});
it("should give correct showPresence value based on enablePresenceByHsUrl", () => {
jest.spyOn(SdkConfig, "get").mockImplementation((key: string) => {
if (key === "enable_presence_by_hs_url") {
return {
[mockClient.baseUrl]: false,
};
}
return oldGet(key as keyof IConfigOptions);
});
const { result } = renderUserInfoHeaderViewModelHook();
const showPresence = result.current.showPresence;
expect(showPresence).toBeFalsy();
});
it("should have default value true for showPresence", () => {
jest.spyOn(SdkConfig, "get").mockImplementation(() => false);
const { result } = renderUserInfoHeaderViewModelHook();
const showPresence = result.current.showPresence;
expect(showPresence).toBeTruthy();
});
it("should open image dialog when avatar is clicked", () => {
const props = Object.assign({}, defaultProps);
const spyModale = jest.spyOn(Modal, "createDialog");
const spyMedia = jest.spyOn(Media, "mediaFromMxc");
jest.spyOn(props.member, "getMxcAvatarUrl").mockReturnValue(mockAvatarUrl);
const { result } = renderUserInfoHeaderViewModelHook(props);
result.current.onMemberAvatarClick();
expect(spyModale).toHaveBeenCalledWith(
ImageView,
{
src: mockAvatarUrl,
name: defaultMember.name,
},
"mx_Dialog_lightbox",
undefined,
true,
);
expect(spyMedia).toHaveBeenCalledWith(mockAvatarUrl);
});
it("should not open image dialog when avatar url is null", () => {
const props = Object.assign({}, defaultProps);
const spyModale = jest.spyOn(Modal, "createDialog");
jest.spyOn(props.member, "getMxcAvatarUrl").mockReturnValue(mockAvatarUrl);
jest.spyOn(Media, "mediaFromMxc").mockReturnValue({
srcHttp: null,
isEncrypted: false,
srcMxc: "",
thumbnailMxc: undefined,
hasThumbnail: false,
thumbnailHttp: null,
getThumbnailHttp: function (width: number, height: number, mode?: "scale" | "crop"): string | null {
throw new Error("Function not implemented.");
},
getThumbnailOfSourceHttp: function (width: number, height: number, mode?: "scale" | "crop"): string | null {
throw new Error("Function not implemented.");
},
getSquareThumbnailHttp: function (dim: number): string | null {
throw new Error("Function not implemented.");
},
downloadSource: function (): Promise<Response> {
throw new Error("Function not implemented.");
},
});
const { result } = renderUserInfoHeaderViewModelHook(props);
result.current.onMemberAvatarClick();
expect(spyModale).not.toHaveBeenCalled();
});
});

View File

@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen, act, waitForElementToBeRemoved, waitFor } from "jest-matrix-react";
import { render, screen, act, waitForElementToBeRemoved } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { type Mocked, mocked } from "jest-mock";
import { type Room, User, type MatrixClient, RoomMember, Device } from "matrix-js-sdk/src/matrix";
@ -23,7 +23,6 @@ import {
import UserInfo, {
disambiguateDevices,
getPowerLevels,
UserInfoHeader,
UserOptionsSection,
} from "../../../../../src/components/views/right_panel/UserInfo";
import dis from "../../../../../src/dispatcher/dispatcher";
@ -440,64 +439,6 @@ describe("<UserInfo />", () => {
});
});
describe("<UserInfoHeader />", () => {
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const defaultProps = {
member: defaultMember,
roomId: defaultRoomId,
};
const renderComponent = (props = {}) => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const devicesMap = new Map<string, Device>([[device1.deviceId, device1]]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(true);
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<UserInfoHeader {...defaultProps} {...props} devices={[device1]} />, {
wrapper: Wrapper,
});
};
it("renders custom user identifiers in the header", () => {
renderComponent();
expect(screen.getByText("customUserIdentifier")).toBeInTheDocument();
});
it("renders verified badge when user is verified", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, false));
const { container } = renderComponent();
await waitFor(() => expect(screen.getByText("Verified")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
it("renders verify button", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
mockCrypto.userHasCrossSigningKeys.mockResolvedValue(true);
const { container } = renderComponent();
await waitFor(() => expect(screen.getByText("Verify User")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
it("renders verification unavailable message", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
mockCrypto.userHasCrossSigningKeys.mockResolvedValue(false);
const { container } = renderComponent();
await waitFor(() => expect(screen.getByText("(User verification unavailable)")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
});
describe("<UserOptionsSection />", () => {
const member = new RoomMember(defaultRoomId, defaultUserId);
const defaultProps = { member, canInvite: false, isSpace: false };

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 { mocked, type Mocked } from "jest-mock";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { UserVerificationStatus, type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { Device, RoomMember } from "matrix-js-sdk/src/matrix";
import { render, waitFor, screen } from "jest-matrix-react";
import React from "react";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { UserInfoHeaderVerificationView } from "../../../../../src/components/views/right_panel/user_info/UserInfoHeaderVerificationView";
import { createTestClient } from "../../../../test-utils";
describe("<UserInfoHeaderVerificationView />", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let mockClient: MatrixClient;
let mockCrypto: Mocked<CryptoApi>;
beforeEach(() => {
mockCrypto = mocked({
bootstrapSecretStorage: jest.fn(),
bootstrapCrossSigning: jest.fn(),
getCrossSigningKeyId: jest.fn(),
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
getUserDeviceInfo: jest.fn(),
getDeviceVerificationStatus: jest.fn(),
getUserVerificationStatus: jest.fn(),
isDehydrationSupported: jest.fn().mockResolvedValue(false),
startDehydration: jest.fn(),
getKeyBackupInfo: jest.fn().mockResolvedValue(null),
userHasCrossSigningKeys: jest.fn().mockResolvedValue(false),
} as unknown as CryptoApi);
mockClient = createTestClient();
jest.spyOn(mockClient, "doesServerSupportUnstableFeature").mockResolvedValue(true);
jest.spyOn(mockClient.secretStorage, "hasKey").mockResolvedValue(true);
jest.spyOn(mockClient, "getCrypto").mockReturnValue(mockCrypto);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
});
const renderComponent = () => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const devicesMap = new Map<string, Device>([[device1.deviceId, device1]]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
jest.spyOn(mockClient, "doesServerSupportUnstableFeature").mockResolvedValue(true);
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<UserInfoHeaderVerificationView member={defaultMember} devices={[device1]} />, {
wrapper: Wrapper,
});
};
it("renders verified badge when user is verified", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, false));
const { container } = renderComponent();
await waitFor(() => expect(screen.getByText("Verified")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
it("renders verify button", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
mockCrypto.userHasCrossSigningKeys.mockResolvedValue(true);
const { container } = renderComponent();
await waitFor(() => expect(screen.getByText("Verify User")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
it("renders verification unavailable message", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
mockCrypto.userHasCrossSigningKeys.mockResolvedValue(false);
const { container } = renderComponent();
await waitFor(() => expect(screen.getByText("(User verification unavailable)")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
});

View File

@ -0,0 +1,195 @@
/*
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 { mocked, type Mocked } from "jest-mock";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { Device, RoomMember } from "matrix-js-sdk/src/matrix";
import { fireEvent, render, screen } from "jest-matrix-react";
import React from "react";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { UserInfoHeaderView } from "../../../../../src/components/views/right_panel/user_info/UserInfoHeaderView";
import { createTestClient } from "../../../../test-utils";
import { useUserfoHeaderViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel";
// Mock the viewmodel hooks
jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel", () => ({
useUserfoHeaderViewModel: jest.fn().mockReturnValue({
onMemberAvatarClick: jest.fn(),
precenseInfo: {
lastActiveAgo: undefined,
currentlyActive: undefined,
state: undefined,
},
showPresence: false,
timezoneInfo: null,
userIdentifier: "customUserIdentifier",
}),
}));
describe("<UserInfoHeaderView />", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const defaultProps = {
member: defaultMember,
roomId: defaultRoomId,
};
let mockClient: MatrixClient;
let mockCrypto: Mocked<CryptoApi>;
beforeEach(() => {
mockCrypto = mocked({
bootstrapSecretStorage: jest.fn(),
bootstrapCrossSigning: jest.fn(),
getCrossSigningKeyId: jest.fn(),
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
getUserDeviceInfo: jest.fn(),
getDeviceVerificationStatus: jest.fn(),
getUserVerificationStatus: jest.fn(),
isDehydrationSupported: jest.fn().mockResolvedValue(false),
startDehydration: jest.fn(),
getKeyBackupInfo: jest.fn().mockResolvedValue(null),
userHasCrossSigningKeys: jest.fn().mockResolvedValue(false),
} as unknown as CryptoApi);
mockClient = createTestClient();
mockClient.doesServerSupportExtendedProfiles = () => Promise.resolve(false);
jest.spyOn(mockClient, "doesServerSupportUnstableFeature").mockResolvedValue(true);
jest.spyOn(mockClient.secretStorage, "hasKey").mockResolvedValue(true);
jest.spyOn(mockClient, "getCrypto").mockReturnValue(mockCrypto);
jest.spyOn(mockClient, "doesServerSupportUnstableFeature").mockResolvedValue(true);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
});
const renderComponent = (
props = {
hideVerificationSection: false,
},
) => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const devicesMap = new Map<string, Device>([[device1.deviceId, device1]]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(
<UserInfoHeaderView
{...defaultProps}
{...props}
devices={[device1]}
hideVerificationSection={props.hideVerificationSection}
/>,
{
wrapper: Wrapper,
},
);
};
it("renders custom user identifiers in the header", () => {
const { container } = renderComponent();
expect(screen.getByText("customUserIdentifier")).toBeInTheDocument();
expect(container).toMatchSnapshot();
});
it("should not render verification view if hideVerificationSection is true", () => {
mocked(useUserfoHeaderViewModel).mockReturnValue({
onMemberAvatarClick: jest.fn(),
precenseInfo: {
lastActiveAgo: undefined,
currentlyActive: undefined,
state: undefined,
},
showPresence: false,
timezoneInfo: null,
userIdentifier: "null",
});
const { container } = renderComponent({ hideVerificationSection: true });
const verificationClass = container.getElementsByClassName("mx_UserInfo_verification").length;
expect(verificationClass).toEqual(0);
});
it("should render timezone if it exist", () => {
mocked(useUserfoHeaderViewModel).mockReturnValue({
onMemberAvatarClick: jest.fn(),
precenseInfo: {
lastActiveAgo: undefined,
currentlyActive: undefined,
state: undefined,
},
showPresence: false,
timezoneInfo: {
timezone: "FR",
friendly: "paris",
},
userIdentifier: null,
});
renderComponent({ hideVerificationSection: false });
expect(screen.getByText("paris")).toBeInTheDocument();
});
it("should render correct presence label", () => {
mocked(useUserfoHeaderViewModel).mockReturnValue({
onMemberAvatarClick: jest.fn(),
precenseInfo: {
lastActiveAgo: 0,
currentlyActive: true,
state: "online",
},
showPresence: true,
timezoneInfo: null,
userIdentifier: null,
});
renderComponent({ hideVerificationSection: false });
expect(screen.getByText("Online")).toBeInTheDocument();
});
it("should be able to click on member avatar", () => {
const onMemberAvatarClick = jest.fn();
mocked(useUserfoHeaderViewModel).mockReturnValue({
onMemberAvatarClick,
precenseInfo: {
lastActiveAgo: undefined,
currentlyActive: undefined,
state: undefined,
},
showPresence: false,
timezoneInfo: {
timezone: "FR",
friendly: "paris",
},
userIdentifier: null,
});
renderComponent();
const avatar = screen.getByRole("button", { name: "Profile picture" });
fireEvent.click(avatar);
expect(onMemberAvatarClick).toHaveBeenCalled();
});
});

View File

@ -614,270 +614,3 @@ exports[`<UserInfo /> with crypto enabled should render a deactivate button for
</div>
</div>
`;
exports[`<UserInfoHeader /> renders verification unavailable message 1`] = `
<div>
<div
class="mx_UserInfo_avatar"
>
<div
class="mx_UserInfo_avatar_transition"
>
<div
class="mx_UserInfo_avatar_transition_child"
>
<button
aria-label="Profile picture"
aria-live="off"
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 120px;"
title="customUserIdentifier"
>
u
</button>
</div>
</div>
</div>
<div
class="mx_UserInfo_container mx_UserInfo_header"
>
<div
class="mx_Flex mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h1
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
dir="auto"
>
<div
class="mx_Flex mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
@user:example.com
</div>
</h1>
<div
class="mx_PresenceLabel mx_UserInfo_profileStatus"
>
Unknown
</div>
<p
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36 mx_UserInfo_profile_mxid"
>
<div
class="mx_CopyableText"
>
customUserIdentifier
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</p>
</div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31 mx_UserInfo_verification_unavailable"
>
(
User verification unavailable
)
</p>
</div>
</div>
</div>
`;
exports[`<UserInfoHeader /> renders verified badge when user is verified 1`] = `
<div>
<div
class="mx_UserInfo_avatar"
>
<div
class="mx_UserInfo_avatar_transition"
>
<div
class="mx_UserInfo_avatar_transition_child"
>
<button
aria-label="Profile picture"
aria-live="off"
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 120px;"
title="customUserIdentifier"
>
u
</button>
</div>
</div>
</div>
<div
class="mx_UserInfo_container mx_UserInfo_header"
>
<div
class="mx_Flex mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h1
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
dir="auto"
>
<div
class="mx_Flex mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
@user:example.com
</div>
</h1>
<div
class="mx_PresenceLabel mx_UserInfo_profileStatus"
>
Unknown
</div>
<p
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36 mx_UserInfo_profile_mxid"
>
<div
class="mx_CopyableText"
>
customUserIdentifier
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</p>
</div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _badge_1t12g_8 mx_UserInfo_verified_badge"
data-kind="green"
>
<svg
class="mx_UserInfo_verified_icon"
fill="currentColor"
height="16px"
viewBox="0 0 24 24"
width="16px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.15 21.75 6.7 19.3l-2.75-.6a.94.94 0 0 1-.6-.387.93.93 0 0 1-.175-.688L3.45 14.8l-1.875-2.15a.93.93 0 0 1-.25-.65q0-.375.25-.65L3.45 9.2l-.275-2.825a.93.93 0 0 1 .175-.687.94.94 0 0 1 .6-.388l2.75-.6 1.45-2.45a.98.98 0 0 1 .55-.437.97.97 0 0 1 .7.037l2.6 1.1 2.6-1.1a.97.97 0 0 1 .7-.038q.35.112.55.438L17.3 4.7l2.75.6q.375.075.6.388.225.312.175.687L20.55 9.2l1.875 2.15q.25.275.25.65t-.25.65L20.55 14.8l.275 2.825a.93.93 0 0 1-.175.688.94.94 0 0 1-.6.387l-2.75.6-1.45 2.45a.98.98 0 0 1-.55.438.97.97 0 0 1-.7-.038l-2.6-1.1-2.6 1.1a.97.97 0 0 1-.7.038.98.98 0 0 1-.55-.438m2.8-9.05L9.5 11.275A.93.93 0 0 0 8.812 11q-.412 0-.712.3a.95.95 0 0 0-.275.7q0 .425.275.7l2.15 2.15q.3.3.7.3t.7-.3l4.25-4.25q.3-.3.287-.7a1.06 1.06 0 0 0-.287-.7 1.02 1.02 0 0 0-.713-.312.93.93 0 0 0-.712.287z"
/>
</svg>
<p
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 mx_UserInfo_verified_label"
>
Verified
</p>
</span>
</div>
</div>
</div>
`;
exports[`<UserInfoHeader /> renders verify button 1`] = `
<div>
<div
class="mx_UserInfo_avatar"
>
<div
class="mx_UserInfo_avatar_transition"
>
<div
class="mx_UserInfo_avatar_transition_child"
>
<button
aria-label="Profile picture"
aria-live="off"
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 120px;"
title="customUserIdentifier"
>
u
</button>
</div>
</div>
</div>
<div
class="mx_UserInfo_container mx_UserInfo_header"
>
<div
class="mx_Flex mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h1
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
dir="auto"
>
<div
class="mx_Flex mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
@user:example.com
</div>
</h1>
<div
class="mx_PresenceLabel mx_UserInfo_profileStatus"
>
Unknown
</div>
<p
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36 mx_UserInfo_profile_mxid"
>
<div
class="mx_CopyableText"
>
customUserIdentifier
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</p>
</div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_UserInfo_container_verifyButton"
>
<button
class="_button_vczzf_8 mx_UserInfo_verify_button"
data-kind="tertiary"
data-size="sm"
role="button"
tabindex="0"
>
Verify User
</button>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,73 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<UserInfoHeaderVerificationView /> renders verification unavailable message 1`] = `
<div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31 mx_UserInfo_verification_unavailable"
>
(
User verification unavailable
)
</p>
</div>
</div>
`;
exports[`<UserInfoHeaderVerificationView /> renders verified badge when user is verified 1`] = `
<div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _badge_1t12g_8 mx_UserInfo_verified_badge"
data-kind="green"
>
<svg
class="mx_UserInfo_verified_icon"
fill="currentColor"
height="16px"
viewBox="0 0 24 24"
width="16px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.15 21.75 6.7 19.3l-2.75-.6a.94.94 0 0 1-.6-.387.93.93 0 0 1-.175-.688L3.45 14.8l-1.875-2.15a.93.93 0 0 1-.25-.65q0-.375.25-.65L3.45 9.2l-.275-2.825a.93.93 0 0 1 .175-.687.94.94 0 0 1 .6-.388l2.75-.6 1.45-2.45a.98.98 0 0 1 .55-.437.97.97 0 0 1 .7.037l2.6 1.1 2.6-1.1a.97.97 0 0 1 .7-.038q.35.112.55.438L17.3 4.7l2.75.6q.375.075.6.388.225.312.175.687L20.55 9.2l1.875 2.15q.25.275.25.65t-.25.65L20.55 14.8l.275 2.825a.93.93 0 0 1-.175.688.94.94 0 0 1-.6.387l-2.75.6-1.45 2.45a.98.98 0 0 1-.55.438.97.97 0 0 1-.7-.038l-2.6-1.1-2.6 1.1a.97.97 0 0 1-.7.038.98.98 0 0 1-.55-.438m2.8-9.05L9.5 11.275A.93.93 0 0 0 8.812 11q-.412 0-.712.3a.95.95 0 0 0-.275.7q0 .425.275.7l2.15 2.15q.3.3.7.3t.7-.3l4.25-4.25q.3-.3.287-.7a1.06 1.06 0 0 0-.287-.7 1.02 1.02 0 0 0-.713-.312.93.93 0 0 0-.712.287z"
/>
</svg>
<p
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 mx_UserInfo_verified_label"
>
Verified
</p>
</span>
</div>
</div>
`;
exports[`<UserInfoHeaderVerificationView /> renders verify button 1`] = `
<div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_UserInfo_container_verifyButton"
>
<button
class="_button_vczzf_8 mx_UserInfo_verify_button"
data-kind="tertiary"
data-size="sm"
role="button"
tabindex="0"
>
Verify User
</button>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,86 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<UserInfoHeaderView /> renders custom user identifiers in the header 1`] = `
<div>
<div
class="mx_UserInfo_avatar"
>
<div
class="mx_UserInfo_avatar_transition"
>
<div
class="mx_UserInfo_avatar_transition_child"
>
<button
aria-label="Profile picture"
aria-live="off"
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 120px;"
title="@user:example.com"
>
u
</button>
</div>
</div>
</div>
<div
class="mx_UserInfo_container mx_UserInfo_header"
>
<div
class="mx_Flex mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h1
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
dir="auto"
>
<div
class="mx_Flex mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
@user:example.com
</div>
</h1>
<p
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36 mx_UserInfo_profile_mxid"
>
<div
class="mx_CopyableText"
>
customUserIdentifier
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</p>
</div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
class="_icon_11k6c_18"
fill="currentColor"
height="1em"
style="width: 24px; height: 24px;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
fill-rule="evenodd"
/>
</svg>
</div>
</div>
</div>
`;