refactor(settings): replace the shared LogoutDialog with a self-contained localised logout-dialog card
This commit is contained in:
parent
caff65d9ee
commit
df9cac8f5c
4 changed files with 244 additions and 91 deletions
|
|
@ -1,91 +0,0 @@
|
|||
import React, { forwardRef, useCallback } from 'react';
|
||||
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { logoutClient } from '../../client/initMatrix';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
||||
import { InfoCard } from './info-card';
|
||||
import {
|
||||
useDeviceVerificationStatus,
|
||||
VerificationStatus,
|
||||
} from '../hooks/useDeviceVerificationStatus';
|
||||
|
||||
type LogoutDialogProps = {
|
||||
handleClose: () => void;
|
||||
};
|
||||
export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
({ handleClose }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent());
|
||||
const crossSigningActive = useCrossSigningActive();
|
||||
const verificationStatus = useDeviceVerificationStatus(
|
||||
mx.getCrypto(),
|
||||
mx.getSafeUserId(),
|
||||
mx.getDeviceId() ?? undefined
|
||||
);
|
||||
|
||||
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
||||
useCallback(async () => {
|
||||
await logoutClient(mx);
|
||||
}, [mx])
|
||||
);
|
||||
|
||||
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
||||
|
||||
return (
|
||||
<Dialog variant="Surface" ref={ref}>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="H4">{t('Settings.logout')}</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
{hasEncryptedRoom &&
|
||||
(crossSigningActive ? (
|
||||
verificationStatus === VerificationStatus.Unverified && (
|
||||
<InfoCard
|
||||
variant="Critical"
|
||||
title={t('Settings.logout_unverified_title')}
|
||||
description={t('Settings.logout_unverified_desc')}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<InfoCard
|
||||
variant="Critical"
|
||||
title={t('Settings.logout_alert_title')}
|
||||
description={t('Settings.logout_alert_desc')}
|
||||
/>
|
||||
))}
|
||||
<Text priority="400">{t('Settings.logout_confirm')}</Text>
|
||||
{logoutState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
{t('Settings.logout_failed', { message: logoutState.error.message })}
|
||||
</Text>
|
||||
)}
|
||||
<Box direction="Column" gap="200">
|
||||
<Button
|
||||
variant="Critical"
|
||||
onClick={logout}
|
||||
disabled={ongoingLogout}
|
||||
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
|
||||
>
|
||||
<Text size="B400">{t('Settings.logout')}</Text>
|
||||
</Button>
|
||||
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
|
||||
<Text size="B400">{t('Settings.cancel')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
79
src/app/components/logout-dialog/LogoutDialog.css.ts
Normal file
79
src/app/components/logout-dialog/LogoutDialog.css.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
// Logout confirmation — same self-styled Vojo card vocabulary as the AI
|
||||
// privacy notice (AiChatPrivacy.css.ts): own surface/radius/border/shadow,
|
||||
// branded badge header with NO bottom-border bar (the border bar is what made
|
||||
// the old folds Dialog read as a generic cinny dialog). Narrower than the
|
||||
// privacy card — it's a yes/no confirm, not a reading surface.
|
||||
export const DialogCard = style({
|
||||
width: '90vw',
|
||||
maxWidth: toRem(400),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: color.Surface.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
|
||||
});
|
||||
|
||||
export const Header = style({
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: config.space.S300,
|
||||
padding: `${config.space.S400} ${config.space.S400} ${config.space.S300}`,
|
||||
});
|
||||
|
||||
// Rounded-square badge in the critical tone — the destructive twin of the
|
||||
// privacy card's fleet shield.
|
||||
export const HeaderBadge = style({
|
||||
flexShrink: 0,
|
||||
width: toRem(40),
|
||||
height: toRem(40),
|
||||
borderRadius: config.radii.R400,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: color.Critical.Container,
|
||||
color: color.Critical.Main,
|
||||
});
|
||||
|
||||
export const HeaderText = style({
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: toRem(2),
|
||||
paddingTop: toRem(2),
|
||||
});
|
||||
|
||||
export const HeaderTitle = style({
|
||||
fontSize: toRem(18),
|
||||
fontWeight: 700,
|
||||
lineHeight: toRem(22),
|
||||
color: color.Surface.OnContainer,
|
||||
});
|
||||
|
||||
export const HeaderSubtitle = style({
|
||||
fontSize: toRem(13),
|
||||
lineHeight: toRem(17),
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
opacity: 0.7,
|
||||
});
|
||||
|
||||
export const Body = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S400,
|
||||
padding: `0 ${config.space.S400} ${config.space.S400}`,
|
||||
});
|
||||
|
||||
// Telegram-style horizontal confirm row: Cancel first, the destructive
|
||||
// action trailing at the end.
|
||||
export const ButtonRow = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: config.space.S200,
|
||||
});
|
||||
164
src/app/components/logout-dialog/LogoutDialog.tsx
Normal file
164
src/app/components/logout-dialog/LogoutDialog.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Spinner,
|
||||
Text,
|
||||
color,
|
||||
} from 'folds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { logoutClient } from '../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
|
||||
import { InfoCard } from '../info-card';
|
||||
import {
|
||||
useDeviceVerificationStatus,
|
||||
VerificationStatus,
|
||||
} from '../../hooks/useDeviceVerificationStatus';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import * as css from './LogoutDialog.css';
|
||||
|
||||
type LogoutDialogProps = {
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
// Logout confirmation — self-contained Vojo card (owns its Overlay +
|
||||
// FocusTrap, callers just conditionally render it). Same chrome as the AI
|
||||
// privacy notice: badge header without a border bar, horizontal confirm row.
|
||||
// The encrypted-rooms key-loss warnings (InfoCard) stay — they are the only
|
||||
// thing standing between an unverified device and losing message history.
|
||||
export function LogoutDialog({ handleClose }: LogoutDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent());
|
||||
const crossSigningActive = useCrossSigningActive();
|
||||
const verificationStatus = useDeviceVerificationStatus(
|
||||
mx.getCrypto(),
|
||||
mx.getSafeUserId(),
|
||||
mx.getDeviceId() ?? undefined
|
||||
);
|
||||
|
||||
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
||||
useCallback(async () => {
|
||||
await logoutClient(mx);
|
||||
}, [mx])
|
||||
);
|
||||
|
||||
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
||||
// focus-trap-react copies focusTrapOptions ONCE at mount — plain values
|
||||
// here would freeze at `ongoingLogout = false` and let a backdrop click /
|
||||
// Esc dismiss the dialog mid-logout (hiding a potential failure while the
|
||||
// session is still alive). Function options are called at event time, so
|
||||
// they read the live state through this ref.
|
||||
const ongoingLogoutRef = useRef(ongoingLogout);
|
||||
ongoingLogoutRef.current = ongoingLogout;
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => {
|
||||
if (!ongoingLogoutRef.current) handleClose();
|
||||
},
|
||||
clickOutsideDeactivates: () => !ongoingLogoutRef.current,
|
||||
escapeDeactivates: (evt) => {
|
||||
// Mid-logout the Esc must still be CONSUMED: just returning
|
||||
// false would let the keydown bubble on to the surface
|
||||
// hosting the dialog (the mobile settings sheet's window
|
||||
// listener, route-level handlers) and tear it down — the
|
||||
// exact dismissal this ref-guard exists to prevent.
|
||||
if (ongoingLogoutRef.current) {
|
||||
evt.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
return stopPropagation(evt);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={css.DialogCard}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('Settings.logout')}
|
||||
>
|
||||
<header className={css.Header}>
|
||||
<span className={css.HeaderBadge} aria-hidden="true">
|
||||
<Icon src={Icons.Power} />
|
||||
</span>
|
||||
<div className={css.HeaderText}>
|
||||
<span className={css.HeaderTitle}>{t('Settings.logout')}</span>
|
||||
<span className={css.HeaderSubtitle}>{t('Settings.logout_confirm')}</span>
|
||||
</div>
|
||||
<IconButton
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
onClick={handleClose}
|
||||
disabled={ongoingLogout}
|
||||
aria-label={t('Settings.cancel')}
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<div className={css.Body}>
|
||||
{hasEncryptedRoom &&
|
||||
(crossSigningActive ? (
|
||||
verificationStatus === VerificationStatus.Unverified && (
|
||||
<InfoCard
|
||||
variant="Critical"
|
||||
title={t('Settings.logout_unverified_title')}
|
||||
description={t('Settings.logout_unverified_desc')}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<InfoCard
|
||||
variant="Critical"
|
||||
title={t('Settings.logout_alert_title')}
|
||||
description={t('Settings.logout_alert_desc')}
|
||||
/>
|
||||
))}
|
||||
{logoutState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
{t('Settings.logout_failed', { message: logoutState.error.message })}
|
||||
</Text>
|
||||
)}
|
||||
<Box className={css.ButtonRow}>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={handleClose}
|
||||
disabled={ongoingLogout}
|
||||
>
|
||||
<Text size="B300">{t('Settings.cancel')}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
radii="300"
|
||||
onClick={logout}
|
||||
disabled={ongoingLogout}
|
||||
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="100" />}
|
||||
>
|
||||
<Text size="B300">{t('Settings.logout')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
1
src/app/components/logout-dialog/index.ts
Normal file
1
src/app/components/logout-dialog/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './LogoutDialog';
|
||||
Loading…
Add table
Reference in a new issue