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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user