refactor(settings): replace the shared LogoutDialog with a self-contained localised logout-dialog card

This commit is contained in:
heaven 2026-06-15 02:57:32 +03:00
parent caff65d9ee
commit df9cac8f5c
4 changed files with 244 additions and 91 deletions

View file

@ -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>
);
}
);

View 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,
});

View 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>
);
}

View file

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