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
279 lines
9.3 KiB
TypeScript
279 lines
9.3 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
Button,
|
|
color,
|
|
Spinner,
|
|
Text,
|
|
Overlay,
|
|
OverlayBackdrop,
|
|
OverlayCenter,
|
|
Dialog,
|
|
Header,
|
|
config,
|
|
Box,
|
|
IconButton,
|
|
Icon,
|
|
Icons,
|
|
} from 'folds';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import { MatrixError, Method } from 'matrix-js-sdk';
|
|
import { RoomTombstoneEventContent } from 'matrix-js-sdk/lib/types';
|
|
import { SequenceCard } from '../../../components/sequence-card';
|
|
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
|
import { SettingTile } from '../../../components/setting-tile';
|
|
import { useRoom } from '../../../hooks/useRoom';
|
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|
import { IRoomCreateContent, StateEvent } from '../../../../types/matrix/room';
|
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
import { useStateEvent } from '../../../hooks/useStateEvent';
|
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
|
import { useCapabilities } from '../../../hooks/useCapabilities';
|
|
import { stopPropagation } from '../../../utils/keyboard';
|
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
|
import {
|
|
AdditionalCreatorInput,
|
|
RoomVersionSelector,
|
|
useAdditionalCreators,
|
|
} from '../../../components/create-room';
|
|
import { useAlive } from '../../../hooks/useAlive';
|
|
import { creatorsSupported } from '../../../utils/matrix';
|
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
|
import { BreakWord } from '../../../styles/Text.css';
|
|
import { useModalStyle } from '../../../hooks/useModalStyle';
|
|
|
|
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
|
const mx = useMatrixClient();
|
|
const room = useRoom();
|
|
const alive = useAlive();
|
|
const creators = useRoomCreators(room);
|
|
const modalStyle = useModalStyle(480);
|
|
|
|
const capabilities = useCapabilities();
|
|
const roomVersions = capabilities['m.room_versions'];
|
|
const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1');
|
|
useEffect(() => {
|
|
// capabilities load async
|
|
selectRoomVersion(roomVersions?.default ?? '1');
|
|
}, [roomVersions?.default]);
|
|
|
|
const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
|
|
const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } =
|
|
useAdditionalCreators(Array.from(creators));
|
|
|
|
const [upgradeState, upgrade] = useAsyncCallback(
|
|
useCallback(
|
|
async (version: string, newAdditionalCreators?: string[]) => {
|
|
await mx.http.authedRequest(Method.Post, `/rooms/${room.roomId}/upgrade`, undefined, {
|
|
new_version: version,
|
|
additional_creators: newAdditionalCreators,
|
|
});
|
|
},
|
|
[mx, room],
|
|
),
|
|
);
|
|
|
|
const upgrading = upgradeState.status === AsyncStatus.Loading;
|
|
|
|
const handleUpgradeRoom = () => {
|
|
const version = selectedRoomVersion;
|
|
|
|
upgrade(version, allowAdditionalCreators ? additionalCreators : undefined).then(() => {
|
|
if (alive()) {
|
|
requestClose();
|
|
}
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
fallbackFocus: '#roomupgrade-dialog-1',
|
|
onDeactivate: requestClose,
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Dialog
|
|
id="roomupgrade-dialog-1"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="roomupgrade-dialog-1-title"
|
|
tabIndex={-1}
|
|
variant="Surface"
|
|
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="roomupgrade-dialog-1-title" as="h2" size="H4">
|
|
{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}
|
|
</Text>
|
|
</Box>
|
|
<IconButton size="300" onClick={requestClose} radii="300" aria-label="Close">
|
|
<Icon src={Icons.Cross} />
|
|
</IconButton>
|
|
</Header>
|
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
|
<Text priority="400" style={{ color: color.Critical.Main }}>
|
|
<b>This action is irreversible!</b>
|
|
</Text>
|
|
<Box direction="Column" gap="100">
|
|
<Text size="L400">Options</Text>
|
|
<RoomVersionSelector
|
|
versions={roomVersions?.available ? Object.keys(roomVersions.available) : ['1']}
|
|
value={selectedRoomVersion}
|
|
onChange={selectRoomVersion}
|
|
disabled={upgrading}
|
|
/>
|
|
{allowAdditionalCreators && (
|
|
<SequenceCard
|
|
style={{ padding: config.space.S300 }}
|
|
variant="SurfaceVariant"
|
|
direction="Column"
|
|
gap="500"
|
|
>
|
|
<AdditionalCreatorInput
|
|
additionalCreators={additionalCreators}
|
|
onSelect={addAdditionalCreator}
|
|
onRemove={removeAdditionalCreator}
|
|
disabled={upgrading}
|
|
/>
|
|
</SequenceCard>
|
|
)}
|
|
</Box>
|
|
{upgradeState.status === AsyncStatus.Error && (
|
|
<Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200">
|
|
{(upgradeState.error as MatrixError).message}
|
|
</Text>
|
|
)}
|
|
<Button
|
|
onClick={handleUpgradeRoom}
|
|
variant="Secondary"
|
|
disabled={upgrading}
|
|
before={upgrading && <Spinner size="200" variant="Secondary" fill="Solid" />}
|
|
>
|
|
<Text size="B400">{room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}</Text>
|
|
</Button>
|
|
</Box>
|
|
</Dialog>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
);
|
|
}
|
|
|
|
type RoomUpgradeProps = {
|
|
permissions: RoomPermissionsAPI;
|
|
requestClose: () => void;
|
|
};
|
|
export function RoomUpgrade({ permissions, requestClose }: RoomUpgradeProps) {
|
|
const mx = useMatrixClient();
|
|
const room = useRoom();
|
|
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
|
const createContent = useStateEvent(
|
|
room,
|
|
StateEvent.RoomCreate,
|
|
)?.getContent<IRoomCreateContent>();
|
|
const roomVersion = createContent?.room_version ?? '1';
|
|
const predecessorRoomId = createContent?.predecessor?.room_id;
|
|
|
|
const tombstoneContent = useStateEvent(
|
|
room,
|
|
StateEvent.RoomTombstone,
|
|
)?.getContent<RoomTombstoneEventContent>();
|
|
const replacementRoom = tombstoneContent?.replacement_room;
|
|
|
|
const canUpgrade = permissions.stateEvent(StateEvent.RoomTombstone, mx.getSafeUserId());
|
|
|
|
const handleOpenRoom = () => {
|
|
if (replacementRoom) {
|
|
requestClose();
|
|
if (room.isSpaceRoom()) {
|
|
navigateSpace(replacementRoom);
|
|
} else {
|
|
navigateRoom(replacementRoom);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleOpenOldRoom = () => {
|
|
if (predecessorRoomId) {
|
|
requestClose();
|
|
if (room.isSpaceRoom()) {
|
|
navigateSpace(predecessorRoomId);
|
|
} else {
|
|
navigateRoom(predecessorRoomId, createContent.predecessor?.event_id);
|
|
}
|
|
}
|
|
};
|
|
|
|
const [prompt, setPrompt] = useState(false);
|
|
|
|
return (
|
|
<SequenceCard
|
|
className={SequenceCardStyle}
|
|
variant="SurfaceVariant"
|
|
direction="Column"
|
|
gap="400"
|
|
>
|
|
<SettingTile
|
|
title={room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}
|
|
description={
|
|
replacementRoom
|
|
? tombstoneContent.body ||
|
|
`This ${room.isSpaceRoom() ? 'space' : 'room'} has been replaced!`
|
|
: `Current version: ${roomVersion}.`
|
|
}
|
|
after={
|
|
<Box alignItems="Center" gap="200">
|
|
{predecessorRoomId && (
|
|
<Button
|
|
size="300"
|
|
variant="Secondary"
|
|
fill="Soft"
|
|
outlined
|
|
radii="300"
|
|
onClick={handleOpenOldRoom}
|
|
>
|
|
<Text size="B300">{room.isSpaceRoom() ? 'Old Space' : 'Old Room'}</Text>
|
|
</Button>
|
|
)}
|
|
{replacementRoom ? (
|
|
<Button
|
|
size="300"
|
|
variant="Success"
|
|
fill="Solid"
|
|
radii="300"
|
|
onClick={handleOpenRoom}
|
|
>
|
|
<Text size="B300">{room.isSpaceRoom() ? 'Open New Space' : 'Open New Room'}</Text>
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="300"
|
|
variant="Secondary"
|
|
fill="Solid"
|
|
radii="300"
|
|
disabled={!canUpgrade}
|
|
onClick={() => setPrompt(true)}
|
|
>
|
|
<Text size="B300">Upgrade</Text>
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
}
|
|
>
|
|
{prompt && <RoomUpgradeDialog requestClose={() => setPrompt(false)} />}
|
|
</SettingTile>
|
|
</SequenceCard>
|
|
);
|
|
}
|