Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
6f182e2
refactor: extract ModalOverlay and fix create room layout on mobile
eleboucher Jul 24, 2026
0de7143
refactor: migrate remaining matching modals to ModalOverlay
eleboucher Jul 24, 2026
19b029a
refactor: support conditional open in ModalOverlay and migrate remain…
eleboucher Jul 24, 2026
4241064
feat: open home and direct tab menus with a long press on touch
eleboucher Jul 24, 2026
fa45d70
refactor: share one FormPage across create room, create space and bug…
eleboucher Jul 24, 2026
9eaec6c
refactor: share one FormModal between create room and create space
eleboucher Jul 24, 2026
f510967
docs: plan for collapsing page and modal surfaces into modal routes
eleboucher Jul 24, 2026
2735a21
refactor: make create, bug report and navigate surfaces shallow routes
eleboucher Jul 24, 2026
093dde9
refactor: merge space settings into room settings
eleboucher Jul 24, 2026
343a813
fix: show space admin actions in the lobby without a reload
eleboucher Jul 24, 2026
7deea84
refactor: collapse route selection hooks into one module
eleboucher Jul 24, 2026
5ad2b28
refactor: open settings from a button in the mobile account header
eleboucher Jul 24, 2026
1b9a3d0
refactor: share one sidebar panel across full page routes
eleboucher Jul 24, 2026
e2cd395
fix: stretch the mobile account header so the settings button sits right
eleboucher Jul 24, 2026
b8b2bae
fix: centre the mobile account title independently of the settings bu…
eleboucher Jul 25, 2026
f4b7b2e
test: restructure e2e around fixtures and page objects, run against a…
eleboucher Jul 25, 2026
5f82ef8
test: restructure e2e around fixtures and page objects, run against a…
eleboucher Jul 25, 2026
3ad9bb5
Merge branch 'test/e2e-harness' into refactor/modal-overlay
eleboucher Jul 25, 2026
cb7e8c0
refactor: always enable mobile gestures and delete the setting
eleboucher Jul 25, 2026
f4349af
refactor: give ModalOverlay mobile presentation and android back hand…
eleboucher Jul 25, 2026
70c7c30
refactor: share one PromptDialog across the prompt components
eleboucher Jul 25, 2026
90ab237
refactor: present anchored menus as bottom sheets on mobile
eleboucher Jul 25, 2026
b36732f
refactor: collapse duplicate select-one menus onto SettingMenuSelector
eleboucher Jul 25, 2026
a43a382
refactor: migrate hand-rolled overlays onto ModalOverlay
eleboucher Jul 25, 2026
e701007
refactor: split ClientNonUIFeatures into feature modules
eleboucher Jul 25, 2026
3022ad7
refactor: fold remaining selectors onto SettingMenuSelector
eleboucher Jul 25, 2026
672a307
refactor: extract MessageSearchHeader from the two identical search p…
eleboucher Jul 25, 2026
95ac370
refactor: add useMenuAnchor and make member sort and filter bottom sh…
eleboucher Jul 25, 2026
44cdd51
refactor: make ResponsiveMenu configurable and migrate the remaining …
eleboucher Jul 25, 2026
dd84e9f
refactor: fold General.tsx selectors onto SettingMenuSelector
eleboucher Jul 25, 2026
86ab28a
fix: restore focus trapping in mobile menus and honour escape opt-out…
eleboucher Jul 25, 2026
77fb621
fix: apply nicknames to thread mentions and route room avatars throug…
eleboucher Jul 25, 2026
ddd5edc
fix: apply inline image sizing in threads, collapse the duplicated se…
eleboucher Jul 25, 2026
7c86018
fix: pass cached profile data and guard failed-send actions in threads
eleboucher Jul 25, 2026
cbb7761
refactor: type the klipy search response instead of disabling no-expl…
eleboucher Jul 25, 2026
f57e508
fix: keep favourite gifs live instead of snapshotting them into state
eleboucher Jul 25, 2026
b430fa5
feat: add a bottom-sheet mobile presentation to ModalOverlay
eleboucher Jul 25, 2026
17e957e
Revert "feat: add a bottom-sheet mobile presentation to ModalOverlay"
eleboucher Jul 25, 2026
b797114
refactor: drop unused overlay props and migrate the escape-opt-out ov…
eleboucher Jul 25, 2026
56befb5
refactor: route every avatar through one helper and fold the date for…
eleboucher Jul 25, 2026
48ae008
refactor: share one persisted string-set atom across the three catego…
eleboucher Jul 25, 2026
17372ac
chore: guard against short import cycles with import/no-cycle
eleboucher Jul 25, 2026
9c2fd74
fix: expose long-press state from useMenuAnchor and defer menu conten…
eleboucher Jul 25, 2026
03c1d30
refactor: share one SequenceCardStyle and one thread reply collector
eleboucher Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/shallow-routes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Shallow routes

