diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 022d6cba..51597e7c 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -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 && ( )} + {activePage === SettingsPages.SessionsPage && ( + + )} {activePage === SettingsPages.EmojisStickersPage && ( )} diff --git a/src/app/features/settings/sessions/Sessions.tsx b/src/app/features/settings/sessions/Sessions.tsx new file mode 100644 index 00000000..045192a6 --- /dev/null +++ b/src/app/features/settings/sessions/Sessions.tsx @@ -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 ( + + + + + + + + ); +} + +function DeviceActiveTime({ ts }: { ts: number }) { + return ( + + + {'Last activity: '} + + <> + {today(ts) && 'Today'} + {yesterday(ts) && 'Yesterday'} + {!today(ts) && !yesterday(ts) && timeDayMonYear(ts)} {timeHourMinute(ts)} + + + ); +} + +function DeviceDetails({ device }: { device: IMyDevice }) { + return ( + <> + {typeof device.device_id === 'string' && ( + + Session ID: {device.device_id} + + )} + {typeof device.last_seen_ip === 'string' && ( + + IP Address: {device.last_seen_ip} + + )} + + ); +} + +type DeviceRenameProps = { + device: IMyDevice; + onCancel: () => void; + onRename: () => void; + refreshDeviceList: () => Promise; +}; +function DeviceRename({ device, onCancel, onRename, refreshDeviceList }: DeviceRenameProps) { + const mx = useMatrixClient(); + + const [renameState, rename] = useAsyncCallback( + 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 = (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 ( + + Session Name + + + + + + + + + + {renameState.status === AsyncStatus.Error && ( + + {renameState.error.message} + + )} + + ); +} + +type DeviceTileProps = { + device: IMyDevice; + deleted: boolean; + onDeleteToggle: (deviceId: string) => void; + refreshDeviceList: () => Promise; +}; +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 ( + <> + setDetails(!details)} + > + + + } + after={ + !edit && ( + + {deleted ? ( + onDeleteToggle?.(device.device_id)} + > + Undo + + ) : ( + <> + onDeleteToggle?.(device.device_id)} + > + + + setEdit(true)}> + Edit + + + )} + + ) + } + > + {device.display_name ?? device.device_id} + + {typeof activeTs === 'number' && } + {details && } + + + {edit && ( + 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>(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 ( + + + + + + Sessions + + + + + + + + + + + + + + {devices === null && } + {currentDevice && ( + + Current + + + + + )} + {otherDevices && otherDevices.length > 0 && ( + + Others + {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) => ( + + + + ))} + + )} + + + + + + ); +} diff --git a/src/app/features/settings/sessions/index.ts b/src/app/features/settings/sessions/index.ts new file mode 100644 index 00000000..24947d86 --- /dev/null +++ b/src/app/features/settings/sessions/index.ts @@ -0,0 +1 @@ +export * from './Sessions'; diff --git a/src/app/hooks/useDeviceList.ts b/src/app/hooks/useDeviceList.ts index daec7cbe..ff0041ab 100644 --- a/src/app/hooks/useDeviceList.ts +++ b/src/app/hooks/useDeviceList.ts @@ -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] { const mx = useMatrixClient(); const [deviceList, setDeviceList] = useState(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]; } diff --git a/src/app/organisms/settings/DeviceManage.jsx b/src/app/organisms/settings/DeviceManage.jsx index 9fa8273e..b79c78fe 100644 --- a/src/app/organisms/settings/DeviceManage.jsx +++ b/src/app/organisms/settings/DeviceManage.jsx @@ -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 ( +
+ +
+ + +
+
+ ); }; - return ( -
- -
- - -
-
- ); - }; - openReusableDialog( - Edit session name, - (requestClose) => renderContent((name) => { - isCompleted = true; - resolve(name); - requestClose(); - }), - () => { - if (!isCompleted) resolve(null); - }, - ); -}); + openReusableDialog( + + Edit session name + , + (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 ( {displayName} {`${displayName ? ' — ' : ''}${deviceId}`} - {isCurrentDevice && Current} + {isCurrentDevice && ( + + Current + + )} - )} - options={ - processing.includes(deviceId) - ? - : ( - <> - {(isCSEnabled && canVerify) && } - handleRename(device)} src={PencilIC} tooltip="Rename" /> - handleRemove(device)} src={BinIC} tooltip="Remove session" /> - - ) } - content={( + options={ + processing.includes(deviceId) ? ( + + ) : ( + <> + {isCSEnabled && canVerify && ( + + )} + handleRename(device)} + src={PencilIC} + tooltip="Rename" + /> + handleRemove(device)} + src={BinIC} + tooltip="Remove session" + /> + + ) + } + content={ <> {lastTS && ( @@ -191,11 +215,14 @@ function DeviceManage() { )} {isCurrentDevice && ( - {`Session Key: ${mx.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`} + {`Session Key: ${mx + .getDeviceEd25519Key() + .match(/.{1,4}/g) + .join(' ')}`} )} - )} + } /> ); }; @@ -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 (
@@ -247,35 +276,37 @@ function DeviceManage() { />
)} - { - unverified.length > 0 - ? unverified.map((device) => renderDevice(device, false)) - : No unverified sessions - } + {unverified.length > 0 ? ( + unverified.map((device) => renderDevice(device, false)) + ) : ( + No unverified sessions + )}
{noEncryption.length > 0 && ( -
- Sessions without encryption support - {noEncryption.map((device) => renderDevice(device, null))} -
+
+ Sessions without encryption support + {noEncryption.map((device) => renderDevice(device, null))} +
)}
Verified sessions - { - verified.length > 0 - ? verified.map((device, index) => { - if (truncated && index >= TRUNCATED_COUNT) return null; - return renderDevice(device, true); - }) - : No verified sessions - } - { verified.length > TRUNCATED_COUNT && ( + {verified.length > 0 ? ( + verified.map((device, index) => { + if (truncated && index >= TRUNCATED_COUNT) return null; + return renderDevice(device, true); + }) + ) : ( + No verified sessions + )} + {verified.length > TRUNCATED_COUNT && ( )} - { deviceList.length > 0 && ( - Session names are visible to everyone, so do not put any private info here. + {deviceList.length > 0 && ( + + Session names are visible to everyone, so do not put any private info here. + )}
diff --git a/src/app/pages/client/sidebar/UnverifiedTab.tsx b/src/app/pages/client/sidebar/UnverifiedTab.tsx index 919c4a70..ecd047c4 100644 --- a/src/app/pages/client/sidebar/UnverifiedTab.tsx +++ b/src/app/pages/client/sidebar/UnverifiedTab.tsx @@ -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 );