CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Most modals rendered a folds Dialog/Modal with no role and no name, and their focus traps used `initialFocus: false`, so focus stayed behind the modal and a screen reader never announced it. - 32 dialogs with a visible heading: role="dialog", aria-modal, aria-labelledby → the heading (given an id), tabIndex=-1. - 4 dialogs that already had a name (Leave Room, room topic viewer, server ACL, room-nav prompt): role + aria-modal. - Their focus traps drop `initialFocus: false` for focus-trap's default (keep an already-focused autoFocus field, else the first tabbable element) with the dialog itself as fallbackFocus, so a dialog without a tabbable node can't crash the trap. Traps that live in a parent (UIA stages, Logout, Forward, Invite) get the semantics only. - The file drop overlay is deliberately left alone (not a dialog). Checked at runtime: Join with Address, Delete Message, Report Message, Leave Room and Logout open as named dialogs with focus inside and close with Escape (Tab first when a text field has focus — the shared stopPropagation keeps Escape from discarding typed text, by design). The axe e2e spec (6 tests) passes; eslint warnings unchanged (46). 17 modals with no heading (image/file viewers, loading screens) remain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
120 lines
4.4 KiB
TypeScript
120 lines
4.4 KiB
TypeScript
import React, { forwardRef, useCallback } from 'react';
|
||
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
||
import { useAtom } from 'jotai';
|
||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||
import { logoutClient } from '../../client/initMatrix';
|
||
import { callEmbedAtom } from '../state/callEmbed';
|
||
import { hangupCallAndWait } from '../plugins/call/hangup';
|
||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||
import { useModalStyle } from '../hooks/useModalStyle';
|
||
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
||
import { InfoCard } from './info-card';
|
||
import {
|
||
useDeviceVerificationStatus,
|
||
VerificationStatus,
|
||
} from '../hooks/useDeviceVerificationStatus';
|
||
|
||
/**
|
||
* Ask Element Call to hang up and wait (bounded) until our own MatrixRTC
|
||
* membership has actually been removed from the room, so the leave reaches
|
||
* the homeserver before the client is stopped and the token is revoked.
|
||
*/
|
||
type LogoutDialogProps = {
|
||
handleClose: () => void;
|
||
};
|
||
export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||
({ handleClose }, ref) => {
|
||
const mx = useMatrixClient();
|
||
const modalStyle = useModalStyle(480);
|
||
const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent());
|
||
const crossSigningActive = useCrossSigningActive();
|
||
const verificationStatus = useDeviceVerificationStatus(
|
||
mx.getCrypto(),
|
||
mx.getSafeUserId(),
|
||
mx.getDeviceId() ?? undefined,
|
||
);
|
||
|
||
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
|
||
|
||
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
||
useCallback(async () => {
|
||
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
|
||
// membership (expires: 4 h) stays behind as a ghost participant and
|
||
// everyone else sees you "in call" until it times out.
|
||
if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
|
||
await hangupCallAndWait(mx, callEmbed);
|
||
setCallEmbed(undefined);
|
||
}
|
||
await logoutClient(mx);
|
||
}, [mx, callEmbed, setCallEmbed]),
|
||
);
|
||
|
||
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
||
|
||
return (
|
||
<Dialog
|
||
id="logoutdialog-dialog-1"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="logoutdialog-dialog-1-title"
|
||
tabIndex={-1}
|
||
variant="Surface"
|
||
ref={ref}
|
||
style={modalStyle}
|
||
>
|
||
<Header
|
||
style={{
|
||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||
borderBottomWidth: config.borderWidth.B300,
|
||
}}
|
||
variant="Surface"
|
||
size="500"
|
||
>
|
||
<Box grow="Yes">
|
||
<Text id="logoutdialog-dialog-1-title" as="h2" size="H4">
|
||
Logout
|
||
</Text>
|
||
</Box>
|
||
</Header>
|
||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||
{hasEncryptedRoom &&
|
||
(crossSigningActive ? (
|
||
verificationStatus === VerificationStatus.Unverified && (
|
||
<InfoCard
|
||
variant="Critical"
|
||
title="Unverified Device"
|
||
description="Verify your device before logging out to save your encrypted messages."
|
||
/>
|
||
)
|
||
) : (
|
||
<InfoCard
|
||
variant="Critical"
|
||
title="Alert"
|
||
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
|
||
/>
|
||
))}
|
||
<Text priority="400">You’re about to log out. Are you sure?</Text>
|
||
{logoutState.status === AsyncStatus.Error && (
|
||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||
Failed to logout! {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">Logout</Text>
|
||
</Button>
|
||
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
|
||
<Text size="B400">Cancel</Text>
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
</Dialog>
|
||
);
|
||
},
|
||
);
|