user session settings - WIP

This commit is contained in:
Ajay Bura 2025-01-18 22:05:24 +05:30
parent fff4a9cbe9
commit f74f104406
6 changed files with 499 additions and 97 deletions
src/app
features/settings
hooks
organisms/settings
pages/client/sidebar

View file

@ -11,6 +11,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar';
import { nameInitials } from '../../utils/common';
import { Notifications } from './notifications';
import { Sessions } from './sessions';
import { EmojisStickers } from './emojis-stickers';
import { DeveloperTools } from './developer-tools';
import { About } from './about';
@ -19,7 +20,7 @@ enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
SessionPage,
SessionsPage,
EncryptionPage,
EmojisStickersPage,
DeveloperToolsPage,
@ -51,7 +52,7 @@ const useSettingsMenuItems = (): SettingsMenuItem[] =>
icon: Icons.Bell,
},
{
page: SettingsPages.SessionPage,
page: SettingsPages.SessionsPage,
name: 'Sessions',
icon: Icons.Category,
},
@ -171,6 +172,9 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
{activePage === SettingsPages.NotificationPage && (
<Notifications requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.SessionsPage && (
<Sessions requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} />
)}

View file

@ -0,0 +1,365 @@
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
import {
Box,
Text,
IconButton,
Icon,
Icons,
Scroll,
Chip,
Input,
Button,
color,
Spinner,
toRem,
} from 'folds';
import { IMyDevice, MatrixError } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useDeviceList } from '../../../hooks/useDeviceList';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../../utils/time';
import { BreakWord } from '../../../styles/Text.css';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
function DevicesPlaceholder() {
return (
<Box direction="Column" gap="100">
<SequenceCard
className={SequenceCardStyle}
style={{ height: toRem(64) }}
variant="SurfaceVariant"
direction="Column"
gap="400"
/>
<SequenceCard
className={SequenceCardStyle}
style={{ height: toRem(64) }}
variant="SurfaceVariant"
direction="Column"
gap="400"
/>
<SequenceCard
className={SequenceCardStyle}
style={{ height: toRem(64) }}
variant="SurfaceVariant"
direction="Column"
gap="400"
/>
<SequenceCard
className={SequenceCardStyle}
style={{ height: toRem(64) }}
variant="SurfaceVariant"
direction="Column"
gap="400"
/>
<SequenceCard
className={SequenceCardStyle}
style={{ height: toRem(64) }}
variant="SurfaceVariant"
direction="Column"
gap="400"
/>
</Box>
);
}
function DeviceActiveTime({ ts }: { ts: number }) {
return (
<Text className={BreakWord} size="T200">
<Text size="Inherit" as="span" priority="300">
{'Last activity: '}
</Text>
<>
{today(ts) && 'Today'}
{yesterday(ts) && 'Yesterday'}
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts)} {timeHourMinute(ts)}
</>
</Text>
);
}
function DeviceDetails({ device }: { device: IMyDevice }) {
return (
<>
{typeof device.device_id === 'string' && (
<Text className={BreakWord} size="T200" priority="300">
Session ID: <i>{device.device_id}</i>
</Text>
)}
{typeof device.last_seen_ip === 'string' && (
<Text className={BreakWord} size="T200" priority="300">
IP Address: <i>{device.last_seen_ip}</i>
</Text>
)}
</>
);
}
type DeviceRenameProps = {
device: IMyDevice;
onCancel: () => void;
onRename: () => void;
refreshDeviceList: () => Promise<void>;
};
function DeviceRename({ device, onCancel, onRename, refreshDeviceList }: DeviceRenameProps) {
const mx = useMatrixClient();
const [renameState, rename] = useAsyncCallback<void, MatrixError, [string]>(
useCallback(
async (name: string) => {
await mx.setDeviceDetails(device.device_id, { display_name: name });
await refreshDeviceList();
},
[mx, device.device_id, refreshDeviceList]
)
);
const renaming = renameState.status === AsyncStatus.Loading;
useEffect(() => {
if (renameState.status === AsyncStatus.Success) {
onRename();
}
}, [renameState, onRename]);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (renaming) return;
const target = evt.target as HTMLFormElement | undefined;
const nameInput = target?.nameInput as HTMLInputElement | undefined;
if (!nameInput) return;
const sessionName = nameInput.value.trim();
if (!sessionName || sessionName === device.display_name) return;
rename(sessionName);
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
<Text size="L400">Session Name</Text>
<Box gap="200">
<Box grow="Yes" direction="Column">
<Input
name="nameInput"
size="300"
variant="Secondary"
radii="300"
defaultValue={device.display_name}
autoFocus
required
readOnly={renaming}
/>
</Box>
<Box shrink="No" gap="200">
<Button
type="submit"
size="300"
variant="Success"
radii="300"
fill="Solid"
disabled={renaming}
before={renaming && <Spinner size="100" variant="Success" fill="Solid" />}
>
<Text size="B300">Save</Text>
</Button>
<Button
type="button"
size="300"
variant="Secondary"
radii="300"
fill="Soft"
onClick={onCancel}
disabled={renaming}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
{renameState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{renameState.error.message}
</Text>
)}
</Box>
);
}
type DeviceTileProps = {
device: IMyDevice;
deleted: boolean;
onDeleteToggle: (deviceId: string) => void;
refreshDeviceList: () => Promise<void>;
};
function DeviceTile({ device, deleted, onDeleteToggle, refreshDeviceList }: DeviceTileProps) {
const activeTs = device.last_seen_ts;
const [details, setDetails] = useState(false);
const [edit, setEdit] = useState(false);
const handleRename = useCallback(() => {
setEdit(false);
}, []);
return (
<>
<SettingTile
before={
<IconButton
variant={deleted ? 'Critical' : 'Secondary'}
outlined={deleted}
radii="300"
onClick={() => setDetails(!details)}
>
<Icon size="50" src={details ? Icons.ChevronTop : Icons.ChevronBottom} />
</IconButton>
}
after={
!edit && (
<Box shrink="No" alignItems="Center" gap="200">
{deleted ? (
<Chip
variant="Critical"
fill="None"
radii="Pill"
onClick={() => onDeleteToggle?.(device.device_id)}
>
<Text size="B300">Undo</Text>
</Chip>
) : (
<>
<Chip
variant="Secondary"
fill="None"
radii="Pill"
onClick={() => onDeleteToggle?.(device.device_id)}
>
<Icon size="50" src={Icons.Delete} />
</Chip>
<Chip variant="Secondary" radii="Pill" onClick={() => setEdit(true)}>
<Text size="B300">Edit</Text>
</Chip>
</>
)}
</Box>
)
}
>
<Text size="T300">{device.display_name ?? device.device_id}</Text>
<Box direction="Column">
{typeof activeTs === 'number' && <DeviceActiveTime ts={activeTs} />}
{details && <DeviceDetails device={device} />}
</Box>
</SettingTile>
{edit && (
<DeviceRename
device={device}
onCancel={() => setEdit(false)}
onRename={handleRename}
refreshDeviceList={refreshDeviceList}
/>
)}
</>
);
}
type SessionsProps = {
requestClose: () => void;
};
export function Sessions({ requestClose }: SessionsProps) {
const mx = useMatrixClient();
const [devices, refreshDeviceList] = useDeviceList();
const currentDeviceId = mx.getDeviceId();
const currentDevice = currentDeviceId
? devices?.find((device) => device.device_id === currentDeviceId)
: undefined;
const otherDevices = devices?.filter((device) => device.device_id !== currentDeviceId);
const [deleted, setDeleted] = useState<Set<string>>(new Set());
const handleToggleDelete = useCallback((deviceId: string) => {
setDeleted((deviceIds) => {
const newIds = new Set(deviceIds);
if (newIds.has(deviceId)) {
newIds.delete(deviceId);
} else {
newIds.add(deviceId);
}
return newIds;
});
}, []);
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Sessions
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
{devices === null && <DevicesPlaceholder />}
{currentDevice && (
<Box direction="Column" gap="100">
<Text size="L400">Current</Text>
<SequenceCard
className={SequenceCardStyle}
variant={deleted.has(currentDevice.device_id) ? 'Critical' : 'SurfaceVariant'}
direction="Column"
gap="400"
>
<DeviceTile
device={currentDevice}
deleted={deleted.has(currentDevice.device_id)}
onDeleteToggle={handleToggleDelete}
refreshDeviceList={refreshDeviceList}
/>
</SequenceCard>
</Box>
)}
{otherDevices && otherDevices.length > 0 && (
<Box direction="Column" gap="100">
<Text size="L400">Others</Text>
{otherDevices
.sort((d1, d2) => {
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
return d1.last_seen_ts < d2.last_seen_ts ? 1 : -1;
})
.map((device) => (
<SequenceCard
key={device.device_id}
className={SequenceCardStyle}
variant={deleted.has(device.device_id) ? 'Critical' : 'SurfaceVariant'}
direction="Column"
gap="400"
>
<DeviceTile
device={device}
deleted={deleted.has(device.device_id)}
onDeleteToggle={handleToggleDelete}
refreshDeviceList={refreshDeviceList}
/>
</SequenceCard>
))}
</Box>
)}
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1 @@
export * from './Sessions';

View file

@ -1,35 +1,36 @@
/* eslint-disable import/prefer-default-export */
import { useState, useEffect } from 'react';
import { CryptoEvent, IMyDevice } from 'matrix-js-sdk';
import { CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto';
import { useState, useEffect, useCallback } from 'react';
import { IMyDevice } from 'matrix-js-sdk';
import { CryptoEvent, CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto';
import { useMatrixClient } from './useMatrixClient';
import { useAlive } from './useAlive';
export function useDeviceList() {
export function useDeviceList(): [null | IMyDevice[], () => Promise<void>] {
const mx = useMatrixClient();
const [deviceList, setDeviceList] = useState<IMyDevice[] | null>(null);
const alive = useAlive();
const refreshDeviceList = useCallback(async () => {
const data = await mx.getDevices();
if (!alive()) return;
setDeviceList(data.devices || []);
}, [mx, alive]);
useEffect(() => {
let isMounted = true;
const updateDevices = () =>
mx.getDevices().then((data) => {
if (!isMounted) return;
setDeviceList(data.devices || []);
});
updateDevices();
refreshDeviceList();
const handleDevicesUpdate: CryptoEventHandlerMap[CryptoEvent.DevicesUpdated] = (users) => {
const userId = mx.getUserId();
if (userId && users.includes(userId)) {
updateDevices();
refreshDeviceList();
}
};
mx.on(CryptoEvent.DevicesUpdated, handleDevicesUpdate);
return () => {
mx.removeListener(CryptoEvent.DevicesUpdated, handleDevicesUpdate);
isMounted = false;
};
}, [mx]);
return deviceList;
}, [mx, refreshDeviceList]);
return [deviceList, refreshDeviceList];
}

View file

@ -27,45 +27,51 @@ import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
import { accessSecretStorage } from './SecretStorageAccess';
import { useMatrixClient } from '../../hooks/useMatrixClient';
const promptDeviceName = async (deviceName) => new Promise((resolve) => {
let isCompleted = false;
const promptDeviceName = async (deviceName) =>
new Promise((resolve) => {
let isCompleted = false;
const renderContent = (onComplete) => {
const handleSubmit = (e) => {
e.preventDefault();
const name = e.target.session.value;
if (typeof name !== 'string') onComplete(null);
onComplete(name);
const renderContent = (onComplete) => {
const handleSubmit = (e) => {
e.preventDefault();
const name = e.target.session.value;
if (typeof name !== 'string') onComplete(null);
onComplete(name);
};
return (
<form className="device-manage__rename" onSubmit={handleSubmit}>
<Input value={deviceName} label="Session name" name="session" />
<div className="device-manage__rename-btn">
<Button variant="primary" type="submit">
Save
</Button>
<Button onClick={() => onComplete(null)}>Cancel</Button>
</div>
</form>
);
};
return (
<form className="device-manage__rename" onSubmit={handleSubmit}>
<Input value={deviceName} label="Session name" name="session" />
<div className="device-manage__rename-btn">
<Button variant="primary" type="submit">Save</Button>
<Button onClick={() => onComplete(null)}>Cancel</Button>
</div>
</form>
);
};
openReusableDialog(
<Text variant="s1" weight="medium">Edit session name</Text>,
(requestClose) => renderContent((name) => {
isCompleted = true;
resolve(name);
requestClose();
}),
() => {
if (!isCompleted) resolve(null);
},
);
});
openReusableDialog(
<Text variant="s1" weight="medium">
Edit session name
</Text>,
(requestClose) =>
renderContent((name) => {
isCompleted = true;
resolve(name);
requestClose();
}),
() => {
if (!isCompleted) resolve(null);
}
);
});
function DeviceManage() {
const TRUNCATED_COUNT = 4;
const mx = useMatrixClient();
const isCSEnabled = useCrossSigningStatus();
const deviceList = useDeviceList();
const [deviceList] = useDeviceList();
const [processing, setProcessing] = useState([]);
const [truncated, setTruncated] = useState(true);
const mountStore = useStore();
@ -117,7 +123,7 @@ function DeviceManage() {
`Logout ${device.display_name}`,
`You are about to logout "${device.display_name}" session.`,
'Logout',
'danger',
'danger'
);
if (!isConfirmed) return;
addToProcessing(device);
@ -160,25 +166,43 @@ function DeviceManage() {
return (
<SettingTile
key={deviceId}
title={(
title={
<Text style={{ color: isVerified !== false ? '' : 'var(--tc-danger-high)' }}>
{displayName}
<Text variant="b3" span>{`${displayName ? ' — ' : ''}${deviceId}`}</Text>
{isCurrentDevice && <Text span className="device-manage__current-label" variant="b3">Current</Text>}
{isCurrentDevice && (
<Text span className="device-manage__current-label" variant="b3">
Current
</Text>
)}
</Text>
)}
options={
processing.includes(deviceId)
? <Spinner size="small" />
: (
<>
{(isCSEnabled && canVerify) && <Button onClick={() => verify(deviceId, isCurrentDevice)} variant="positive">Verify</Button>}
<IconButton size="small" onClick={() => handleRename(device)} src={PencilIC} tooltip="Rename" />
<IconButton size="small" onClick={() => handleRemove(device)} src={BinIC} tooltip="Remove session" />
</>
)
}
content={(
options={
processing.includes(deviceId) ? (
<Spinner size="small" />
) : (
<>
{isCSEnabled && canVerify && (
<Button onClick={() => verify(deviceId, isCurrentDevice)} variant="positive">
Verify
</Button>
)}
<IconButton
size="small"
onClick={() => handleRename(device)}
src={PencilIC}
tooltip="Rename"
/>
<IconButton
size="small"
onClick={() => handleRemove(device)}
src={BinIC}
tooltip="Remove session"
/>
</>
)
}
content={
<>
{lastTS && (
<Text variant="b3">
@ -191,11 +215,14 @@ function DeviceManage() {
)}
{isCurrentDevice && (
<Text style={{ marginTop: 'var(--sp-ultra-tight)' }} variant="b3">
{`Session Key: ${mx.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
{`Session Key: ${mx
.getDeviceEd25519Key()
.match(/.{1,4}/g)
.join(' ')}`}
</Text>
)}
</>
)}
}
/>
);
};
@ -203,16 +230,18 @@ function DeviceManage() {
const unverified = [];
const verified = [];
const noEncryption = [];
deviceList.sort((a, b) => b.last_seen_ts - a.last_seen_ts).forEach((device) => {
const isVerified = isCrossVerified(mx, device.device_id);
if (isVerified === true) {
verified.push(device);
} else if (isVerified === false) {
unverified.push(device);
} else {
noEncryption.push(device);
}
});
deviceList
.sort((a, b) => b.last_seen_ts - a.last_seen_ts)
.forEach((device) => {
const isVerified = isCrossVerified(mx, device.device_id);
if (isVerified === true) {
verified.push(device);
} else if (isVerified === false) {
unverified.push(device);
} else {
noEncryption.push(device);
}
});
return (
<div className="device-manage">
<div>
@ -247,35 +276,37 @@ function DeviceManage() {
/>
</div>
)}
{
unverified.length > 0
? unverified.map((device) => renderDevice(device, false))
: <Text className="device-manage__info">No unverified sessions</Text>
}
{unverified.length > 0 ? (
unverified.map((device) => renderDevice(device, false))
) : (
<Text className="device-manage__info">No unverified sessions</Text>
)}
</div>
{noEncryption.length > 0 && (
<div>
<MenuHeader>Sessions without encryption support</MenuHeader>
{noEncryption.map((device) => renderDevice(device, null))}
</div>
<div>
<MenuHeader>Sessions without encryption support</MenuHeader>
{noEncryption.map((device) => renderDevice(device, null))}
</div>
)}
<div>
<MenuHeader>Verified sessions</MenuHeader>
{
verified.length > 0
? verified.map((device, index) => {
if (truncated && index >= TRUNCATED_COUNT) return null;
return renderDevice(device, true);
})
: <Text className="device-manage__info">No verified sessions</Text>
}
{ verified.length > TRUNCATED_COUNT && (
{verified.length > 0 ? (
verified.map((device, index) => {
if (truncated && index >= TRUNCATED_COUNT) return null;
return renderDevice(device, true);
})
) : (
<Text className="device-manage__info">No verified sessions</Text>
)}
{verified.length > TRUNCATED_COUNT && (
<Button className="device-manage__info" onClick={() => setTruncated(!truncated)}>
{truncated ? `View ${verified.length - 4} more` : 'View less'}
</Button>
)}
{ deviceList.length > 0 && (
<Text className="device-manage__info" variant="b3">Session names are visible to everyone, so do not put any private info here.</Text>
{deviceList.length > 0 && (
<Text className="device-manage__info" variant="b3">
Session names are visible to everyone, so do not put any private info here.
</Text>
)}
</div>
</div>

View file

@ -15,7 +15,7 @@ import * as css from './UnverifiedTab.css';
export function UnverifiedTab() {
const mx = useMatrixClient();
const deviceList = useDeviceList();
const [deviceList] = useDeviceList();
const unverified = deviceList?.filter(
(device) => isCrossVerified(mx, device.device_id) === false
);