From 467275fc02f7e82e4853b6a376c666caf126e772 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 23 May 2026 19:44:22 -0400 Subject: [PATCH] feat: add other-user device sessions list with per-device verification Adds a collapsible "Sessions" section to the user profile card that appears when cross-signing is active and the profile belongs to another user. Each session shows a colour-coded shield (green = verified, yellow = unverified) and a "Verify" button for unverified devices that initiates the SAS emoji flow via crypto.requestDeviceVerification. New hook useOtherUserDevices fetches the target user's device list via crypto.getUserDeviceInfo and reacts to CryptoEvent.DevicesUpdated. Co-Authored-By: Claude Sonnet 4.6 --- .../user-profile/UserRoomProfile.tsx | 161 +++++++++++++++++- src/app/hooks/useOtherUserDevices.ts | 54 ++++++ 2 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 src/app/hooks/useOtherUserDevices.ts diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index cb6f6ec4f..58198062e 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -1,6 +1,12 @@ -import { Box, Button, config, Icon, Icons, Text } from 'folds'; -import React from 'react'; +import { Box, Button, config, Icon, IconButton, Icons, Spinner, Text, toRem } from 'folds'; +import React, { useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { VerificationRequest } from 'matrix-js-sdk/lib/crypto-api'; +import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback'; +import { VerificationStatus } from '../../hooks/useDeviceVerificationStatus'; +import { DeviceVerificationStatus } from '../DeviceVerificationStatus'; +import { DeviceVerification } from '../DeviceVerification'; +import { useOtherUserDevices, UserDevice } from '../../hooks/useOtherUserDevices'; import { UserHero, UserHeroName } from './UserHero'; import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; @@ -25,6 +31,156 @@ import { CreatorChip } from './CreatorChip'; import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils'; import { DirectCreateSearchParams } from '../../pages/paths'; +type VerifyDeviceButtonProps = { + userId: string; + deviceId: string; +}; +function VerifyDeviceButton({ userId, deviceId }: VerifyDeviceButtonProps) { + const mx = useMatrixClient(); + const [requestState, setRequestState] = useState>({ + status: AsyncStatus.Idle, + }); + + const requestVerification = useAsync( + useCallback(() => { + const crypto = mx.getCrypto(); + if (!crypto) return Promise.reject(new Error('Crypto unavailable')); + return crypto.requestDeviceVerification(userId, deviceId); + }, [mx, userId, deviceId]), + setRequestState, + ); + + const handleExit = useCallback(() => setRequestState({ status: AsyncStatus.Idle }), []); + + const requesting = requestState.status === AsyncStatus.Loading; + + return ( + <> + + {requestState.status === AsyncStatus.Success && ( + + )} + + ); +} + +type UserDeviceRowProps = { + userId: string; + device: UserDevice; +}; +function UserDeviceRow({ userId, device }: UserDeviceRowProps) { + const mx = useMatrixClient(); + const crypto = mx.getCrypto() ?? undefined; + + return ( + + {(status) => { + const color = + status === VerificationStatus.Verified + ? 'var(--tc-positive-normal, #5effc4)' + : status === VerificationStatus.Unverified + ? 'var(--tc-warning-normal, #ffcc55)' + : 'var(--tc-surface-low-contrast)'; + return ( + + + + + {device.displayName ?? device.deviceId} + + {device.displayName && ( + + {device.deviceId} + + )} + + {status === VerificationStatus.Unverified && ( + + )} + + ); + }} + + ); +} + +type UserDeviceSessionsProps = { + userId: string; +}; +function UserDeviceSessions({ userId }: UserDeviceSessionsProps) { + const devices = useOtherUserDevices(userId); + const [expanded, setExpanded] = useState(false); + + if (!devices || devices.length === 0) return null; + + return ( + + + + + + Sessions + + + {devices.length} + + + setExpanded((v) => !v)} + > + + + + {expanded && ( + + {devices.map((device) => ( + + + + ))} + + )} + + ); +} + type UserRoomProfileProps = { userId: string; }; @@ -141,6 +297,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { canKick={canKickUser && membership === Membership.Join} canBan={canBanUser && membership !== Membership.Ban} /> + {showEncryption && userId !== myUserId && } ); diff --git a/src/app/hooks/useOtherUserDevices.ts b/src/app/hooks/useOtherUserDevices.ts new file mode 100644 index 000000000..4273c0d95 --- /dev/null +++ b/src/app/hooks/useOtherUserDevices.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useMatrixClient } from './useMatrixClient'; +import { useCrossSigningActive } from './useCrossSigning'; +import { useDeviceListChange } from './useDeviceList'; + +export type UserDevice = { + deviceId: string; + displayName?: string; +}; + +export function useOtherUserDevices(userId: string): UserDevice[] | undefined { + const mx = useMatrixClient(); + const crossSigningActive = useCrossSigningActive(); + const [devices, setDevices] = useState(undefined); + + const fetchDevices = useCallback(async () => { + const crypto = mx.getCrypto(); + if (!crypto || !crossSigningActive) { + setDevices(undefined); + return; + } + try { + const deviceMap = await crypto.getUserDeviceInfo([userId], true); + const userDevices = deviceMap.get(userId); + if (!userDevices) { + setDevices([]); + return; + } + setDevices( + Array.from(userDevices.values()).map((device) => ({ + deviceId: device.deviceId, + displayName: device.displayName, + })), + ); + } catch { + setDevices(undefined); + } + }, [mx, userId, crossSigningActive]); + + useEffect(() => { + fetchDevices(); + }, [fetchDevices]); + + useDeviceListChange( + useCallback( + (userIds: string[]) => { + if (userIds.includes(userId)) fetchDevices(); + }, + [userId, fetchDevices], + ), + ); + + return devices; +}