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 { Box, Button, config, Icon, IconButton, Icons, Spinner, Text, toRem } from 'folds';
|
||||||
import React from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { UserHero, UserHeroName } from './UserHero';
|
||||||
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||||
@@ -25,6 +31,156 @@ import { CreatorChip } from './CreatorChip';
|
|||||||
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
|
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
|
||||||
import { DirectCreateSearchParams } from '../../pages/paths';
|
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 = {
|
type UserRoomProfileProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
};
|
};
|
||||||
@@ -141,6 +297,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
canKick={canKickUser && membership === Membership.Join}
|
canKick={canKickUser && membership === Membership.Join}
|
||||||
canBan={canBanUser && membership !== Membership.Ban}
|
canBan={canBanUser && membership !== Membership.Ban}
|
||||||
/>
|
/>
|
||||||
|
{showEncryption && userId !== myUserId && <UserDeviceSessions userId={userId} />}
|
||||||
</Box>
|
</Box>
|
||||||
</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