diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel.tsx new file mode 100644 index 0000000000..401c536525 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel.tsx @@ -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; +} + +const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => { + return useAsyncMemo( + 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( + 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 => verifyUser(cli, member as User); + + return { + canVerify, + hasCrossSigningKeys, + isUserVerified, + verifySelectedUser, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel.tsx new file mode 100644 index 0000000000..12899f0bc1 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel.tsx @@ -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, + }; +} diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 970f8a2278..4556063303 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -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 => { 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( - 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( - 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 = ( - - - - {_t("common|verified")} - - - ); - } else if (hasCrossSigningKeys === undefined) { - // We are still fetching the cross-signing keys for the user, show spinner. - content = ; - } else if (canVerify && hasCrossSigningKeys) { - content = ( -
- -
- ); - } else { - content = ( - - ({_t("user_info|verification_unavailable")}) - - ); - } - - return ( - - {content} - - ); -}; - 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 = ( - - ); - } - - const timezoneInfo = useUserTimezone(cli, member.userId); - - const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, { - roomId, - withDisplayName: true, - }); - const displayName = (member as RoomMember).rawDisplayName; - return ( - -
-
-
- -
-
-
- - - - - - {displayName} - - - {presenceLabel} - {timezoneInfo && ( - - - - {timezoneInfo?.friendly ?? ""} - - - - )} - - userIdentifier} border={false}> - {userIdentifier} - - - - {!hideVerificationSection && } - -
- ); -}; - interface IProps { user: Member; room?: Room; @@ -927,7 +725,7 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha const header = ( <> - = ({ member, devices }) => { + let content; + const vm = useUserInfoVerificationViewModel(member, devices); + + if (vm.isUserVerified) { + content = ( + + + + {_t("common|verified")} + + + ); + } else if (vm.hasCrossSigningKeys === undefined) { + // We are still fetching the cross-signing keys for the user, show spinner. + content = ; + } else if (vm.canVerify && vm.hasCrossSigningKeys) { + content = ( +
+ +
+ ); + } else { + content = ( + + ({_t("user_info|verification_unavailable")}) + + ); + } + + return ( + + {content} + + ); +}; diff --git a/src/components/views/right_panel/user_info/UserInfoHeaderView.tsx b/src/components/views/right_panel/user_info/UserInfoHeaderView.tsx new file mode 100644 index 0000000000..25dd0a5cde --- /dev/null +++ b/src/components/views/right_panel/user_info/UserInfoHeaderView.tsx @@ -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 = ({ + 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 = ( + + ); + } + + return ( + +
+
+
+ +
+
+
+ + + + + + {displayName} + + + {presenceLabel} + {vm.timezoneInfo && ( + + + + {vm.timezoneInfo?.friendly ?? ""} + + + + )} + + vm.userIdentifier} border={false}> + {vm.userIdentifier} + + + + {!hideVerificationSection && } + +
+ ); +}; diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel-test.tsx new file mode 100644 index 0000000000..fb9fc12fa6 --- /dev/null +++ b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel-test.tsx @@ -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; + + 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; + + 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(); + }); + }); +}); diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel-test.tsx new file mode 100644 index 0000000000..adab891435 --- /dev/null +++ b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel-test.tsx @@ -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; + let mockCrypto: Mocked; + + 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 { + throw new Error("Function not implemented."); + }, + }); + + const { result } = renderUserInfoHeaderViewModelHook(props); + + result.current.onMemberAvatarClick(); + + expect(spyModale).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx index d019e2147b..df417e1950 100644 --- a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx +++ b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx @@ -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("", () => { }); }); -describe("", () => { - 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([[device1.deviceId, device1]]); - const userDeviceMap = new Map>([[defaultUserId, devicesMap]]); - mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); - mockClient.doesServerSupportUnstableFeature.mockResolvedValue(true); - const Wrapper = (wrapperProps = {}) => { - return ; - }; - - return render(, { - 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("", () => { const member = new RoomMember(defaultRoomId, defaultUserId); const defaultProps = { member, canInvite: false, isSpace: false }; diff --git a/test/unit-tests/components/views/right_panel/UserInfoHeaderVerificationView-test.tsx b/test/unit-tests/components/views/right_panel/UserInfoHeaderVerificationView-test.tsx new file mode 100644 index 0000000000..65db069d05 --- /dev/null +++ b/test/unit-tests/components/views/right_panel/UserInfoHeaderVerificationView-test.tsx @@ -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("", () => { + const defaultRoomId = "!fkfk"; + const defaultUserId = "@user:example.com"; + + const defaultMember = new RoomMember(defaultRoomId, defaultUserId); + + let mockClient: MatrixClient; + let mockCrypto: Mocked; + + 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([[device1.deviceId, device1]]); + const userDeviceMap = new Map>([[defaultUserId, devicesMap]]); + + mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); + jest.spyOn(mockClient, "doesServerSupportUnstableFeature").mockResolvedValue(true); + const Wrapper = (wrapperProps = {}) => { + return ; + }; + + return render(, { + 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(); + }); +}); diff --git a/test/unit-tests/components/views/right_panel/UserInfoHeaderView-test.tsx b/test/unit-tests/components/views/right_panel/UserInfoHeaderView-test.tsx new file mode 100644 index 0000000000..04f59f16f6 --- /dev/null +++ b/test/unit-tests/components/views/right_panel/UserInfoHeaderView-test.tsx @@ -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("", () => { + 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; + + 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([[device1.deviceId, device1]]); + const userDeviceMap = new Map>([[defaultUserId, devicesMap]]); + + mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); + + const Wrapper = (wrapperProps = {}) => { + return ; + }; + + return render( + , + { + 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(); + }); +}); diff --git a/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap b/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap index 92de5429cb..439a9ef04e 100644 --- a/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap +++ b/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap @@ -614,270 +614,3 @@ exports[` with crypto enabled should render a deactivate button for `; - -exports[` renders verification unavailable message 1`] = ` -
-
-
-
- -
-
-
-
-
-

-
- @user:example.com -
-

-
- Unknown -
-

-

- customUserIdentifier -
-
-

-
-
-

- ( - User verification unavailable - ) -

-
-
-
-`; - -exports[` renders verified badge when user is verified 1`] = ` -
-
-
-
- -
-
-
-
-
-

-
- @user:example.com -
-

-
- Unknown -
-

-

- customUserIdentifier -
-
-

-
-
- - - - -

- Verified -

-
-
-
-
-`; - -exports[` renders verify button 1`] = ` -
-
-
-
- -
-
-
-
-
-

-
- @user:example.com -
-

-
- Unknown -
-

-

- customUserIdentifier -
-
-

-
-
-
- -
-
-
-
-`; diff --git a/test/unit-tests/components/views/right_panel/__snapshots__/UserInfoHeaderVerificationView-test.tsx.snap b/test/unit-tests/components/views/right_panel/__snapshots__/UserInfoHeaderVerificationView-test.tsx.snap new file mode 100644 index 0000000000..01a400bbba --- /dev/null +++ b/test/unit-tests/components/views/right_panel/__snapshots__/UserInfoHeaderVerificationView-test.tsx.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders verification unavailable message 1`] = ` +
+
+

+ ( + User verification unavailable + ) +

+
+
+`; + +exports[` renders verified badge when user is verified 1`] = ` +
+
+ + + + +

+ Verified +

+
+
+
+`; + +exports[` renders verify button 1`] = ` +
+
+
+ +
+
+
+`; diff --git a/test/unit-tests/components/views/right_panel/__snapshots__/UserInfoHeaderView-test.tsx.snap b/test/unit-tests/components/views/right_panel/__snapshots__/UserInfoHeaderView-test.tsx.snap new file mode 100644 index 0000000000..05ec267d3e --- /dev/null +++ b/test/unit-tests/components/views/right_panel/__snapshots__/UserInfoHeaderView-test.tsx.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders custom user identifiers in the header 1`] = ` +
+
+
+
+ +
+
+
+
+
+

+
+ @user:example.com +
+

+

+

+ customUserIdentifier +
+
+

+
+
+ + + +
+
+
+`;