diff --git a/playwright/snapshots/devtools/devtools.spec.ts/devtools-dialog-linux.png b/playwright/snapshots/devtools/devtools.spec.ts/devtools-dialog-linux.png index ad239ddc04a..d4125008417 100644 Binary files a/playwright/snapshots/devtools/devtools.spec.ts/devtools-dialog-linux.png and b/playwright/snapshots/devtools/devtools.spec.ts/devtools-dialog-linux.png differ diff --git a/res/css/views/rooms/_E2EIcon.pcss b/res/css/views/rooms/_E2EIcon.pcss index 7dd0aa476dd..97db1959a48 100644 --- a/res/css/views/rooms/_E2EIcon.pcss +++ b/res/css/views/rooms/_E2EIcon.pcss @@ -13,6 +13,10 @@ Please see LICENSE files in the repository root for full details. display: block; } +.mx_E2EIcon.mx_E2EIcon_inline { + display: inline-block; +} + .mx_E2EIcon_warning, .mx_E2EIcon_normal, .mx_E2EIcon_verified { diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index 094e685fc92..e7c1092e6af 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -18,6 +18,7 @@ import SettingExplorer from "./devtools/SettingExplorer"; import { RoomStateExplorer } from "./devtools/RoomState"; import BaseTool, { DevtoolsContext, type IDevtoolsProps } from "./devtools/BaseTool"; import WidgetExplorer from "./devtools/WidgetExplorer"; +import { UserList } from "./devtools/Users"; import { AccountDataExplorer, RoomAccountDataExplorer } from "./devtools/AccountData"; import SettingsFlag from "../elements/SettingsFlag"; import { SettingLevel } from "../../../settings/SettingLevel"; @@ -46,6 +47,7 @@ const Tools: Record = { [_td("devtools|view_servers_in_room"), ServersInRoom], [_td("devtools|notifications_debug"), RoomNotifications], [_td("devtools|active_widgets"), WidgetExplorer], + [_td("devtools|users"), UserList], ], [Category.Other]: [ [_td("devtools|explore_account_data"), AccountDataExplorer], diff --git a/src/components/views/dialogs/devtools/Users.tsx b/src/components/views/dialogs/devtools/Users.tsx new file mode 100644 index 00000000000..1bca92519fc --- /dev/null +++ b/src/components/views/dialogs/devtools/Users.tsx @@ -0,0 +1,356 @@ +/* + * 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. + */ + +/** + * @file Devtool for viewing room members and their devices. + */ + +import React, { type JSX, useContext, useState } from "react"; +import { type Device, type RoomMember } from "matrix-js-sdk/src/matrix"; +import { type CryptoApi } from "matrix-js-sdk/src/crypto-api"; + +import { _t } from "../../../../languageHandler"; +import BaseTool, { DevtoolsContext, type IDevtoolsProps } from "./BaseTool"; +import FilteredList from "./FilteredList"; +import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch"; +import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; +import CopyableText from "../../elements/CopyableText"; +import E2EIcon from "../../rooms/E2EIcon"; +import { E2EStatus } from "../../../../utils/ShieldUtils"; + +/** + * Replacement function for `` tags in translation strings. + */ +function i(sub: string): JSX.Element { + return {sub}; +} + +/** + * Shows a list of users in the room, and allows selecting a user to view. + * + * By default, filters to only show joined users. + * + * If the `member` state is set, delegates to `User` to view a single user. + */ +export const UserList: React.FC> = ({ onBack }) => { + const context = useContext(DevtoolsContext); + const [query, setQuery] = useState(""); + // Show only joined users or all users with member events? + const [showOnlyJoined, setShowOnlyJoined] = useState(true); + // The `RoomMember` for the selected user (if any) + const [member, setMember] = useState(null); + + if (member) { + const _onBack = (): void => { + setMember(null); + }; + return ; + } + + const members = showOnlyJoined ? context.room.getJoinedMembers() : context.room.getMembers(); + + return ( + + + {members.map((member) => ( + setMember(member)} /> + ))} + + + + ); +}; + +interface UserButtonProps { + member: RoomMember; + onClick(): void; +} + +/** + * Button to select a user to view. + */ +const UserButton: React.FC = ({ member, onClick }) => { + return ( + + ); +}; + +interface UserProps extends Pick { + member: RoomMember; +} + +/** + * Shows a single user to view, and allows selecting a device to view. + * + * If the `device` state is set, delegates to `Device` to show a single device. + */ +const UserView: React.FC = ({ member, onBack }) => { + const context = useContext(DevtoolsContext); + const crypto = context.room.client.getCrypto(); + const verificationStatus = useAsyncMemo( + async () => { + if (!crypto) { + return null; + } + const status = await crypto.getUserVerificationStatus(member.userId); + if (status.isCrossSigningVerified()) { + const e2eIcon = (): JSX.Element => ( + + ); + return _t("devtools|user_verification_status|verified", {}, { E2EIcon: e2eIcon }); + } else if (status.wasCrossSigningVerified()) { + const e2eIcon = (): JSX.Element => ( + + ); + return _t("devtools|user_verification_status|was_verified", {}, { E2EIcon: e2eIcon }); + } else if (status.needsUserApproval) { + const e2eIcon = (): JSX.Element => ( + + ); + return _t("devtools|user_verification_status|identity_changed", {}, { E2EIcon: e2eIcon }); + } else { + const e2eIcon = (): JSX.Element => ( + + ); + return _t("devtools|user_verification_status|unverified", {}, { E2EIcon: e2eIcon }); + } + }, + [context], + _t("common|loading"), + ); + const devices = useAsyncMemo( + async () => { + const devices = await crypto?.getUserDeviceInfo([member.userId]); + return devices?.get(member.userId) ?? new Map(); + }, + [context], + new Map(), + ); + // The device to show, if any. + const [device, setDevice] = useState(null); + + if (device) { + const _onBack = (): void => { + setDevice(null); + }; + return ; + } + + const avatarUrl = member.getMxcAvatarUrl(); + const memberEventContent = member.events.member?.getContent(); + + return ( + +
    +
  • + member.userId} border={false}> + {_t("devtools|user_id", { userId: member.userId })} + +
  • +
  • {_t("devtools|user_room_membership", { membership: member.membership ?? "leave" })}
  • +
  • + {memberEventContent && "displayname" in memberEventContent + ? _t("devtools|user_displayname", { displayname: member.rawDisplayName }) + : _t("devtools|user_no_displayname", {}, { i })} +
  • +
  • + {avatarUrl !== undefined ? ( + avatarUrl} border={false}> + {_t("devtools|user_avatar", { avatar: avatarUrl })} + + ) : ( + _t("devtools|user_no_avatar", {}, { i }) + )} +
  • +
  • {verificationStatus}
  • +