Some surfaces should be an overlay on desktop and a full page on mobile: create
room, create space, bug report, navigate, and settings. They are all plain
routes — the URL is the only source of truth — and the presentation is chosen at
render time.

## How it works

`src/app/pages/client/shallowRoute.ts` lists the paths that behave this way. A
route is _shallow_ when it matches one of them, the viewport is not mobile, and
`location.state.backgroundLocation` is set.

- `useOpenShallowRoute()` navigates and records the current location as the
background. Navigating within the same surface (settings section to settings
section) keeps the background it was opened over.
- `ClientRouteOutlet` renders the previous outlet while a shallow route is
active, so the page behind stays mounted.
- `ShallowRouteRenderer` (and `SettingsShallowRouteRenderer`) draw the overlay
from the persistent layout, since the route element itself is suppressed.
- `useCloseShallowRoute()` returns to the background location, or home.

React Router's data router (`createBrowserRouter`) does not support the
`<Routes location={background}>` trick — descendant `<Routes>` trees cannot use
the data APIs — so the outlet is cached instead.

## Adding a surface

1. Add its path to `SHALLOW_ROUTE_PATHS`.
2. Render `RouteSurface` from the route element. It picks `FormModal` on desktop
and `FormPage` on mobile, and wires up close for both.
3. Add the path to `ShallowRouteRenderer`.

