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 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 19:44:22 -04:00
parent 6c9fd17f27
commit 9b18804927
2 changed files with 213 additions and 2 deletions
@@ -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<AsyncState<VerificationRequest, Error>>({
status: AsyncStatus.Idle,
});
const requestVerification = useAsync<VerificationRequest, Error, []>(
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 (
<>
<Button
size="300"
variant="Warning"
fill="Soft"
radii="300"
onClick={requestVerification}
before={requesting && <Spinner size="100" variant="Warning" />}
disabled={requesting}
>
<Text size="B300">Verify</Text>
</Button>
{requestState.status === AsyncStatus.Success && (
<DeviceVerification request={requestState.data} onExit={handleExit} />
)}
</>
);
}
type UserDeviceRowProps = {
userId: string;
device: UserDevice;
};
function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
const mx = useMatrixClient();
const crypto = mx.getCrypto() ?? undefined;
return (
<DeviceVerificationStatus crypto={crypto} userId={userId} deviceId={device.deviceId}>
{(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 (
<Box alignItems="Center" gap="200">
<Icon size="100" src={Icons.ShieldUser} style={{ color, flexShrink: 0 }} />
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
<Text size="T300" truncate>
{device.displayName ?? device.deviceId}
</Text>
{device.displayName && (
<Text
size="T200"
truncate
style={{ color: 'var(--tc-surface-low-contrast)', fontFamily: 'monospace' }}
>
{device.deviceId}
</Text>
)}
</Box>
{status === VerificationStatus.Unverified && (
<VerifyDeviceButton userId={userId} deviceId={device.deviceId} />
)}
</Box>
);
}}
</DeviceVerificationStatus>
);
}
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 (
<Box
direction="Column"
gap="100"
style={{
background: 'var(--bg-surface-variant)',
borderRadius: config.radii.R300,
padding: config.space.S300,
}}
>
<Box alignItems="Center" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon size="100" src={Icons.Device} style={{ flexShrink: 0 }} />
<Text size="T300">
<b>Sessions</b>
</Text>
<Text size="T200" style={{ color: 'var(--tc-surface-low-contrast)' }}>
{devices.length}
</Text>
</Box>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label={expanded ? 'Collapse sessions' : 'Expand sessions'}
onClick={() => setExpanded((v) => !v)}
>
<Icon
size="100"
src={expanded ? Icons.ChevronTop : Icons.ChevronBottom}
style={{ flexShrink: 0 }}
/>
</IconButton>
</Box>
{expanded && (
<Box direction="Column" gap="200" style={{ paddingTop: config.space.S100 }}>
{devices.map((device) => (
<Box
key={device.deviceId}
style={{
paddingTop: config.space.S100,
paddingBottom: config.space.S100,
borderTop: `${toRem(1)} solid var(--border-surface-variant)`,
}}
>
<UserDeviceRow userId={userId} device={device} />
</Box>
))}
</Box>
)}
</Box>
);
}
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 && <UserDeviceSessions userId={userId} />}
</Box>
</Box>
);
+54
View File
@@ -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<UserDevice[] | undefined>(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;
}