+
+

{_t("devtools|devices", { count: devices.size })}

+
    + {Array.from(devices.values()).map((device) => ( +
  • + setDevice(device)} /> +
  • + ))} +
+
+
+ ); +}; + +interface DeviceButtonProps { + crypto: CryptoApi; + device: Device; + onClick(): void; +} + +/** + * Button to select a user to view. + */ +const DeviceButton: React.FC = ({ crypto, device, onClick }) => { + const verificationIcon = useAsyncMemo( + async () => { + const status = await crypto.getDeviceVerificationStatus(device.userId, device.deviceId); + if (!status) { + return; + } else if (status.crossSigningVerified) { + return ( + + ); + } else if (status.signedByOwner) { + return ( + + ); + } else { + return ( + + ); + } + }, + [], + null, + ); + return ( + + ); +}; + +interface DeviceProps extends Pick { + crypto: CryptoApi; + device: Device; +} + +/** + * Show a single device to view. + */ +const DeviceView: React.FC = ({ crypto, device, onBack }) => { + const verificationStatus = useAsyncMemo( + async () => { + const status = await crypto.getDeviceVerificationStatus(device.userId, device.deviceId); + if (!status) { + // `status` will be `null` if the device is unknown or if the + // device doesn't have device keys. In either case, it's not a + // security issue since we won't be sending it decryption keys. + return _t("devtools|device_verification_status|unknown"); + } else if (status.crossSigningVerified) { + const e2eIcon = (): JSX.Element => ( + + ); + return _t("devtools|device_verification_status|verified", {}, { E2EIcon: e2eIcon }); + } else if (status.signedByOwner) { + const e2eIcon = (): JSX.Element => ( + + ); + return _t("devtools|device_verification_status|signed_by_owner", {}, { E2EIcon: e2eIcon }); + } else { + const e2eIcon = (): JSX.Element => ( + + ); + return _t("devtools|device_verification_status|unverified", {}, { E2EIcon: e2eIcon }); + } + }, + [], + _t("common|loading"), + ); + + const keyIdSuffix = ":" + device.deviceId; + const deviceKeys = ( +
    + {Array.from(device.keys.entries()).map(([keyId, key]) => { + if (keyId.endsWith(keyIdSuffix)) { + return ( +
  • + key} border={false}> + {keyId.slice(0, -keyIdSuffix.length)}: {key} + +
  • + ); + } else { + return ( +
  • + {_t("devtools|invalid_device_key_id")}: {keyId}: {key} +
  • + ); + } + })} +
+ ); + + return ( + +
    +
  • + device.userId} border={false}> + {_t("devtools|user_id", { userId: device.userId })} + +
  • +
  • + device.deviceId} border={false}> + {_t("devtools|device_id", { deviceId: device.deviceId })} + +
  • +
  • + {"displayName" in device + ? _t("devtools|user_displayname", { displayname: device.displayName }) + : _t("devtools|user_no_displayname", {}, { i })} +
  • +
  • {verificationStatus}
  • +
  • + {device.dehydrated ? _t("devtools|device_dehydrated_yes") : _t("devtools|device_dehydrated_no")} +
  • +
  • + {_t("devtools|device_keys")} + {deviceKeys} +
  • +
+
+ ); +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 00ac3eb1168..ddc22f3eb1d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -806,6 +806,17 @@ }, "developer_mode": "Developer mode", "developer_tools": "Developer Tools", + "device_dehydrated_no": "Dehydrated: No", + "device_dehydrated_yes": "Dehydrated: Yes", + "device_id": "Device ID: %(deviceId)s", + "device_keys": "Device keys", + "device_verification_status": { + "signed_by_owner": "Verification status: Signed by owner", + "unknown": "Verification status: Unknown", + "unverified": "Verification status: Not signed by owner", + "verified": "Verification status: Verified by cross-signing" + }, + "devices": "Devices (%(count)s)", "edit_setting": "Edit setting", "edit_values": "Edit values", "empty_string": "", @@ -821,6 +832,7 @@ "failed_to_save": "Failed to save settings.", "failed_to_send": "Failed to send event!", "id": "ID: ", + "invalid_device_key_id": "Invalid device key ID", "invalid_json": "Doesn't look like valid JSON.", "level": "Level", "low_bandwidth_mode": "Low bandwidth mode", @@ -831,6 +843,7 @@ "notification_state": "Notification state is %(notificationState)s", "notifications_debug": "Notifications debug", "number_of_users": "Number of users", + "only_joined_members": "Only joined users", "original_event_source": "Original event source", "room_encrypted": "Room is encrypted ✅", "room_id": "Room ID: %(roomId)s", @@ -877,10 +890,23 @@ "toggle_event": "toggle event", "toolbox": "Toolbox", "use_at_own_risk": "This UI does NOT check the types of the values. Use at your own risk.", + "user_avatar": "Avatar: %(avatar)s", + "user_displayname": "Displayname: %(displayname)s", + "user_id": "User ID: %(userId)s", + "user_no_avatar": "Avatar: None", + "user_no_displayname": "Displayname: None", "user_read_up_to": "User read up to: ", "user_read_up_to_ignore_synthetic": "User read up to (ignoreSynthetic): ", "user_read_up_to_private": "User read up to (m.read.private): ", "user_read_up_to_private_ignore_synthetic": "User read up to (m.read.private;ignoreSynthetic): ", + "user_room_membership": "Membership: %(membership)s", + "user_verification_status": { + "identity_changed": "Verification status: Unverified, and identity changed", + "unverified": "Verification status: Unverified", + "verified": "Verification status: Verified", + "was_verified": "Verification status: Was verified, but identity changed" + }, + "users": "Users", "value": "Value", "value_colon": "Value:", "value_in_this_room": "Value in this room", diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap index 9913c2cd870..0f577eb5017 100644 --- a/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap +++ b/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap @@ -83,6 +83,11 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = ` > Active Widgets +

", () => { + let matrixClient: MatrixClient; + beforeEach(() => { + matrixClient = createTestClient(); + }); + + it("should render a user list", () => { + const room = new Room("!roomId", matrixClient, userId, { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + room.getJoinedMembers = jest.fn().mockReturnValue([]); + + const { asFragment } = render( + + + {}} /> + + , + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render a single user", async () => { + const room = new Room("!roomId", matrixClient, userId, { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + const alice = new RoomMember("!roomId", userId); + alice.setMembershipEvent( + new MatrixEvent({ + content: { + membership: "join", + }, + state_key: userId, + room_id: "!roomId", + type: "m.room.member", + sender: userId, + }), + ); + room.getJoinedMembers = jest.fn().mockReturnValue([alice]); + + mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({ + isCrossSigningVerified: jest.fn().mockReturnValue(true), + wasCrossSigningVerified: jest.fn().mockReturnValue(true), + needsUserApproval: false, + } as unknown as UserVerificationStatus); + mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue( + new Map([ + [ + userId, + new Map([ + [ + "VERIFIED", + new Device({ + deviceId: "VERIFIED", + userId: userId, + algorithms: [], + keys: new Map([ + ["ed25519:VERIFIED", "an_ed25519_public_key"], + ["curve25519:VERIFIED", "a_curve25519_public_key"], + ]), + }), + ], + [ + "SIGNED", + new Device({ + deviceId: "SIGNED", + userId: userId, + algorithms: [], + keys: new Map([ + ["ed25519:SIGNED", "an_ed25519_public_key"], + ["curve25519:SIGNED", "a_curve25519_public_key"], + ]), + }), + ], + [ + "UNSIGNED", + new Device({ + deviceId: "UNSIGNED", + userId: userId, + algorithms: [], + keys: new Map([ + ["ed25519:UNSIGNED", "an_ed25519_public_key"], + ["curve25519:UNSIGNED", "a_curve25519_public_key"], + ]), + }), + ], + ]), + ], + ]), + ); + mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockImplementation( + async (userId: string, deviceId: string) => { + switch (deviceId) { + case "VERIFIED": + return { + signedByOwner: true, + crossSigningVerified: true, + } as unknown as DeviceVerificationStatus; + case "SIGNED": + return { + signedByOwner: true, + crossSigningVerified: false, + } as unknown as DeviceVerificationStatus; + case "UNSIGNED": + return { + signedByOwner: false, + crossSigningVerified: false, + } as unknown as DeviceVerificationStatus; + default: + return null; + } + }, + ); + + const { asFragment } = render( + + + {}} /> + + , + ); + + screen.getByRole("button", { name: userId }).click(); + + await waitFor(() => expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Verified/)); + await waitFor(() => expect(screen.getByRole("button", { name: "VERIFIED" })).toBeInTheDocument()); + + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render a single device - verified by cross-signing", async () => { + const room = new Room("!roomId", matrixClient, userId, { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + const alice = new RoomMember("!roomId", userId); + alice.setMembershipEvent( + new MatrixEvent({ + content: { + membership: "join", + }, + state_key: userId, + room_id: "!roomId", + type: "m.room.member", + sender: userId, + }), + ); + room.getJoinedMembers = jest.fn().mockReturnValue([alice]); + + mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({ + isCrossSigningVerified: jest.fn().mockReturnValue(true), + wasCrossSigningVerified: jest.fn().mockReturnValue(true), + needsUserApproval: false, + } as unknown as UserVerificationStatus); + mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue( + new Map([ + [ + userId, + new Map([ + [ + "VERIFIED", + new Device({ + deviceId: "VERIFIED", + userId: userId, + algorithms: [], + keys: new Map([ + ["ed25519:VERIFIED", "an_ed25519_public_key"], + ["curve25519:VERIFIED", "a_curve25519_public_key"], + ]), + }), + ], + ]), + ], + ]), + ); + mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockResolvedValue({ + signedByOwner: true, + crossSigningVerified: true, + } as unknown as DeviceVerificationStatus); + + const { asFragment } = render( + + + {}} /> + + , + ); + + screen.getByRole("button", { name: userId }).click(); + + await waitFor(() => expect(screen.getByRole("button", { name: "VERIFIED" })).toBeInTheDocument()); + screen.getByRole("button", { name: "VERIFIED" }).click(); + + await waitFor(() => + expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Verified by cross-signing/), + ); + + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render a single device - signed by owner", async () => { + const room = new Room("!roomId", matrixClient, userId, { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + const alice = new RoomMember("!roomId", userId); + alice.setMembershipEvent( + new MatrixEvent({ + content: { + membership: "join", + }, + state_key: userId, + room_id: "!roomId", + type: "m.room.member", + sender: userId, + }), + ); + room.getJoinedMembers = jest.fn().mockReturnValue([alice]); + + mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({ + isCrossSigningVerified: jest.fn().mockReturnValue(true), + wasCrossSigningVerified: jest.fn().mockReturnValue(true), + needsUserApproval: false, + } as unknown as UserVerificationStatus); + mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue( + new Map([ + [ + userId, + new Map([ + [ + "SIGNED", + new Device({ + deviceId: "SIGNED", + userId: userId, + algorithms: [], + keys: new Map([ + ["ed25519:SIGNED", "an_ed25519_public_key"], + ["curve25519:SIGNED", "a_curve25519_public_key"], + ]), + }), + ], + ]), + ], + ]), + ); + mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockResolvedValue({ + signedByOwner: true, + crossSigningVerified: false, + } as unknown as DeviceVerificationStatus); + + const { asFragment } = render( + + + {}} /> + + , + ); + + screen.getByRole("button", { name: userId }).click(); + + await waitFor(() => expect(screen.getByRole("button", { name: "SIGNED" })).toBeInTheDocument()); + screen.getByRole("button", { name: "SIGNED" }).click(); + + await waitFor(() => expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Signed by owner/)); + + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render a single device - unsigned", async () => { + const room = new Room("!roomId", matrixClient, userId, { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + const alice = new RoomMember("!roomId", userId); + alice.setMembershipEvent( + new MatrixEvent({ + content: { + membership: "join", + }, + state_key: userId, + room_id: "!roomId", + type: "m.room.member", + sender: userId, + }), + ); + room.getJoinedMembers = jest.fn().mockReturnValue([alice]); + + mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({ + isCrossSigningVerified: jest.fn().mockReturnValue(true), + wasCrossSigningVerified: jest.fn().mockReturnValue(true), + needsUserApproval: false, + } as unknown as UserVerificationStatus); + mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue( + new Map([ + [ + userId, + new Map([ + [ + "UNSIGNED", + new Device({ + deviceId: "UNSIGNED", + userId: userId, + algorithms: [], + keys: new Map([ + ["ed25519:UNSIGNED", "an_ed25519_public_key"], + ["curve25519:UNSIGNED", "a_curve25519_public_key"], + ]), + verified: DeviceVerification.Verified, + }), + ], + ]), + ], + ]), + ); + mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockResolvedValue({ + signedByOwner: false, + crossSigningVerified: false, + } as unknown as DeviceVerificationStatus); + + const { asFragment } = render( + + + {}} /> + + , + ); + + screen.getByRole("button", { name: userId }).click(); + + await waitFor(() => expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Verified/)); + + await waitFor(() => expect(screen.getByRole("button", { name: "UNSIGNED" })).toBeInTheDocument()); + screen.getByRole("button", { name: "UNSIGNED" }).click(); + + await waitFor(() => expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Not signed by owner/)); + + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/unit-tests/components/views/dialogs/devtools/__snapshots__/Users-test.tsx.snap b/test/unit-tests/components/views/dialogs/devtools/__snapshots__/Users-test.tsx.snap new file mode 100644 index 00000000000..c1b9071fb64 --- /dev/null +++ b/test/unit-tests/components/views/dialogs/devtools/__snapshots__/Users-test.tsx.snap @@ -0,0 +1,454 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render a single device - signed by owner 1`] = ` + +
+
    +
  • +
    + User ID: @alice:example.com +
    +
    +
  • +
  • +
    + Device ID: SIGNED +
    +
    +
  • +
  • + Displayname: +
  • +
  • + + Verification status: +
    + Signed by owner + +
  • +
  • + Dehydrated: No +
  • +
  • + Device keys +
      +
    • +
      + ed25519: an_ed25519_public_key +
      +
      +
    • +
    • +
      + curve25519: a_curve25519_public_key +
      +
      +
    • +
    +
  • +
+
+
+ +
+
+`; + +exports[` should render a single device - unsigned 1`] = ` + +
+
    +
  • +
    + User ID: @alice:example.com +
    +
    +
  • +
  • +
    + Device ID: UNSIGNED +
    +
    +
  • +
  • + Displayname: +
  • +
  • + + Verification status: +
    +
    +
    + Not signed by owner + +
  • +
  • + Dehydrated: No +
  • +
  • + Device keys +
      +
    • +
      + ed25519: an_ed25519_public_key +
      +
      +
    • +
    • +
      + curve25519: a_curve25519_public_key +
      +
      +
    • +
    +
  • +
+
+
+ +
+
+`; + +exports[` should render a single device - verified by cross-signing 1`] = ` + +
+
    +
  • +
    + User ID: @alice:example.com +
    +
    +
  • +
  • +
    + Device ID: VERIFIED +
    +
    +
  • +
  • + Displayname: +
  • +
  • + + Verification status: +
    +
    +
    + Verified by cross-signing + +
  • +
  • + Dehydrated: No +
  • +
  • + Device keys +
      +
    • +
      + ed25519: an_ed25519_public_key +
      +
      +
    • +
    • +
      + curve25519: a_curve25519_public_key +
      +
      +
    • +
    +
  • +
+
+
+ +
+
+`; + +exports[` should render a single user 1`] = ` + +
+
    +
  • +
    + User ID: @alice:example.com +
    +
    +
  • +
  • + Membership: join +
  • +
  • + + Displayname: + + None + + +
  • +
  • + + Avatar: + + None + + +
  • +
  • + + Verification status: +
    +
    +
    + Verified + +
  • +
+
+

+ Devices (3) +

+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+ +
+
+`; + +exports[` should render a user list 1`] = ` + +
+
+ + +
+ No results found +
+ +
+ Only joined users +
+
+
+
+
+
+
+
+ +
+ +`;