`FormPage` renders its own desktop sidebar, so `RouteSurface` must only use it
when the surface is _not_ an overlay.
9 changes: 9 additions & 0 deletions oxlint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default defineConfig({
builtin: true,
},
rules: {
'import/no-cycle': ['error', { maxDepth: 3 }],
'import/no-unassigned-import': 'off',
'import/no-named-as-default': 'off',
'import/no-named-as-default-member': 'off',
Expand Down Expand Up @@ -81,5 +82,13 @@ export default defineConfig({
'typescript/no-unsafe-enum-comparison': 'off',
},
},
{
// Playwright fixtures take a `use` callback, which the React hook rule
// mistakes for the `use` hook.
files: ['tests/e2e/**'],
rules: {
'react/rules-of-hooks': 'off',
},
},
],
});
26 changes: 19 additions & 7 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,23 @@ export default defineConfig({
testDir: './tests/e2e',
testMatch: '**/*.spec.ts',
snapshotPathTemplate: 'tests/e2e/__screenshots__/{projectName}/{testFileName}/{arg}{ext}',
// Every context shares one Matrix account and device id from the seeded
// storageState, so concurrent clients contend over sync and crypto state.
// Parallelising would need a worker-scoped account fixture; the suite runs in
// well under a minute serially, so it is not worth the plumbing.
fullyParallel: false,
workers: 1,
retries: 2,
// Locally a failure should surface at once; CI keeps retries for flakes.
retries: process.env.CI ? 2 : 0,
reporter: [['html', { open: 'never' }], ['list']],
globalSetup: './tests/e2e/global-setup.ts',
timeout: 240_000,
// Tests run in 3-6s against a built app; this is headroom, not a wait budget.
timeout: 60_000,
expect: {
toHaveScreenshot: { maxDiffPixelRatio: 0.01 },
},
use: {
baseURL: 'http://localhost:8080',
baseURL: 'http://localhost:8081',
storageState: 'tests/e2e/.auth/state.json',
trace: 'on-first-retry',
},
Expand All @@ -29,10 +35,16 @@ export default defineConfig({
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:8080',
reuseExistingServer: !process.env.CI,
timeout: 240_000,
// A real build, not the dev server: no HMR and no dependency optimiser, both
// of which invalidate the module graph mid-run and make the suite lie.
// Port 8081, not the dev server's 8080, so a running `pnpm dev` is untouched.
command: 'pnpm run build && pnpm exec vite preview --port 8081 --strictPort',
url: 'http://localhost:8081',
// Never reuse: a leftover server would silently serve a stale build. The
// build is ~30s, which is worth paying to know what is under test.
reuseExistingServer: false,
// ~35s observed (31s build + preview start), with headroom for a cold CI box.
timeout: 180_000,
env: { NODE_OPTIONS: '--max-old-space-size=8192' },
},
});
2 changes: 1 addition & 1 deletion src/app/components/DefaultErrorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Box, Button, Dialog, Text, color, config } from 'folds';
import { Warning, sizedIcon } from '$components/icons/phosphor';
import * as Sentry from '@sentry/react';
import { SplashScreen } from '$components/splash-screen';
import { buildGitHubUrl } from '$features/bug-report/BugReportModal';
import { buildGitHubUrl } from '$features/bug-report';

type ErrorPageProps = {
error: Error;
Expand Down
110 changes: 43 additions & 67 deletions src/app/components/DeviceVerification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,8 @@ import type { ShowSasCallbacks, VerificationRequest, Verifier } from '$types/mat
import { VerificationPhase, VerificationMethod } from '$types/matrix-sdk';
import type { CSSProperties } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Box,
Button,
config,
Dialog,
Header,
IconButton,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
} from 'folds';
import { Box, Button, config, Dialog, Header, IconButton, Spinner, Text } from 'folds';
import { composerIcon, X } from '$components/icons/phosphor';
import FocusTrap from 'focus-trap-react';
import * as Sentry from '@sentry/react';
import {
useVerificationRequestPhase,
Expand All @@ -25,8 +12,8 @@ import {
useVerifierShowSas,
} from '$hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { useDismissOnBack } from '$utils/androidBack';
import { ContainerColor } from '$styles/ContainerColor.css';
import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';

const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
Expand Down Expand Up @@ -240,9 +227,6 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
onExit();
}, [request, onExit]);

// Android back cancels/dismisses the verification overlay instead of navigating away.
useDismissOnBack(handleCancel);

const handleAccept = useCallback(() => request.accept(), [request]);
const handleStart = useCallback(async () => {
await request.startVerification(VerificationMethod.Sas);
Expand All @@ -261,55 +245,47 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
}, [phase]);

