/* * 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 { Form, SettingsToggleInput } from "@vector-im/compound-web"; import { _t } from "../../../../languageHandler"; import BaseTool, { DevtoolsContext, type IDevtoolsProps } from "./BaseTool"; import FilteredList from "./FilteredList"; 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. * * Initially, filters to only show joined users, but offers the user an option to show all users. * * Once the user chooses a specific member, delegates to {@link UserView} 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) { return setMember(null)} />; } const members = showOnlyJoined ? context.room.getJoinedMembers() : context.room.getMembers(); return ( {members.map((member) => ( setMember(member)} /> ))} { evt.preventDefault(); evt.stopPropagation(); }} > { setShowOnlyJoined(e.target.checked); }} checked={showOnlyJoined} /> ); }; 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. * * Once the user chooses a specific device, delegates to {@link DeviceView} to show a single device. */ const UserView: React.FC = ({ member, onBack }) => { const context = useContext(DevtoolsContext); const crypto = context.room.client.getCrypto(); // An element to show the verification status of the device (unknown, // unverified, verified by cross signing, signed by owner). The element // will show text as well as an icon. If crypto is not available, the value // will be `null`. 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, member], _t("common|loading"), ); // The user's devices, as a Map from device ID to device information (see // the `Device` type in `matrix-js-sdk/src/models/device.ts`). const devices = useAsyncMemo( async () => { const devices = await crypto?.getUserDeviceInfo([member.userId]); return devices?.get(member.userId) ?? new Map(); }, [context, member], new Map(), ); // The device to show, if any. const [device, setDevice] = useState(null); if (device) { return setDevice(null)} />; } 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 ( ); } }, [crypto, device], null, ); return ( ); }; interface DeviceProps extends Pick { crypto: CryptoApi; device: Device; } /** * Show a single device to view. */ const DeviceView: React.FC = ({ crypto, device, onBack }) => { // An element to show the verification status of the device (unknown, // unverified, verified by cross signing, signed by owner). The element // will show text as well as an icon if applicable. 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 }); } }, [crypto, device], _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}
); };