return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: false,
escapeDeactivates: false,
}}
>
<Dialog variant="Surface">
<Header style={DialogHeaderStyles} variant="Surface" size="500">
<Box grow="Yes">
<Text size="H4">Device Verification</Text>
</Box>
<IconButton size="300" radii="300" onClick={handleCancel}>
{composerIcon(X)}
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{phase === VerificationPhase.Requested &&
(request.initiatedByMe ? (
<VerificationWaitAccept />
) : (
<VerificationAccept onAccept={handleAccept} />
))}
{phase === VerificationPhase.Ready &&
(request.initiatedByMe ? (
<AutoVerificationStart onStart={handleStart} />
) : (
<VerificationWaitStart />
))}
{phase === VerificationPhase.Started &&
(request.verifier ? (
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
) : (
<VerificationUnexpected
message="Unexpected Error! Verification is started but verifier is missing."
onClose={handleCancel}
/>
))}
{phase === VerificationPhase.Done && <VerificationDone onExit={onExit} />}
{phase === VerificationPhase.Cancelled && (
<VerificationCanceled onClose={handleCancel} />
)}
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
<ModalOverlay
requestClose={handleCancel}
dismissOnClickOutside={false}
escapeDeactivates={false}
>
<Dialog variant="Surface">
<Header style={DialogHeaderStyles} variant="Surface" size="500">
<Box grow="Yes">
<Text size="H4">Device Verification</Text>
</Box>
<IconButton size="300" radii="300" onClick={handleCancel}>
{composerIcon(X)}
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{phase === VerificationPhase.Requested &&
(request.initiatedByMe ? (
<VerificationWaitAccept />
) : (
<VerificationAccept onAccept={handleAccept} />
))}
{phase === VerificationPhase.Ready &&
(request.initiatedByMe ? (
<AutoVerificationStart onStart={handleStart} />
) : (
<VerificationWaitStart />
))}
{phase === VerificationPhase.Started &&
(request.verifier ? (
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
) : (
<VerificationUnexpected
message="Unexpected Error! Verification is started but verifier is missing."
onClose={handleCancel}
/>
))}
{phase === VerificationPhase.Done && <VerificationDone onExit={onExit} />}
{phase === VerificationPhase.Cancelled && <VerificationCanceled onClose={handleCancel} />}
</Box>
</Dialog>
</ModalOverlay>
);
}

Expand Down
14 changes: 14 additions & 0 deletions src/app/components/GlobalKeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getHomeRoomPath,
getHomeSearchPath,
getInboxBookmarksPath,
getNavigatePath,
getSpaceRoomPath,
getSpaceSearchPath,
withSearchParam,
Expand All @@ -33,11 +34,13 @@ import type { Room } from '$types/matrix-sdk';
import { useSelectedSpace } from '$hooks/router/useSelectedSpace';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import { useOpenShallowRoute } from '$pages/client/useShallowRoute';
import { matchesShortcut } from '../keyboard/shortcuts';

export function GlobalKeyboardShortcuts() {
const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides');
const navigate = useNavigate();
const openShallowRoute = useOpenShallowRoute();
const location = useLocation();
const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom);
Expand Down Expand Up @@ -177,6 +180,16 @@ export function GlobalKeyboardShortcuts() {
[navigate, shortcutOverrides]
);

/** Opens the room search palette */
const handleOpenRoomSearch = useCallback(
(evt: KeyboardEvent) => {
if (!matchesShortcut('navigation.openRoomSearch', evt, shortcutOverrides)) return;
evt.preventDefault();
openShallowRoute(getNavigatePath());
},
[openShallowRoute, shortcutOverrides]
);

/** Ctrl+F: Search for messages */
const handleSearchMessageInRoom = useCallback(
(evt: KeyboardEvent) => {
Expand All @@ -201,6 +214,7 @@ export function GlobalKeyboardShortcuts() {
useKeyDown(window, handleReplyKeyDown);
useKeyDown(window, handleBookmarkKeyDown);
useKeyDown(window, handleSearchMessageInRoom);
useKeyDown(window, handleOpenRoomSearch);

return null;
}
5 changes: 3 additions & 2 deletions src/app/components/IncomingCallModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ vi.mock('$hooks/useRoomMeta', () => ({
useRoomName: () => 'Direct Message',
}));

vi.mock('$utils/room', () => ({
getRoomAvatarUrl: () => null,
vi.mock(import('$utils/room'), async (importOriginal) => ({
...(await importOriginal()),
getRoomAvatarUrl: () => undefined,
getMemberDisplayName: () => 'Alice',
}));

Expand Down
Loading