From 6f182e2f4ce5c525729e4b76b14674f730f4d318 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Fri, 24 Jul 2026 23:28:31 +0200
Subject: [PATCH 01/43] refactor: extract ModalOverlay and fix create room
layout on mobile
---
.../leave-room-prompt/LeaveRoomPrompt.tsx | 118 +++++++-----------
.../components/modal-overlay/ModalOverlay.tsx | 28 +++++
src/app/features/create-room/CreateRoom.tsx | 1 -
.../features/create-room/CreateRoomModal.tsx | 97 ++++++--------
.../client/create-room/CreateRoomPage.tsx | 15 ++-
tests/e2e/leave-room-prompt.spec.ts | 22 ++++
6 files changed, 140 insertions(+), 141 deletions(-)
create mode 100644 src/app/components/modal-overlay/ModalOverlay.tsx
create mode 100644 tests/e2e/leave-room-prompt.spec.ts
diff --git a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
index 48f11d0e8a..9a457f5a76 100644
--- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
+++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
@@ -1,24 +1,10 @@
import { useCallback, useEffect } from 'react';
-import FocusTrap from 'focus-trap-react';
-import {
- Dialog,
- Overlay,
- OverlayCenter,
- OverlayBackdrop,
- Header,
- config,
- Box,
- Text,
- IconButton,
- color,
- Button,
- Spinner,
-} from 'folds';
+import { Dialog, Header, config, Box, Text, IconButton, color, Button, Spinner } from 'folds';
import type { MatrixError } from '$types/matrix-sdk';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { composerIcon, X } from '$components/icons/phosphor';
-import { stopPropagation } from '$utils/keyboard';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
import { createDebugLogger } from '$utils/debugLogger';
const debugLog = createDebugLogger('LeaveRoomPrompt');
@@ -50,63 +36,51 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
}, [leaveState, onDone, roomId]);
return (
- }>
-
-
+
-
-
+
+ Leave Room
+
+
+ {composerIcon(X)}
+
+
+
+
+ Are you sure you want to leave this room?
+ {leaveState.status === AsyncStatus.Error && (
+
+ Failed to leave room! {leaveState.error.message}
+
+ )}
+
+
+ ) : undefined
+ }
+ aria-disabled={
+ leaveState.status === AsyncStatus.Loading || leaveState.status === AsyncStatus.Success
+ }
+ >
+
+ {leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
+
+
+
+
+
);
}
diff --git a/src/app/components/modal-overlay/ModalOverlay.tsx b/src/app/components/modal-overlay/ModalOverlay.tsx
new file mode 100644
index 0000000000..3915a97f5f
--- /dev/null
+++ b/src/app/components/modal-overlay/ModalOverlay.tsx
@@ -0,0 +1,28 @@
+import type { ReactNode } from 'react';
+import FocusTrap from 'focus-trap-react';
+import { Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
+import { stopPropagation } from '$utils/keyboard';
+
+type ModalOverlayProps = {
+ requestClose: () => void;
+ children: ReactNode;
+};
+
+export function ModalOverlay({ requestClose, children }: ModalOverlayProps) {
+ return (
+ }>
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/src/app/features/create-room/CreateRoom.tsx b/src/app/features/create-room/CreateRoom.tsx
index c41378017a..9cb42c524d 100644
--- a/src/app/features/create-room/CreateRoom.tsx
+++ b/src/app/features/create-room/CreateRoom.tsx
@@ -211,7 +211,6 @@ export function CreateRoomForm({
required
before={getCreateRoomAccessToIcon(access, type, '100')}
name="nameInput"
- autoFocus
size="500"
variant="SurfaceVariant"
radii="400"
diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx
index b5e7026d06..f8ec2c0ec0 100644
--- a/src/app/features/create-room/CreateRoomModal.tsx
+++ b/src/app/features/create-room/CreateRoomModal.tsx
@@ -1,22 +1,10 @@
-import {
- Box,
- config,
- Header,
- IconButton,
- Modal,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Scroll,
- Text,
-} from 'folds';
+import { Box, config, Header, IconButton, Modal, Scroll, Text } from 'folds';
import { X, composerIcon } from '$components/icons/phosphor';
-import FocusTrap from 'focus-trap-react';
import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
import { SpaceProvider } from '$hooks/useSpace';
import { useCloseCreateRoomModal, useCreateRoomModalState } from '$state/hooks/createRoomModal';
import type { CreateRoomModalState } from '$state/createRoomModal';
-import { stopPropagation } from '$utils/keyboard';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
import { CreateRoomType } from '$components/create-room/types';
import { CreateRoomForm } from './CreateRoom';
@@ -33,53 +21,42 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
return (
- }>
-
-
-
-
-
-
-
- {type === CreateRoomType.VoiceRoom ? 'New Voice Room' : 'New Chat Room'}
-
-
-
-
- {composerIcon(X)}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ {type === CreateRoomType.VoiceRoom ? 'New Voice Room' : 'New Chat Room'}
+
-
-
-
-
+
+
+ {composerIcon(X)}
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/app/pages/client/create-room/CreateRoomPage.tsx b/src/app/pages/client/create-room/CreateRoomPage.tsx
index 75a1f22fd7..ebd89b2c8a 100644
--- a/src/app/pages/client/create-room/CreateRoomPage.tsx
+++ b/src/app/pages/client/create-room/CreateRoomPage.tsx
@@ -98,17 +98,16 @@ export function CreateRoomPage() {
)}
-
+
{isMobile && (
-
-
-
- {title}
-
-
-
+
+
+
+ {title}
+
+
backNavigate(-1)}
diff --git a/tests/e2e/leave-room-prompt.spec.ts b/tests/e2e/leave-room-prompt.spec.ts
new file mode 100644
index 0000000000..efe856dcb9
--- /dev/null
+++ b/tests/e2e/leave-room-prompt.spec.ts
@@ -0,0 +1,22 @@
+import { test, expect, type Page } from '@playwright/test';
+
+async function dismissDeviceBanner(page: Page): Promise {
+ await page
+ .getByRole('button', { name: 'Dismiss' })
+ .click({ timeout: 5_000 })
+ .catch(() => undefined);
+}
+
+test('leave room prompt opens from the room options menu', async ({ page }) => {
+ await page.goto('/');
+ const room = page.getByText('General').first();
+ await expect(room).toBeVisible({ timeout: 180_000 });
+ await dismissDeviceBanner(page);
+
+ await room.hover();
+ await page.getByRole('button', { name: 'More Options' }).first().click();
+ // The mobile sheet can overflow the viewport, where a real pointer click fails.
+ await page.getByRole('button', { name: 'Leave Room' }).dispatchEvent('click');
+
+ await expect(page.getByText('Are you sure you want to leave this room?')).toBeVisible();
+});
From 0de7143be96c500082172df3966f6d0cf40b19e8 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 00:08:43 +0200
Subject: [PATCH 02/43] refactor: migrate remaining matching modals to
ModalOverlay
---
.../DirectInvitePrompt.tsx | 134 +++----
.../join-address-prompt/JoinAddressPrompt.tsx | 133 +++---
.../knock-room-prompt/KnockRoomPrompt.tsx | 132 +++---
.../leave-space-prompt/LeaveSpacePrompt.tsx | 157 +++-----
src/app/components/message/PollEvent.tsx | 50 +--
src/app/components/theme/CssViewerButton.tsx | 36 +-
src/app/components/user-profile/PowerChip.tsx | 138 +++----
src/app/components/user-profile/UserHero.tsx | 38 +-
src/app/features/add-existing/AddExisting.tsx | 379 +++++++++---------
.../features/bug-report/BugReportModal.tsx | 27 +-
.../general/RoomEncryption.tsx | 67 ++--
.../common-settings/general/RoomUpgrade.tsx | 143 +++----
.../create-space/CreateSpaceModal.tsx | 95 ++---
.../features/room/jump-to-time/JumpToTime.tsx | 315 +++++++--------
.../room/location-modal/LocationDialog.tsx | 268 ++++++-------
.../features/room/poll-modals/PollDialog.tsx | 314 +++++++--------
.../schedule-send/SchedulePickerDialog.tsx | 368 ++++++++---------
src/app/pages/client/sidebar/SpaceTabs.tsx | 109 +++--
src/app/pages/client/space/Space.tsx | 41 +-
19 files changed, 1279 insertions(+), 1665 deletions(-)
diff --git a/src/app/components/direct-invite-prompt/DirectInvitePrompt.tsx b/src/app/components/direct-invite-prompt/DirectInvitePrompt.tsx
index f17c4d1139..ead5938dfb 100644
--- a/src/app/components/direct-invite-prompt/DirectInvitePrompt.tsx
+++ b/src/app/components/direct-invite-prompt/DirectInvitePrompt.tsx
@@ -1,20 +1,6 @@
-import FocusTrap from 'focus-trap-react';
-import {
- Box,
- Button,
- Dialog,
- Header,
- IconButton,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Spinner,
- Text,
- color,
- config,
-} from 'folds';
+import { Box, Button, Dialog, Header, IconButton, Spinner, Text, color, config } from 'folds';
import { composerIcon, X } from '$components/icons/phosphor';
-import { stopPropagation } from '$utils/keyboard';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type DirectInvitePromptProps = {
onCancel: () => void;
@@ -32,74 +18,58 @@ export function DirectInvitePrompt({
convertError,
}: DirectInvitePromptProps) {
return (
- }>
-
-
+
+
-
-
+ Invite another Member
+
+
+ {composerIcon(X)}
+
+
+
+
+
+ This is a Direct Message room, intended for a conversation between two persons.
+ Would you like to convert it into a group chat before continuing?
+
+ {convertError && (
+
+ Failed to convert direct message to room! {convertError}
+
+ )}
+
+
+ : undefined
+ }
+ aria-disabled={converting}
>
-
- Invite another Member
-
-
- {composerIcon(X)}
-
-
-
-
-
- This is a Direct Message room, intended for a conversation between two
- persons. Would you like to convert it into a group chat before continuing?
-
- {convertError && (
-
- Failed to convert direct message to room! {convertError}
-
- )}
-
-
- : undefined
- }
- aria-disabled={converting}
- >
-
- {converting ? 'Converting...' : 'Convert to Group Chat and Invite'}
-
-
-
-
-
-
-
-
-
-
+
+ {converting ? 'Converting...' : 'Convert to Group Chat and Invite'}
+
+
+
+
+
+
+
+
);
}
diff --git a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx
index 98cf7883cf..f1eefb05a4 100644
--- a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx
+++ b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx
@@ -1,24 +1,10 @@
import type { FormEventHandler } from 'react';
import { useState } from 'react';
-import FocusTrap from 'focus-trap-react';
-import {
- Dialog,
- Overlay,
- OverlayCenter,
- OverlayBackdrop,
- Header,
- config,
- Box,
- Text,
- IconButton,
- Button,
- Input,
- color,
-} from 'folds';
+import { Dialog, Header, config, Box, Text, IconButton, Button, Input, color } from 'folds';
import { composerIcon, X } from '$components/icons/phosphor';
-import { stopPropagation } from '$utils/keyboard';
import { isRoomAlias, isRoomId } from '$utils/matrix';
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '$plugins/matrix-to';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type JoinAddressProps = {
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
@@ -61,71 +47,60 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
};
return (
- }>
-
-
+
+
+
+ {composerIcon(X)}
+
+
+
+
+
+ Enter public address to join the community. Addresses looks like:
+
+
+ #community:server
+ https://matrix.to/#/#community:server
+ https://matrix.to/#/!xYzAj?via=server
+
+
+
+ Address
+
-
- Join with Address
-
-
- {composerIcon(X)}
-
-
-
-
-
- Enter public address to join the community. Addresses looks like:
-
-
- #community:server
- https://matrix.to/#/#community:server
- https://matrix.to/#/!xYzAj?via=server
-
-
-
- Address
-
- {invalid && (
-
- Invalid Address
-
- )}
-
-
-
-
-
-
-
+ autoFocus
+ name="addressInput"
+ variant="Background"
+ placeholder="#community:server"
+ required
+ />
+ {invalid && (
+
+ Invalid Address
+
+ )}
+
+
+
+
+
);
}
diff --git a/src/app/components/knock-room-prompt/KnockRoomPrompt.tsx b/src/app/components/knock-room-prompt/KnockRoomPrompt.tsx
index 36ddcdd0b8..c31a027048 100644
--- a/src/app/components/knock-room-prompt/KnockRoomPrompt.tsx
+++ b/src/app/components/knock-room-prompt/KnockRoomPrompt.tsx
@@ -1,11 +1,7 @@
import type { FormEventHandler } from 'react';
import { useCallback, useEffect } from 'react';
-import FocusTrap from 'focus-trap-react';
import {
Dialog,
- Overlay,
- OverlayCenter,
- OverlayBackdrop,
Header,
config,
Box,
@@ -21,8 +17,8 @@ import type { MatrixError } from '$types/matrix-sdk';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { composerIcon, X } from '$components/icons/phosphor';
-import { stopPropagation } from '$utils/keyboard';
import { createDebugLogger } from '$utils/debugLogger';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
const debugLog = createDebugLogger('KnockRoomPrompt');
@@ -61,79 +57,67 @@ export function KnockRoomPrompt({ roomId, via, onDone, onCancel }: KnockRoomProp
}, [knockState, onDone, roomId]);
return (
- }>
-
-
+
+
-
-
-
- Knock On Room
-
-
- {composerIcon(X)}
-
-
-
-
-
- Request to join this room. You can optionally leave a reason for the moderators.
+
+ Knock On Room
+
+
+ {composerIcon(X)}
+
+
+
+
+
+ Request to join this room. You can optionally leave a reason for the moderators.
+
+
+
+ Reason{' '}
+
+ (Optional)
-
-
- Reason{' '}
-
- (Optional)
-
-
-
- {knockState.status === AsyncStatus.Error && (
-
- Failed to knock! {knockState.error.message}
-
- )}
-
-
-
- ) : undefined
- }
- aria-disabled={
- knockState.status === AsyncStatus.Loading ||
- knockState.status === AsyncStatus.Success
- }
- >
-
- {knockState.status === AsyncStatus.Loading ? 'Knocking...' : 'Knock'}
+
+
+ {knockState.status === AsyncStatus.Error && (
+
+ Failed to knock! {knockState.error.message}
-
+ )}
-
-
-
-
+
+
+ ) : undefined
+ }
+ aria-disabled={
+ knockState.status === AsyncStatus.Loading || knockState.status === AsyncStatus.Success
+ }
+ >
+
+ {knockState.status === AsyncStatus.Loading ? 'Knocking...' : 'Knock'}
+
+
+
+
+
);
}
diff --git a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx
index f4942122ee..ecec11fa25 100644
--- a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx
+++ b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx
@@ -1,26 +1,12 @@
import { useCallback, useEffect, useMemo } from 'react';
-import FocusTrap from 'focus-trap-react';
-import {
- Dialog,
- Overlay,
- OverlayCenter,
- OverlayBackdrop,
- Header,
- config,
- Box,
- Text,
- IconButton,
- color,
- Button,
- Spinner,
-} from 'folds';
+import { Dialog, Header, config, Box, Text, IconButton, color, Button, Spinner } from 'folds';
import type { MatrixError } from '$types/matrix-sdk';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { composerIcon, X } from '$components/icons/phosphor';
-import { stopPropagation } from '$utils/keyboard';
import { getJoinedSpaceChildrenSummary, getRecursiveSpaceLeaveOrder } from '$utils/room';
import { rateLimitedActions } from '$utils/matrix';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type LeaveSpacePromptProps = {
roomId: string;
@@ -91,85 +77,70 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
}, [leaveState, leaveAllState, onDone]);
return (
- }>
-
-
+
+
+
+ {composerIcon(X)}
+
+
+
+
+ Are you sure you want to leave this space?
+ {joinedChildrenCount > 0 && (
+
+ {formatJoinedContentsMessage(roomCount, subspaceCount)}
+
+ )}
+ {leaveState.status === AsyncStatus.Error && (
+
+ Failed to leave space! {leaveState.error.message}
+
+ )}
+ {leaveAllState.status === AsyncStatus.Error && (
+
+ Failed to leave space, rooms, and subspaces! {leaveAllState.error.message}
+
+ )}
+
+
+
+ {joinedChildrenCount > 0 && (
+ leaveAll()}
+ before={
+ leavingAll ? : undefined
+ }
+ aria-disabled={isBusy || leaveAllState.status === AsyncStatus.Success}
+ >
+
+ {formatRecursiveLeaveLabel(leavingAll, roomCount, subspaceCount)}
+
+
+ )}
+
+
+
+
);
}
diff --git a/src/app/components/message/PollEvent.tsx b/src/app/components/message/PollEvent.tsx
index bfb1e12104..fa3f6abb1d 100644
--- a/src/app/components/message/PollEvent.tsx
+++ b/src/app/components/message/PollEvent.tsx
@@ -1,16 +1,4 @@
-import {
- Box,
- Button,
- Checkbox,
- Line,
- Modal,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- ProgressBar,
- RadioButton,
- Text,
-} from 'folds';
+import { Box, Button, Checkbox, Line, Modal, ProgressBar, RadioButton, Text } from 'folds';
import type { MatrixClient, PollStartSubtype, Room, TimelineEvents } from 'matrix-js-sdk';
import { M_TEXT } from 'matrix-js-sdk';
import {
@@ -26,8 +14,7 @@ import {
import * as css from './PollEvent.css';
import { useCallback, useEffect, useState } from 'react';
import { PollResponsesViewer } from '$features/room/poll-modals';
-import { stopPropagation } from '$utils/keyboard';
-import FocusTrap from 'focus-trap-react';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type PollEventProps = {
content: Record;
@@ -346,28 +333,17 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) {
{ViewVotersAnswer && (
- }>
-
- setViewVotersAnswer(undefined),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
-
- setViewVotersAnswer(undefined)}
- />
-
-
-
-
+ setViewVotersAnswer(undefined)}>
+
+ setViewVotersAnswer(undefined)}
+ />
+
+
)}
>
);
diff --git a/src/app/components/theme/CssViewerButton.tsx b/src/app/components/theme/CssViewerButton.tsx
index fff20ddaec..e51ebd6d71 100644
--- a/src/app/components/theme/CssViewerButton.tsx
+++ b/src/app/components/theme/CssViewerButton.tsx
@@ -1,11 +1,10 @@
import { useState } from 'react';
-import FocusTrap from 'focus-trap-react';
-import { IconButton, Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
+import { IconButton, Modal } from 'folds';
import { Code, sizedIcon } from '$components/icons/phosphor';
import { TextViewer } from '$components/text-viewer';
import { ModalWide } from '$styles/Modal.css';
-import { stopPropagation } from '$utils/keyboard';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type CssViewerButtonProps = {
title: string;
@@ -56,27 +55,16 @@ export function CssViewerButton({ title, cssText, loadCssText, ariaLabel }: CssV
{sizedIcon(Code, '200')}
{open && (
- }>
-
- setOpen(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
-
- setOpen(false)}
- />
-
-
-
-
+ setOpen(false)}>
+
+ setOpen(false)}
+ />
+
+
)}
>
);
diff --git a/src/app/components/user-profile/PowerChip.tsx b/src/app/components/user-profile/PowerChip.tsx
index 2ee1504537..c490a8e55d 100644
--- a/src/app/components/user-profile/PowerChip.tsx
+++ b/src/app/components/user-profile/PowerChip.tsx
@@ -10,9 +10,6 @@ import {
Line,
Menu,
MenuItem,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
PopOut,
Spinner,
Text,
@@ -47,6 +44,7 @@ import { PowerColorBadge, PowerIcon } from '$components/power';
import { EventType } from '$types/matrix-sdk';
import { heroMenuItemStyle } from './heroMenuItemStyle';
import * as css from './styles.css';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type SelfDemoteAlertProps = {
power: number;
@@ -55,46 +53,35 @@ type SelfDemoteAlertProps = {
};
function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) {
return (
- }>
-
-
+
+
-
-
-
- Self Demotion
-
-
- {menuIcon(X)}
-
-
-
-
-
- You are about to demote yourself! You will not be able to regain this power
- yourself. Are you sure?
-
-
-
- onChange(power)}>
- Demote
-
-
-
-
-
-
-
+
+ Self Demotion
+
+
+ {menuIcon(X)}
+
+
+
+
+
+ You are about to demote yourself! You will not be able to regain this power yourself.
+ Are you sure?
+
+
+
+ onChange(power)}>
+ Demote
+
+
+
+
+
);
}
@@ -105,46 +92,35 @@ type SharedPowerAlertProps = {
};
function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) {
return (
- }>
-
-
+
+
-
-
-
- Shared Power
-
-
- {menuIcon(X)}
-
-
-
-
-
- You are promoting the user to have the same power as yourself! You will not be
- able to change their power afterward. Are you sure?
-
-
-
- onChange(power)}>
- Promote
-
-
-
-
-
-
-
+
+ Shared Power
+
+
+ {menuIcon(X)}
+
+
+
+
+
+ You are promoting the user to have the same power as yourself! You will not be able to
+ change their power afterward. Are you sure?
+
+
+
+ onChange(power)}>
+ Promote
+
+
+
+
+
);
}
diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx
index a41b5332f9..9d82236019 100644
--- a/src/app/components/user-profile/UserHero.tsx
+++ b/src/app/components/user-profile/UserHero.tsx
@@ -5,9 +5,6 @@ import {
Box,
color as standardColors,
Modal,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
Scroll,
Text,
Tooltip,
@@ -16,12 +13,10 @@ import {
config,
} from 'folds';
import classNames from 'classnames';
-import FocusTrap from 'focus-trap-react';
import colorMXID from '$utils/colorMXID';
import { getMxIdLocalPart } from '$utils/matrix';
import { BreakWord, LineClamp3 } from '$styles/Text.css';
import type { UserPresence } from '$hooks/useUserPresence';
-import { stopPropagation } from '$utils/keyboard';
import { useRoom } from '$hooks/useRoom';
import { useSableCosmetics } from '$hooks/useSableCosmetics';
import { useNickname } from '$hooks/useNickname';
@@ -44,6 +39,7 @@ import { copyToClipboard } from '$utils/dom';
import { useTimeoutToggle } from '$hooks/useTimeoutToggle';
import { CopyIcon, CrossIcon } from '@phosphor-icons/react';
import { useOpenSettings } from '$features/settings';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type UserHeroProps = {
userId: string;
@@ -156,29 +152,15 @@ export function UserHero({
{viewAvatar && (
- }>
-
- setViewAvatar(undefined),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
- evt.stopPropagation()}
- >
- setViewAvatar(undefined)}
- />
-
-
-
-
+ setViewAvatar(undefined)}>
+ evt.stopPropagation()}>
+ setViewAvatar(undefined)}
+ />
+
+
)}
{((status && status.length > 0) || allowEditing) && (
diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx
index ca743979b7..b0876fba10 100644
--- a/src/app/features/add-existing/AddExisting.tsx
+++ b/src/app/features/add-existing/AddExisting.tsx
@@ -1,4 +1,3 @@
-import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
@@ -10,9 +9,6 @@ import {
Menu,
MenuItem,
Modal,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
Scroll,
Spinner,
Text,
@@ -30,7 +26,6 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { Room, StateEvents } from '$types/matrix-sdk';
-import { stopPropagation } from '$utils/keyboard';
import { useDirects, useRooms, useSpaces } from '$state/hooks/roomList';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { allRoomsAtom } from '$state/room-list/roomList';
@@ -52,6 +47,7 @@ import { getViaServers } from '$plugins/via-servers';
import { rateLimitedActions } from '$utils/matrix';
import { useAlive } from '$hooks/useAlive';
import { EventType } from '$types/matrix-sdk';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
const SEARCH_OPTS: UseAsyncSearchOptions = {
limit: 500,
@@ -202,207 +198,194 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
};
return (
- }>
-
-
-
-
-
-
-
+ {vItems.length === 0 && (
-
-
-
- {vItems.length === 0 && (
-
-
- {searchResult ? 'No Match Found' : `No ${space ? 'Spaces' : 'Rooms'}`}
-
-
- {searchResult
- ? `No match found for "${searchResult.query}".`
- : `You do not have any ${space ? 'Spaces' : 'Rooms'} to display yet.`}
-
-
- )}
-
- {vItems.map((vItem) => {
- const roomId = items[vItem.index];
- if (!roomId) return null;
- const room = getRoom(roomId);
- if (!room) return null;
- const selectedItem = selected?.includes(roomId);
- const dm = mDirects.has(room.roomId);
+
+ {searchResult ? 'No Match Found' : `No ${space ? 'Spaces' : 'Rooms'}`}
+
+
+ {searchResult
+ ? `No match found for "${searchResult.query}".`
+ : `You do not have any ${space ? 'Spaces' : 'Rooms'} to display yet.`}
+
+
+ )}
+
+ {vItems.map((vItem) => {
+ const roomId = items[vItem.index];
+ if (!roomId) return null;
+ const room = getRoom(roomId);
+ if (!room) return null;
+ const selectedItem = selected?.includes(roomId);
+ const dm = mDirects.has(room.roomId);
- return (
-
-
-
- );
- })}
-
- {selected.length > 0 && (
-
- )}
-
-
+
+
+ );
+ })}
+
+ {selected.length > 0 && (
+
+ )}
-
-
-
-
-
+
+
+
+
+
);
}
diff --git a/src/app/features/bug-report/BugReportModal.tsx b/src/app/features/bug-report/BugReportModal.tsx
index 5d4bde2443..7042f0da2f 100644
--- a/src/app/features/bug-report/BugReportModal.tsx
+++ b/src/app/features/bug-report/BugReportModal.tsx
@@ -1,5 +1,4 @@
import { useState, useEffect } from 'react';
-import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
@@ -9,9 +8,6 @@ import {
IconButton,
Input,
Modal,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
Scroll,
Spinner,
Text,
@@ -21,9 +17,9 @@ import {
import { ArrowRight, X, chipIcon, composerIcon } from '$components/icons/phosphor';
import * as Sentry from '@sentry/react';
import { useCloseBugReportModal, useBugReportModalOpen } from '$state/hooks/bugReportModal';
-import { stopPropagation } from '$utils/keyboard';
import { getDebugLogger } from '$utils/debugLogger';
import { fetch } from '$utils/fetch';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type ReportType = 'bug' | 'feature';
@@ -499,22 +495,11 @@ function BugReportModal() {
const close = useCloseBugReportModal();
return (
- }>
-
-
-
-
-
-
-
-
+
+
+
+
+
);
}
diff --git a/src/app/features/common-settings/general/RoomEncryption.tsx b/src/app/features/common-settings/general/RoomEncryption.tsx
index 9da75c6667..326938d9d7 100644
--- a/src/app/features/common-settings/general/RoomEncryption.tsx
+++ b/src/app/features/common-settings/general/RoomEncryption.tsx
@@ -7,16 +7,12 @@ import {
Dialog,
Header,
IconButton,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
Spinner,
Text,
} from 'folds';
import { composerIcon, X } from '$components/icons/phosphor';
import { useCallback, useState } from 'react';
import type { MatrixError, StateEvents } from '$types/matrix-sdk';
-import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '$components/sequence-card';
import { SequenceCardStyle } from '$features/room-settings/styles.css';
import { SettingTile } from '$components/setting-tile';
@@ -25,9 +21,9 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { useRoom } from '$hooks/useRoom';
import { useStateEvent } from '$hooks/useStateEvent';
-import { stopPropagation } from '$utils/keyboard';
import type { RoomPermissionsAPI } from '$hooks/useRoomPermissions';
import { EventType } from '$types/matrix-sdk';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
@@ -101,44 +97,33 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
)}
{prompt && (
- }>
-
- setPrompt(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
+ setPrompt(false)}>
+
+
-
-
-
- Enable Encryption
-
- setPrompt(false)} radii="300">
- {composerIcon(X)}
-
-
-
-
- Are you sure? Once enabled, encryption cannot be disabled!
-
-
- Enable E2E Encryption
-
-
-
-
-
-
+
+ Enable Encryption
+
+ setPrompt(false)} radii="300">
+ {composerIcon(X)}
+
+
+
+
+ Are you sure? Once enabled, encryption cannot be disabled!
+
+
+ Enable E2E Encryption
+
+
+
+
)}
diff --git a/src/app/features/common-settings/general/RoomUpgrade.tsx b/src/app/features/common-settings/general/RoomUpgrade.tsx
index 6c8b8eb731..b2f8ae9f6f 100644
--- a/src/app/features/common-settings/general/RoomUpgrade.tsx
+++ b/src/app/features/common-settings/general/RoomUpgrade.tsx
@@ -1,20 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
-import {
- Button,
- color,
- Spinner,
- Text,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Dialog,
- Header,
- config,
- Box,
- IconButton,
-} from 'folds';
+import { Button, color, Spinner, Text, Dialog, Header, config, Box, IconButton } from 'folds';
import { composerIcon, X } from '$components/icons/phosphor';
-import FocusTrap from 'focus-trap-react';
import type { MatrixError, RoomTombstoneEventContent } from '$types/matrix-sdk';
import { Method, EventType } from '$types/matrix-sdk';
import { SequenceCard } from '$components/sequence-card';
@@ -28,7 +14,6 @@ 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 type { RoomPermissionsAPI } from '$hooks/useRoomPermissions';
import {
AdditionalCreatorInput,
@@ -39,6 +24,7 @@ import { useAlive } from '$hooks/useAlive';
import { useRoomCreators } from '$hooks/useRoomCreators';
import { BreakWord } from '$styles/Text.css';
import { creatorsSupported } from '$utils/roomSupport';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
const mx = useMatrixClient();
@@ -83,78 +69,67 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
};
return (
- }>
-
-
+
+
-
-
-
- {room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}
-
-
- {composerIcon(X)}
-
-
-
-
- This action is irreversible!
-
-
- Options
-
+ {room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}
+
+
+ {composerIcon(X)}
+
+
+
+
+ This action is irreversible!
+
+
+ Options
+
+ {allowAdditionalCreators && (
+
+
- {allowAdditionalCreators && (
-
-
-
- )}
-
- {upgradeState.status === AsyncStatus.Error && (
-
- {(upgradeState.error as MatrixError).message}
-
- )}
- }
- >
- {room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}
-
-
-
-
-
-
+
+ )}
+
+ {upgradeState.status === AsyncStatus.Error && (
+
+ {(upgradeState.error as MatrixError).message}
+
+ )}
+ }
+ >
+ {room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}
+
+
+
+
);
}
diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx
index d3d1ef54ed..139ea04023 100644
--- a/src/app/features/create-space/CreateSpaceModal.tsx
+++ b/src/app/features/create-space/CreateSpaceModal.tsx
@@ -1,23 +1,11 @@
-import {
- Box,
- config,
- Header,
- IconButton,
- Modal,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Scroll,
- Text,
-} from 'folds';
+import { Box, config, Header, IconButton, Modal, Scroll, Text } from 'folds';
import { composerIcon, X } from '$components/icons/phosphor';
-import FocusTrap from 'focus-trap-react';
import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
import { SpaceProvider } from '$hooks/useSpace';
import { useCloseCreateSpaceModal, useCreateSpaceModalState } from '$state/hooks/createSpaceModal';
import type { CreateSpaceModalState } from '$state/createSpaceModal';
-import { stopPropagation } from '$utils/keyboard';
import { CreateSpaceForm } from './CreateSpace';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type CreateSpaceModalProps = {
state: CreateSpaceModalState;
@@ -32,52 +20,41 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
return (
- }>
-
-
-
-
-
-
- New Space
-
-
-
- {composerIcon(X)}
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
+
+
+ {composerIcon(X)}
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/app/features/room/jump-to-time/JumpToTime.tsx b/src/app/features/room/jump-to-time/JumpToTime.tsx
index 0cf34fa1ec..4fdde66410 100644
--- a/src/app/features/room/jump-to-time/JumpToTime.tsx
+++ b/src/app/features/room/jump-to-time/JumpToTime.tsx
@@ -5,9 +5,6 @@ import { CaretDown, chipIcon, composerIcon, X } from '$components/icons/phosphor
import type { RectCords } from 'folds';
import {
Dialog,
- Overlay,
- OverlayCenter,
- OverlayBackdrop,
Header,
config,
Box,
@@ -32,6 +29,7 @@ import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '$utils
import { DatePicker, TimePicker } from '$components/time-date';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type JumpToTimeProps = {
onCancel: () => void;
@@ -87,175 +85,164 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
};
return (
- }>
-
-
+
+
-
-
-
- Jump to Time
-
-
- {composerIcon(X)}
-
-
-
-
-
-
- Time
-
-
-
- {timeHourMinute(ts, hour24Clock)}
-
- setTimePickerCords(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
- isKeyBackward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
- }
- />
-
-
-
-
- Date
-
-
-
+ Jump to Time
+
+
+ {composerIcon(X)}
+
+
+
+
+
+
+ Time
+
+
+
+ {timeHourMinute(ts, hour24Clock)}
+
+ setTimePickerCords(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
+ isKeyBackward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
+ escapeDeactivates: stopPropagation,
+ }}
>
- {timeDayMonthYear(ts)}
-
- setDatePickerCords(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
- isKeyBackward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
- }
- />
-
-
+
+
+ }
+ />
-
- Preset
-
- {createTs < todayTs && (
-
- Today
-
- )}
- {createTs < yesterdayTs && (
-
+
+
+ Date
+
+
+
+ {timeDayMonthYear(ts)}
+
+ setDatePickerCords(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
+ isKeyBackward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
+ escapeDeactivates: stopPropagation,
+ }}
>
- Yesterday
-
- )}
-
- Beginning
-
-
+
+
+ }
+ />
- {timestampState.status === AsyncStatus.Error && (
-
- {timestampState.error.message}
-
+
+
+
+ Preset
+
+ {createTs < todayTs && (
+
+ Today
+
+ )}
+ {createTs < yesterdayTs && (
+
+ Yesterday
+
)}
-
- ) : undefined
- }
- aria-disabled={
- timestampState.status === AsyncStatus.Loading ||
- timestampState.status === AsyncStatus.Success
- }
- onClick={handleSubmit}
+
- Open Timeline
-
+ Beginning
+
-
-
-
-
+
+ {timestampState.status === AsyncStatus.Error && (
+
+ {timestampState.error.message}
+
+ )}
+
+ ) : undefined
+ }
+ aria-disabled={
+ timestampState.status === AsyncStatus.Loading ||
+ timestampState.status === AsyncStatus.Success
+ }
+ onClick={handleSubmit}
+ >
+ Open Timeline
+
+
+
+
);
}
diff --git a/src/app/features/room/location-modal/LocationDialog.tsx b/src/app/features/room/location-modal/LocationDialog.tsx
index 9241066b1a..c675fda3bd 100644
--- a/src/app/features/room/location-modal/LocationDialog.tsx
+++ b/src/app/features/room/location-modal/LocationDialog.tsx
@@ -1,20 +1,6 @@
-import FocusTrap from 'focus-trap-react';
-import {
- Dialog,
- Overlay,
- OverlayCenter,
- OverlayBackdrop,
- Header,
- Box,
- Text,
- IconButton,
- Button,
- Input,
- Chip,
-} from 'folds';
+import { Dialog, Header, Box, Text, IconButton, Button, Input, Chip } from 'folds';
import { ClipboardIcon, MapPinAreaIcon, MapPinLineIcon } from '@phosphor-icons/react';
import { chipIcon, composerIcon, Warning, X } from '$components/icons/phosphor';
-import { stopPropagation } from '$utils/keyboard';
import { readClipboardText } from '$utils/dom';
import type { IContent, MatrixClient, Room } from 'matrix-js-sdk';
import * as css from './LocationDialog.css';
@@ -31,6 +17,7 @@ import { useSetting } from '$state/hooks/settings';
import classNames from 'classnames';
import markerIconPng from 'leaflet/dist/images/marker-icon.png';
import { Icon } from 'leaflet';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
export const markerIcon = new Icon({
iconUrl: markerIconPng,
@@ -265,141 +252,130 @@ export function LocationDialog({
};
return (
- }>
-
-
-
-
-
-
- }
- >
- Share Current Location
-
- }
- >
- Paste Clipboard
-
-
- {locationError !== LocationErrors.none && (
-
- {chipIcon(Warning)}
- {locationError}
-
- )}
-
-
- Latitude
-
-
-
- Longitude
-
-
-
- {showMaps && (
-
-
-
- {
- e.originalEvent.preventDefault();
- e.originalEvent.stopPropagation();
- },
- }}
- icon={markerIcon}
- />
+
+ {
+ e.originalEvent.preventDefault();
+ e.originalEvent.stopPropagation();
+ },
+ }}
+ icon={markerIcon}
+ />
-
-
-
- )}
-
- Share Location
-
+
+
-
-
-
-
+ )}
+
+ Share Location
+
+
+
+
);
}
diff --git a/src/app/features/room/poll-modals/PollDialog.tsx b/src/app/features/room/poll-modals/PollDialog.tsx
index bdaa90128c..b4a48c851a 100644
--- a/src/app/features/room/poll-modals/PollDialog.tsx
+++ b/src/app/features/room/poll-modals/PollDialog.tsx
@@ -1,9 +1,5 @@
-import FocusTrap from 'focus-trap-react';
import {
Dialog,
- Overlay,
- OverlayCenter,
- OverlayBackdrop,
Header,
Box,
Text,
@@ -16,7 +12,6 @@ import {
Scroll,
} from 'folds';
import { chipIcon, composerIcon, ListBullets, Minus, X } from '$components/icons/phosphor';
-import { stopPropagation } from '$utils/keyboard';
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
import { useCallback, useRef, useState } from 'react';
import type { PollAnswerItem } from '$components/message/PollEvent';
@@ -35,6 +30,7 @@ import { isKeyHotkey } from 'is-hotkey';
import * as css from './PollDialog.css';
import type { IReplyDraft } from '$state/room/roomInputDrafts';
import { getReplyContent } from '../RoomInput';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type PollDialogProps = {
onCancel: () => void;
@@ -154,179 +150,157 @@ export function PollDialog({ onCancel, mx, room, replyDraft, clearReplyDraft }:
}
};
return (
- }>
-
-
-
-
-
-
- Title
- (title.current = evt.currentTarget.value.trim())}
- placeholder={'What should we have for dinner?'}
- />
-
-
-
- Options ({answers.length})
-
- Add Option
-
-
-
- {answers.map((item, index) => (
-
- {
- let newAnswers = answers;
- newAnswers[index] = {
- id: answers[index]?.id ?? randomStr(),
- [M_TEXT.name]: evt.currentTarget.value.trim() ?? '',
- };
- setAnswers(newAnswers);
- }}
- placeholder={`Type Option ${index + 1}`}
- after={
- delOption(item.id)}
- >
- {chipIcon(Minus)}
-
- }
- />
-
- ))}
-
-
-
- Add Option
+
+
+
+ {answers.map((item, index) => (
+
-
+
-
-
- {
+ let newAnswers = answers;
+ newAnswers[index] = {
+ id: answers[index]?.id ?? randomStr(),
+ [M_TEXT.name]: evt.currentTarget.value.trim() ?? '',
+ };
+ setAnswers(newAnswers);
+ }}
+ placeholder={`Type Option ${index + 1}`}
after={
-
+ delOption(item.id)}
+ >
+ {chipIcon(Minus)}
+
}
/>
-
+ ))}
+
+
+
+
+ }
+ />
+
+
+ {
- const val = Number.parseInt(e.target.value);
- if (val) {
- setInputValue(val);
- setMaxSelections(val);
- }
- }}
- className={css.PollDialogMaxSelectionSlider}
+ value={inputValue}
+ onChange={handleMaxOptions}
+ onKeyDown={handleMaxKeyDown}
+ outlined
/>
-
-
-
- Create Poll
-
- {!!error && (
-
- {error.errorString}
-
- )}
-
-
-
-
-
+ }
+ />
+ {
+ const val = Number.parseInt(e.target.value);
+ if (val) {
+ setInputValue(val);
+ setMaxSelections(val);
+ }
+ }}
+ className={css.PollDialogMaxSelectionSlider}
+ />
+
+
+
+ Create Poll
+
+ {!!error && (
+
+ {error.errorString}
+
+ )}
+
+
+
);
}
diff --git a/src/app/features/room/schedule-send/SchedulePickerDialog.tsx b/src/app/features/room/schedule-send/SchedulePickerDialog.tsx
index c6609702a9..27aab78305 100644
--- a/src/app/features/room/schedule-send/SchedulePickerDialog.tsx
+++ b/src/app/features/room/schedule-send/SchedulePickerDialog.tsx
@@ -4,27 +4,14 @@ import { useAtomValue } from 'jotai';
import { CaretDown, chipIcon, composerIcon, X } from '$components/icons/phosphor';
import FocusTrap from 'focus-trap-react';
import type { RectCords } from 'folds';
-import {
- Dialog,
- Overlay,
- OverlayCenter,
- OverlayBackdrop,
- Header,
- config,
- Box,
- Text,
- IconButton,
- color,
- Button,
- Chip,
- PopOut,
-} from 'folds';
+import { Dialog, Header, config, Box, Text, IconButton, color, Button, Chip, PopOut } from 'folds';
import { stopPropagation } from '$utils/keyboard';
import { timeDayMonthYear, timeHourMinute, hoursToMs, daysToMs } from '$utils/time';
import { DatePicker, TimePicker } from '$components/time-date';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import { serverMaxDelayMsAtom } from '$state/scheduledMessages';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type SchedulePickerDialogProps = {
initialTime?: number;
@@ -80,193 +67,182 @@ export function SchedulePickerDialog({
const isPast = ts <= now;
return (
- }>
-
-
+
+
-
-
-
- Schedule Send
-
-
- {composerIcon(X)}
-
-
-
-
-
-
- Time
-
-
-
- {timeHourMinute(ts, hour24Clock)}
-
- setTimePickerCords(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
- isKeyBackward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
- }
- />
-
-
-
-
- Date
-
-
-
+ Schedule Send
+
+
+ {composerIcon(X)}
+
+
+
+
+
+
+ Time
+
+
+
+ {timeHourMinute(ts, hour24Clock)}
+
+ setTimePickerCords(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
+ isKeyBackward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
+ escapeDeactivates: stopPropagation,
+ }}
>
- {timeDayMonthYear(ts)}
-
- setDatePickerCords(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
- isKeyBackward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
- }
- />
-
-
+
+
+ }
+ />
-
- Quick Schedule
-
- handlePreset(hoursToMs(1))}
- >
- In 1 hour
-
- handlePreset(hoursToMs(4))}
- >
- In 4 hours
-
- {
- const tomorrow = new Date();
- tomorrow.setDate(tomorrow.getDate() + 1);
- tomorrow.setHours(9, 0, 0, 0);
- setTs(tomorrow.getTime());
- setError(undefined);
- }}
- >
- Tomorrow 9 AM
-
- {
- const tomorrow = new Date();
- tomorrow.setDate(tomorrow.getDate() + 1);
- tomorrow.setHours(14, 0, 0, 0);
- setTs(tomorrow.getTime());
- setError(undefined);
- }}
- >
- Tomorrow 2 PM
-
-
+
+
+
+ Date
+
+
+
+ {timeDayMonthYear(ts)}
+
+ setDatePickerCords(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
+ isKeyBackward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+ }
+ />
- {showEncryptionWarning && (
-
- Note: This message will be encrypted with current room keys. Devices that join or
- are added after scheduling may not be able to decrypt it.
-
- )}
- {(error || isPast) && (
-
- {error || 'Selected time is in the past'}
-
- )}
-
+
+
+ Quick Schedule
+
+ handlePreset(hoursToMs(1))}
+ >
+ In 1 hour
+
+ handlePreset(hoursToMs(4))}
+ >
+ In 4 hours
+
+ {
+ const tomorrow = new Date();
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ tomorrow.setHours(9, 0, 0, 0);
+ setTs(tomorrow.getTime());
+ setError(undefined);
+ }}
+ >
+ Tomorrow 9 AM
+
+ {
+ const tomorrow = new Date();
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ tomorrow.setHours(14, 0, 0, 0);
+ setTs(tomorrow.getTime());
+ setError(undefined);
+ }}
>
- Schedule Send
-
+ Tomorrow 2 PM
+
-
-
-
-
+
+ {showEncryptionWarning && (
+
+ Note: This message will be encrypted with current room keys. Devices that join or are
+ added after scheduling may not be able to decrypt it.
+
+ )}
+ {(error || isPast) && (
+
+ {error || 'Selected time is in the past'}
+
+ )}
+
+ Schedule Send
+
+
+
+
);
}
diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx
index a1954644c5..a46709ee05 100644
--- a/src/app/pages/client/sidebar/SpaceTabs.tsx
+++ b/src/app/pages/client/sidebar/SpaceTabs.tsx
@@ -13,9 +13,6 @@ import {
Line,
Menu,
MenuItem,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
PopOut,
Text,
config,
@@ -104,6 +101,7 @@ import { InviteUserPrompt } from '$components/invite-user-prompt';
import { CustomAccountDataEvent } from '$types/matrix/accountData';
import { lastVisitedSpaceIdAtom } from '$state/room/lastSpace';
import { useMobileTapActivation } from '$hooks/useMobileTapActivation';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type SpaceMenuProps = {
room: Room;
@@ -284,66 +282,55 @@ function RenameFolderDialog({ mx, folder, onClose, onSave }: Readonly}>
-
-
+
+
-
-
+
+ Rename Folder
+
+
+ {composerIcon(X)}
+
+
+
+
+ Choose a short label for this folder. Leave empty to show space names again.
+
+
+ Folder name
+ ) => setDraft(e.target.value)}
+ autoFocus
+ />
+
+
+
+ Cancel
+
+
+ Save
+
+
+
+
+
);
}
diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx
index f7aca955a0..9e275feaac 100644
--- a/src/app/pages/client/space/Space.tsx
+++ b/src/app/pages/client/space/Space.tsx
@@ -11,9 +11,6 @@ import {
Menu,
MenuItem,
Modal,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
PopOut,
Spinner,
Text,
@@ -110,6 +107,7 @@ import * as css from './styles.css';
import { isResizingSidebarAtom } from '$state/isResizingSidebar';
import { UserQuickTools } from '../sidebar/UserQuickTools';
import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
const debugLog = createDebugLogger('Space');
@@ -424,30 +422,19 @@ function SpaceHeader({ hideText, mx }: { hideText?: boolean; mx: MatrixClient })
>
)}
{hasBanner && bannerViewerOpen && (
- }>
-
- setBannerViewerOpen(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
- evt.stopPropagation()}
- >
- setBannerViewerOpen(false)}
- />
-
-
-
-
+ setBannerViewerOpen(false)}>
+ evt.stopPropagation()}
+ >
+ setBannerViewerOpen(false)}
+ />
+
+
)}
>
);
From 19b029ae80df7812d51abfa223054fcfe61bf17a Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 00:12:16 +0200
Subject: [PATCH 03/43] refactor: support conditional open in ModalOverlay and
migrate remaining matching modals
---
.../message/content/FileContent.tsx | 97 ++++-------
.../message/content/ImageContent.tsx | 47 ++----
.../components/modal-overlay/ModalOverlay.tsx | 5 +-
src/app/components/room-card/RoomCard.tsx | 79 +++------
.../common-settings/cosmetics/Cosmetics.tsx | 96 ++++-------
.../common-settings/general/RoomProfile.tsx | 63 +++-----
src/app/features/lobby/LobbyHero.tsx | 31 ++--
src/app/features/lobby/RoomItem.tsx | 32 ++--
src/app/features/room/RoomViewHeader.tsx | 29 +---
src/app/features/settings/account/Profile.tsx | 153 +++++++-----------
src/app/pages/client/explore/Explore.tsx | 109 ++++++-------
src/app/pages/client/inbox/Bookmarks.tsx | 83 ++++------
src/app/pages/client/inbox/Invites.tsx | 32 ++--
13 files changed, 305 insertions(+), 551 deletions(-)
diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx
index ae751be894..53f448b617 100644
--- a/src/app/components/message/content/FileContent.tsx
+++ b/src/app/components/message/content/FileContent.tsx
@@ -1,21 +1,8 @@
import type { ReactNode } from 'react';
import { useCallback, useState } from 'react';
-import {
- Box,
- Button,
- Modal,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Spinner,
- Text,
- Tooltip,
- TooltipProvider,
- as,
-} from 'folds';
+import { Box, Button, Modal, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { ArrowRight, Download, sizedIcon, Warning } from '$components/icons/phosphor';
import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import FocusTrap from 'focus-trap-react';
import type { IFileInfo } from '$types/matrix/common';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { useMatrixClient } from '$hooks/useMatrixClient';
@@ -26,13 +13,13 @@ import {
getFileNameExt,
mimeTypeToExt,
} from '$utils/mimeTypes';
-import { stopPropagation } from '$utils/keyboard';
import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useRevokeObjectURL } from '$hooks/useObjectURL';
import { useDismissOnBack } from '$utils/androidBack';
import { ModalWide } from '$styles/Modal.css';
import { getDownloadFilename, saveFileToDevice } from '$utils/download';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
const renderErrorButton = (retry: () => void, text: string) => (
{textState.status === AsyncStatus.Success && (
- }>
-
- setTextViewer(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
- evt.stopPropagation()}
- >
- {renderViewer({
- name: body,
- text: textState.data,
- langName: READABLE_TEXT_MIME_TYPES.includes(mimeType)
- ? mimeTypeToExt(mimeType)
- : mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType),
- requestClose: () => setTextViewer(false),
- })}
-
-
-
-
+ setTextViewer(false)}>
+ evt.stopPropagation()}
+ >
+ {renderViewer({
+ name: body,
+ text: textState.data,
+ langName: READABLE_TEXT_MIME_TYPES.includes(mimeType)
+ ? mimeTypeToExt(mimeType)
+ : mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType),
+ requestClose: () => setTextViewer(false),
+ })}
+
+
)}
{textState.status === AsyncStatus.Error ? (
renderErrorButton(loadText, 'Open File')
@@ -195,30 +171,19 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
return (
<>
{pdfState.status === AsyncStatus.Success && (
- }>
-
- setPdfViewer(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
- evt.stopPropagation()}
- >
- {renderViewer({
- name: body,
- src: pdfState.data,
- requestClose: () => setPdfViewer(false),
- })}
-
-
-
-
+ setPdfViewer(false)}>
+ evt.stopPropagation()}
+ >
+ {renderViewer({
+ name: body,
+ src: pdfState.data,
+ requestClose: () => setPdfViewer(false),
+ })}
+
+
)}
{pdfState.status === AsyncStatus.Error ? (
renderErrorButton(loadPdf, 'Open PDF')
diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx
index cb0325cb64..7d1f2e3ea5 100644
--- a/src/app/components/message/content/ImageContent.tsx
+++ b/src/app/components/message/content/ImageContent.tsx
@@ -8,9 +8,6 @@ import {
Menu,
MenuItem,
Modal,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
Spinner,
Text,
Tooltip,
@@ -31,14 +28,12 @@ import {
} from '$components/icons/phosphor';
import classNames from 'classnames';
import { BlurhashCanvas } from 'react-blurhash';
-import FocusTrap from 'focus-trap-react';
import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import type { IImageInfo } from '$types/matrix/common';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { bytesToSize } from '$utils/common';
import { FALLBACK_MIMETYPE } from '$utils/mimeTypes';
-import { stopPropagation } from '$utils/keyboard';
import {
decryptFile,
downloadEncryptedMedia,
@@ -58,6 +53,7 @@ import {
import { useFavoriteGifs } from '$hooks/useFavoriteGifs';
import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl';
import { useRevokeObjectURL } from '$hooks/useObjectURL';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
export function checkIfGif(url: string, mimetype?: string, body?: string) {
return (
@@ -262,32 +258,21 @@ export const ImageContent = as<'div', ImageContentProps>(
onPointerLeave={() => setIsHovered(false)}
>
{srcState.status === AsyncStatus.Success && (
- }>
-
- setViewer(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
- evt.stopPropagation()}
- >
- {renderViewer({
- src: viewerFullSrc ?? srcState.data,
- alt: body ?? '',
- filename,
- requestClose: () => setViewer(false),
- info: info,
- })}
-
-
-
-
+ setViewer(false)}>
+ evt.stopPropagation()}
+ >
+ {renderViewer({
+ src: viewerFullSrc ?? srcState.data,
+ alt: body ?? '',
+ filename,
+ requestClose: () => setViewer(false),
+ info: info,
+ })}
+
+
)}
{typeof blurHash === 'string' && !load && (
void;
children: ReactNode;
};
-export function ModalOverlay({ requestClose, children }: ModalOverlayProps) {
+export function ModalOverlay({ open = true, requestClose, children }: ModalOverlayProps) {
return (
- }>
+ }>
{
@@ -111,32 +96,21 @@ function ErrorDialog({
return (
<>
{children(openError)}
- }>
-
-
-
-
-
- {title}
-
- {message}
-
-
-
- Cancel
-
-
-
-
-
-
+
+
+
+
+ {title}
+
+ {message}
+
+
+
+ Cancel
+
+
+
+
>
);
}
@@ -263,20 +237,9 @@ export const RoomCard = as<'div', RoomCardProps>(
{roomTopic}
- }>
-
-
- {renderTopicViewer(roomName, roomTopic, closeTopic)}
-
-
-
+
+ {renderTopicViewer(roomName, roomTopic, closeTopic)}
+
{(roomType === RoomType.Space || joinedRoom?.isSpaceRoom()) && (
Space
diff --git a/src/app/features/common-settings/cosmetics/Cosmetics.tsx b/src/app/features/common-settings/cosmetics/Cosmetics.tsx
index 462d7eaff9..0843862863 100644
--- a/src/app/features/common-settings/cosmetics/Cosmetics.tsx
+++ b/src/app/features/common-settings/cosmetics/Cosmetics.tsx
@@ -11,9 +11,6 @@ import {
config,
Button,
Spinner,
- OverlayBackdrop,
- Overlay,
- OverlayCenter,
Modal,
Dialog,
Header,
@@ -46,15 +43,14 @@ import type { UploadSuccess } from '$state/upload';
import { createUploadAtom } from '$state/upload';
import { useFilePicker } from '$hooks/useFilePicker';
import { CompactUploadCardRenderer } from '$components/upload-card';
-import FocusTrap from 'focus-trap-react';
import { ImageEditor } from '$components/image-editor';
-import { stopPropagation } from '$utils/keyboard';
import { ModalWide } from '$styles/Modal.css';
import { NameColorEditor } from '$features/settings/account/NameColorEditor';
import { PronounEditor } from '$features/settings/account/PronounEditor';
import type { PronounSet } from '$utils/pronouns';
import { EventType } from '$types/matrix-sdk';
import { CustomStateEvent } from '$types/matrix/room';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
const log = createLogger('Cosmetics');
@@ -171,66 +167,44 @@ export function CosmeticsAvatar({ profile, member, userId, room }: CosmeticsSett
)}
{imageFileURL && (
- }>
-
-
-
-
-
-
-
-
+
+
+
+
+
)}
- }>
-
- setAlertRemove(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
+ setAlertRemove(false)}>
+
+
-
-
-
- Remove Room Avatar
-
- setAlertRemove(false)} radii="300">
- {composerIcon(X)}
-
-
-
-
- Are you sure you want to remove room avatar?
-
-
- Remove
-
-
-
-
-
-
+
+ Remove Room Avatar
+
+ setAlertRemove(false)} radii="300">
+ {composerIcon(X)}
+
+
+
+
+ Are you sure you want to remove room avatar?
+
+
+ Remove
+
+
+
+
);
}
diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx
index ccfbb0e6a1..e6faea5a46 100644
--- a/src/app/features/common-settings/general/RoomProfile.tsx
+++ b/src/app/features/common-settings/general/RoomProfile.tsx
@@ -9,9 +9,6 @@ import {
Header,
IconButton,
Input,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
Spinner,
Text,
TextArea,
@@ -57,9 +54,8 @@ import { useStateEvent } from '$hooks/useStateEvent';
import type { RoomBannerContent } from '$types/matrix-sdk-events';
import { CustomStateEvent } from '$types/matrix/room';
import { SettingTile } from '$components/setting-tile';
-import { stopPropagation } from '$utils/keyboard';
-import FocusTrap from 'focus-trap-react';
import { reportMediaLoadFailure } from '$utils/mediaLoadDiagnostics';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type RoomProfileEditProps = {
canEditAvatar: boolean;
@@ -453,42 +449,31 @@ function RoomBannerEdit({ bannerURI, permissions }: Readonly) {
)}
- }>
-
- setAlertRemove(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
+ setAlertRemove(false)}>
+
+
-
-
-
- Remove Banner
-
- setAlertRemove(false)} radii="300">
- {composerIcon(X)}
-
-
-
- Are you sure you want to remove profile banner?
-
- Remove
-
-
-
-
-
-
+
+ Remove Banner
+
+ setAlertRemove(false)} radii="300">
+ {composerIcon(X)}
+
+
+
+ Are you sure you want to remove profile banner?
+
+ Remove
+
+
+
+
);
}
diff --git a/src/app/features/lobby/LobbyHero.tsx b/src/app/features/lobby/LobbyHero.tsx
index 6fe92839cf..fad26dd065 100644
--- a/src/app/features/lobby/LobbyHero.tsx
+++ b/src/app/features/lobby/LobbyHero.tsx
@@ -1,5 +1,4 @@
-import { Avatar, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
-import FocusTrap from 'focus-trap-react';
+import { Avatar, Text } from 'folds';
import { useRoomAvatar, useRoomName, useRoomTopic } from '$hooks/useRoomMeta';
import { useSpace } from '$hooks/useSpace';
import { useMatrixClient } from '$hooks/useMatrixClient';
@@ -8,10 +7,11 @@ import { nameInitials } from '$utils/common';
import { UseStateProvider } from '$components/UseStateProvider';
import { RoomTopicViewer } from '$components/room-topic-viewer';
import { PageHero } from '$components/page';
-import { onEnterOrSpace, stopPropagation } from '$utils/keyboard';
+import { onEnterOrSpace } from '$utils/keyboard';
import { mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import * as css from './LobbyHero.css';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
export function LobbyHero() {
const mx = useMatrixClient();
@@ -43,24 +43,13 @@ export function LobbyHero() {
{(viewTopic, setViewTopic) => (
<>
- }>
-
- setViewTopic(false),
- escapeDeactivates: stopPropagation,
- }}
- >
- setViewTopic(false)}
- />
-
-
-
+ setViewTopic(false)}>
+ setViewTopic(false)}
+ />
+
setViewTopic(true)}
diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx
index 123bacce26..6f5f148264 100644
--- a/src/app/features/lobby/RoomItem.tsx
+++ b/src/app/features/lobby/RoomItem.tsx
@@ -6,9 +6,6 @@ import {
Box,
Chip,
Line,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
Spinner,
Text,
Tooltip,
@@ -17,7 +14,6 @@ import {
color,
toRem,
} from 'folds';
-import FocusTrap from 'focus-trap-react';
import type { MatrixError, Room, IHierarchyRoom } from '$types/matrix-sdk';
import { JoinRule, KnownMembership } from '$types/matrix-sdk';
import { RoomAvatar, RoomIcon } from '$components/room-avatar';
@@ -28,7 +24,7 @@ import { KnockRoomPrompt } from '$components/knock-room-prompt';
import { LocalRoomSummaryLoader } from '$components/RoomSummaryLoader';
import { UseStateProvider } from '$components/UseStateProvider';
import { RoomTopicViewer } from '$components/room-topic-viewer';
-import { onEnterOrSpace, stopPropagation } from '$utils/keyboard';
+import { onEnterOrSpace } from '$utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '$utils/room';
@@ -47,6 +43,7 @@ import {
} from '$components/icons/phosphor';
import * as styleCss from './style.css';
import * as css from './RoomItem.css';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type RoomJoinButtonProps = {
roomId: string;
@@ -296,24 +293,13 @@ function RoomProfile({
>
{topic}
- }>
-
- setView(false),
- escapeDeactivates: stopPropagation,
- }}
- >
- setView(false)}
- />
-
-
-
+ setView(false)}>
+ setView(false)}
+ />
+
>
)}
diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx
index 2dd08bc31a..4976dc471d 100644
--- a/src/app/features/room/RoomViewHeader.tsx
+++ b/src/app/features/room/RoomViewHeader.tsx
@@ -7,9 +7,6 @@ import {
Box,
Avatar,
Text,
- Overlay,
- OverlayCenter,
- OverlayBackdrop,
IconButton,
Tooltip,
TooltipProvider,
@@ -117,6 +114,7 @@ import { RoomPinMenu } from './room-pin-menu';
import * as css from './RoomViewHeader.css';
import { RoomCallButton } from './RoomCallButton';
import { CustomAccountDataEvent } from '$types/matrix/accountData';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
const log = createLogger('RoomViewHeader');
@@ -664,24 +662,13 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) {
{(viewTopic, setViewTopic) => (
<>
- }>
-
- setViewTopic(false),
- escapeDeactivates: stopPropagation,
- }}
- >
- setViewTopic(false)}
- />
-
-
-
+ setViewTopic(false)}>
+ setViewTopic(false)}
+ />
+
)
)}
{imageFileURL && (
- }>
-
-
-
-
-
-
-
-
+
+
+
+
+
)}
- }>
-
- setAlertRemove(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
+ setAlertRemove(false)}>
+
+
-
-
-
- Remove Avatar
-
- setAlertRemove(false)} radii="300">
- {composerIcon(X)}
-
-
-
-
- Are you sure you want to remove profile avatar?
-
-
- Remove
-
-
-
-
-
-
+
+ Remove Avatar
+
+ setAlertRemove(false)} radii="300">
+ {composerIcon(X)}
+
+
+
+
+ Are you sure you want to remove profile avatar?
+
+
+ Remove
+
+
+
+
);
}
@@ -363,42 +337,31 @@ function ProfileBanner({ profile }: Readonly>) {
)}
- }>
-
- setAlertRemove(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
+ setAlertRemove(false)}>
+
+
-
-
-
- Remove Banner
-
- setAlertRemove(false)} radii="300">
- {composerIcon(X)}
-
-
-
- Are you sure you want to remove profile banner?
-
- Remove
-
-
-
-
-
-
+
+ Remove Banner
+
+ setAlertRemove(false)} radii="300">
+ {composerIcon(X)}
+
+
+
+ Are you sure you want to remove profile banner?
+
+ Remove
+
+
+
+
);
}
diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx
index 184ede69fd..d3195fdd02 100644
--- a/src/app/pages/client/explore/Explore.tsx
+++ b/src/app/pages/client/explore/Explore.tsx
@@ -1,7 +1,6 @@
import type { FormEventHandler, MouseEventHandler } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
@@ -10,9 +9,6 @@ import {
Header,
IconButton,
Input,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
Text,
color,
config,
@@ -45,7 +41,6 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { useNavToActivePathMapper } from '$hooks/useNavToActivePathMapper';
import { PageNav, PageNavContent, PageNavHeader } from '$components/page';
-import { stopPropagation } from '$utils/keyboard';
import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer';
import { settingsAtom } from '$state/settings';
import { useSetting } from '$state/hooks/settings';
@@ -55,6 +50,7 @@ import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize';
import { isResizingSidebarAtom } from '$state/isResizingSidebar';
import { useSetAtom } from 'jotai';
import { UserQuickTools } from '../sidebar/UserQuickTools';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type AddServerProps = {
hideText?: boolean;
@@ -110,56 +106,47 @@ export function AddServer({ hideText, onAddServer }: AddServerProps) {
return (
<>
- }>
-
- setDialog(false),
- escapeDeactivates: stopPropagation,
+ setDialog(false)}>
+
+
+
+ Add server name to explore public communities.
+
+ Server Name
+
+ {serverError && (
+
+ {serverError}
+
+ )}
+ {exploreState.status === AsyncStatus.Error && (
+
+ Failed to load public rooms. Please try again.
+
+ )}
+
+
+ {/* Save
*/}
-
- Add
-
-
-
-
-
-
-
+
+ Add
+
+
+
+
+
{!hideText ? (
}>
-
-
+
+
-
-
-
- Remove Bookmark
-
-
-
-
-
-
- Are you sure you want to remove this bookmark?
-
- {renderMatrixEvent()}
-
-
- Remove
-
-
-
-
-
-
+
+ Remove Bookmark
+
+
+
+
+
+
+ Are you sure you want to remove this bookmark?
+
+ {renderMatrixEvent()}
+
+
+ Remove
+
+
+
+
);
}
diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx
index ddedd279d5..f48383fbdc 100644
--- a/src/app/pages/client/inbox/Invites.tsx
+++ b/src/app/pages/client/inbox/Invites.tsx
@@ -6,9 +6,6 @@ import {
Button,
Chip,
IconButton,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
Scroll,
Spinner,
Text,
@@ -36,7 +33,6 @@ import type {
Room,
AccountDataEvents,
} from '$types/matrix-sdk';
-import FocusTrap from 'focus-trap-react';
import {
Page,
PageContent,
@@ -70,7 +66,7 @@ import {
} from '$utils/matrix';
import { Time } from '$components/message';
import { useElementSizeObserver } from '$hooks/useElementSizeObserver';
-import { onEnterOrSpace, stopPropagation } from '$utils/keyboard';
+import { onEnterOrSpace } from '$utils/keyboard';
import { RoomTopicViewer } from '$components/room-topic-viewer';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { useRoomNavigate } from '$hooks/useRoomNavigate';
@@ -88,6 +84,7 @@ import { EventType } from '$types/matrix-sdk';
import { CustomAccountDataEvent } from '$types/matrix/accountData';
import { updateInviteList } from '$state/updateInvites';
import { useDismissedInviteList } from '$hooks/useDismissedInvites';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
const COMPACT_CARD_WIDTH = 548;
@@ -310,24 +307,13 @@ function InviteCard({
{invite.roomTopic}
)}
- }>
-
-
-
-
-
-
+
+
+
{joinState.status === AsyncStatus.Error && (
From 4241064e2ffa733d77ead837c0496fcdefbbe8be Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 00:25:57 +0200
Subject: [PATCH 04/43] feat: open home and direct tab menus with a long press
on touch
---
src/app/pages/client/sidebar/DirectTab.tsx | 31 +++++++++++++++++-----
src/app/pages/client/sidebar/HomeTab.tsx | 31 +++++++++++++++++-----
2 files changed, 48 insertions(+), 14 deletions(-)
diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx
index 7732bf46f7..0b94665398 100644
--- a/src/app/pages/client/sidebar/DirectTab.tsx
+++ b/src/app/pages/client/sidebar/DirectTab.tsx
@@ -1,5 +1,5 @@
-import type { MouseEventHandler } from 'react';
-import { forwardRef, useMemo, useState } from 'react';
+import type { MouseEventHandler, TouchEvent as ReactTouchEvent } from 'react';
+import { forwardRef, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { RectCords } from 'folds';
import { Box, Menu, MenuItem, PopOut, Text, config, toRem } from 'folds';
@@ -28,6 +28,7 @@ import { settingsAtom } from '$state/settings';
import { useSetting } from '$state/hooks/settings';
import { useDirectRooms } from '$pages/client/direct/useDirectRooms';
import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds';
+import { useMobileLongPress } from '$hooks/useMobileLongPress';
type DirectMenuProps = {
requestClose: () => void;
@@ -96,13 +97,25 @@ export function DirectTab() {
navigate(getDirectPath());
};
+ const buttonRef = useRef(null);
+
+ const openMenuAt = (element: HTMLElement) => {
+ const cords = element.getBoundingClientRect();
+ setMenuAnchor((currentState) => (currentState ? undefined : cords));
+ };
+
const handleContextMenu: MouseEventHandler = (evt) => {
evt.preventDefault();
- const cords = evt.currentTarget.getBoundingClientRect();
- setMenuAnchor((currentState) => {
- if (currentState) return undefined;
- return cords;
- });
+ openMenuAt(evt.currentTarget);
+ };
+
+ const longPress = useMobileLongPress(() => {
+ if (buttonRef.current) openMenuAt(buttonRef.current);
+ });
+
+ const handleTouchStart = (evt: ReactTouchEvent) => {
+ buttonRef.current = evt.currentTarget;
+ longPress.onTouchStart(evt);
};
return (
@@ -114,6 +127,10 @@ export function DirectTab() {
outlined
onClick={handleDirectClick}
onContextMenu={handleContextMenu}
+ onTouchStart={handleTouchStart}
+ onTouchEnd={longPress.onTouchEnd}
+ onTouchMove={longPress.onTouchMove}
+ onTouchCancel={longPress.onTouchCancel}
>
(null);
+
+ const openMenuAt = (element: HTMLElement) => {
+ const cords = element.getBoundingClientRect();
+ setMenuAnchor((currentState) => (currentState ? undefined : cords));
+ };
+
const handleContextMenu: MouseEventHandler = (evt) => {
evt.preventDefault();
- const cords = evt.currentTarget.getBoundingClientRect();
- setMenuAnchor((currentState) => {
- if (currentState) return undefined;
- return cords;
- });
+ openMenuAt(evt.currentTarget);
+ };
+
+ const longPress = useMobileLongPress(() => {
+ if (buttonRef.current) openMenuAt(buttonRef.current);
+ });
+
+ const handleTouchStart = (evt: ReactTouchEvent) => {
+ buttonRef.current = evt.currentTarget;
+ longPress.onTouchStart(evt);
};
return (
@@ -105,6 +118,10 @@ export function HomeTab() {
outlined
onClick={handleHomeClick}
onContextMenu={handleContextMenu}
+ onTouchStart={handleTouchStart}
+ onTouchEnd={longPress.onTouchEnd}
+ onTouchMove={longPress.onTouchMove}
+ onTouchCancel={longPress.onTouchCancel}
>
Date: Sat, 25 Jul 2026 00:38:39 +0200
Subject: [PATCH 05/43] refactor: share one FormPage across create room, create
space and bug report
---
src/app/components/page/FormPage.tsx | 124 +++++
.../features/bug-report/BugReportModal.tsx | 500 +++++++++---------
.../pages/client/bug-report/BugReportPage.tsx | 103 +---
.../client/create-room/CreateRoomPage.tsx | 131 +----
src/app/pages/client/create/Create.tsx | 131 +----
5 files changed, 404 insertions(+), 585 deletions(-)
create mode 100644 src/app/components/page/FormPage.tsx
diff --git a/src/app/components/page/FormPage.tsx b/src/app/components/page/FormPage.tsx
new file mode 100644
index 0000000000..922c631de3
--- /dev/null
+++ b/src/app/components/page/FormPage.tsx
@@ -0,0 +1,124 @@
+import type { ReactNode } from 'react';
+import { useEffect, useState } from 'react';
+import { useSetAtom } from 'jotai';
+import { Box, IconButton, Scroll, Text, color, config, toRem } from 'folds';
+import { SquaresFour, composerIcon, sizedIcon, X } from '$components/icons/phosphor';
+import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
+import { isResizingSidebarAtom } from '$state/isResizingSidebar';
+import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer';
+import { UserQuickTools } from '$pages/client/sidebar/UserQuickTools';
+import {
+ Page,
+ PageContent,
+ PageContentCenter,
+ PageHero,
+ PageHeroSection,
+ PageNav,
+ PageNavHeader,
+} from './Page';
+
+type FormPageProps = {
+ title: string;
+ subTitle: string;
+ closeLabel: string;
+ onClose: () => void;
+ children: ReactNode;
+};
+
+export function FormPage({ title, subTitle, closeLabel, onClose, children }: FormPageProps) {
+ const setIsResizingSidebar = useSetAtom(isResizingSidebarAtom);
+ const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth');
+ const [curWidth, setCurWidth] = useState(roomSidebarWidth);
+
+ useEffect(() => {
+ setCurWidth(roomSidebarWidth);
+ }, [roomSidebarWidth]);
+
+ const screenSize = useScreenSizeContext();
+ const isMobile = screenSize === ScreenSize.Mobile;
+ const hideText = curWidth <= 80 && !isMobile;
+ const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar');
+
+ return (
+ <>
+ {!isMobile && (
+
+
+
+
+ {hideText ? (
+ sizedIcon(SquaresFour, '200', { filled: true })
+ ) : (
+
+
+ {title}
+
+
+ )}
+
+
+
+
+ {!oldSidebar && }
+
+ )}
+
+
+ {isMobile && (
+
+
+
+
+
+ {title}
+
+
+
+ {composerIcon(X)}
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/features/bug-report/BugReportModal.tsx b/src/app/features/bug-report/BugReportModal.tsx
index 7042f0da2f..76c21f1b7d 100644
--- a/src/app/features/bug-report/BugReportModal.tsx
+++ b/src/app/features/bug-report/BugReportModal.tsx
@@ -218,275 +218,248 @@ export function BugReportForm({ onDone }: { onDone: () => void }) {
};
return (
-
-
-
- Report an Issue
+
+ {/* Type */}
+
+ Type
+
+ setType('bug')}
+ >
+ Bug Report
+
+ setType('feature')}
+ >
+ Feature Request
+
-
- {composerIcon(X)}
-
-
-
-
- {/* Type */}
-
- Type
-
- setType('bug')}
- >
- Bug Report
-
- setType('feature')}
- >
- Feature Request
-
-
+
+
+ {/* Title + duplicate check */}
+
+ Title *
+ setTitle((e.target as HTMLInputElement).value)}
+ />
+ {searching && (
+
+
+ Searching for similar issues…
-
- {/* Title + duplicate check */}
+ )}
+ {!searching && similarIssues.length > 0 && (
- Title *
- setTitle((e.target as HTMLInputElement).value)}
- />
- {searching && (
-
-
- Searching for similar issues…
-
- )}
- {!searching && similarIssues.length > 0 && (
-
- Similar open issues — please check before submitting:
- {similarIssues.map((issue) => (
-
- {'→ '}
-
- #{issue.number}: {issue.title}
-
-
- ))}
-
- )}
+ Similar open issues — please check before submitting:
+ {similarIssues.map((issue) => (
+
+ {'→ '}
+
+ #{issue.number}: {issue.title}
+
+
+ ))}
-
- {/* Description */}
-
-
- {type === 'bug' ? 'Describe the bug *' : 'Describe the problem *'}
-
-
+
+ {/* Description */}
+
+ {type === 'bug' ? 'Describe the bug *' : 'Describe the problem *'}
+
+
+ {/* Bug: steps to reproduce */}
+ {type === 'bug' && (
+
+ Steps to reproduce (optional)
+
+ )}
+
+ {/* Bug: expected behavior */}
+ {type === 'bug' && (
+
+ Expected behavior (optional)
+
+ )}
+
+ {/* Feature: solution */}
+ {type === 'feature' && (
+
+ Describe the solution you'd like *
+
+ )}
+
+ {/* Feature: alternatives */}
+ {type === 'feature' && (
+
+ Alternatives considered (optional)
+
+ )}
+
+ {/* Platform info for bugs */}
+ {type === 'bug' && (
+
+ Platform info (auto-included)
+
+ {`Sable v${APP_VERSION}${IS_RELEASE_TAG ? '' : '-dev'} • ${navigator.userAgent}`}
+
+
+ )}
+
+ {/* Additional context — shared */}
+
+ Additional context (optional)
+
+
+ {/* Sentry integration options (only for bug reports when Sentry is configured) */}
+ {type === 'bug' && sentryEnabled && (
+
+ Error Tracking
+
+ setSendToSentry((v) => !v)}
/>
-
-
- {/* Bug: steps to reproduce */}
- {type === 'bug' && (
-
- Steps to reproduce (optional)
-
- )}
-
- {/* Bug: expected behavior */}
- {type === 'bug' && (
-
- Expected behavior (optional)
-
- )}
-
- {/* Feature: solution */}
- {type === 'feature' && (
-
- Describe the solution you'd like *
-
- )}
-
- {/* Feature: alternatives */}
- {type === 'feature' && (
-
- Alternatives considered (optional)
-
- )}
-
- {/* Platform info for bugs */}
- {type === 'bug' && (
-
- Platform info (auto-included)
-
- {`Sable v${APP_VERSION}${IS_RELEASE_TAG ? '' : '-dev'} • ${navigator.userAgent}`}
+
+ Send anonymous report to Sentry for error tracking
+
+ Helps developers identify and fix issues faster. No personal data is sent.
- )}
-
- {/* Additional context — shared */}
-
- Additional context (optional)
-
-
- {/* Sentry integration options (only for bug reports when Sentry is configured) */}
- {type === 'bug' && sentryEnabled && (
-
- Error Tracking
-
- setSendToSentry((v) => !v)}
- />
-
- Send anonymous report to Sentry for error tracking
-
- Helps developers identify and fix issues faster. No personal data is sent.
-
-
-
- {sendToSentry && (
-
- setIncludeDebugLogs((v) => !v)}
- />
-
- Include recent debug logs (last 100 entries)
-
- Provides additional context to help diagnose the issue. Logs are filtered for
- sensitive data.
-
-
-
- )}
-
- setOpenOnGitHub((v) => !v)}
- />
-
- Also create a GitHub issue
-
- Opens a pre-filled GitHub issue in addition to the Sentry report.
-
-
+ {sendToSentry && (
+
+ setIncludeDebugLogs((v) => !v)}
+ />
+
+ Include recent debug logs (last 100 entries)
+
+ Provides additional context to help diagnose the issue. Logs are filtered for
+ sensitive data.
+
)}
-
- {/* Actions */}
-
-
- Cancel
-
-
+
-
- {sentryEnabled && type === 'bug' ? 'Submit Report' : 'Open on GitHub'}
+ checked={openOnGitHub}
+ onClick={() => setOpenOnGitHub((v) => !v)}
+ />
+
+ Also create a GitHub issue
+
+ Opens a pre-filled GitHub issue in addition to the Sentry report.
-
+
-
+ )}
+
+ {/* Actions */}
+
+
+ Cancel
+
+
+
+ {sentryEnabled && type === 'bug' ? 'Submit Report' : 'Open on GitHub'}
+
+
+
);
}
@@ -497,7 +470,24 @@ function BugReportModal() {
return (
-
+
+
+
+ Report an Issue
+
+
+ {composerIcon(X)}
+
+
+
+
+
+
+
+
);
diff --git a/src/app/pages/client/bug-report/BugReportPage.tsx b/src/app/pages/client/bug-report/BugReportPage.tsx
index 19f28c41bf..28ced10fa7 100644
--- a/src/app/pages/client/bug-report/BugReportPage.tsx
+++ b/src/app/pages/client/bug-report/BugReportPage.tsx
@@ -1,102 +1,19 @@
-import { Box, Scroll, toRem, Text, color, config } from 'folds';
-import { SquaresFour, sizedIcon } from '$components/icons/phosphor';
-import {
- Page,
- PageContent,
- PageContentCenter,
- PageHero,
- PageHeroSection,
- PageNav,
- PageNavHeader,
-} from '$components/page';
-import { BugReportForm } from '$features/bug-report';
-import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
-import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
-import { SidebarResizer } from '../sidebar/SidebarResizer';
-import { useSetAtom } from 'jotai';
-import { isResizingSidebarAtom } from '$state/isResizingSidebar';
-import { UserQuickTools } from '../sidebar/UserQuickTools';
+import { FormPage } from '$components/page/FormPage';
+import { BugReportForm } from '$features/bug-report';
export function BugReportPage() {
const navigate = useNavigate();
const navigateBack = () => navigate(-1);
- const setIsResizingSidebar = useSetAtom(isResizingSidebarAtom);
- const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth');
- const [curWidth, setCurWidth] = useState(roomSidebarWidth);
-
- useEffect(() => {
- setCurWidth(roomSidebarWidth);
- }, [roomSidebarWidth]);
- const screenSize = useScreenSizeContext();
- const isMobile = screenSize === ScreenSize.Mobile;
- const hideText = curWidth <= 80 && !isMobile;
- const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar');
-
return (
- <>
- {!isMobile && (
-
-
-
-
- {!hideText ? (
-
-
- Report an Issue
-
-
- ) : (
- sizedIcon(SquaresFour, '200', { filled: true })
- )}
-
-
-
-
- {!oldSidebar && !isMobile && }
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
+
+
+
);
}
diff --git a/src/app/pages/client/create-room/CreateRoomPage.tsx b/src/app/pages/client/create-room/CreateRoomPage.tsx
index ebd89b2c8a..d679c5b4a3 100644
--- a/src/app/pages/client/create-room/CreateRoomPage.tsx
+++ b/src/app/pages/client/create-room/CreateRoomPage.tsx
@@ -1,38 +1,19 @@
-import { Box, IconButton, Scroll, toRem, Text, color, config } from 'folds';
-import { SquaresFour, composerIcon, sizedIcon, X } from '$components/icons/phosphor';
-import {
- Page,
- PageContent,
- PageContentCenter,
- PageHero,
- PageHeroSection,
- PageNav,
- PageNavHeader,
-} from '$components/page';
+import { useSearchParams, useNavigate } from 'react-router-dom';
+import { FormPage } from '$components/page/FormPage';
import { CreateRoomForm } from '$features/create-room/CreateRoom';
import { useRoomNavigate } from '$hooks/useRoomNavigate';
-import { useEffect, useState } from 'react';
-import { useSearchParams, useNavigate } from 'react-router-dom';
-import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { SpaceProvider } from '$hooks/useSpace';
import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
-import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
-import { SidebarResizer } from '../sidebar/SidebarResizer';
-import { useSetAtom } from 'jotai';
-import { isResizingSidebarAtom } from '$state/isResizingSidebar';
-import { UserQuickTools } from '../sidebar/UserQuickTools';
import { CreateRoomType } from '$components/create-room/types';
export function CreateRoomPage() {
const { navigateRoom } = useRoomNavigate();
- const backNavigate = useNavigate();
+ const navigate = useNavigate();
const [searchParams] = useSearchParams();
const spaceIdParam = searchParams.get('spaceId') ?? undefined;
const typeParam = searchParams.get('type') ?? undefined;
- // Parse type only for valid CreateRoomType enum values
const type: CreateRoomType | undefined =
typeParam === CreateRoomType.TextRoom || typeParam === CreateRoomType.VoiceRoom
? (typeParam as CreateRoomType)
@@ -42,104 +23,18 @@ export function CreateRoomPage() {
const getRoom = useGetRoom(allJoinedRooms);
const space = spaceIdParam ? getRoom(spaceIdParam) : undefined;
- const setIsResizingSidebar = useSetAtom(isResizingSidebarAtom);
- const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth');
- const [curWidth, setCurWidth] = useState(roomSidebarWidth);
-
- useEffect(() => {
- setCurWidth(roomSidebarWidth);
- }, [roomSidebarWidth]);
- const screenSize = useScreenSizeContext();
- const isMobile = screenSize === ScreenSize.Mobile;
- const hideText = curWidth <= 80 && !isMobile;
- const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar');
-
const title = type === CreateRoomType.VoiceRoom ? 'New Voice Room' : 'New Chat Room';
return (
- <>
- {!isMobile && (
-
-
-
-
- {!hideText ? (
-
-
- {title}
-
-
- ) : (
- sizedIcon(SquaresFour, '200', { filled: true })
- )}
-
-
-
-
- {!oldSidebar && !isMobile && }
-
- )}
-
-
- {isMobile && (
-
-
-
-
-
- {title}
-
-
- backNavigate(-1)}
- variant="Background"
- >
- {composerIcon(X)}
-
-
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
+ navigate(-1)}
+ >
+
+
+
+
);
}
diff --git a/src/app/pages/client/create/Create.tsx b/src/app/pages/client/create/Create.tsx
index 3b8bc99c84..bf1c89cb75 100644
--- a/src/app/pages/client/create/Create.tsx
+++ b/src/app/pages/client/create/Create.tsx
@@ -1,28 +1,9 @@
-import { Box, IconButton, Scroll, toRem, Text, color, config } from 'folds';
-import { SquaresFour, composerIcon, sizedIcon, X } from '$components/icons/phosphor';
-import {
- Page,
- PageContent,
- PageContentCenter,
- PageHero,
- PageHeroSection,
- PageNav,
- PageNavHeader,
-} from '$components/page';
+import { useSearchParams, useNavigate } from 'react-router-dom';
+import { FormPage } from '$components/page/FormPage';
import { CreateSpaceForm } from '$features/create-space';
import { useRoomNavigate } from '$hooks/useRoomNavigate';
-import { useEffect, useState } from 'react';
-import { useSearchParams } from 'react-router-dom';
-import { useNavigate } from 'react-router-dom';
-import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { SpaceProvider } from '$hooks/useSpace';
import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
-import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
-import { SidebarResizer } from '../sidebar/SidebarResizer';
-import { useSetAtom } from 'jotai';
-import { isResizingSidebarAtom } from '$state/isResizingSidebar';
-import { UserQuickTools } from '../sidebar/UserQuickTools';
export function Create() {
const { navigateSpace } = useRoomNavigate();
@@ -34,104 +15,16 @@ export function Create() {
const getRoom = useGetRoom(allJoinedRooms);
const space = spaceId ? getRoom(spaceId) : undefined;
- const setIsResizingSidebar = useSetAtom(isResizingSidebarAtom);
- const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth');
- const [curWidth, setCurWidth] = useState(roomSidebarWidth);
-
- useEffect(() => {
- setCurWidth(roomSidebarWidth);
- }, [roomSidebarWidth]);
- const screenSize = useScreenSizeContext();
- const isMobile = screenSize === ScreenSize.Mobile;
- const hideText = curWidth <= 80 && !isMobile;
- const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar');
-
return (
- <>
- {!isMobile && (
-
-
-
-
- {!hideText ? (
-
-
- Create Space
-
-
- ) : (
- sizedIcon(SquaresFour, '200', { filled: true })
- )}
-
-
-
-
- {!oldSidebar && !isMobile && }
-
- )}
-
-
- {isMobile && (
-
-
-
-
-
- Create Space
-
-
-
- navigate(-1)}
- >
- {composerIcon(X)}
-
-
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
+ navigate(-1)}
+ >
+
+
+
+
);
}
From 9eaec6c174bd0898b7ff1dacb7db6b4f031f2f22 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 00:40:13 +0200
Subject: [PATCH 06/43] refactor: share one FormModal between create room and
create space
---
.../components/modal-overlay/FormModal.tsx | 47 +++++++++++++++++++
.../features/create-room/CreateRoomModal.tsx | 46 +++---------------
.../create-space/CreateSpaceModal.tsx | 42 ++---------------
3 files changed, 58 insertions(+), 77 deletions(-)
create mode 100644 src/app/components/modal-overlay/FormModal.tsx
diff --git a/src/app/components/modal-overlay/FormModal.tsx b/src/app/components/modal-overlay/FormModal.tsx
new file mode 100644
index 0000000000..390813c533
--- /dev/null
+++ b/src/app/components/modal-overlay/FormModal.tsx
@@ -0,0 +1,47 @@
+import type { ReactNode } from 'react';
+import { Box, config, Header, IconButton, Modal, Scroll, Text } from 'folds';
+import { composerIcon, X } from '$components/icons/phosphor';
+import { ModalOverlay } from './ModalOverlay';
+
+type FormModalProps = {
+ title: string;
+ requestClose: () => void;
+ children: ReactNode;
+};
+
+export function FormModal({ title, requestClose, children }: FormModalProps) {
+ return (
+
+
+
+
+
+ {title}
+
+
+
+ {composerIcon(X)}
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx
index f8ec2c0ec0..4b5bbf01b9 100644
--- a/src/app/features/create-room/CreateRoomModal.tsx
+++ b/src/app/features/create-room/CreateRoomModal.tsx
@@ -1,10 +1,8 @@
-import { Box, config, Header, IconButton, Modal, Scroll, Text } from 'folds';
-import { X, composerIcon } from '$components/icons/phosphor';
import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
import { SpaceProvider } from '$hooks/useSpace';
import { useCloseCreateRoomModal, useCreateRoomModalState } from '$state/hooks/createRoomModal';
import type { CreateRoomModalState } from '$state/createRoomModal';
-import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
+import { FormModal } from '$components/modal-overlay/FormModal';
import { CreateRoomType } from '$components/create-room/types';
import { CreateRoomForm } from './CreateRoom';
@@ -21,42 +19,12 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
return (
-
-
-
-
-
-
- {type === CreateRoomType.VoiceRoom ? 'New Voice Room' : 'New Chat Room'}
-
-
-
-
- {composerIcon(X)}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
);
}
diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx
index 139ea04023..1e7ad25861 100644
--- a/src/app/features/create-space/CreateSpaceModal.tsx
+++ b/src/app/features/create-space/CreateSpaceModal.tsx
@@ -1,11 +1,9 @@
-import { Box, config, Header, IconButton, Modal, Scroll, Text } from 'folds';
-import { composerIcon, X } from '$components/icons/phosphor';
import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
import { SpaceProvider } from '$hooks/useSpace';
import { useCloseCreateSpaceModal, useCreateSpaceModalState } from '$state/hooks/createSpaceModal';
import type { CreateSpaceModalState } from '$state/createSpaceModal';
+import { FormModal } from '$components/modal-overlay/FormModal';
import { CreateSpaceForm } from './CreateSpace';
-import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type CreateSpaceModalProps = {
state: CreateSpaceModalState;
@@ -20,41 +18,9 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
return (
-
-
-
-
-
- New Space
-
-
-
- {composerIcon(X)}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
);
}
From f510967231d2737215c36fd5329a106c6dc750d2 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 00:42:56 +0200
Subject: [PATCH 07/43] docs: plan for collapsing page and modal surfaces into
modal routes
---
docs/modal-routes-plan.md | 66 +++++++++++++++++++++++++++++++++++++++
1 file changed, 66 insertions(+)
create mode 100644 docs/modal-routes-plan.md
diff --git a/docs/modal-routes-plan.md b/docs/modal-routes-plan.md
new file mode 100644
index 0000000000..fd6f0fa8fe
--- /dev/null
+++ b/docs/modal-routes-plan.md
@@ -0,0 +1,66 @@
+# Modal routes: one surface per feature
+
+## Problem
+
+Several features are reachable two independent ways, and the trigger decides the
+presentation rather than the viewport:
+
+- a **route** that always renders a full page
+- a **modal atom** that always renders a centred modal
+
+On a phone the lobby button opens a cramped modal while the route opens a proper
+full-screen page — the same form, two experiences. The duplicated chrome that
+used to sit either side of this has been collapsed into `FormPage` and
+`FormModal`, but the two trigger mechanisms remain.
+
+Affected features:
+
+| Feature | Route | Modal atom |
+| --- | --- | --- |
+| Create room | `/create-room`, `/home/create` | `createRoomModal` |
+| Create space | `/create` | `createSpaceModal` |
+| Bug report | `/bug-report` | `bugReportModal` |
+| Navigate | `/navigate` | `NavigateModal` |
+
+## Target
+
+The URL is the single source of truth. A route renders as an overlay on desktop
+(over whatever was behind it) and as a full page on mobile. This is the pattern
+GitHub and Element use.
+
+## Steps
+
+1. **`RouteSurface`** — new component choosing presentation by
+ `useScreenSizeContext()`: `FormModal` on desktop, `FormPage` on mobile. Props
+ are the union of both (`title`, `subTitle`, `closeLabel`, `onClose`).
+
+2. **Background location in the router** — in `src/app/pages/Router.tsx`, read
+ `location.state?.backgroundLocation`. When present, render the route tree
+ against the background location and render the overlay route separately, so
+ the page behind stays mounted.
+
+3. **Triggers navigate instead of setting atoms** — replace
+ `useOpenCreateRoomModal()` and friends with a navigate that passes
+ `state={{ backgroundLocation: location }}`. Closing is `navigate(-1)`.
+
+4. **Delete the modal machinery** once no callers remain:
+ - `src/app/state/createRoomModal.ts`, `createSpaceModal.ts`
+ - `src/app/state/hooks/createRoomModal.ts`, `createSpaceModal.ts`,
+ `bugReportModal.ts`
+ - the `*ModalRenderer` components and their mounts in `Router.tsx`
+
+5. **Collapse duplicate create-room routes** — `/create-room` and `/home/create`
+ both render the same form; keep one.
+
+## Verification
+
+Each step should keep `pnpm test:e2e:docker` green. Worth adding specs for:
+opening a surface from the lobby on desktop leaves the lobby mounted behind it;
+the same URL on mobile renders full screen; browser back closes it.
+
+## Notes
+
+- `FormPage` renders a desktop sidebar + resizer; `RouteSurface` should only use
+ it on mobile, or the sidebar will appear inside the desktop overlay.
+- `BugReportForm` is now a pure form (no chrome). `CreateRoomForm` and
+ `CreateSpaceForm` were already pure.
From 2735a21718668d240e9c4c95266a4668077f1c3b Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 01:21:29 +0200
Subject: [PATCH 08/43] refactor: make create, bug report and navigate surfaces
shallow routes
---
docs/modal-routes-plan.md | 66 ------------------
docs/shallow-routes.md | 35 ++++++++++
src/app/components/DefaultErrorPage.tsx | 2 +-
.../components/GlobalKeyboardShortcuts.tsx | 14 ++++
src/app/components/page/RouteSurface.tsx | 40 +++++++++++
.../{BugReportModal.tsx => BugReportForm.tsx} | 56 +---------------
src/app/features/bug-report/index.ts | 2 +-
.../features/create-room/CreateRoomModal.tsx | 37 ----------
src/app/features/create-room/index.ts | 1 -
.../create-space/CreateSpaceModal.tsx | 33 ---------
src/app/features/create-space/index.ts | 1 -
src/app/features/lobby/SpaceItem.tsx | 30 +++------
.../{NavigateModal.tsx => RoomSearch.tsx} | 41 +-----------
src/app/features/navigate/index.ts | 2 +-
src/app/features/settings/SettingsRoute.tsx | 7 +-
.../settings/SettingsShallowRouteRenderer.tsx | 18 ++---
src/app/features/settings/about/About.tsx | 6 +-
src/app/features/settings/navigation.ts | 33 +--------
src/app/features/settings/useOpenSettings.ts | 16 ++---
src/app/hooks/router/useHomeSelected.ts | 10 +--
src/app/hooks/useCommands.ts | 8 ++-
src/app/pages/Router.tsx | 21 ++----
src/app/pages/client/ClientLayout.tsx | 4 +-
src/app/pages/client/ClientRouteOutlet.tsx | 26 ++-----
src/app/pages/client/ShallowRouteRenderer.tsx | 35 ++++++++++
.../pages/client/bug-report/BugReportPage.tsx | 14 ++--
.../client/create-room/CreateRoomPage.tsx | 29 +++-----
src/app/pages/client/create/Create.tsx | 12 ++--
src/app/pages/client/home/CreateRoom.tsx | 52 --------------
src/app/pages/client/home/Home.tsx | 13 +++-
src/app/pages/client/navigate/Navigate.tsx | 17 ++++-
src/app/pages/client/shallowRoute.ts | 67 +++++++++++++++++++
src/app/pages/client/sidebar/InboxTab.tsx | 4 +-
src/app/pages/client/sidebar/MessageTab.tsx | 5 +-
src/app/pages/client/sidebar/NavigateTab.tsx | 20 ++----
src/app/pages/client/useShallowRoute.ts | 38 +++++++++++
src/app/pages/pathUtils.ts | 11 +--
src/app/pages/paths.ts | 1 -
src/app/state/bugReportModal.ts | 3 -
src/app/state/createRoomModal.ts | 9 ---
src/app/state/createSpaceModal.ts | 7 --
src/app/state/hooks/bugReportModal.ts | 26 -------
src/app/state/hooks/createRoomModal.ts | 45 -------------
src/app/state/hooks/createSpaceModal.ts | 44 ------------
src/app/state/searchModal.ts | 3 -
tests/e2e/modal-routes.spec.ts | 64 ++++++++++++++++++
46 files changed, 406 insertions(+), 622 deletions(-)
delete mode 100644 docs/modal-routes-plan.md
create mode 100644 docs/shallow-routes.md
create mode 100644 src/app/components/page/RouteSurface.tsx
rename src/app/features/bug-report/{BugReportModal.tsx => BugReportForm.tsx} (91%)
delete mode 100644 src/app/features/create-room/CreateRoomModal.tsx
delete mode 100644 src/app/features/create-space/CreateSpaceModal.tsx
rename src/app/features/navigate/{NavigateModal.tsx => RoomSearch.tsx} (93%)
create mode 100644 src/app/pages/client/ShallowRouteRenderer.tsx
delete mode 100644 src/app/pages/client/home/CreateRoom.tsx
create mode 100644 src/app/pages/client/shallowRoute.ts
create mode 100644 src/app/pages/client/useShallowRoute.ts
delete mode 100644 src/app/state/bugReportModal.ts
delete mode 100644 src/app/state/createRoomModal.ts
delete mode 100644 src/app/state/createSpaceModal.ts
delete mode 100644 src/app/state/hooks/bugReportModal.ts
delete mode 100644 src/app/state/hooks/createRoomModal.ts
delete mode 100644 src/app/state/hooks/createSpaceModal.ts
delete mode 100644 src/app/state/searchModal.ts
create mode 100644 tests/e2e/modal-routes.spec.ts
diff --git a/docs/modal-routes-plan.md b/docs/modal-routes-plan.md
deleted file mode 100644
index fd6f0fa8fe..0000000000
--- a/docs/modal-routes-plan.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# Modal routes: one surface per feature
-
-## Problem
-
-Several features are reachable two independent ways, and the trigger decides the
-presentation rather than the viewport:
-
-- a **route** that always renders a full page
-- a **modal atom** that always renders a centred modal
-
-On a phone the lobby button opens a cramped modal while the route opens a proper
-full-screen page — the same form, two experiences. The duplicated chrome that
-used to sit either side of this has been collapsed into `FormPage` and
-`FormModal`, but the two trigger mechanisms remain.
-
-Affected features:
-
-| Feature | Route | Modal atom |
-| --- | --- | --- |
-| Create room | `/create-room`, `/home/create` | `createRoomModal` |
-| Create space | `/create` | `createSpaceModal` |
-| Bug report | `/bug-report` | `bugReportModal` |
-| Navigate | `/navigate` | `NavigateModal` |
-
-## Target
-
-The URL is the single source of truth. A route renders as an overlay on desktop
-(over whatever was behind it) and as a full page on mobile. This is the pattern
-GitHub and Element use.
-
-## Steps
-
-1. **`RouteSurface`** — new component choosing presentation by
- `useScreenSizeContext()`: `FormModal` on desktop, `FormPage` on mobile. Props
- are the union of both (`title`, `subTitle`, `closeLabel`, `onClose`).
-
-2. **Background location in the router** — in `src/app/pages/Router.tsx`, read
- `location.state?.backgroundLocation`. When present, render the route tree
- against the background location and render the overlay route separately, so
- the page behind stays mounted.
-
-3. **Triggers navigate instead of setting atoms** — replace
- `useOpenCreateRoomModal()` and friends with a navigate that passes
- `state={{ backgroundLocation: location }}`. Closing is `navigate(-1)`.
-
-4. **Delete the modal machinery** once no callers remain:
- - `src/app/state/createRoomModal.ts`, `createSpaceModal.ts`
- - `src/app/state/hooks/createRoomModal.ts`, `createSpaceModal.ts`,
- `bugReportModal.ts`
- - the `*ModalRenderer` components and their mounts in `Router.tsx`
-
-5. **Collapse duplicate create-room routes** — `/create-room` and `/home/create`
- both render the same form; keep one.
-
-## Verification
-
-Each step should keep `pnpm test:e2e:docker` green. Worth adding specs for:
-opening a surface from the lobby on desktop leaves the lobby mounted behind it;
-the same URL on mobile renders full screen; browser back closes it.
-
-## Notes
-
-- `FormPage` renders a desktop sidebar + resizer; `RouteSurface` should only use
- it on mobile, or the sidebar will appear inside the desktop overlay.
-- `BugReportForm` is now a pure form (no chrome). `CreateRoomForm` and
- `CreateSpaceForm` were already pure.
diff --git a/docs/shallow-routes.md b/docs/shallow-routes.md
new file mode 100644
index 0000000000..58880bae84
--- /dev/null
+++ b/docs/shallow-routes.md
@@ -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
+`` trick — descendant `` 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.
diff --git a/src/app/components/DefaultErrorPage.tsx b/src/app/components/DefaultErrorPage.tsx
index 7145657020..16cbfd2e42 100644
--- a/src/app/components/DefaultErrorPage.tsx
+++ b/src/app/components/DefaultErrorPage.tsx
@@ -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;
diff --git a/src/app/components/GlobalKeyboardShortcuts.tsx b/src/app/components/GlobalKeyboardShortcuts.tsx
index 4fe06a18d8..17cd07edcb 100644
--- a/src/app/components/GlobalKeyboardShortcuts.tsx
+++ b/src/app/components/GlobalKeyboardShortcuts.tsx
@@ -20,6 +20,7 @@ import {
getHomeRoomPath,
getHomeSearchPath,
getInboxBookmarksPath,
+ getNavigatePath,
getSpaceRoomPath,
getSpaceSearchPath,
withSearchParam,
@@ -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);
@@ -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) => {
@@ -201,6 +214,7 @@ export function GlobalKeyboardShortcuts() {
useKeyDown(window, handleReplyKeyDown);
useKeyDown(window, handleBookmarkKeyDown);
useKeyDown(window, handleSearchMessageInRoom);
+ useKeyDown(window, handleOpenRoomSearch);
return null;
}
diff --git a/src/app/components/page/RouteSurface.tsx b/src/app/components/page/RouteSurface.tsx
new file mode 100644
index 0000000000..535c638d3d
--- /dev/null
+++ b/src/app/components/page/RouteSurface.tsx
@@ -0,0 +1,40 @@
+import type { ReactNode } from 'react';
+import { useLocation } from 'react-router-dom';
+import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
+import { FormModal } from '$components/modal-overlay/FormModal';
+import { getBackgroundLocation } from '$pages/client/shallowRoute';
+import { useCloseShallowRoute } from '$pages/client/useShallowRoute';
+import { FormPage } from './FormPage';
+
+type RouteSurfaceProps = {
+ title: string;
+ subTitle: string;
+ closeLabel: string;
+ children: ReactNode;
+};
+
+/**
+ * A route that presents as an overlay over the page it was opened from on
+ * desktop, and as a full page on mobile or when deep linked.
+ */
+export function RouteSurface({ title, subTitle, closeLabel, children }: RouteSurfaceProps) {
+ const location = useLocation();
+ const screenSize = useScreenSizeContext();
+ const requestClose = useCloseShallowRoute();
+
+ const asOverlay = screenSize !== ScreenSize.Mobile && !!getBackgroundLocation(location.state);
+
+ if (asOverlay) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/features/bug-report/BugReportModal.tsx b/src/app/features/bug-report/BugReportForm.tsx
similarity index 91%
rename from src/app/features/bug-report/BugReportModal.tsx
rename to src/app/features/bug-report/BugReportForm.tsx
index 76c21f1b7d..8c6d3c2508 100644
--- a/src/app/features/bug-report/BugReportModal.tsx
+++ b/src/app/features/bug-report/BugReportForm.tsx
@@ -1,25 +1,9 @@
import { useState, useEffect } from 'react';
-import {
- Box,
- Button,
- Chip,
- config,
- Header,
- IconButton,
- Input,
- Modal,
- Scroll,
- Spinner,
- Text,
- TextArea,
- Checkbox,
-} from 'folds';
-import { ArrowRight, X, chipIcon, composerIcon } from '$components/icons/phosphor';
+import { Box, Button, Chip, config, Input, Spinner, Text, TextArea, Checkbox } from 'folds';
+import { ArrowRight, chipIcon } from '$components/icons/phosphor';
import * as Sentry from '@sentry/react';
-import { useCloseBugReportModal, useBugReportModalOpen } from '$state/hooks/bugReportModal';
import { getDebugLogger } from '$utils/debugLogger';
import { fetch } from '$utils/fetch';
-import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type ReportType = 'bug' | 'feature';
@@ -463,39 +447,3 @@ export function BugReportForm({ onDone }: { onDone: () => void }) {
);
}
-
-function BugReportModal() {
- const close = useCloseBugReportModal();
-
- return (
-
-
-
-
-
- Report an Issue
-
-
- {composerIcon(X)}
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-export function BugReportModalRenderer() {
- const open = useBugReportModalOpen();
-
- if (!open) return null;
- return ;
-}
diff --git a/src/app/features/bug-report/index.ts b/src/app/features/bug-report/index.ts
index 978d509a38..690883023f 100644
--- a/src/app/features/bug-report/index.ts
+++ b/src/app/features/bug-report/index.ts
@@ -1 +1 @@
-export { BugReportModalRenderer, BugReportForm, buildGitHubUrl } from './BugReportModal';
+export { BugReportForm, buildGitHubUrl } from './BugReportForm';
diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx
deleted file mode 100644
index 4b5bbf01b9..0000000000
--- a/src/app/features/create-room/CreateRoomModal.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
-import { SpaceProvider } from '$hooks/useSpace';
-import { useCloseCreateRoomModal, useCreateRoomModalState } from '$state/hooks/createRoomModal';
-import type { CreateRoomModalState } from '$state/createRoomModal';
-import { FormModal } from '$components/modal-overlay/FormModal';
-import { CreateRoomType } from '$components/create-room/types';
-import { CreateRoomForm } from './CreateRoom';
-
-type CreateRoomModalProps = {
- state: CreateRoomModalState;
-};
-function CreateRoomModal({ state }: CreateRoomModalProps) {
- const { spaceId, type } = state;
- const closeDialog = useCloseCreateRoomModal();
-
- const allJoinedRooms = useAllJoinedRoomsSet();
- const getRoom = useGetRoom(allJoinedRooms);
- const space = spaceId ? getRoom(spaceId) : undefined;
-
- return (
-
-
-
-
-
- );
-}
-
-export function CreateRoomModalRenderer() {
- const state = useCreateRoomModalState();
-
- if (!state) return null;
- return ;
-}
diff --git a/src/app/features/create-room/index.ts b/src/app/features/create-room/index.ts
index f60c94b37b..5f61eca629 100644
--- a/src/app/features/create-room/index.ts
+++ b/src/app/features/create-room/index.ts
@@ -1,2 +1 @@
export * from './CreateRoom';
-export * from './CreateRoomModal';
diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx
deleted file mode 100644
index 1e7ad25861..0000000000
--- a/src/app/features/create-space/CreateSpaceModal.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
-import { SpaceProvider } from '$hooks/useSpace';
-import { useCloseCreateSpaceModal, useCreateSpaceModalState } from '$state/hooks/createSpaceModal';
-import type { CreateSpaceModalState } from '$state/createSpaceModal';
-import { FormModal } from '$components/modal-overlay/FormModal';
-import { CreateSpaceForm } from './CreateSpace';
-
-type CreateSpaceModalProps = {
- state: CreateSpaceModalState;
-};
-function CreateSpaceModal({ state }: CreateSpaceModalProps) {
- const { spaceId } = state;
- const closeDialog = useCloseCreateSpaceModal();
-
- const allJoinedRooms = useAllJoinedRoomsSet();
- const getRoom = useGetRoom(allJoinedRooms);
- const space = spaceId ? getRoom(spaceId) : undefined;
-
- return (
-
-
-
-
-
- );
-}
-
-export function CreateSpaceModalRenderer() {
- const state = useCreateSpaceModalState();
-
- if (!state) return null;
- return ;
-}
diff --git a/src/app/features/create-space/index.ts b/src/app/features/create-space/index.ts
index d203993bda..2cfe665742 100644
--- a/src/app/features/create-space/index.ts
+++ b/src/app/features/create-space/index.ts
@@ -1,2 +1 @@
export * from './CreateSpace';
-export * from './CreateSpaceModal';
diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx
index 60ec814f9e..9d0af7fd3f 100644
--- a/src/app/features/lobby/SpaceItem.tsx
+++ b/src/app/features/lobby/SpaceItem.tsx
@@ -26,11 +26,9 @@ import { getRoomAvatarUrl } from '$utils/room';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
-import { BetaNoticeBadge } from '$components/BetaNoticeBadge';
-import { CreateRoomType } from '$components/create-room';
import { AddExistingModal } from '$features/add-existing';
-import { useOpenCreateRoomModal } from '$state/hooks/createRoomModal';
-import { useOpenCreateSpaceModal } from '$state/hooks/createSpaceModal';
+import { useOpenShallowRoute } from '$pages/client/useShallowRoute';
+import { getCreateRoomPath, getCreateSpacePath } from '$pages/pathUtils';
import { stopPropagation } from '$utils/keyboard';
import FocusTrap from 'focus-trap-react';
import * as css from './SpaceItem.css';
@@ -241,15 +239,15 @@ function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileP
function AddRoomButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState();
- const openCreateRoomModal = useOpenCreateRoomModal();
+ const openShallowRoute = useOpenShallowRoute();
const [addExisting, setAddExisting] = useState(false);
const handleAddRoom: MouseEventHandler = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
- const handleCreateRoom = (type?: CreateRoomType) => {
- openCreateRoomModal(item.roomId, type);
+ const handleCreateRoom = () => {
+ openShallowRoute(getCreateRoomPath(item.roomId));
setCords(undefined);
};
@@ -280,19 +278,9 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
radii="300"
variant="Primary"
fill="None"
- onClick={() => handleCreateRoom(CreateRoomType.TextRoom)}
+ onClick={handleCreateRoom}
>
- Chat Room
-
-
} />
} />
{
- if (screenSize === ScreenSize.Mobile) return false;
- if (!matchPath(SETTINGS_PATH, pathname)) return false;
-
- const backgroundLocation = (state as BackgroundLocationState | null)?.backgroundLocation;
- return !!backgroundLocation;
-};
+import { useLocation, useOutlet } from 'react-router-dom';
+import { useScreenSizeContext } from '$hooks/useScreenSize';
+import { isShallowRoute } from './shallowRoute';
export function ClientRouteOutlet() {
const outlet = useOutlet();
const location = useLocation();
const screenSize = useScreenSizeContext();
const cachedOutletRef = useRef(outlet);
- const shallowSettings = isShallowSettingsRoute(location.pathname, location.state, screenSize);
+ const shallow = isShallowRoute(location.pathname, location.state, screenSize);
- if (!shallowSettings) {
+ if (!shallow) {
cachedOutletRef.current = outlet;
return outlet;
}
diff --git a/src/app/pages/client/ShallowRouteRenderer.tsx b/src/app/pages/client/ShallowRouteRenderer.tsx
new file mode 100644
index 0000000000..d1b4226eed
--- /dev/null
+++ b/src/app/pages/client/ShallowRouteRenderer.tsx
@@ -0,0 +1,35 @@
+import { lazy, Suspense } from 'react';
+import { matchPath, useLocation } from 'react-router-dom';
+import { useScreenSizeContext } from '$hooks/useScreenSize';
+import { BUG_REPORT_PATH, CREATE_PATH, CREATE_ROOM_PATH, NAVIGATE_PATH } from '../paths';
+import { isShallowRoute } from './shallowRoute';
+
+const CreateRoomPage = lazy(() =>
+ import('./create-room').then((m) => ({ default: m.CreateRoomPage }))
+);
+const Create = lazy(() => import('./create').then((m) => ({ default: m.Create })));
+const BugReportPage = lazy(() =>
+ import('./bug-report').then((m) => ({ default: m.BugReportPage }))
+);
+const Navigate = lazy(() => import('./navigate').then((m) => ({ default: m.Navigate })));
+
+/**
+ * Renders shallow routes as an overlay while `ClientRouteOutlet` keeps the
+ * page they were opened from mounted underneath.
+ */
+export function ShallowRouteRenderer() {
+ const location = useLocation();
+ const screenSize = useScreenSizeContext();
+
+ if (!isShallowRoute(location.pathname, location.state, screenSize)) return null;
+
+ const { pathname } = location;
+ let surface = null;
+ if (matchPath(CREATE_ROOM_PATH, pathname)) surface = ;
+ else if (matchPath(CREATE_PATH, pathname)) surface = ;
+ else if (matchPath(BUG_REPORT_PATH, pathname)) surface = ;
+ else if (matchPath(NAVIGATE_PATH, pathname)) surface = ;
+
+ if (!surface) return null;
+ return {surface};
+}
diff --git a/src/app/pages/client/bug-report/BugReportPage.tsx b/src/app/pages/client/bug-report/BugReportPage.tsx
index 28ced10fa7..887664799e 100644
--- a/src/app/pages/client/bug-report/BugReportPage.tsx
+++ b/src/app/pages/client/bug-report/BugReportPage.tsx
@@ -1,19 +1,17 @@
-import { useNavigate } from 'react-router-dom';
-import { FormPage } from '$components/page/FormPage';
+import { RouteSurface } from '$components/page/RouteSurface';
import { BugReportForm } from '$features/bug-report';
+import { useCloseShallowRoute } from '$pages/client/useShallowRoute';
export function BugReportPage() {
- const navigate = useNavigate();
- const navigateBack = () => navigate(-1);
+ const close = useCloseShallowRoute();
return (
-
-
-
+
+
);
}
diff --git a/src/app/pages/client/create-room/CreateRoomPage.tsx b/src/app/pages/client/create-room/CreateRoomPage.tsx
index d679c5b4a3..cd5bbcbe34 100644
--- a/src/app/pages/client/create-room/CreateRoomPage.tsx
+++ b/src/app/pages/client/create-room/CreateRoomPage.tsx
@@ -1,40 +1,29 @@
-import { useSearchParams, useNavigate } from 'react-router-dom';
-import { FormPage } from '$components/page/FormPage';
+import { useSearchParams } from 'react-router-dom';
+import { RouteSurface } from '$components/page/RouteSurface';
import { CreateRoomForm } from '$features/create-room/CreateRoom';
import { useRoomNavigate } from '$hooks/useRoomNavigate';
import { SpaceProvider } from '$hooks/useSpace';
import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
-import { CreateRoomType } from '$components/create-room/types';
export function CreateRoomPage() {
const { navigateRoom } = useRoomNavigate();
- const navigate = useNavigate();
const [searchParams] = useSearchParams();
- const spaceIdParam = searchParams.get('spaceId') ?? undefined;
- const typeParam = searchParams.get('type') ?? undefined;
-
- const type: CreateRoomType | undefined =
- typeParam === CreateRoomType.TextRoom || typeParam === CreateRoomType.VoiceRoom
- ? (typeParam as CreateRoomType)
- : undefined;
+ const spaceId = searchParams.get('spaceId') ?? undefined;
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
- const space = spaceIdParam ? getRoom(spaceIdParam) : undefined;
-
- const title = type === CreateRoomType.VoiceRoom ? 'New Voice Room' : 'New Chat Room';
+ const space = spaceId ? getRoom(spaceId) : undefined;
return (
- navigate(-1)}
>
-
+
-
+
);
}
diff --git a/src/app/pages/client/create/Create.tsx b/src/app/pages/client/create/Create.tsx
index bf1c89cb75..cc0c103bbe 100644
--- a/src/app/pages/client/create/Create.tsx
+++ b/src/app/pages/client/create/Create.tsx
@@ -1,5 +1,5 @@
-import { useSearchParams, useNavigate } from 'react-router-dom';
-import { FormPage } from '$components/page/FormPage';
+import { useSearchParams } from 'react-router-dom';
+import { RouteSurface } from '$components/page/RouteSurface';
import { CreateSpaceForm } from '$features/create-space';
import { useRoomNavigate } from '$hooks/useRoomNavigate';
import { SpaceProvider } from '$hooks/useSpace';
@@ -7,7 +7,6 @@ import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
export function Create() {
const { navigateSpace } = useRoomNavigate();
- const navigate = useNavigate();
const [searchParams] = useSearchParams();
const spaceId = searchParams.get('spaceId') ?? undefined;
@@ -16,15 +15,14 @@ export function Create() {
const space = spaceId ? getRoom(spaceId) : undefined;
return (
- navigate(-1)}
>
-
+
);
}
diff --git a/src/app/pages/client/home/CreateRoom.tsx b/src/app/pages/client/home/CreateRoom.tsx
deleted file mode 100644
index e7bd763f2a..0000000000
--- a/src/app/pages/client/home/CreateRoom.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Box, IconButton, Scroll } from 'folds';
-import {
- Page,
- PageContent,
- PageContentCenter,
- PageHeader,
- PageHero,
- PageHeroSection,
-} from '$components/page';
-import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
-import { BackRouteHandler } from '$components/BackRouteHandler';
-import { CreateRoomForm } from '$features/create-room';
-import { useRoomNavigate } from '$hooks/useRoomNavigate';
-import { ArrowLeft, composerIcon, dropzoneIcon, Hash } from '$components/icons/phosphor';
-
-export function HomeCreateRoom() {
- const screenSize = useScreenSizeContext();
-
- const { navigateRoom } = useRoomNavigate();
-
- return (
-
- {screenSize === ScreenSize.Mobile && (
-
-
-
- {(onBack) => {composerIcon(ArrowLeft)}}
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx
index 941fff02f6..9b0c4937e1 100644
--- a/src/app/pages/client/home/Home.tsx
+++ b/src/app/pages/client/home/Home.tsx
@@ -33,11 +33,12 @@ import {
getExploreFeaturedPath,
getExplorePath,
getExploreServerPath,
- getHomeCreatePath,
+ getCreateRoomPath,
getHomeRoomPath,
getHomeSearchPath,
withSearchParam,
} from '$pages/pathUtils';
+import { useOpenShallowRoute } from '$pages/client/useShallowRoute';
import { getCanonicalAliasOrRoomId } from '$utils/matrix';
import { useSelectedOrLastRoom } from '$hooks/router/useSelectedRoom';
import { useHomeCreateSelected, useHomeSearchSelected } from '$hooks/router/useHomeSelected';
@@ -195,6 +196,7 @@ function HomeHeader({ hideText }: { hideText?: boolean }) {
function HomeEmpty() {
const navigate = useNavigate();
+ const openShallowRoute = useOpenShallowRoute();
return (
@@ -212,7 +214,11 @@ function HomeEmpty() {
}
options={
<>
- navigate(getHomeCreatePath())} variant="Secondary" size="300">
+ openShallowRoute(getCreateRoomPath())}
+ variant="Secondary"
+ size="300"
+ >
Create Room
@@ -267,6 +273,7 @@ export function Home() {
const selectedRoomId = useSelectedOrLastRoom();
const createRoomSelected = useHomeCreateSelected();
+ const openShallowRoute = useOpenShallowRoute();
const searchSelected = useHomeSearchSelected();
const noRoomToDisplay = rooms.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
@@ -346,7 +353,7 @@ export function Home() {
- navigate(getHomeCreatePath())}>
+ openShallowRoute(getCreateRoomPath())}>
;
+ }
+
+ return ;
+}
+
+function NavigatePage() {
const setIsResizingSidebar = useSetAtom(isResizingSidebarAtom);
const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth');
const [curWidth, setCurWidth] = useState(roomSidebarWidth);
diff --git a/src/app/pages/client/shallowRoute.ts b/src/app/pages/client/shallowRoute.ts
new file mode 100644
index 0000000000..ba92ee2275
--- /dev/null
+++ b/src/app/pages/client/shallowRoute.ts
@@ -0,0 +1,67 @@
+import { matchPath, type To } from 'react-router-dom';
+import { ScreenSize } from '$hooks/useScreenSize';
+import { getHomePath } from '$pages/pathUtils';
+import {
+ BUG_REPORT_PATH,
+ CREATE_PATH,
+ CREATE_ROOM_PATH,
+ NAVIGATE_PATH,
+ SETTINGS_PATH,
+} from '../paths';
+
+export type StoredLocation = {
+ pathname: string;
+ search: string;
+ hash: string;
+ state?: unknown;
+ key?: string;
+};
+
+export type ShallowRouteState = {
+ backgroundLocation?: StoredLocation;
+};
+
+/**
+ * Routes that render as an overlay over the page they were opened from on
+ * desktop, and as a full page on mobile.
+ */
+const SHALLOW_ROUTE_PATHS = [
+ SETTINGS_PATH,
+ CREATE_ROOM_PATH,
+ CREATE_PATH,
+ BUG_REPORT_PATH,
+ NAVIGATE_PATH,
+];
+
+export const getBackgroundLocation = (state: unknown): StoredLocation | undefined =>
+ (state as ShallowRouteState | null)?.backgroundLocation;
+
+/** The shallow route pattern a pathname belongs to, if any. */
+export const getShallowRoutePath = (pathname: string): string | undefined =>
+ SHALLOW_ROUTE_PATHS.find((path) => matchPath(path, pathname) !== null);
+
+export const isShallowRoute = (
+ pathname: string,
+ state: unknown,
+ screenSize: ScreenSize
+): boolean => {
+ if (screenSize === ScreenSize.Mobile) return false;
+ if (!getBackgroundLocation(state)) return false;
+ return getShallowRoutePath(pathname) !== undefined;
+};
+
+export function getShallowCloseTarget(state: unknown): { to: To; state?: unknown } {
+ const backgroundLocation = getBackgroundLocation(state);
+ if (!backgroundLocation) {
+ return { to: getHomePath() };
+ }
+
+ return {
+ to: {
+ pathname: backgroundLocation.pathname,
+ search: backgroundLocation.search,
+ hash: backgroundLocation.hash,
+ },
+ state: backgroundLocation.state,
+ };
+}
diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx
index 6d25388a76..7f3bb1443e 100644
--- a/src/app/pages/client/sidebar/InboxTab.tsx
+++ b/src/app/pages/client/sidebar/InboxTab.tsx
@@ -22,7 +22,6 @@ import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { useNavToActivePathAtom } from '$state/hooks/navToActivePath';
import { useInviteCount } from '$hooks/useInviteCount';
import { Text, Box, color } from 'folds';
-import { searchModalAtom } from '$state/searchModal';
import { EnvelopeSimple, getPhosphorIconSize, Tray } from '$components/icons/phosphor';
import { BookmarkIcon, ChatCircleDotsIcon } from '@phosphor-icons/react';
import { useMobileTapActivation } from '$hooks/useMobileTapActivation';
@@ -33,8 +32,7 @@ export function InboxTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const inboxSelected = useInboxSelected();
const inviteCount = useInviteCount();
- const isSearch = useAtomValue(searchModalAtom);
- const opened = inboxSelected && !isSearch;
+ const opened = inboxSelected;
const InboxIconSize = getPhosphorIconSize(isBottom ? 'inline' : 'toolbar');
const notificationsSelected = useInboxNotificationsSelected();
diff --git a/src/app/pages/client/sidebar/MessageTab.tsx b/src/app/pages/client/sidebar/MessageTab.tsx
index 24fa2caa35..39ca1f682c 100644
--- a/src/app/pages/client/sidebar/MessageTab.tsx
+++ b/src/app/pages/client/sidebar/MessageTab.tsx
@@ -8,8 +8,7 @@ import { getPhosphorIconSize } from '$components/icons/phosphor';
import { matchPath, useNavigate } from 'react-router-dom';
import { HOME_PATH, SETTINGS_PATH } from '$pages/paths';
import { ChatTextIcon } from '@phosphor-icons/react';
-import { useAtom, useAtomValue } from 'jotai';
-import { searchModalAtom } from '$state/searchModal';
+import { useAtomValue } from 'jotai';
import { useInboxSelected } from '$hooks/router/useInbox';
import { Box, color, Text, toRem } from 'folds';
import { useNavigateSelected } from '$hooks/router/useNavigateSelected';
@@ -30,13 +29,11 @@ export function MessageTab({ isBottom, isMobile }: { isBottom?: boolean; isMobil
const navigate = useNavigate();
const lastSpaceId = useAtomValue(lastVisitedSpaceIdAtom);
- const [searchSelected] = useAtom(searchModalAtom);
const navigateRouteActive = useNavigateSelected();
const profileRouteActive = useProfileSelected();
const inboxSelected = useInboxSelected();
const opened = !(
matchPath(SETTINGS_PATH, location.pathname) ||
- searchSelected ||
navigateRouteActive ||
profileRouteActive ||
inboxSelected
diff --git a/src/app/pages/client/sidebar/NavigateTab.tsx b/src/app/pages/client/sidebar/NavigateTab.tsx
index c9bbfb906a..3ce7a957ef 100644
--- a/src/app/pages/client/sidebar/NavigateTab.tsx
+++ b/src/app/pages/client/sidebar/NavigateTab.tsx
@@ -1,27 +1,21 @@
-import { useAtom } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '$components/sidebar';
-import { searchModalAtom } from '$state/searchModal';
import { ListMagnifyingGlassIcon } from '@phosphor-icons/react';
import { getPhosphorIconSize } from '$components/icons/phosphor';
import { Text, Box, color } from 'folds';
-import { useNavigate } from 'react-router-dom';
import { getNavigatePath } from '$pages/pathUtils';
+import { useOpenShallowRoute } from '$pages/client/useShallowRoute';
import { useNavigateSelected } from '$hooks/router/useNavigateSelected';
import { useMobileTapActivation } from '$hooks/useMobileTapActivation';
export function NavigateTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) {
- const [opened, setOpen] = useAtom(searchModalAtom);
- const navigateRouteActive = useNavigateSelected();
- const isNavigate = opened || navigateRouteActive;
- const navigate = useNavigate();
- const open = () => {
- if (isMobile) navigate(getNavigatePath());
- else setOpen(true);
- };
- const mobileTapActivation = useMobileTapActivation(isMobile ?? false, open, open);
+
+ const isNavigate = useNavigateSelected();
+ const openShallowRoute = useOpenShallowRoute();
+ const open = () => openShallowRoute(getNavigatePath());
+ const mobileTapActivation = useMobileTapActivation(isMobile ?? false, open);
return (
-
+
{(triggerRef) => (
diff --git a/src/app/pages/client/useShallowRoute.ts b/src/app/pages/client/useShallowRoute.ts
new file mode 100644
index 0000000000..8bf98b40e5
--- /dev/null
+++ b/src/app/pages/client/useShallowRoute.ts
@@ -0,0 +1,38 @@
+import { useCallback } from 'react';
+import { useLocation, useNavigate, type To } from 'react-router-dom';
+import { getShallowCloseTarget, getShallowRoutePath } from './shallowRoute';
+
+/**
+ * Navigates to a shallow route, remembering the current location so the route
+ * can render over it on desktop and return to it when closed. Navigating
+ * within the same surface (settings section to settings section) keeps the
+ * background it was opened over instead of recording itself.
+ */
+export function useOpenShallowRoute() {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ return useCallback(
+ (to: string) => {
+ const [targetPathname] = to.split('?');
+ const currentSurface = getShallowRoutePath(location.pathname);
+ const sameSurface =
+ currentSurface !== undefined &&
+ getShallowRoutePath(targetPathname ?? to) === currentSurface;
+
+ navigate(to, { state: sameSurface ? location.state : { backgroundLocation: location } });
+ },
+ [location, navigate]
+ );
+}
+
+/** Returns to the location a shallow route was opened from, or home. */
+export function useCloseShallowRoute() {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ return useCallback(() => {
+ const target: { to: To; state?: unknown } = getShallowCloseTarget(location.state);
+ navigate(target.to, { replace: true, state: target.state });
+ }, [location, navigate]);
+}
diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts
index 18981c5722..635b7de7f1 100644
--- a/src/app/pages/pathUtils.ts
+++ b/src/app/pages/pathUtils.ts
@@ -11,7 +11,6 @@ import {
EXPLORE_FEATURED_PATH,
EXPLORE_PATH,
EXPLORE_SERVER_PATH,
- HOME_CREATE_PATH,
HOME_JOIN_PATH,
HOME_PATH,
HOME_ROOM_PATH,
@@ -101,7 +100,6 @@ export const getResetPasswordPath = (server?: string): string => {
};
export const getHomePath = (): string => HOME_PATH;
-export const getHomeCreatePath = (): string => HOME_CREATE_PATH;
export const getHomeJoinPath = (): string => HOME_JOIN_PATH;
export const getHomeSearchPath = (): string => HOME_SEARCH_PATH;
export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string => {
@@ -169,13 +167,8 @@ export const getExploreServerPath = (server: string): string => {
export const getCreatePath = (): string => CREATE_PATH;
export const getCreateSpacePath = (spaceId?: string): string =>
spaceId ? withSearchParam(CREATE_PATH, { spaceId }) : CREATE_PATH;
-export const getCreateRoomPath = (spaceId?: string, type?: string): string => {
- const params: Record = {};
- if (spaceId) params.spaceId = spaceId;
- if (type) params.type = type;
- const keys = Object.keys(params);
- return keys.length === 0 ? CREATE_ROOM_PATH : withSearchParam(CREATE_ROOM_PATH, params);
-};
+export const getCreateRoomPath = (spaceId?: string): string =>
+ spaceId ? withSearchParam(CREATE_ROOM_PATH, { spaceId }) : CREATE_ROOM_PATH;
export const getBugReportPath = (): string => BUG_REPORT_PATH;
export const getNavigatePath = (): string => NAVIGATE_PATH;
export const getProfilePath = (): string => PROFILE_PATH;
diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts
index 2c36ac826c..814a7e81f7 100644
--- a/src/app/pages/paths.ts
+++ b/src/app/pages/paths.ts
@@ -48,7 +48,6 @@ export type RoomSearchParams = {
export const ROOM_PATH_SEGMENT = ':roomIdOrAlias/:eventId?/';
export const HOME_PATH = '/home/';
-export const HOME_CREATE_PATH = `/home/${CREATE_PATH_SEGMENT}`;
export const HOME_JOIN_PATH = `/home/${JOIN_PATH_SEGMENT}`;
export const HOME_SEARCH_PATH = `/home/${SEARCH_PATH_SEGMENT}`;
export const HOME_ROOM_PATH = `/home/${ROOM_PATH_SEGMENT}`;
diff --git a/src/app/state/bugReportModal.ts b/src/app/state/bugReportModal.ts
deleted file mode 100644
index 2ef4d3b71e..0000000000
--- a/src/app/state/bugReportModal.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { atom } from 'jotai';
-
-export const bugReportOpenAtom = atom(false);
diff --git a/src/app/state/createRoomModal.ts b/src/app/state/createRoomModal.ts
deleted file mode 100644
index 1d5c04d684..0000000000
--- a/src/app/state/createRoomModal.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { atom } from 'jotai';
-import type { CreateRoomType } from '$components/create-room/types';
-
-export type CreateRoomModalState = {
- spaceId?: string;
- type?: CreateRoomType;
-};
-
-export const createRoomModalAtom = atom(undefined);
diff --git a/src/app/state/createSpaceModal.ts b/src/app/state/createSpaceModal.ts
deleted file mode 100644
index fe4db75584..0000000000
--- a/src/app/state/createSpaceModal.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { atom } from 'jotai';
-
-export type CreateSpaceModalState = {
- spaceId?: string;
-};
-
-export const createSpaceModalAtom = atom(undefined);
diff --git a/src/app/state/hooks/bugReportModal.ts b/src/app/state/hooks/bugReportModal.ts
deleted file mode 100644
index e709d704ea..0000000000
--- a/src/app/state/hooks/bugReportModal.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { useCallback } from 'react';
-import { useAtomValue, useSetAtom } from 'jotai';
-import { useNavigate } from 'react-router-dom';
-import { bugReportOpenAtom } from '$state/bugReportModal';
-import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
-import { getBugReportPath } from '$pages/pathUtils';
-
-export const useBugReportModalOpen = (): boolean => useAtomValue(bugReportOpenAtom);
-
-export const useOpenBugReportModal = (): (() => void) => {
- const setOpen = useSetAtom(bugReportOpenAtom);
- const navigate = useNavigate();
- const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
- return useCallback(() => {
- if (isMobile) {
- navigate(getBugReportPath());
- return;
- }
- setOpen(true);
- }, [setOpen, navigate, isMobile]);
-};
-
-export const useCloseBugReportModal = (): (() => void) => {
- const setOpen = useSetAtom(bugReportOpenAtom);
- return useCallback(() => setOpen(false), [setOpen]);
-};
diff --git a/src/app/state/hooks/createRoomModal.ts b/src/app/state/hooks/createRoomModal.ts
deleted file mode 100644
index dcd7710005..0000000000
--- a/src/app/state/hooks/createRoomModal.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { useCallback } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { useAtomValue, useSetAtom } from 'jotai';
-import type { CreateRoomType } from '$components/create-room/types';
-import type { CreateRoomModalState } from '$state/createRoomModal';
-import { createRoomModalAtom } from '$state/createRoomModal';
-import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
-import { getCreateRoomPath } from '$pages/pathUtils';
-
-export const useCreateRoomModalState = (): CreateRoomModalState | undefined => {
- const data = useAtomValue(createRoomModalAtom);
-
- return data;
-};
-
-type CloseCallback = () => void;
-export const useCloseCreateRoomModal = (): CloseCallback => {
- const setSettings = useSetAtom(createRoomModalAtom);
-
- const close: CloseCallback = useCallback(() => {
- setSettings(undefined);
- }, [setSettings]);
-
- return close;
-};
-
-type OpenCallback = (space?: string, type?: CreateRoomType) => void;
-export const useOpenCreateRoomModal = (): OpenCallback => {
- const setSettings = useSetAtom(createRoomModalAtom);
- const navigate = useNavigate();
- const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
-
- const open: OpenCallback = useCallback(
- (spaceId, type) => {
- if (isMobile) {
- navigate(getCreateRoomPath(spaceId, type));
- return;
- }
- setSettings({ spaceId, type });
- },
- [setSettings, isMobile, navigate]
- );
-
- return open;
-};
diff --git a/src/app/state/hooks/createSpaceModal.ts b/src/app/state/hooks/createSpaceModal.ts
deleted file mode 100644
index 431a4729b6..0000000000
--- a/src/app/state/hooks/createSpaceModal.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { useCallback } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { useAtomValue, useSetAtom } from 'jotai';
-import type { CreateSpaceModalState } from '$state/createSpaceModal';
-import { createSpaceModalAtom } from '$state/createSpaceModal';
-import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
-import { getCreateSpacePath } from '$pages/pathUtils';
-
-export const useCreateSpaceModalState = (): CreateSpaceModalState | undefined => {
- const data = useAtomValue(createSpaceModalAtom);
-
- return data;
-};
-
-type CloseCallback = () => void;
-export const useCloseCreateSpaceModal = (): CloseCallback => {
- const setSettings = useSetAtom(createSpaceModalAtom);
-
- const close: CloseCallback = useCallback(() => {
- setSettings(undefined);
- }, [setSettings]);
-
- return close;
-};
-
-type OpenCallback = (spaceId?: string) => void;
-export const useOpenCreateSpaceModal = (): OpenCallback => {
- const setSettings = useSetAtom(createSpaceModalAtom);
- const navigate = useNavigate();
- const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
-
- const open: OpenCallback = useCallback(
- (spaceId) => {
- if (isMobile) {
- navigate(getCreateSpacePath(spaceId));
- return;
- }
- setSettings({ spaceId });
- },
- [setSettings, isMobile, navigate]
- );
-
- return open;
-};
diff --git a/src/app/state/searchModal.ts b/src/app/state/searchModal.ts
deleted file mode 100644
index 59f5fe9427..0000000000
--- a/src/app/state/searchModal.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { atom } from 'jotai';
-
-export const searchModalAtom = atom(false);
diff --git a/tests/e2e/modal-routes.spec.ts b/tests/e2e/modal-routes.spec.ts
new file mode 100644
index 0000000000..a4e4ed24c8
--- /dev/null
+++ b/tests/e2e/modal-routes.spec.ts
@@ -0,0 +1,64 @@
+import { test, expect, type Page } from '@playwright/test';
+
+async function openApp(page: Page): Promise {
+ await page.goto('/');
+ await expect(page.getByText('General').first()).toBeVisible({ timeout: 180_000 });
+ await page
+ .getByRole('button', { name: 'Dismiss' })
+ .click({ timeout: 5_000 })
+ .catch(() => undefined);
+}
+
+test.describe('create room surface', () => {
+ test('desktop opens it over the page it was launched from', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'desktop');
+ await openApp(page);
+
+ await page.getByRole('button', { name: 'Create Room' }).first().click();
+
+ await expect(page).toHaveURL(/\/create-room$/);
+ await expect(page.getByText('New Chat Room')).toBeVisible();
+ // The room list stays mounted behind the overlay.
+ await expect(page.getByText('General').first()).toBeVisible();
+
+ await page.keyboard.press('Escape');
+ await expect(page).toHaveURL(/\/home\/?$/);
+ await expect(page.getByText('New Chat Room')).toBeHidden();
+ });
+
+ test('mobile opens it as a full page', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'mobile');
+ await openApp(page);
+
+ await page.goto('/create-room');
+
+ await expect(page.getByText('New Chat Room').first()).toBeVisible({ timeout: 180_000 });
+ await expect(page.getByRole('button', { name: 'Close create room' })).toBeVisible();
+ await expect(page.getByText('General')).toBeHidden();
+ });
+
+ test('deep linking on desktop renders the full page, not an overlay', async ({
+ page,
+ }, testInfo) => {
+ test.skip(testInfo.project.name !== 'desktop');
+ await openApp(page);
+
+ await page.goto('/create-room');
+
+ await expect(page.getByText('New Chat Room').first()).toBeVisible({ timeout: 180_000 });
+ await expect(page.getByRole('button', { name: 'Close create room' })).toBeVisible();
+ });
+});
+
+test('browser back closes a surface opened over a page', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'desktop');
+ await openApp(page);
+
+ await page.getByRole('button', { name: 'Create Room' }).first().click();
+ await expect(page).toHaveURL(/\/create-room$/);
+
+ await page.goBack();
+
+ await expect(page).toHaveURL(/\/home\/?$/);
+ await expect(page.getByText('New Chat Room')).toBeHidden();
+});
From 093dde9df03da71d203b5ede1589e604e6ea3de8 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 01:21:46 +0200
Subject: [PATCH 09/43] refactor: merge space settings into room settings
---
.../emoji-board/components/NoStickerPacks.tsx | 9 +-
.../components/user-profile/CreatorChip.tsx | 13 +-
src/app/components/user-profile/PowerChip.tsx | 17 +-
src/app/features/lobby/HierarchyItemMenu.tsx | 4 +-
src/app/features/lobby/LobbyHeader.tsx | 6 +-
.../features/room-settings/RoomSettings.tsx | 27 +-
.../room-settings/RoomSettingsRenderer.tsx | 2 +-
.../room-settings/general/General.tsx | 9 +-
.../room-settings/permissions/Permissions.tsx | 4 +-
.../permissions/permissionGroups.ts | 364 ++++++++++++++++++
.../permissions/usePermissionItems.ts | 218 -----------
.../features/space-settings/SpaceSettings.tsx | 241 ------------
.../space-settings/SpaceSettingsRenderer.tsx | 38 --
.../space-settings/general/General.tsx | 53 ---
.../features/space-settings/general/index.ts | 1 -
src/app/features/space-settings/index.ts | 2 -
.../permissions/Permissions.tsx | 63 ---
.../space-settings/permissions/index.ts | 1 -
.../permissions/usePermissionItems.ts | 159 --------
src/app/pages/client/sidebar/SpaceTabs.tsx | 6 +-
src/app/pages/client/space/Space.tsx | 6 +-
src/app/state/hooks/spaceSettings.ts | 35 --
src/app/state/roomSettings.ts | 2 +
src/app/state/spaceSettings.ts | 21 -
24 files changed, 414 insertions(+), 887 deletions(-)
create mode 100644 src/app/features/room-settings/permissions/permissionGroups.ts
delete mode 100644 src/app/features/room-settings/permissions/usePermissionItems.ts
delete mode 100644 src/app/features/space-settings/SpaceSettings.tsx
delete mode 100644 src/app/features/space-settings/SpaceSettingsRenderer.tsx
delete mode 100644 src/app/features/space-settings/general/General.tsx
delete mode 100644 src/app/features/space-settings/general/index.ts
delete mode 100644 src/app/features/space-settings/index.ts
delete mode 100644 src/app/features/space-settings/permissions/Permissions.tsx
delete mode 100644 src/app/features/space-settings/permissions/index.ts
delete mode 100644 src/app/features/space-settings/permissions/usePermissionItems.ts
delete mode 100644 src/app/state/hooks/spaceSettings.ts
delete mode 100644 src/app/state/spaceSettings.ts
diff --git a/src/app/components/emoji-board/components/NoStickerPacks.tsx b/src/app/components/emoji-board/components/NoStickerPacks.tsx
index 2335fc9e96..c0229b3c40 100644
--- a/src/app/components/emoji-board/components/NoStickerPacks.tsx
+++ b/src/app/components/emoji-board/components/NoStickerPacks.tsx
@@ -1,12 +1,10 @@
import { Box, config, Text, toRem } from 'folds';
import { dropzoneIcon, Sticker } from '$components/icons/phosphor';
import { useOpenRoomSettings } from '$state/hooks/roomSettings.ts';
-import { useOpenSpaceSettings } from '$state/hooks/spaceSettings.ts';
import { useRoomOptionally } from '$hooks/useRoom.ts';
import { useSpaceOptionally } from '$hooks/useSpace.ts';
import * as css from './styles.css';
import { RoomSettingsPage } from '$state/roomSettings.ts';
-import { SpaceSettingsPage } from '$state/spaceSettings.ts';
function OptionallyLinkedText(props: { text: string; isLink: boolean; onClick: () => void }) {
return props.isLink ? (
@@ -20,7 +18,6 @@ function OptionallyLinkedText(props: { text: string; isLink: boolean; onClick: (
export function NoStickerPacks() {
const openRoomSettings = useOpenRoomSettings();
- const openSpaceSettings = useOpenSpaceSettings();
const room = useRoomOptionally();
const space = useSpaceOptionally();
@@ -54,10 +51,10 @@ export function NoStickerPacks() {
text="space"
isLink={room !== null && space !== null}
onClick={() =>
- openSpaceSettings(
- room?.roomId as string,
+ openRoomSettings(
+ space?.roomId as string,
space?.roomId,
- SpaceSettingsPage.EmojisStickersPage
+ RoomSettingsPage.EmojisStickersPage
)
}
/>{' '}
diff --git a/src/app/components/user-profile/CreatorChip.tsx b/src/app/components/user-profile/CreatorChip.tsx
index 13968cec9d..a31dc43e5b 100644
--- a/src/app/components/user-profile/CreatorChip.tsx
+++ b/src/app/components/user-profile/CreatorChip.tsx
@@ -14,8 +14,6 @@ import { stopPropagation } from '$utils/keyboard';
import { useRoom } from '$hooks/useRoom';
import { useSpaceOptionally } from '$hooks/useSpace';
import { useOpenRoomSettings } from '$state/hooks/roomSettings';
-import { useOpenSpaceSettings } from '$state/hooks/spaceSettings';
-import { SpaceSettingsPage } from '$state/spaceSettings';
import { RoomSettingsPage } from '$state/roomSettings';
import { PowerColorBadge, PowerIcon } from '$components/power';
import { heroMenuItemStyle } from './heroMenuItemStyle';
@@ -42,7 +40,6 @@ export function CreatorChip({
const room = useRoom();
const space = useSpaceOptionally();
const openRoomSettings = useOpenRoomSettings();
- const openSpaceSettings = useOpenSpaceSettings();
const [cords, setCords] = useState();
const tag = useRoomCreatorsTag();
@@ -84,15 +81,7 @@ export function CreatorChip({
size="300"
radii="300"
onClick={() => {
- if (room.isSpaceRoom()) {
- openSpaceSettings(
- room.roomId,
- space?.roomId,
- SpaceSettingsPage.PermissionsPage
- );
- } else {
- openRoomSettings(room.roomId, space?.roomId, RoomSettingsPage.PermissionsPage);
- }
+ openRoomSettings(room.roomId, space?.roomId, RoomSettingsPage.PermissionsPage);
close();
}}
>
diff --git a/src/app/components/user-profile/PowerChip.tsx b/src/app/components/user-profile/PowerChip.tsx
index c490a8e55d..a858cdd430 100644
--- a/src/app/components/user-profile/PowerChip.tsx
+++ b/src/app/components/user-profile/PowerChip.tsx
@@ -31,8 +31,6 @@ import { useOpenRoomSettings } from '$state/hooks/roomSettings';
import { RoomSettingsPage } from '$state/roomSettings';
import { useRoom } from '$hooks/useRoom';
import { useSpaceOptionally } from '$hooks/useSpace';
-import { useOpenSpaceSettings } from '$state/hooks/spaceSettings';
-import { SpaceSettingsPage } from '$state/spaceSettings';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { BreakWord } from '$styles/Text.css';
import { getPowerTagIconSrc, useGetMemberPowerTag } from '$hooks/useMemberPowerTag';
@@ -147,7 +145,6 @@ export function PowerChip({
const space = useSpaceOptionally();
const useAuthentication = useMediaAuthentication();
const openRoomSettings = useOpenRoomSettings();
- const openSpaceSettings = useOpenSpaceSettings();
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
@@ -304,19 +301,7 @@ export function PowerChip({
chipHoverBrightness
)}
onClick={() => {
- if (room.isSpaceRoom()) {
- openSpaceSettings(
- room.roomId,
- space?.roomId,
- SpaceSettingsPage.PermissionsPage
- );
- } else {
- openRoomSettings(
- room.roomId,
- space?.roomId,
- RoomSettingsPage.PermissionsPage
- );
- }
+ openRoomSettings(room.roomId, space?.roomId, RoomSettingsPage.PermissionsPage);
close();
}}
>
diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx
index 6c594ad4da..05dec0dabe 100644
--- a/src/app/features/lobby/HierarchyItemMenu.tsx
+++ b/src/app/features/lobby/HierarchyItemMenu.tsx
@@ -15,7 +15,6 @@ import { LeaveRoomPrompt } from '$components/leave-room-prompt';
import { stopPropagation } from '$utils/keyboard';
import { useOpenRoomSettings } from '$state/hooks/roomSettings';
import { useSpaceOptionally } from '$hooks/useSpace';
-import { useOpenSpaceSettings } from '$state/hooks/spaceSettings';
import type { IPowerLevels } from '$hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '$hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '$hooks/useRoomPermissions';
@@ -177,12 +176,11 @@ function SettingsMenuItem({
disabled?: boolean;
}) {
const openRoomSettings = useOpenRoomSettings();
- const openSpaceSettings = useOpenSpaceSettings();
const space = useSpaceOptionally();
const handleSettings = () => {
if ('space' in item) {
- openSpaceSettings(item.roomId, item.parentId);
+ openRoomSettings(item.roomId, item.parentId);
} else {
openRoomSettings(item.roomId, space?.roomId);
}
diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx
index 7139e168e3..ea2c54cf42 100644
--- a/src/app/features/lobby/LobbyHeader.tsx
+++ b/src/app/features/lobby/LobbyHeader.tsx
@@ -42,11 +42,11 @@ import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { BackRouteHandler } from '$components/BackRouteHandler';
import { mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
-import { useOpenSpaceSettings } from '$state/hooks/spaceSettings';
import { useRoomCreators } from '$hooks/useRoomCreators';
import { useRoomPermissions } from '$hooks/useRoomPermissions';
import { InviteUserPrompt } from '$components/invite-user-prompt';
import * as css from './LobbyHeader.css';
+import { useOpenRoomSettings } from '$state/hooks/roomSettings';
type LobbyMenuProps = {
powerLevels: IPowerLevels;
@@ -60,7 +60,7 @@ const LobbyMenu = forwardRef(
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
- const openSpaceSettings = useOpenSpaceSettings();
+ const openRoomSettings = useOpenRoomSettings();
const [invitePrompt, setInvitePrompt] = useState(false);
@@ -69,7 +69,7 @@ const LobbyMenu = forwardRef(
};
const handleRoomSettings = () => {
- openSpaceSettings(space.roomId);
+ openRoomSettings(space.roomId);
requestClose();
};
diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx
index 6ea63e412c..418ba7103c 100644
--- a/src/app/features/room-settings/RoomSettings.tsx
+++ b/src/app/features/room-settings/RoomSettings.tsx
@@ -20,6 +20,7 @@ import { Members } from '$features/common-settings/members';
import { EmojisStickers } from '$features/common-settings/emojis-stickers';
import { DeveloperTools } from '$features/common-settings/developer-tools';
import { Cosmetics } from '$features/common-settings/cosmetics/Cosmetics';
+import { Appearance } from '$features/common-settings/appearance/Appearance';
import { settingsAtom } from '$state/settings';
import { useSetting } from '$state/hooks/settings';
import {
@@ -28,6 +29,7 @@ import {
Info,
Lock,
PaintBrush,
+ Palette,
settingsNavIcon,
Smiley,
Terminal,
@@ -47,7 +49,7 @@ type RoomSettingsMenuItem = {
activeIcon?: PhosphorIcon;
};
-const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] =>
+const useRoomSettingsMenuItems = (isSpace: boolean): RoomSettingsMenuItem[] =>
useMemo(
() => [
{
@@ -85,8 +87,17 @@ const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] =>
name: 'Developer Tools',
icon: Terminal,
},
+ ...(isSpace
+ ? [
+ {
+ page: RoomSettingsPage.AppearancePage,
+ name: 'Appearance',
+ icon: Palette,
+ },
+ ]
+ : []),
],
- []
+ [isSpace]
);
type RoomSettingsProps = {
@@ -96,6 +107,7 @@ type RoomSettingsProps = {
export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
const room = useRoom();
+ const isSpace = room.isSpaceRoom();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const mDirects = useAtomValue(mDirectAtom);
@@ -114,7 +126,7 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
if (initialPage) return initialPage;
return screenSize === ScreenSize.Mobile ? undefined : RoomSettingsPage.GeneralPage;
});
- const menuItems = useRoomSettingsMenuItems();
+ const menuItems = useRoomSettingsMenuItems(isSpace);
const handlePageRequestClose = () => {
if (screenSize === ScreenSize.Mobile) {
@@ -223,7 +235,14 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
)}
{activePage === RoomSettingsPage.AbbreviationsPage && (
-
+
+ )}
+ {activePage === RoomSettingsPage.AppearancePage && isSpace && (
+
)}
diff --git a/src/app/features/room-settings/RoomSettingsRenderer.tsx b/src/app/features/room-settings/RoomSettingsRenderer.tsx
index 7487255ec4..dfba79a81a 100644
--- a/src/app/features/room-settings/RoomSettingsRenderer.tsx
+++ b/src/app/features/room-settings/RoomSettingsRenderer.tsx
@@ -15,7 +15,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
const room = getRoom(roomId);
- const space = spaceId ? getRoom(spaceId) : undefined;
+ const space = spaceId && spaceId !== roomId ? getRoom(spaceId) : undefined;
if (!room) return null;
diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx
index fc028fedd7..56b52dea81 100644
--- a/src/app/features/room-settings/general/General.tsx
+++ b/src/app/features/room-settings/general/General.tsx
@@ -21,6 +21,7 @@ type GeneralProps = {
};
export function General({ requestBack, requestClose }: GeneralProps) {
const room = useRoom();
+ const isSpace = room.isSpaceRoom();
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
@@ -35,8 +36,12 @@ export function General({ requestBack, requestClose }: GeneralProps) {
Options
-
-
+ {!isSpace && (
+ <>
+
+
+ >
+ )}
diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx
index d215437d97..aa8b540df7 100644
--- a/src/app/features/room-settings/permissions/Permissions.tsx
+++ b/src/app/features/room-settings/permissions/Permissions.tsx
@@ -8,7 +8,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { PermissionGroups, Powers, PowersEditor } from '$features/common-settings/permissions';
import { useRoomCreators } from '$hooks/useRoomCreators';
import { useRoomPermissions } from '$hooks/useRoomPermissions';
-import { usePermissionGroups } from './usePermissionItems';
+import { ROOM_PERMISSION_GROUPS, SPACE_PERMISSION_GROUPS } from './permissionGroups';
import { EventType } from '$types/matrix-sdk';
import { CustomStateEvent } from '$types/matrix/room';
@@ -26,7 +26,7 @@ export function Permissions({ requestBack, requestClose }: PermissionsProps) {
const canEditPowers = permissions.stateEvent(CustomStateEvent.PowerLevelTags, mx.getSafeUserId());
const canEditPermissions = permissions.stateEvent(EventType.RoomPowerLevels, mx.getSafeUserId());
- const permissionGroups = usePermissionGroups();
+ const permissionGroups = room.isSpaceRoom() ? SPACE_PERMISSION_GROUPS : ROOM_PERMISSION_GROUPS;
const [powerEditor, setPowerEditor] = useState(false);
diff --git a/src/app/features/room-settings/permissions/permissionGroups.ts b/src/app/features/room-settings/permissions/permissionGroups.ts
new file mode 100644
index 0000000000..87271d2f5a
--- /dev/null
+++ b/src/app/features/room-settings/permissions/permissionGroups.ts
@@ -0,0 +1,364 @@
+import type { PermissionGroup } from '$features/common-settings/permissions';
+import { CALL_PERMISSIONS_GROUP } from '$features/common-settings/permissions';
+import { EventType } from '$types/matrix-sdk';
+import { CustomStateEvent } from '$types/matrix/room';
+
+const buildRoomPermissionGroups = (): PermissionGroup[] => {
+ const messagesGroup: PermissionGroup = {
+ name: 'Messages',
+ items: [
+ {
+ location: {
+ key: EventType.RoomMessage,
+ },
+ name: 'Send Messages',
+ },
+ {
+ location: {
+ key: EventType.Sticker,
+ },
+ name: 'Send Stickers',
+ },
+ {
+ location: {
+ key: EventType.Reaction,
+ },
+ name: 'Send Reactions',
+ },
+ {
+ location: {
+ notification: true,
+ key: 'room',
+ },
+ name: 'Ping @room',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomPinnedEvents,
+ },
+ name: 'Pin Messages',
+ },
+ {
+ location: {},
+ name: 'Other Message Events',
+ },
+ ],
+ };
+
+ const moderationGroup: PermissionGroup = {
+ name: 'Moderation',
+ items: [
+ {
+ location: {
+ action: true,
+ key: 'invite',
+ },
+ name: 'Invite',
+ },
+ {
+ location: {
+ action: true,
+ key: 'kick',
+ },
+ name: 'Kick',
+ },
+ {
+ location: {
+ action: true,
+ key: 'ban',
+ },
+ name: 'Ban',
+ },
+ {
+ location: {
+ action: true,
+ key: 'redact',
+ },
+ name: 'Delete Others Messages',
+ },
+ {
+ location: {
+ key: EventType.RoomRedaction,
+ },
+ name: 'Delete Self Messages',
+ },
+ ],
+ };
+
+ const roomOverviewGroup: PermissionGroup = {
+ name: 'Room Overview',
+ items: [
+ {
+ location: {
+ state: true,
+ key: EventType.RoomAvatar,
+ },
+ name: 'Room Avatar',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomName,
+ },
+ name: 'Room Name',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomTopic,
+ },
+ name: 'Room Topic',
+ },
+ ],
+ };
+
+ const roomSettingsGroup: PermissionGroup = {
+ name: 'Settings',
+ items: [
+ {
+ location: {
+ state: true,
+ key: EventType.RoomJoinRules,
+ },
+ name: 'Change Room Access',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomCanonicalAlias,
+ },
+ name: 'Publish Address',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomPowerLevels,
+ },
+ name: 'Change All Permission',
+ },
+ {
+ location: {
+ state: true,
+ key: CustomStateEvent.PowerLevelTags,
+ },
+ name: 'Edit Power Levels',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomEncryption,
+ },
+ name: 'Enable Encryption',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomHistoryVisibility,
+ },
+ name: 'History Visibility',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomTombstone,
+ },
+ name: 'Upgrade Room',
+ },
+ {
+ location: {
+ state: true,
+ },
+ name: 'Other Settings',
+ },
+ ],
+ };
+
+ const otherSettingsGroup: PermissionGroup = {
+ name: 'Other',
+ items: [
+ {
+ location: {
+ state: true,
+ key: CustomStateEvent.ImagePack,
+ },
+ name: 'Manage Emojis & Stickers',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomServerAcl,
+ },
+ name: 'Change Server ACLs',
+ },
+ {
+ location: {
+ state: true,
+ key: 'im.vector.modular.widgets',
+ },
+ name: 'Modify Widgets',
+ },
+ ],
+ };
+
+ return [
+ messagesGroup,
+ CALL_PERMISSIONS_GROUP,
+ moderationGroup,
+ roomOverviewGroup,
+ roomSettingsGroup,
+ otherSettingsGroup,
+ ];
+};
+
+const buildSpacePermissionGroups = (): PermissionGroup[] => {
+ const messagesGroup: PermissionGroup = {
+ name: 'Manage',
+ items: [
+ {
+ location: {
+ state: true,
+ key: EventType.SpaceChild,
+ },
+ name: 'Manage space rooms',
+ },
+ {
+ location: {},
+ name: 'Message Events',
+ },
+ ],
+ };
+
+ const moderationGroup: PermissionGroup = {
+ name: 'Moderation',
+ items: [
+ {
+ location: {
+ action: true,
+ key: 'invite',
+ },
+ name: 'Invite',
+ },
+ {
+ location: {
+ action: true,
+ key: 'kick',
+ },
+ name: 'Kick',
+ },
+ {
+ location: {
+ action: true,
+ key: 'ban',
+ },
+ name: 'Ban',
+ },
+ ],
+ };
+
+ const roomOverviewGroup: PermissionGroup = {
+ name: 'Space Overview',
+ items: [
+ {
+ location: {
+ state: true,
+ key: EventType.RoomAvatar,
+ },
+ name: 'Space Avatar',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomName,
+ },
+ name: 'Space Name',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomTopic,
+ },
+ name: 'Space Topic',
+ },
+ ],
+ };
+
+ const roomSettingsGroup: PermissionGroup = {
+ name: 'Settings',
+ items: [
+ {
+ location: {
+ state: true,
+ key: EventType.RoomJoinRules,
+ },
+ name: 'Change Space Access',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomCanonicalAlias,
+ },
+ name: 'Publish Address',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomPowerLevels,
+ },
+ name: 'Change All Permission',
+ },
+ {
+ location: {
+ state: true,
+ key: CustomStateEvent.PowerLevelTags,
+ },
+ name: 'Edit Power Levels',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomTombstone,
+ },
+ name: 'Upgrade Space',
+ },
+ {
+ location: {
+ state: true,
+ },
+ name: 'Other Settings',
+ },
+ ],
+ };
+
+ const otherSettingsGroup: PermissionGroup = {
+ name: 'Other',
+ items: [
+ {
+ location: {
+ state: true,
+ key: CustomStateEvent.ImagePack,
+ },
+ name: 'Manage Emojis & Stickers',
+ },
+ {
+ location: {
+ state: true,
+ key: EventType.RoomServerAcl,
+ },
+ name: 'Change Server ACLs',
+ },
+ ],
+ };
+
+ return [
+ messagesGroup,
+ CALL_PERMISSIONS_GROUP,
+ moderationGroup,
+ roomOverviewGroup,
+ roomSettingsGroup,
+ otherSettingsGroup,
+ ];
+};
+
+export const ROOM_PERMISSION_GROUPS = buildRoomPermissionGroups();
+export const SPACE_PERMISSION_GROUPS = buildSpacePermissionGroups();
diff --git a/src/app/features/room-settings/permissions/usePermissionItems.ts b/src/app/features/room-settings/permissions/usePermissionItems.ts
deleted file mode 100644
index 5641669b65..0000000000
--- a/src/app/features/room-settings/permissions/usePermissionItems.ts
+++ /dev/null
@@ -1,218 +0,0 @@
-import { useMemo } from 'react';
-
-import type { PermissionGroup } from '$features/common-settings/permissions';
-import { CALL_PERMISSIONS_GROUP } from '$features/common-settings/permissions';
-import { EventType } from '$types/matrix-sdk';
-import { CustomStateEvent } from '$types/matrix/room';
-
-export const usePermissionGroups = (): PermissionGroup[] => {
- const groups: PermissionGroup[] = useMemo(() => {
- const messagesGroup: PermissionGroup = {
- name: 'Messages',
- items: [
- {
- location: {
- key: EventType.RoomMessage,
- },
- name: 'Send Messages',
- },
- {
- location: {
- key: EventType.Sticker,
- },
- name: 'Send Stickers',
- },
- {
- location: {
- key: EventType.Reaction,
- },
- name: 'Send Reactions',
- },
- {
- location: {
- notification: true,
- key: 'room',
- },
- name: 'Ping @room',
- },
- {
- location: {
- state: true,
- key: EventType.RoomPinnedEvents,
- },
- name: 'Pin Messages',
- },
- {
- location: {},
- name: 'Other Message Events',
- },
- ],
- };
-
- const moderationGroup: PermissionGroup = {
- name: 'Moderation',
- items: [
- {
- location: {
- action: true,
- key: 'invite',
- },
- name: 'Invite',
- },
- {
- location: {
- action: true,
- key: 'kick',
- },
- name: 'Kick',
- },
- {
- location: {
- action: true,
- key: 'ban',
- },
- name: 'Ban',
- },
- {
- location: {
- action: true,
- key: 'redact',
- },
- name: 'Delete Others Messages',
- },
- {
- location: {
- key: EventType.RoomRedaction,
- },
- name: 'Delete Self Messages',
- },
- ],
- };
-
- const roomOverviewGroup: PermissionGroup = {
- name: 'Room Overview',
- items: [
- {
- location: {
- state: true,
- key: EventType.RoomAvatar,
- },
- name: 'Room Avatar',
- },
- {
- location: {
- state: true,
- key: EventType.RoomName,
- },
- name: 'Room Name',
- },
- {
- location: {
- state: true,
- key: EventType.RoomTopic,
- },
- name: 'Room Topic',
- },
- ],
- };
-
- const roomSettingsGroup: PermissionGroup = {
- name: 'Settings',
- items: [
- {
- location: {
- state: true,
- key: EventType.RoomJoinRules,
- },
- name: 'Change Room Access',
- },
- {
- location: {
- state: true,
- key: EventType.RoomCanonicalAlias,
- },
- name: 'Publish Address',
- },
- {
- location: {
- state: true,
- key: EventType.RoomPowerLevels,
- },
- name: 'Change All Permission',
- },
- {
- location: {
- state: true,
- key: CustomStateEvent.PowerLevelTags,
- },
- name: 'Edit Power Levels',
- },
- {
- location: {
- state: true,
- key: EventType.RoomEncryption,
- },
- name: 'Enable Encryption',
- },
- {
- location: {
- state: true,
- key: EventType.RoomHistoryVisibility,
- },
- name: 'History Visibility',
- },
- {
- location: {
- state: true,
- key: EventType.RoomTombstone,
- },
- name: 'Upgrade Room',
- },
- {
- location: {
- state: true,
- },
- name: 'Other Settings',
- },
- ],
- };
-
- const otherSettingsGroup: PermissionGroup = {
- name: 'Other',
- items: [
- {
- location: {
- state: true,
- key: CustomStateEvent.ImagePack,
- },
- name: 'Manage Emojis & Stickers',
- },
- {
- location: {
- state: true,
- key: EventType.RoomServerAcl,
- },
- name: 'Change Server ACLs',
- },
- {
- location: {
- state: true,
- key: 'im.vector.modular.widgets',
- },
- name: 'Modify Widgets',
- },
- ],
- };
-
- return [
- messagesGroup,
- CALL_PERMISSIONS_GROUP,
- moderationGroup,
- roomOverviewGroup,
- roomSettingsGroup,
- otherSettingsGroup,
- ];
- }, []);
-
- return groups;
-};
diff --git a/src/app/features/space-settings/SpaceSettings.tsx b/src/app/features/space-settings/SpaceSettings.tsx
deleted file mode 100644
index bb091a12af..0000000000
--- a/src/app/features/space-settings/SpaceSettings.tsx
+++ /dev/null
@@ -1,241 +0,0 @@
-import { useMemo, useState } from 'react';
-import type { ComponentType } from 'react';
-import type { IconProps } from '@phosphor-icons/react';
-import { useAtomValue } from 'jotai';
-import { Avatar, Box, config, IconButton, MenuItem, Text } from 'folds';
-import { JoinRule } from '$types/matrix-sdk';
-import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '$components/page';
-import { SettingsSectionHeader } from '$components/page/style.css';
-import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
-import { useMatrixClient } from '$hooks/useMatrixClient';
-import { mxcUrlToHttp } from '$utils/matrix';
-import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
-import { useRoomAvatar, useRoomJoinRule, useRoomName } from '$hooks/useRoomMeta';
-import { mDirectAtom } from '$state/mDirectList';
-import { RoomAvatar, RoomIcon } from '$components/room-avatar';
-import { SpaceSettingsPage } from '$state/spaceSettings';
-import { useRoom } from '$hooks/useRoom';
-import { EmojisStickers } from '$features/common-settings/emojis-stickers';
-import { Members } from '$features/common-settings/members';
-import { DeveloperTools } from '$features/common-settings/developer-tools';
-import { Cosmetics } from '$features/common-settings/cosmetics/Cosmetics';
-import { Appearance } from '$features/common-settings/appearance/Appearance';
-import { RoomAbbreviations } from '$features/room-settings/abbreviations/RoomAbbreviations';
-import { SwipeableOverlayWrapper } from '$components/SwipeableOverlayWrapper';
-import {
- composerIcon,
- GearSix,
- Info,
- Lock,
- PaintBrush,
- Palette,
- settingsNavIcon,
- Smiley,
- Terminal,
- User,
- X,
-} from '$components/icons/phosphor';
-import { General } from './general';
-import { Permissions } from './permissions';
-
-type PhosphorIcon = ComponentType;
-
-type SpaceSettingsMenuItem = {
- page: SpaceSettingsPage;
- name: string;
- icon: PhosphorIcon;
- activeIcon?: PhosphorIcon;
-};
-
-const useSpaceSettingsMenuItems = (): SpaceSettingsMenuItem[] =>
- useMemo(
- () => [
- {
- page: SpaceSettingsPage.GeneralPage,
- name: 'General',
- icon: GearSix,
- },
- {
- page: SpaceSettingsPage.MembersPage,
- name: 'Members',
- icon: User,
- },
- {
- page: SpaceSettingsPage.PermissionsPage,
- name: 'Permissions',
- icon: Lock,
- },
- {
- page: SpaceSettingsPage.CosmeticsPage,
- name: 'Cosmetics',
- icon: PaintBrush,
- },
- {
- page: SpaceSettingsPage.AbbreviationsPage,
- name: 'Abbreviations',
- icon: Info,
- },
- {
- page: SpaceSettingsPage.EmojisStickersPage,
- name: 'Emojis & Stickers',
- icon: Smiley,
- },
- {
- page: SpaceSettingsPage.DeveloperToolsPage,
- name: 'Developer Tools',
- icon: Terminal,
- },
- {
- page: SpaceSettingsPage.AppearancePage,
- name: 'Appearance',
- icon: Palette,
- },
- ],
- []
- );
-
-type SpaceSettingsProps = {
- initialPage?: SpaceSettingsPage;
- requestClose: () => void;
-};
-export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps) {
- const room = useRoom();
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
- const mDirects = useAtomValue(mDirectAtom);
-
- const roomAvatar = useRoomAvatar(room, mDirects.has(room.roomId));
- const roomName = useRoomName(room);
- const joinRuleContent = useRoomJoinRule(room);
-
- const avatarUrl = roomAvatar
- ? (mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined)
- : undefined;
-
- const screenSize = useScreenSizeContext();
- const [activePage, setActivePage] = useState(() => {
- if (initialPage) return initialPage;
- return screenSize === ScreenSize.Mobile ? undefined : SpaceSettingsPage.GeneralPage;
- });
- const menuItems = useSpaceSettingsMenuItems();
-
- const handlePageRequestClose = () => {
- if (screenSize === ScreenSize.Mobile) {
- setActivePage(undefined);
- return;
- }
- requestClose();
- };
-
- const handleSwipeBack = () => {
- if (screenSize !== ScreenSize.Mobile) return;
- if (activePage !== undefined) {
- setActivePage(undefined);
- return;
- }
- requestClose();
- };
-
- return (
-
-
-
-
-
- (
-
- )}
- />
-
-
- {roomName}
-
-
-
- {screenSize === ScreenSize.Mobile && (
-
- {composerIcon(X)}
-
- )}
-
-
-
-
-
- {menuItems.map((item) => {
- const active = activePage === item.page;
- const IconComponent = active && item.activeIcon ? item.activeIcon : item.icon;
-
- return (
-
- );
- })}
-
-
-
-
- )
- }
- >
- {activePage === SpaceSettingsPage.GeneralPage && (
-
- )}
- {activePage === SpaceSettingsPage.MembersPage && (
-
- )}
- {activePage === SpaceSettingsPage.PermissionsPage && (
-
- )}
- {activePage === SpaceSettingsPage.CosmeticsPage && (
-
- )}
- {activePage === SpaceSettingsPage.EmojisStickersPage && (
-
- )}
- {activePage === SpaceSettingsPage.DeveloperToolsPage && (
-
- )}
- {activePage === SpaceSettingsPage.AbbreviationsPage && (
-
- )}
- {activePage === SpaceSettingsPage.AppearancePage && (
-
- )}
-
-
- );
-}
diff --git a/src/app/features/space-settings/SpaceSettingsRenderer.tsx b/src/app/features/space-settings/SpaceSettingsRenderer.tsx
deleted file mode 100644
index 7f5bd1e5f3..0000000000
--- a/src/app/features/space-settings/SpaceSettingsRenderer.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { Modal500 } from '$components/Modal500';
-import { useCloseSpaceSettings, useSpaceSettingsState } from '$state/hooks/spaceSettings';
-import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
-import type { SpaceSettingsState } from '$state/spaceSettings';
-import { RoomProvider } from '$hooks/useRoom';
-import { SpaceProvider } from '$hooks/useSpace';
-import { SpaceSettings } from './SpaceSettings';
-
-type RenderSettingsProps = {
- state: SpaceSettingsState;
-};
-function RenderSettings({ state }: RenderSettingsProps) {
- const { roomId, spaceId, page } = state;
- const closeSettings = useCloseSpaceSettings();
- const allJoinedRooms = useAllJoinedRoomsSet();
- const getRoom = useGetRoom(allJoinedRooms);
- const room = getRoom(roomId);
- const space = spaceId && spaceId !== roomId ? getRoom(spaceId) : undefined;
-
- if (!room) return null;
-
- return (
-
-
-
-
-
-
-
- );
-}
-
-export function SpaceSettingsRenderer() {
- const state = useSpaceSettingsState();
-
- if (!state) return null;
- return ;
-}
diff --git a/src/app/features/space-settings/general/General.tsx b/src/app/features/space-settings/general/General.tsx
deleted file mode 100644
index 1db6d92abc..0000000000
--- a/src/app/features/space-settings/general/General.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { Box, Scroll, Text } from 'folds';
-import { PageContent, SettingsSectionPage } from '$components/page';
-import { usePowerLevels } from '$hooks/usePowerLevels';
-import { useRoom } from '$hooks/useRoom';
-import {
- RoomProfile,
- RoomJoinRules,
- RoomLocalAddresses,
- RoomPublishedAddresses,
- RoomPublish,
- RoomUpgrade,
-} from '$features/common-settings/general';
-import { useRoomCreators } from '$hooks/useRoomCreators';
-import { useRoomPermissions } from '$hooks/useRoomPermissions';
-
-type GeneralProps = {
- requestBack?: () => void;
- requestClose: () => void;
-};
-export function General({ requestBack, requestClose }: GeneralProps) {
- const room = useRoom();
- const powerLevels = usePowerLevels(room);
- const creators = useRoomCreators(room);
- const permissions = useRoomPermissions(creators, powerLevels);
-
- return (
-
-
-
-
-
-
-
- Options
-
-
-
-
- Addresses
-
-
-
-
- Advanced Options
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/features/space-settings/general/index.ts b/src/app/features/space-settings/general/index.ts
deleted file mode 100644
index 0ab02c523e..0000000000
--- a/src/app/features/space-settings/general/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './General';
diff --git a/src/app/features/space-settings/index.ts b/src/app/features/space-settings/index.ts
deleted file mode 100644
index e01a1b2681..0000000000
--- a/src/app/features/space-settings/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './SpaceSettings';
-export * from './SpaceSettingsRenderer';
diff --git a/src/app/features/space-settings/permissions/Permissions.tsx b/src/app/features/space-settings/permissions/Permissions.tsx
deleted file mode 100644
index d215437d97..0000000000
--- a/src/app/features/space-settings/permissions/Permissions.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useState } from 'react';
-import { Box, Scroll } from 'folds';
-import { PageContent, SettingsSectionPage } from '$components/page';
-import { useRoom } from '$hooks/useRoom';
-import { usePowerLevels } from '$hooks/usePowerLevels';
-import { useMatrixClient } from '$hooks/useMatrixClient';
-
-import { PermissionGroups, Powers, PowersEditor } from '$features/common-settings/permissions';
-import { useRoomCreators } from '$hooks/useRoomCreators';
-import { useRoomPermissions } from '$hooks/useRoomPermissions';
-import { usePermissionGroups } from './usePermissionItems';
-import { EventType } from '$types/matrix-sdk';
-import { CustomStateEvent } from '$types/matrix/room';
-
-type PermissionsProps = {
- requestBack?: () => void;
- requestClose: () => void;
-};
-export function Permissions({ requestBack, requestClose }: PermissionsProps) {
- const mx = useMatrixClient();
- const room = useRoom();
- const powerLevels = usePowerLevels(room);
- const creators = useRoomCreators(room);
-
- const permissions = useRoomPermissions(creators, powerLevels);
-
- const canEditPowers = permissions.stateEvent(CustomStateEvent.PowerLevelTags, mx.getSafeUserId());
- const canEditPermissions = permissions.stateEvent(EventType.RoomPowerLevels, mx.getSafeUserId());
- const permissionGroups = usePermissionGroups();
-
- const [powerEditor, setPowerEditor] = useState(false);
-
- const handleEditPowers = () => {
- setPowerEditor(true);
- };
-
- if (canEditPowers && powerEditor) {
- return setPowerEditor(false)} />;
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/features/space-settings/permissions/index.ts b/src/app/features/space-settings/permissions/index.ts
deleted file mode 100644
index 753f2b4bcc..0000000000
--- a/src/app/features/space-settings/permissions/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './Permissions';
diff --git a/src/app/features/space-settings/permissions/usePermissionItems.ts b/src/app/features/space-settings/permissions/usePermissionItems.ts
deleted file mode 100644
index e41b4cee43..0000000000
--- a/src/app/features/space-settings/permissions/usePermissionItems.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-import { useMemo } from 'react';
-
-import type { PermissionGroup } from '$features/common-settings/permissions';
-import { CALL_PERMISSIONS_GROUP } from '$features/common-settings/permissions';
-import { EventType } from '$types/matrix-sdk';
-import { CustomStateEvent } from '$types/matrix/room';
-
-export const usePermissionGroups = (): PermissionGroup[] => {
- const groups: PermissionGroup[] = useMemo(() => {
- const messagesGroup: PermissionGroup = {
- name: 'Manage',
- items: [
- {
- location: {
- state: true,
- key: EventType.SpaceChild,
- },
- name: 'Manage space rooms',
- },
- {
- location: {},
- name: 'Message Events',
- },
- ],
- };
-
- const moderationGroup: PermissionGroup = {
- name: 'Moderation',
- items: [
- {
- location: {
- action: true,
- key: 'invite',
- },
- name: 'Invite',
- },
- {
- location: {
- action: true,
- key: 'kick',
- },
- name: 'Kick',
- },
- {
- location: {
- action: true,
- key: 'ban',
- },
- name: 'Ban',
- },
- ],
- };
-
- const roomOverviewGroup: PermissionGroup = {
- name: 'Space Overview',
- items: [
- {
- location: {
- state: true,
- key: EventType.RoomAvatar,
- },
- name: 'Space Avatar',
- },
- {
- location: {
- state: true,
- key: EventType.RoomName,
- },
- name: 'Space Name',
- },
- {
- location: {
- state: true,
- key: EventType.RoomTopic,
- },
- name: 'Space Topic',
- },
- ],
- };
-
- const roomSettingsGroup: PermissionGroup = {
- name: 'Settings',
- items: [
- {
- location: {
- state: true,
- key: EventType.RoomJoinRules,
- },
- name: 'Change Space Access',
- },
- {
- location: {
- state: true,
- key: EventType.RoomCanonicalAlias,
- },
- name: 'Publish Address',
- },
- {
- location: {
- state: true,
- key: EventType.RoomPowerLevels,
- },
- name: 'Change All Permission',
- },
- {
- location: {
- state: true,
- key: CustomStateEvent.PowerLevelTags,
- },
- name: 'Edit Power Levels',
- },
- {
- location: {
- state: true,
- key: EventType.RoomTombstone,
- },
- name: 'Upgrade Space',
- },
- {
- location: {
- state: true,
- },
- name: 'Other Settings',
- },
- ],
- };
-
- const otherSettingsGroup: PermissionGroup = {
- name: 'Other',
- items: [
- {
- location: {
- state: true,
- key: CustomStateEvent.ImagePack,
- },
- name: 'Manage Emojis & Stickers',
- },
- {
- location: {
- state: true,
- key: EventType.RoomServerAcl,
- },
- name: 'Change Server ACLs',
- },
- ],
- };
-
- return [
- messagesGroup,
- CALL_PERMISSIONS_GROUP,
- moderationGroup,
- roomOverviewGroup,
- roomSettingsGroup,
- otherSettingsGroup,
- ];
- }, []);
-
- return groups;
-};
diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx
index a46709ee05..f229fb9157 100644
--- a/src/app/pages/client/sidebar/SpaceTabs.tsx
+++ b/src/app/pages/client/sidebar/SpaceTabs.tsx
@@ -94,7 +94,6 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useRoomAvatar } from '$hooks/useRoomMeta';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
-import { useOpenSpaceSettings } from '$state/hooks/spaceSettings';
import { useRoomCreators } from '$hooks/useRoomCreators';
import { useRoomPermissions } from '$hooks/useRoomPermissions';
import { InviteUserPrompt } from '$components/invite-user-prompt';
@@ -102,6 +101,7 @@ import { CustomAccountDataEvent } from '$types/matrix/accountData';
import { lastVisitedSpaceIdAtom } from '$state/room/lastSpace';
import { useMobileTapActivation } from '$hooks/useMobileTapActivation';
import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
+import { useOpenRoomSettings } from '$state/hooks/roomSettings';
type SpaceMenuProps = {
room: Room;
@@ -118,7 +118,7 @@ const SpaceMenu = forwardRef(
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
- const openSpaceSettings = useOpenSpaceSettings();
+ const openRoomSettings = useOpenRoomSettings();
const [invitePrompt, setInvitePrompt] = useState(false);
@@ -158,7 +158,7 @@ const SpaceMenu = forwardRef(
};
const handleRoomSettings = () => {
- openSpaceSettings(room.roomId);
+ openRoomSettings(room.roomId);
requestClose();
};
diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx
index 9e275feaac..f4fcea8298 100644
--- a/src/app/pages/client/space/Space.tsx
+++ b/src/app/pages/client/space/Space.tsx
@@ -82,7 +82,6 @@ import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '$hooks/useRoomsNotificationPreferences';
-import { useOpenSpaceSettings } from '$state/hooks/spaceSettings';
import { useRoomNavigate } from '$hooks/useRoomNavigate';
import { useRoomCreators } from '$hooks/useRoomCreators';
import { useRoomPermissions } from '$hooks/useRoomPermissions';
@@ -108,6 +107,7 @@ import { isResizingSidebarAtom } from '$state/isResizingSidebar';
import { UserQuickTools } from '../sidebar/UserQuickTools';
import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl';
import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
+import { useOpenRoomSettings } from '$state/hooks/roomSettings';
const debugLog = createDebugLogger('Space');
@@ -126,7 +126,7 @@ const SpaceMenu = forwardRef(({ room, requestClo
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
- const openSpaceSettings = useOpenSpaceSettings();
+ const openRoomSettings = useOpenRoomSettings();
const { navigateRoom } = useRoomNavigate();
const [invitePrompt, setInvitePrompt] = useState(false);
@@ -162,7 +162,7 @@ const SpaceMenu = forwardRef(({ room, requestClo
};
const handleRoomSettings = () => {
- openSpaceSettings(room.roomId);
+ openRoomSettings(room.roomId);
requestClose();
};
diff --git a/src/app/state/hooks/spaceSettings.ts b/src/app/state/hooks/spaceSettings.ts
deleted file mode 100644
index aa153bdd1f..0000000000
--- a/src/app/state/hooks/spaceSettings.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { useCallback } from 'react';
-import { useAtomValue, useSetAtom } from 'jotai';
-import type { SpaceSettingsPage, SpaceSettingsState } from '$state/spaceSettings';
-import { spaceSettingsAtom } from '$state/spaceSettings';
-
-export const useSpaceSettingsState = (): SpaceSettingsState | undefined => {
- const data = useAtomValue(spaceSettingsAtom);
-
- return data;
-};
-
-type CloseCallback = () => void;
-export const useCloseSpaceSettings = (): CloseCallback => {
- const setSettings = useSetAtom(spaceSettingsAtom);
-
- const close: CloseCallback = useCallback(() => {
- setSettings(undefined);
- }, [setSettings]);
-
- return close;
-};
-
-type OpenCallback = (roomId: string, space?: string, page?: SpaceSettingsPage) => void;
-export const useOpenSpaceSettings = (): OpenCallback => {
- const setSettings = useSetAtom(spaceSettingsAtom);
-
- const open: OpenCallback = useCallback(
- (roomId, spaceId, page) => {
- setSettings({ roomId, spaceId, page });
- },
- [setSettings]
- );
-
- return open;
-};
diff --git a/src/app/state/roomSettings.ts b/src/app/state/roomSettings.ts
index 64bc0ef998..cfeaef7ba0 100644
--- a/src/app/state/roomSettings.ts
+++ b/src/app/state/roomSettings.ts
@@ -9,6 +9,8 @@ export enum RoomSettingsPage {
// Sable pages
CosmeticsPage,
AbbreviationsPage,
+ /** Spaces only. */
+ AppearancePage,
}
export type RoomSettingsState = {
diff --git a/src/app/state/spaceSettings.ts b/src/app/state/spaceSettings.ts
deleted file mode 100644
index de91d6f3a5..0000000000
--- a/src/app/state/spaceSettings.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { atom } from 'jotai';
-
-export enum SpaceSettingsPage {
- GeneralPage,
- MembersPage,
- PermissionsPage,
- EmojisStickersPage,
- DeveloperToolsPage,
- // Sable pages
- CosmeticsPage,
- AbbreviationsPage,
- AppearancePage,
-}
-
-export type SpaceSettingsState = {
- page?: SpaceSettingsPage;
- roomId: string;
- spaceId?: string;
-};
-
-export const spaceSettingsAtom = atom(undefined);
From 343a8139b4f81b843f09896b6243ac69dd7d6726 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 01:21:46 +0200
Subject: [PATCH 10/43] fix: show space admin actions in the lobby without a
reload
---
src/app/hooks/usePowerLevels.ts | 37 ++++++++++++++++++++-------------
1 file changed, 23 insertions(+), 14 deletions(-)
diff --git a/src/app/hooks/usePowerLevels.ts b/src/app/hooks/usePowerLevels.ts
index 03d145e472..4268a8996e 100644
--- a/src/app/hooks/usePowerLevels.ts
+++ b/src/app/hooks/usePowerLevels.ts
@@ -85,20 +85,29 @@ export const usePowerLevelsContext = (): IPowerLevels => {
return pl;
};
-export const useRoomsPowerLevels = (rooms: Room[]): Map => {
- const mx = useMatrixClient();
- const getRoomsPowerLevels = useCallback(() => {
- const rToPl = new Map();
+const buildRoomsPowerLevels = (rooms: Room[]): Map => {
+ const rToPl = new Map();
- rooms.forEach((room) => {
- const mEvent = getStateEvent(room, EventType.RoomPowerLevels, '');
- rToPl.set(room.roomId, getPowersLevelFromMatrixEvent(mEvent));
- });
+ rooms.forEach((room) => {
+ const mEvent = getStateEvent(room, EventType.RoomPowerLevels, '');
+ rToPl.set(room.roomId, getPowersLevelFromMatrixEvent(mEvent));
+ });
- return rToPl;
- }, [rooms]);
+ return rToPl;
+};
- const [roomToPowerLevels, setRoomToPowerLevels] = useState(() => getRoomsPowerLevels());
+export const useRoomsPowerLevels = (rooms: Room[]): Map => {
+ const mx = useMatrixClient();
+ const [roomToPowerLevels, setRoomToPowerLevels] = useState(() => buildRoomsPowerLevels(rooms));
+ const [derivedFrom, setDerivedFrom] = useState(rooms);
+
+ // Rooms arriving after mount — a space hierarchy resolving, or state loading
+ // lazily under sliding sync — must re-derive, or their permissions are stuck
+ // at the defaults until a reload.
+ if (derivedFrom !== rooms) {
+ setDerivedFrom(rooms);
+ setRoomToPowerLevels(buildRoomsPowerLevels(rooms));
+ }
useStateEventCallback(
mx,
@@ -109,12 +118,12 @@ export const useRoomsPowerLevels = (rooms: Room[]): Map =>
roomId &&
event.getType() === (EventType.RoomPowerLevels as string) &&
event.getStateKey() === '' &&
- rooms.find((r) => r.roomId === roomId)
+ rooms.some((r) => r.roomId === roomId)
) {
- setRoomToPowerLevels(getRoomsPowerLevels());
+ setRoomToPowerLevels(buildRoomsPowerLevels(rooms));
}
},
- [rooms, getRoomsPowerLevels]
+ [rooms]
)
);
From 7deea840506f0c0ea12c73aef8c2949ef321db8f Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 01:23:12 +0200
Subject: [PATCH 11/43] refactor: collapse route selection hooks into one
module
---
src/app/hooks/router/useCreateSelected.ts | 12 -----
src/app/hooks/router/useDirectSelected.ts | 22 ---------
src/app/hooks/router/useExploreSelected.ts | 28 ------------
src/app/hooks/router/useHomeSelected.ts | 43 ------------------
src/app/hooks/router/useInbox.ts | 47 --------------------
src/app/hooks/router/useNavigateSelected.ts | 12 -----
src/app/hooks/router/useProfileSelected.ts | 12 -----
src/app/hooks/router/useRouteSelected.ts | 46 +++++++++++++++++++
src/app/pages/client/ClientNonUIFeatures.tsx | 2 +-
src/app/pages/client/direct/Direct.tsx | 2 +-
src/app/pages/client/explore/Explore.tsx | 2 +-
src/app/pages/client/home/Home.tsx | 2 +-
src/app/pages/client/inbox/Inbox.tsx | 2 +-
src/app/pages/client/sidebar/CreateTab.tsx | 4 +-
src/app/pages/client/sidebar/DirectTab.tsx | 2 +-
src/app/pages/client/sidebar/HomeTab.tsx | 2 +-
src/app/pages/client/sidebar/InboxTab.tsx | 2 +-
src/app/pages/client/sidebar/MessageTab.tsx | 6 +--
src/app/pages/client/sidebar/NavigateTab.tsx | 2 +-
src/app/pages/client/sidebar/UserMenuTab.tsx | 2 +-
20 files changed, 61 insertions(+), 191 deletions(-)
delete mode 100644 src/app/hooks/router/useCreateSelected.ts
delete mode 100644 src/app/hooks/router/useDirectSelected.ts
delete mode 100644 src/app/hooks/router/useExploreSelected.ts
delete mode 100644 src/app/hooks/router/useHomeSelected.ts
delete mode 100644 src/app/hooks/router/useInbox.ts
delete mode 100644 src/app/hooks/router/useNavigateSelected.ts
delete mode 100644 src/app/hooks/router/useProfileSelected.ts
create mode 100644 src/app/hooks/router/useRouteSelected.ts
diff --git a/src/app/hooks/router/useCreateSelected.ts b/src/app/hooks/router/useCreateSelected.ts
deleted file mode 100644
index 891594c750..0000000000
--- a/src/app/hooks/router/useCreateSelected.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useMatch } from 'react-router-dom';
-import { getCreatePath } from '$pages/pathUtils';
-
-export const useCreateSelected = (): boolean => {
- const match = useMatch({
- path: getCreatePath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
diff --git a/src/app/hooks/router/useDirectSelected.ts b/src/app/hooks/router/useDirectSelected.ts
deleted file mode 100644
index 07605bbe32..0000000000
--- a/src/app/hooks/router/useDirectSelected.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useMatch } from 'react-router-dom';
-import { getDirectCreatePath, getDirectPath } from '$pages/pathUtils';
-
-export const useDirectSelected = (): boolean => {
- const directMatch = useMatch({
- path: getDirectPath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!directMatch;
-};
-
-export const useDirectCreateSelected = (): boolean => {
- const match = useMatch({
- path: getDirectCreatePath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
diff --git a/src/app/hooks/router/useExploreSelected.ts b/src/app/hooks/router/useExploreSelected.ts
deleted file mode 100644
index 87024189fe..0000000000
--- a/src/app/hooks/router/useExploreSelected.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { useMatch, useParams } from 'react-router-dom';
-import { getExploreFeaturedPath, getExplorePath } from '$pages/pathUtils';
-
-export const useExploreSelected = (): boolean => {
- const match = useMatch({
- path: getExplorePath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
-
-export const useExploreFeaturedSelected = (): boolean => {
- const match = useMatch({
- path: getExploreFeaturedPath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
-
-export const useExploreServer = (): string | undefined => {
- const { server } = useParams();
-
- return server;
-};
diff --git a/src/app/hooks/router/useHomeSelected.ts b/src/app/hooks/router/useHomeSelected.ts
deleted file mode 100644
index b71d7e53a4..0000000000
--- a/src/app/hooks/router/useHomeSelected.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { useMatch } from 'react-router-dom';
-import { getHomeJoinPath, getHomePath, getHomeSearchPath } from '$pages/pathUtils';
-import { CREATE_ROOM_PATH } from '$pages/paths';
-
-export const useHomeSelected = (): boolean => {
- const homeMatch = useMatch({
- path: getHomePath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!homeMatch;
-};
-
-export const useHomeCreateSelected = (): boolean => {
- const match = useMatch({
- path: CREATE_ROOM_PATH,
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
-
-export const useHomeJoinSelected = (): boolean => {
- const match = useMatch({
- path: getHomeJoinPath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
-
-export const useHomeSearchSelected = (): boolean => {
- const match = useMatch({
- path: getHomeSearchPath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
diff --git a/src/app/hooks/router/useInbox.ts b/src/app/hooks/router/useInbox.ts
deleted file mode 100644
index c19c0cc4b0..0000000000
--- a/src/app/hooks/router/useInbox.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { useMatch } from 'react-router-dom';
-import {
- getInboxBookmarksPath,
- getInboxInvitesPath,
- getInboxNotificationsPath,
- getInboxPath,
-} from '$pages/pathUtils';
-
-export const useInboxSelected = (): boolean => {
- const match = useMatch({
- path: getInboxPath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
-
-export const useInboxNotificationsSelected = (): boolean => {
- const match = useMatch({
- path: getInboxNotificationsPath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
-
-export const useInboxInvitesSelected = (): boolean => {
- const match = useMatch({
- path: getInboxInvitesPath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
-
-export const useInboxBookmarksSelected = (): boolean => {
- const match = useMatch({
- path: getInboxBookmarksPath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
diff --git a/src/app/hooks/router/useNavigateSelected.ts b/src/app/hooks/router/useNavigateSelected.ts
deleted file mode 100644
index 81e2168b9c..0000000000
--- a/src/app/hooks/router/useNavigateSelected.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useMatch } from 'react-router-dom';
-import { getNavigatePath } from '$pages/pathUtils';
-
-export const useNavigateSelected = (): boolean => {
- const match = useMatch({
- path: getNavigatePath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
diff --git a/src/app/hooks/router/useProfileSelected.ts b/src/app/hooks/router/useProfileSelected.ts
deleted file mode 100644
index 9a7abdde21..0000000000
--- a/src/app/hooks/router/useProfileSelected.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useMatch } from 'react-router-dom';
-import { getProfilePath } from '$pages/pathUtils';
-
-export const useProfileSelected = (): boolean => {
- const match = useMatch({
- path: getProfilePath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
diff --git a/src/app/hooks/router/useRouteSelected.ts b/src/app/hooks/router/useRouteSelected.ts
new file mode 100644
index 0000000000..57ee887394
--- /dev/null
+++ b/src/app/hooks/router/useRouteSelected.ts
@@ -0,0 +1,46 @@
+import { useMatch, useParams } from 'react-router-dom';
+import {
+ getCreatePath,
+ getDirectCreatePath,
+ getDirectPath,
+ getExploreFeaturedPath,
+ getExplorePath,
+ getHomeJoinPath,
+ getHomePath,
+ getHomeSearchPath,
+ getInboxBookmarksPath,
+ getInboxInvitesPath,
+ getInboxNotificationsPath,
+ getInboxPath,
+ getNavigatePath,
+ getProfilePath,
+} from '$pages/pathUtils';
+import { CREATE_ROOM_PATH } from '$pages/paths';
+
+/** True when the current location is at, or inside, the given path. */
+export const useRouteSelected = (path: string): boolean =>
+ !!useMatch({ path, caseSensitive: true, end: false });
+
+export const useCreateSelected = (): boolean => useRouteSelected(getCreatePath());
+
+export const useDirectSelected = (): boolean => useRouteSelected(getDirectPath());
+export const useDirectCreateSelected = (): boolean => useRouteSelected(getDirectCreatePath());
+
+export const useExploreSelected = (): boolean => useRouteSelected(getExplorePath());
+export const useExploreFeaturedSelected = (): boolean => useRouteSelected(getExploreFeaturedPath());
+export const useExploreServer = (): string | undefined => useParams().server;
+
+export const useHomeSelected = (): boolean => useRouteSelected(getHomePath());
+export const useHomeCreateSelected = (): boolean => useRouteSelected(CREATE_ROOM_PATH);
+export const useHomeJoinSelected = (): boolean => useRouteSelected(getHomeJoinPath());
+export const useHomeSearchSelected = (): boolean => useRouteSelected(getHomeSearchPath());
+
+export const useInboxSelected = (): boolean => useRouteSelected(getInboxPath());
+export const useInboxNotificationsSelected = (): boolean =>
+ useRouteSelected(getInboxNotificationsPath());
+export const useInboxInvitesSelected = (): boolean => useRouteSelected(getInboxInvitesPath());
+export const useInboxBookmarksSelected = (): boolean => useRouteSelected(getInboxBookmarksPath());
+
+export const useNavigateSelected = (): boolean => useRouteSelected(getNavigatePath());
+
+export const useProfileSelected = (): boolean => useRouteSelected(getProfilePath());
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 24ea5bc2bc..8e8fccc112 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -51,7 +51,7 @@ import {
import { NotificationType, type RoomToUnread } from '$types/matrix/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix';
import { useSelectedRoom } from '$hooks/router/useSelectedRoom';
-import { useInboxNotificationsSelected } from '$hooks/router/useInbox';
+import { useInboxNotificationsSelected } from '$hooks/router/useRouteSelected';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { ShareTargetFeature } from '$features/share-target/ShareTargetFeature';
import { registrationAtom } from '$state/serviceWorkerRegistration';
diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx
index 425bc504d7..1ee56880e9 100644
--- a/src/app/pages/client/direct/Direct.tsx
+++ b/src/app/pages/client/direct/Direct.tsx
@@ -60,7 +60,7 @@ import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '$hooks/useRoomsNotificationPreferences';
-import { useDirectCreateSelected } from '$hooks/router/useDirectSelected';
+import { useDirectCreateSelected } from '$hooks/router/useRouteSelected';
import { useDirectRooms } from './useDirectRooms';
import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer';
import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize';
diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx
index d3195fdd02..02514c8a82 100644
--- a/src/app/pages/client/explore/Explore.tsx
+++ b/src/app/pages/client/explore/Explore.tsx
@@ -35,7 +35,7 @@ import {
} from '$components/nav';
import { getExploreFeaturedPath, getExploreServerPath } from '$pages/pathUtils';
import { useClientConfig } from '$hooks/useClientConfig';
-import { useExploreFeaturedSelected, useExploreServer } from '$hooks/router/useExploreSelected';
+import { useExploreFeaturedSelected, useExploreServer } from '$hooks/router/useRouteSelected';
import { useExploreServers } from '$hooks/useExploreServers';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx
index 9b0c4937e1..23b5630a54 100644
--- a/src/app/pages/client/home/Home.tsx
+++ b/src/app/pages/client/home/Home.tsx
@@ -41,7 +41,7 @@ import {
import { useOpenShallowRoute } from '$pages/client/useShallowRoute';
import { getCanonicalAliasOrRoomId } from '$utils/matrix';
import { useSelectedOrLastRoom } from '$hooks/router/useSelectedRoom';
-import { useHomeCreateSelected, useHomeSearchSelected } from '$hooks/router/useHomeSelected';
+import { useHomeCreateSelected, useHomeSearchSelected } from '$hooks/router/useRouteSelected';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { VirtualTile } from '$components/virtualizer';
import { RoomNavCategoryButton, RoomNavItem } from '$features/room-nav';
diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx
index 0439c5fc70..edb9d52a6f 100644
--- a/src/app/pages/client/inbox/Inbox.tsx
+++ b/src/app/pages/client/inbox/Inbox.tsx
@@ -10,7 +10,7 @@ import {
useInboxBookmarksSelected,
useInboxInvitesSelected,
useInboxNotificationsSelected,
-} from '$hooks/router/useInbox';
+} from '$hooks/router/useRouteSelected';
import { UnreadBadge } from '$components/unread-badge';
import { useNavToActivePathMapper } from '$hooks/useNavToActivePathMapper';
import { PageNav, PageNavContent, PageNavHeader } from '$components/page';
diff --git a/src/app/pages/client/sidebar/CreateTab.tsx b/src/app/pages/client/sidebar/CreateTab.tsx
index 7b8295a4ae..c0beedd174 100644
--- a/src/app/pages/client/sidebar/CreateTab.tsx
+++ b/src/app/pages/client/sidebar/CreateTab.tsx
@@ -19,7 +19,7 @@ import {
joinPathComponent,
withSearchParam,
} from '$pages/pathUtils';
-import { useCreateSelected } from '$hooks/router/useCreateSelected';
+import { useCreateSelected } from '$hooks/router/useRouteSelected';
import { JoinAddressPrompt } from '$components/join-address-prompt';
import {
composerIcon,
@@ -35,7 +35,7 @@ import { useClientConfig } from '$hooks/useClientConfig';
import { useAtomValue } from 'jotai';
import { useNavToActivePathAtom } from '$state/hooks/navToActivePath';
import { getMxIdServer } from '$utils/mxIdHelper';
-import { useExploreSelected } from '$hooks/router/useExploreSelected';
+import { useExploreSelected } from '$hooks/router/useRouteSelected';
export function CreateTab() {
const mx = useMatrixClient();
diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx
index 0b94665398..25e39493b3 100644
--- a/src/app/pages/client/sidebar/DirectTab.tsx
+++ b/src/app/pages/client/sidebar/DirectTab.tsx
@@ -18,7 +18,7 @@ import {
SidebarUnreadBadge,
SidebarItemTooltip,
} from '$components/sidebar';
-import { useDirectSelected } from '$hooks/router/useDirectSelected';
+import { useDirectSelected } from '$hooks/router/useRouteSelected';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { useNavToActivePathAtom } from '$state/hooks/navToActivePath';
import { markAsRead } from '$utils/notifications';
diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx
index 28644c7de5..18c812eba6 100644
--- a/src/app/pages/client/sidebar/HomeTab.tsx
+++ b/src/app/pages/client/sidebar/HomeTab.tsx
@@ -19,7 +19,7 @@ import {
SidebarUnreadBadge,
SidebarItemTooltip,
} from '$components/sidebar';
-import { useHomeSelected } from '$hooks/router/useHomeSelected';
+import { useHomeSelected } from '$hooks/router/useRouteSelected';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { useNavToActivePathAtom } from '$state/hooks/navToActivePath';
import { markAsRead } from '$utils/notifications';
diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx
index 7f3bb1443e..aee67e56bd 100644
--- a/src/app/pages/client/sidebar/InboxTab.tsx
+++ b/src/app/pages/client/sidebar/InboxTab.tsx
@@ -17,7 +17,7 @@ import {
useInboxInvitesSelected,
useInboxNotificationsSelected,
useInboxSelected,
-} from '$hooks/router/useInbox';
+} from '$hooks/router/useRouteSelected';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { useNavToActivePathAtom } from '$state/hooks/navToActivePath';
import { useInviteCount } from '$hooks/useInviteCount';
diff --git a/src/app/pages/client/sidebar/MessageTab.tsx b/src/app/pages/client/sidebar/MessageTab.tsx
index 39ca1f682c..50f47b144a 100644
--- a/src/app/pages/client/sidebar/MessageTab.tsx
+++ b/src/app/pages/client/sidebar/MessageTab.tsx
@@ -9,10 +9,10 @@ import { matchPath, useNavigate } from 'react-router-dom';
import { HOME_PATH, SETTINGS_PATH } from '$pages/paths';
import { ChatTextIcon } from '@phosphor-icons/react';
import { useAtomValue } from 'jotai';
-import { useInboxSelected } from '$hooks/router/useInbox';
+import { useInboxSelected } from '$hooks/router/useRouteSelected';
import { Box, color, Text, toRem } from 'folds';
-import { useNavigateSelected } from '$hooks/router/useNavigateSelected';
-import { useProfileSelected } from '$hooks/router/useProfileSelected';
+import { useNavigateSelected } from '$hooks/router/useRouteSelected';
+import { useProfileSelected } from '$hooks/router/useRouteSelected';
import { getSpacePath } from '$pages/pathUtils';
import { lastVisitedSpaceIdAtom } from '$state/room/lastSpace';
import { allRoomsAtom } from '$state/room-list/roomList';
diff --git a/src/app/pages/client/sidebar/NavigateTab.tsx b/src/app/pages/client/sidebar/NavigateTab.tsx
index 3ce7a957ef..0547fed182 100644
--- a/src/app/pages/client/sidebar/NavigateTab.tsx
+++ b/src/app/pages/client/sidebar/NavigateTab.tsx
@@ -4,7 +4,7 @@ import { getPhosphorIconSize } from '$components/icons/phosphor';
import { Text, Box, color } from 'folds';
import { getNavigatePath } from '$pages/pathUtils';
import { useOpenShallowRoute } from '$pages/client/useShallowRoute';
-import { useNavigateSelected } from '$hooks/router/useNavigateSelected';
+import { useNavigateSelected } from '$hooks/router/useRouteSelected';
import { useMobileTapActivation } from '$hooks/useMobileTapActivation';
export function NavigateTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) {
diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx
index f630a87312..288744a9b0 100644
--- a/src/app/pages/client/sidebar/UserMenuTab.tsx
+++ b/src/app/pages/client/sidebar/UserMenuTab.tsx
@@ -49,7 +49,7 @@ import { useFocusWithin, useHover } from 'react-aria';
import { setUserPresence } from '$utils/presence';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
-import { useProfileSelected } from '$hooks/router/useProfileSelected';
+import { useProfileSelected } from '$hooks/router/useRouteSelected';
import { useDeviceIds, useDeviceList, useSplitCurrentDevice } from '$hooks/useDeviceList';
import {
useDeviceVerificationStatus,
From 5ad2b287e78bb03341a99b496cdff175b3234f99 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 01:24:59 +0200
Subject: [PATCH 12/43] refactor: open settings from a button in the mobile
account header
---
src/app/pages/client/profile/Profile.tsx | 105 ++++++-----------------
1 file changed, 26 insertions(+), 79 deletions(-)
diff --git a/src/app/pages/client/profile/Profile.tsx b/src/app/pages/client/profile/Profile.tsx
index cf265d0f4f..3153923f48 100644
--- a/src/app/pages/client/profile/Profile.tsx
+++ b/src/app/pages/client/profile/Profile.tsx
@@ -4,6 +4,7 @@ import {
Text,
color,
config,
+ IconButton,
Menu,
Line,
MenuItem,
@@ -11,10 +12,15 @@ import {
Overlay,
OverlayCenter,
} from 'folds';
-import { GearSix, SquaresFour, menuIcon, sizedIcon } from '$components/icons/phosphor';
+import {
+ composerIcon,
+ GearSix,
+ SquaresFour,
+ menuIcon,
+ sizedIcon,
+} from '$components/icons/phosphor';
import { PageNav, PageNavHeader } from '$components/page';
-import { NavButton, NavItem, NavItemContent } from '$components/nav';
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useState } from 'react';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
@@ -32,16 +38,9 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useUserPresence } from '$hooks/useUserPresence';
import { useUserProfile } from '$hooks/useUserProfile';
-import type { SettingsMenuItem } from '$features/settings';
-import { settingsMenuIcons, settingsSections, useOpenSettings } from '$features/settings';
-import { isDesktopTauri } from '$utils/platform';
+import { useOpenSettings } from '$features/settings';
import { UserQuickTools } from '../sidebar/UserQuickTools';
-import {
- CaretDownIcon,
- CaretRightIcon,
- PencilSimpleIcon,
- SignOutIcon,
-} from '@phosphor-icons/react';
+import { PencilSimpleIcon, SignOutIcon } from '@phosphor-icons/react';
import { UseStateProvider } from '$components/UseStateProvider';
import { FocusTrap } from 'focus-trap-react';
import { LogoutDialog } from '$components/LogoutDialog';
@@ -58,7 +57,6 @@ export function ProfileMobile() {
const server = getMxIdServer(userId);
const profile = useUserProfile(userId);
const presence = useUserPresence(userId);
- const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const heroAvatarUrl = profile.avatarUrl
@@ -82,22 +80,6 @@ export function ProfileMobile() {
const isMobile = screenSize === ScreenSize.Mobile;
const hideText = curWidth <= 80 && !isMobile;
- const [showPersona] = useSetting(settingsAtom, 'showPersonaSetting');
- const isDesktop = isDesktopTauri();
- const menuItems = useMemo(
- () =>
- settingsSections
- .filter(
- (section) =>
- (showPersona || section.id !== 'persona') && (isDesktop || section.id !== 'desktop')
- )
- .map((section) => {
- const icon = settingsMenuIcons[section.id];
- return { id: section.id, name: section.label, ...icon };
- }),
- [showPersona, isDesktop]
- );
-
return (
<>
{!isMobile && (
@@ -147,11 +129,21 @@ export function ProfileMobile() {
style={{ width: '100%', minWidth: '100%', background: color.Background.Container }}
>
-
-
-
- Account
-
+
+
+
+ Account
+
+
+ openSettings()}
+ >
+ {composerIcon(GearSix)}
+
@@ -185,9 +177,6 @@ export function ProfileMobile() {
size="300"
radii="300"
before={menuIcon(PencilSimpleIcon)}
- style={{
- background: isSettingsOpen ? color.Surface.Container : color.Background.Container,
- }}
onClick={() => openSettings('account')}
>
@@ -205,48 +194,6 @@ export function ProfileMobile() {
-
- {isSettingsOpen && (
-
- {menuItems.map((item) => {
- const IconComponent = item.icon;
-
- return (
-
- openSettings(item.id)} aria-label={item.name}>
-
-
- {menuIcon(IconComponent)}
-
- {item.name}
-
-
-
-
-
- );
- })}
-
- )}
-
{(logout, setLogout) => (
<>
From 1b9a3d0b77ce8af00ab183ca7b36b5acb339a119 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 01:28:27 +0200
Subject: [PATCH 13/43] refactor: share one sidebar panel across full page
routes
---
src/app/components/page/FormPage.tsx | 60 +----------------
src/app/components/page/SidebarPanel.tsx | 65 +++++++++++++++++++
src/app/pages/client/navigate/Navigate.tsx | 67 ++-----------------
src/app/pages/client/profile/Profile.tsx | 75 ++--------------------
4 files changed, 80 insertions(+), 187 deletions(-)
create mode 100644 src/app/components/page/SidebarPanel.tsx
diff --git a/src/app/components/page/FormPage.tsx b/src/app/components/page/FormPage.tsx
index 922c631de3..ec8c27ea34 100644
--- a/src/app/components/page/FormPage.tsx
+++ b/src/app/components/page/FormPage.tsx
@@ -1,14 +1,7 @@
import type { ReactNode } from 'react';
-import { useEffect, useState } from 'react';
-import { useSetAtom } from 'jotai';
-import { Box, IconButton, Scroll, Text, color, config, toRem } from 'folds';
+import { Box, IconButton, Scroll, Text, color } from 'folds';
import { SquaresFour, composerIcon, sizedIcon, X } from '$components/icons/phosphor';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
-import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
-import { isResizingSidebarAtom } from '$state/isResizingSidebar';
-import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer';
-import { UserQuickTools } from '$pages/client/sidebar/UserQuickTools';
import {
Page,
PageContent,
@@ -18,6 +11,7 @@ import {
PageNav,
PageNavHeader,
} from './Page';
+import { SidebarPanel } from './SidebarPanel';
type FormPageProps = {
title: string;
@@ -28,60 +22,12 @@ type FormPageProps = {
};
export function FormPage({ title, subTitle, closeLabel, onClose, children }: FormPageProps) {
- const setIsResizingSidebar = useSetAtom(isResizingSidebarAtom);
- const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth');
- const [curWidth, setCurWidth] = useState(roomSidebarWidth);
-
- useEffect(() => {
- setCurWidth(roomSidebarWidth);
- }, [roomSidebarWidth]);
-
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
- const hideText = curWidth <= 80 && !isMobile;
- const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar');
return (
<>
- {!isMobile && (
-
-
-
-
- {hideText ? (
- sizedIcon(SquaresFour, '200', { filled: true })
- ) : (
-
-
- {title}
-
-
- )}
-
-
-
-
- {!oldSidebar && }
-
- )}
+ {!isMobile && }
{isMobile && (
diff --git a/src/app/components/page/SidebarPanel.tsx b/src/app/components/page/SidebarPanel.tsx
new file mode 100644
index 0000000000..4df42f0e42
--- /dev/null
+++ b/src/app/components/page/SidebarPanel.tsx
@@ -0,0 +1,65 @@
+import { useEffect, useState } from 'react';
+import { useSetAtom } from 'jotai';
+import { Box, Text, color, config, toRem } from 'folds';
+import { SquaresFour, sizedIcon } from '$components/icons/phosphor';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
+import { isResizingSidebarAtom } from '$state/isResizingSidebar';
+import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer';
+import { UserQuickTools } from '$pages/client/sidebar/UserQuickTools';
+import { PageNav, PageNavHeader } from './Page';
+
+/**
+ * The resizable left panel that full-page routes show on desktop. Collapses to
+ * an icon once dragged narrow enough for the title to stop fitting.
+ */
+export function SidebarPanel({ title }: { title: string }) {
+ const setIsResizingSidebar = useSetAtom(isResizingSidebarAtom);
+ const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth');
+ const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar');
+ const [curWidth, setCurWidth] = useState(roomSidebarWidth);
+
+ useEffect(() => {
+ setCurWidth(roomSidebarWidth);
+ }, [roomSidebarWidth]);
+
+ return (
+
+
+
+
+ {curWidth <= 80 ? (
+ sizedIcon(SquaresFour, '200', { filled: true })
+ ) : (
+
+
+ {title}
+
+
+ )}
+
+
+
+
+ {!oldSidebar && }
+
+ );
+}
diff --git a/src/app/pages/client/navigate/Navigate.tsx b/src/app/pages/client/navigate/Navigate.tsx
index 61267e006d..2a3e4f958d 100644
--- a/src/app/pages/client/navigate/Navigate.tsx
+++ b/src/app/pages/client/navigate/Navigate.tsx
@@ -1,18 +1,11 @@
-import { Box, Scroll, toRem, Text, color, config } from 'folds';
-import { SquaresFour, sizedIcon } from '$components/icons/phosphor';
-import { Page, PageContent, PageContentCenter, PageNav, PageNavHeader } from '$components/page';
-import { useEffect, useState } from 'react';
+import { Box, Scroll, Text, color } from 'folds';
+import { Page, PageContent, PageContentCenter, PageNavHeader } from '$components/page';
+import { SidebarPanel } from '$components/page/SidebarPanel';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
-import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
-import { SidebarResizer } from '../sidebar/SidebarResizer';
-import { useSetAtom } from 'jotai';
-import { isResizingSidebarAtom } from '$state/isResizingSidebar';
import { useLocation } from 'react-router-dom';
import { RoomSearchModal, SearchWrapper } from '$features/navigate';
import { getBackgroundLocation } from '../shallowRoute';
import { useCloseShallowRoute } from '../useShallowRoute';
-import { UserQuickTools } from '../sidebar/UserQuickTools';
export function Navigate() {
const location = useLocation();
@@ -27,61 +20,11 @@ export function Navigate() {
}
function NavigatePage() {
- const setIsResizingSidebar = useSetAtom(isResizingSidebarAtom);
- const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth');
- const [curWidth, setCurWidth] = useState(roomSidebarWidth);
- const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar');
-
- useEffect(() => {
- setCurWidth(roomSidebarWidth);
- }, [roomSidebarWidth]);
- const screenSize = useScreenSizeContext();
- const isMobile = screenSize === ScreenSize.Mobile;
- const hideText = curWidth <= 80 && !isMobile;
+ const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
return (
<>
- {!isMobile && (
-
-
-
-
- {!hideText ? (
-
-
- Inbox
-
-
- ) : (
- sizedIcon(SquaresFour, '200', { filled: true })
- )}
-
-
-
-
-
- {!oldSidebar && !isMobile && }
-
- )}
+ {!isMobile && }
diff --git a/src/app/pages/client/profile/Profile.tsx b/src/app/pages/client/profile/Profile.tsx
index 3153923f48..5ebe260047 100644
--- a/src/app/pages/client/profile/Profile.tsx
+++ b/src/app/pages/client/profile/Profile.tsx
@@ -1,6 +1,5 @@
import {
Box,
- toRem,
Text,
color,
config,
@@ -12,21 +11,10 @@ import {
Overlay,
OverlayCenter,
} from 'folds';
-import {
- composerIcon,
- GearSix,
- SquaresFour,
- menuIcon,
- sizedIcon,
-} from '$components/icons/phosphor';
-import { PageNav, PageNavHeader } from '$components/page';
-import { useEffect, useState } from 'react';
+import { GearSix, menuIcon, sizedIcon } from '$components/icons/phosphor';
+import { PageNavHeader } from '$components/page';
+import { SidebarPanel } from '$components/page/SidebarPanel';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
-import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
-import { SidebarResizer } from '../sidebar/SidebarResizer';
-import { useSetAtom } from 'jotai';
-import { isResizingSidebarAtom } from '$state/isResizingSidebar';
import {
AccountMenuOption,
PresenceMenuOption,
@@ -39,7 +27,6 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useUserPresence } from '$hooks/useUserPresence';
import { useUserProfile } from '$hooks/useUserProfile';
import { useOpenSettings } from '$features/settings';
-import { UserQuickTools } from '../sidebar/UserQuickTools';
import { PencilSimpleIcon, SignOutIcon } from '@phosphor-icons/react';
import { UseStateProvider } from '$components/UseStateProvider';
import { FocusTrap } from 'focus-trap-react';
@@ -51,7 +38,6 @@ export function ProfileMobile() {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const openSettings = useOpenSettings();
- const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar');
const userId = mx.getUserId() ?? '';
const server = getMxIdServer(userId);
@@ -69,58 +55,11 @@ export function ProfileMobile() {
? (mxcUrlToHttp(mx, parsedBanner, useAuthentication, 640, 192, 'scale') ?? undefined)
: undefined;
- const setIsResizingSidebar = useSetAtom(isResizingSidebarAtom);
- const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth');
- const [curWidth, setCurWidth] = useState(roomSidebarWidth);
-
- useEffect(() => {
- setCurWidth(roomSidebarWidth);
- }, [roomSidebarWidth]);
- const screenSize = useScreenSizeContext();
- const isMobile = screenSize === ScreenSize.Mobile;
- const hideText = curWidth <= 80 && !isMobile;
+ const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
return (
<>
- {!isMobile && (
-
-
-
-
- {!hideText ? (
-
-
- Profile
-
-
- ) : (
- sizedIcon(SquaresFour, '200', { filled: true })
- )}
-
-
-
-
- {!oldSidebar && !isMobile && }
-
- )}
+ {!isMobile && }
openSettings()}
>
- {composerIcon(GearSix)}
+ {sizedIcon(GearSix, '400')}
From e2cd39522637198799f23c605453fbad65b4902d Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 01:30:50 +0200
Subject: [PATCH 14/43] fix: stretch the mobile account header so the settings
button sits right
---
src/app/components/page/FormPage.tsx | 4 ++--
src/app/pages/client/profile/Profile.tsx | 18 +++++++++---------
2 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/src/app/components/page/FormPage.tsx b/src/app/components/page/FormPage.tsx
index ec8c27ea34..5d3b81fe32 100644
--- a/src/app/components/page/FormPage.tsx
+++ b/src/app/components/page/FormPage.tsx
@@ -34,11 +34,11 @@ export function FormPage({ title, subTitle, closeLabel, onClose, children }: For
-
+
{title}
-
+
{composerIcon(X)}
diff --git a/src/app/pages/client/profile/Profile.tsx b/src/app/pages/client/profile/Profile.tsx
index 5ebe260047..40ad111ee2 100644
--- a/src/app/pages/client/profile/Profile.tsx
+++ b/src/app/pages/client/profile/Profile.tsx
@@ -67,13 +67,14 @@ export function ProfileMobile() {
justifyContent="SpaceBetween"
style={{ width: '100%', minWidth: '100%', background: color.Background.Container }}
>
-
-
-
-
- Account
-
-
+
+
+
+
+ Account
+
+
+
openSettings()}
>
- {sizedIcon(GearSix, '400')}
+ {sizedIcon(GearSix, '500')}
@@ -130,7 +131,6 @@ export function ProfileMobile() {
-
From b8b2baea37ab46437a22966d05f27cff3869f5b1 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 10:01:18 +0200
Subject: [PATCH 15/43] fix: centre the mobile account title independently of
the settings button
---
src/app/pages/client/profile/Profile.tsx | 16 ++++++++--------
src/app/pages/client/sidebar/NavigateTab.tsx | 1 -
2 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/src/app/pages/client/profile/Profile.tsx b/src/app/pages/client/profile/Profile.tsx
index 40ad111ee2..0e9c468326 100644
--- a/src/app/pages/client/profile/Profile.tsx
+++ b/src/app/pages/client/profile/Profile.tsx
@@ -68,13 +68,14 @@ export function ProfileMobile() {
style={{ width: '100%', minWidth: '100%', background: color.Background.Container }}
>
-
-
-
- Account
-
-
-
+
+
+ Account
+
+
-
{(logout, setLogout) => (
diff --git a/src/app/pages/client/sidebar/NavigateTab.tsx b/src/app/pages/client/sidebar/NavigateTab.tsx
index 0547fed182..efefd7738e 100644
--- a/src/app/pages/client/sidebar/NavigateTab.tsx
+++ b/src/app/pages/client/sidebar/NavigateTab.tsx
@@ -8,7 +8,6 @@ import { useNavigateSelected } from '$hooks/router/useRouteSelected';
import { useMobileTapActivation } from '$hooks/useMobileTapActivation';
export function NavigateTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) {
-
const isNavigate = useNavigateSelected();
const openShallowRoute = useOpenShallowRoute();
const open = () => openShallowRoute(getNavigatePath());
From f4b7b2eee1b88b87d92e98292da29decea398aec Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 11:22:50 +0200
Subject: [PATCH 16/43] test: restructure e2e around fixtures and page objects,
run against a build
---
oxlint.config.ts | 8 ++++
playwright.config.ts | 21 +++++++--
tests/e2e/fixtures/test.ts | 23 ++++++++++
tests/e2e/leave-room-prompt.spec.ts | 24 ++--------
tests/e2e/modal-routes.spec.ts | 69 +++++++++++++---------------
tests/e2e/pages/AppShell.ts | 54 ++++++++++++++++++++++
tests/e2e/pages/CreateRoomSurface.ts | 22 +++++++++
tests/e2e/shell.spec.ts | 34 +++++---------
8 files changed, 172 insertions(+), 83 deletions(-)
create mode 100644 tests/e2e/fixtures/test.ts
create mode 100644 tests/e2e/pages/AppShell.ts
create mode 100644 tests/e2e/pages/CreateRoomSurface.ts
diff --git a/oxlint.config.ts b/oxlint.config.ts
index ec935b4da5..24ab29872e 100644
--- a/oxlint.config.ts
+++ b/oxlint.config.ts
@@ -81,5 +81,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',
+ },
+ },
],
});
diff --git a/playwright.config.ts b/playwright.config.ts
index 73c623517b..b52db5abcd 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -4,12 +4,18 @@ 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 },
},
@@ -29,10 +35,15 @@ export default defineConfig({
},
],
webServer: {
- command: 'pnpm dev',
+ // 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.
+ command: 'pnpm run build && pnpm exec vite preview --port 8080 --strictPort',
url: 'http://localhost:8080',
- reuseExistingServer: !process.env.CI,
- timeout: 240_000,
+ // 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' },
},
});
diff --git a/tests/e2e/fixtures/test.ts b/tests/e2e/fixtures/test.ts
new file mode 100644
index 0000000000..5f55ef8e88
--- /dev/null
+++ b/tests/e2e/fixtures/test.ts
@@ -0,0 +1,23 @@
+import { test as base } from '@playwright/test';
+import { AppShell } from '../pages/AppShell';
+import { CreateRoomSurface } from '../pages/CreateRoomSurface';
+
+type Fixtures = {
+ /** The signed-in app, already loaded and past the device banner. */
+ app: AppShell;
+ createRoom: CreateRoomSurface;
+};
+
+export const test = base.extend({
+ app: async ({ page }, use) => {
+ const app = new AppShell(page);
+ await app.open();
+ await use(app);
+ },
+
+ createRoom: async ({ page }, use) => {
+ await use(new CreateRoomSurface(page));
+ },
+});
+
+export { expect } from '@playwright/test';
diff --git a/tests/e2e/leave-room-prompt.spec.ts b/tests/e2e/leave-room-prompt.spec.ts
index efe856dcb9..85a771e0ee 100644
--- a/tests/e2e/leave-room-prompt.spec.ts
+++ b/tests/e2e/leave-room-prompt.spec.ts
@@ -1,22 +1,8 @@
-import { test, expect, type Page } from '@playwright/test';
+import { test, expect } from './fixtures/test';
-async function dismissDeviceBanner(page: Page): Promise {
- await page
- .getByRole('button', { name: 'Dismiss' })
- .click({ timeout: 5_000 })
- .catch(() => undefined);
-}
+test('leave room prompt opens from the room options menu', async ({ app }) => {
+ const menu = await app.openRoomOptions('General');
+ await menu.leaveRoom();
-test('leave room prompt opens from the room options menu', async ({ page }) => {
- await page.goto('/');
- const room = page.getByText('General').first();
- await expect(room).toBeVisible({ timeout: 180_000 });
- await dismissDeviceBanner(page);
-
- await room.hover();
- await page.getByRole('button', { name: 'More Options' }).first().click();
- // The mobile sheet can overflow the viewport, where a real pointer click fails.
- await page.getByRole('button', { name: 'Leave Room' }).dispatchEvent('click');
-
- await expect(page.getByText('Are you sure you want to leave this room?')).toBeVisible();
+ await expect(app.leaveRoomPrompt).toBeVisible();
});
diff --git a/tests/e2e/modal-routes.spec.ts b/tests/e2e/modal-routes.spec.ts
index a4e4ed24c8..58b1d15b50 100644
--- a/tests/e2e/modal-routes.spec.ts
+++ b/tests/e2e/modal-routes.spec.ts
@@ -1,64 +1,61 @@
-import { test, expect, type Page } from '@playwright/test';
-
-async function openApp(page: Page): Promise {
- await page.goto('/');
- await expect(page.getByText('General').first()).toBeVisible({ timeout: 180_000 });
- await page
- .getByRole('button', { name: 'Dismiss' })
- .click({ timeout: 5_000 })
- .catch(() => undefined);
-}
+import { test, expect } from './fixtures/test';
test.describe('create room surface', () => {
- test('desktop opens it over the page it was launched from', async ({ page }, testInfo) => {
+ test('desktop opens it over the page it was launched from', async ({
+ app,
+ createRoom,
+ page,
+ }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop');
- await openApp(page);
- await page.getByRole('button', { name: 'Create Room' }).first().click();
+ await app.createRoomButton.click();
await expect(page).toHaveURL(/\/create-room$/);
- await expect(page.getByText('New Chat Room')).toBeVisible();
+ await expect(createRoom.title).toBeVisible();
// The room list stays mounted behind the overlay.
- await expect(page.getByText('General').first()).toBeVisible();
+ await expect(app.room('General')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page).toHaveURL(/\/home\/?$/);
- await expect(page.getByText('New Chat Room')).toBeHidden();
+ await expect(createRoom.title).toBeHidden();
});
- test('mobile opens it as a full page', async ({ page }, testInfo) => {
+ test('mobile opens it as a full page', async ({ app, createRoom }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile');
- await openApp(page);
- await page.goto('/create-room');
+ await createRoom.gotoDirectly();
- await expect(page.getByText('New Chat Room').first()).toBeVisible({ timeout: 180_000 });
- await expect(page.getByRole('button', { name: 'Close create room' })).toBeVisible();
- await expect(page.getByText('General')).toBeHidden();
+ await expect(createRoom.title).toBeVisible();
+ await expect(createRoom.closeButton).toBeVisible();
+ await expect(app.room('General')).toBeHidden();
});
test('deep linking on desktop renders the full page, not an overlay', async ({
- page,
+ app,
+ createRoom,
}, testInfo) => {
test.skip(testInfo.project.name !== 'desktop');
- await openApp(page);
- await page.goto('/create-room');
+ await createRoom.gotoDirectly();
- await expect(page.getByText('New Chat Room').first()).toBeVisible({ timeout: 180_000 });
- await expect(page.getByRole('button', { name: 'Close create room' })).toBeVisible();
+ await expect(createRoom.title).toBeVisible();
+ // No background was recorded, so it replaces the page rather than sitting over it.
+ await expect(app.room('General')).toBeHidden();
});
-});
-test('browser back closes a surface opened over a page', async ({ page }, testInfo) => {
- test.skip(testInfo.project.name !== 'desktop');
- await openApp(page);
+ test('browser back closes a surface opened over a page', async ({
+ app,
+ createRoom,
+ page,
+ }, testInfo) => {
+ test.skip(testInfo.project.name !== 'desktop');
- await page.getByRole('button', { name: 'Create Room' }).first().click();
- await expect(page).toHaveURL(/\/create-room$/);
+ await app.createRoomButton.click();
+ await expect(page).toHaveURL(/\/create-room$/);
- await page.goBack();
+ await page.goBack();
- await expect(page).toHaveURL(/\/home\/?$/);
- await expect(page.getByText('New Chat Room')).toBeHidden();
+ await expect(page).toHaveURL(/\/home\/?$/);
+ await expect(createRoom.title).toBeHidden();
+ });
});
diff --git a/tests/e2e/pages/AppShell.ts b/tests/e2e/pages/AppShell.ts
new file mode 100644
index 0000000000..071dc358dc
--- /dev/null
+++ b/tests/e2e/pages/AppShell.ts
@@ -0,0 +1,54 @@
+import type { Locator, Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+
+/**
+ * How long the Matrix client may take to sync and paint the room list. One
+ * definition so a cold start and a warm one are held to the same bar.
+ */
+export const CLIENT_READY_TIMEOUT = 30_000;
+
+/** The signed-in app: room list, rail, and the menus reachable from them. */
+export class AppShell {
+ readonly leaveRoomPrompt: Locator;
+
+ readonly createRoomButton: Locator;
+
+ constructor(readonly page: Page) {
+ this.leaveRoomPrompt = page.getByText('Are you sure you want to leave this room?');
+ this.createRoomButton = page.getByRole('button', { name: 'Create Room' }).first();
+ }
+
+ /** Loads the app and waits until the seeded rooms are on screen. */
+ async open(): Promise {
+ await this.page.goto('/');
+ await expect(this.room('General')).toBeVisible({ timeout: CLIENT_READY_TIMEOUT });
+ await this.dismissDeviceBanner();
+ }
+
+ room(name: string): Locator {
+ return this.page.getByText(name).first();
+ }
+
+ /** The unverified-device banner is not always present, so this is best effort. */
+ async dismissDeviceBanner(): Promise {
+ await this.page
+ .getByRole('button', { name: 'Dismiss' })
+ .click({ timeout: 5_000 })
+ .catch(() => undefined);
+ }
+
+ async openRoomOptions(name: string): Promise {
+ await this.room(name).hover();
+ await this.page.getByRole('button', { name: 'More Options' }).first().click();
+ return new RoomOptionsMenu(this.page);
+ }
+}
+
+export class RoomOptionsMenu {
+ constructor(readonly page: Page) {}
+
+ async leaveRoom(): Promise {
+ // The mobile sheet can overflow the viewport, where a real pointer click fails.
+ await this.page.getByRole('button', { name: 'Leave Room' }).dispatchEvent('click');
+ }
+}
diff --git a/tests/e2e/pages/CreateRoomSurface.ts b/tests/e2e/pages/CreateRoomSurface.ts
new file mode 100644
index 0000000000..af1494b030
--- /dev/null
+++ b/tests/e2e/pages/CreateRoomSurface.ts
@@ -0,0 +1,22 @@
+import type { Locator, Page } from '@playwright/test';
+
+/**
+ * The create-room shallow route. It presents as an overlay over the page it was
+ * opened from on desktop, and as a full page on mobile or when deep linked —
+ * so the surface itself looks the same either way and the difference is
+ * whether the page behind it is still mounted.
+ */
+export class CreateRoomSurface {
+ readonly title: Locator;
+
+ readonly closeButton: Locator;
+
+ constructor(readonly page: Page) {
+ this.title = page.getByText('New Room').first();
+ this.closeButton = page.getByRole('button', { name: 'Close create room' });
+ }
+
+ async gotoDirectly(): Promise {
+ await this.page.goto('/create-room');
+ }
+}
diff --git a/tests/e2e/shell.spec.ts b/tests/e2e/shell.spec.ts
index 938a551b1d..48c449e554 100644
--- a/tests/e2e/shell.spec.ts
+++ b/tests/e2e/shell.spec.ts
@@ -1,41 +1,29 @@
-import { test, expect, type Page } from '@playwright/test';
+import type { Page } from '@playwright/test';
+import { test, expect } from './fixtures/test';
const containerised = Boolean(process.env.PW_TEST_CONNECT_WS_ENDPOINT);
-async function dismissDeviceBanner(page: Page): Promise {
- await page
- .getByRole('button', { name: 'Dismiss' })
- .click({ timeout: 5_000 })
- .catch(() => undefined);
-}
-
const maskDynamic = (page: Page) => ({
mask: [page.locator('time'), page.getByText(/^Created by /)],
});
test.describe('app shell', () => {
- test('shows seeded rooms after login', async ({ page }) => {
- await page.goto('/');
-
- await expect(page.getByText('General').first()).toBeVisible({ timeout: 180_000 });
- await expect(page.getByText('Random').first()).toBeVisible();
+ test('shows seeded rooms after login', async ({ app }) => {
+ await expect(app.room('General')).toBeVisible();
+ await expect(app.room('Random')).toBeVisible();
});
- test('opens a room and renders its timeline', async ({ page }) => {
- await page.goto('/');
- await dismissDeviceBanner(page);
+ test('opens a room and renders its timeline', async ({ app, page }) => {
+ await app.room('General').click();
- await page.getByText('General').first().click();
- await expect(page.getByText('Welcome to the test room.')).toBeVisible({ timeout: 180_000 });
+ await expect(page.getByText('Welcome to the test room.')).toBeVisible();
await expect(page.getByText('Layout baseline seed message.')).toBeVisible();
});
- test('matches the shell layout baseline', async ({ page }) => {
+ // `app` is requested so the fixture loads the shell before the snapshot.
+ test('matches the shell layout baseline', async ({ app, page }) => {
test.skip(!containerised, 'run via pnpm test:e2e:docker');
-
- await page.goto('/');
- await expect(page.getByText('General').first()).toBeVisible({ timeout: 180_000 });
- await dismissDeviceBanner(page);
+ await expect(app.room('General')).toBeVisible();
await page.evaluate(async () => {
await document.fonts.ready;
From 5f82ef883ae8ab6d9d6a5168c3e0e460dbf4b431 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 11:44:38 +0200
Subject: [PATCH 17/43] test: restructure e2e around fixtures and page objects,
run against a build
---
oxlint.config.ts | 8 +++++
playwright.config.ts | 21 ++++++++---
tests/e2e/fixtures/test.ts | 17 +++++++++
tests/e2e/leave-room-prompt.spec.ts | 8 +++++
tests/e2e/pages/AppShell.ts | 54 +++++++++++++++++++++++++++++
tests/e2e/shell.spec.ts | 34 ++++++------------
6 files changed, 114 insertions(+), 28 deletions(-)
create mode 100644 tests/e2e/fixtures/test.ts
create mode 100644 tests/e2e/leave-room-prompt.spec.ts
create mode 100644 tests/e2e/pages/AppShell.ts
diff --git a/oxlint.config.ts b/oxlint.config.ts
index ec935b4da5..24ab29872e 100644
--- a/oxlint.config.ts
+++ b/oxlint.config.ts
@@ -81,5 +81,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',
+ },
+ },
],
});
diff --git a/playwright.config.ts b/playwright.config.ts
index 73c623517b..b52db5abcd 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -4,12 +4,18 @@ 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 },
},
@@ -29,10 +35,15 @@ export default defineConfig({
},
],
webServer: {
- command: 'pnpm dev',
+ // 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.
+ command: 'pnpm run build && pnpm exec vite preview --port 8080 --strictPort',
url: 'http://localhost:8080',
- reuseExistingServer: !process.env.CI,
- timeout: 240_000,
+ // 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' },
},
});
diff --git a/tests/e2e/fixtures/test.ts b/tests/e2e/fixtures/test.ts
new file mode 100644
index 0000000000..2cf4ae351c
--- /dev/null
+++ b/tests/e2e/fixtures/test.ts
@@ -0,0 +1,17 @@
+import { test as base } from '@playwright/test';
+import { AppShell } from '../pages/AppShell';
+
+type Fixtures = {
+ /** The signed-in app, already loaded and past the device banner. */
+ app: AppShell;
+};
+
+export const test = base.extend({
+ app: async ({ page }, use) => {
+ const app = new AppShell(page);
+ await app.open();
+ await use(app);
+ },
+});
+
+export { expect } from '@playwright/test';
diff --git a/tests/e2e/leave-room-prompt.spec.ts b/tests/e2e/leave-room-prompt.spec.ts
new file mode 100644
index 0000000000..85a771e0ee
--- /dev/null
+++ b/tests/e2e/leave-room-prompt.spec.ts
@@ -0,0 +1,8 @@
+import { test, expect } from './fixtures/test';
+
+test('leave room prompt opens from the room options menu', async ({ app }) => {
+ const menu = await app.openRoomOptions('General');
+ await menu.leaveRoom();
+
+ await expect(app.leaveRoomPrompt).toBeVisible();
+});
diff --git a/tests/e2e/pages/AppShell.ts b/tests/e2e/pages/AppShell.ts
new file mode 100644
index 0000000000..071dc358dc
--- /dev/null
+++ b/tests/e2e/pages/AppShell.ts
@@ -0,0 +1,54 @@
+import type { Locator, Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+
+/**
+ * How long the Matrix client may take to sync and paint the room list. One
+ * definition so a cold start and a warm one are held to the same bar.
+ */
+export const CLIENT_READY_TIMEOUT = 30_000;
+
+/** The signed-in app: room list, rail, and the menus reachable from them. */
+export class AppShell {
+ readonly leaveRoomPrompt: Locator;
+
+ readonly createRoomButton: Locator;
+
+ constructor(readonly page: Page) {
+ this.leaveRoomPrompt = page.getByText('Are you sure you want to leave this room?');
+ this.createRoomButton = page.getByRole('button', { name: 'Create Room' }).first();
+ }
+
+ /** Loads the app and waits until the seeded rooms are on screen. */
+ async open(): Promise {
+ await this.page.goto('/');
+ await expect(this.room('General')).toBeVisible({ timeout: CLIENT_READY_TIMEOUT });
+ await this.dismissDeviceBanner();
+ }
+
+ room(name: string): Locator {
+ return this.page.getByText(name).first();
+ }
+
+ /** The unverified-device banner is not always present, so this is best effort. */
+ async dismissDeviceBanner(): Promise {
+ await this.page
+ .getByRole('button', { name: 'Dismiss' })
+ .click({ timeout: 5_000 })
+ .catch(() => undefined);
+ }
+
+ async openRoomOptions(name: string): Promise {
+ await this.room(name).hover();
+ await this.page.getByRole('button', { name: 'More Options' }).first().click();
+ return new RoomOptionsMenu(this.page);
+ }
+}
+
+export class RoomOptionsMenu {
+ constructor(readonly page: Page) {}
+
+ async leaveRoom(): Promise {
+ // The mobile sheet can overflow the viewport, where a real pointer click fails.
+ await this.page.getByRole('button', { name: 'Leave Room' }).dispatchEvent('click');
+ }
+}
diff --git a/tests/e2e/shell.spec.ts b/tests/e2e/shell.spec.ts
index 938a551b1d..48c449e554 100644
--- a/tests/e2e/shell.spec.ts
+++ b/tests/e2e/shell.spec.ts
@@ -1,41 +1,29 @@
-import { test, expect, type Page } from '@playwright/test';
+import type { Page } from '@playwright/test';
+import { test, expect } from './fixtures/test';
const containerised = Boolean(process.env.PW_TEST_CONNECT_WS_ENDPOINT);
-async function dismissDeviceBanner(page: Page): Promise {
- await page
- .getByRole('button', { name: 'Dismiss' })
- .click({ timeout: 5_000 })
- .catch(() => undefined);
-}
-
const maskDynamic = (page: Page) => ({
mask: [page.locator('time'), page.getByText(/^Created by /)],
});
test.describe('app shell', () => {
- test('shows seeded rooms after login', async ({ page }) => {
- await page.goto('/');
-
- await expect(page.getByText('General').first()).toBeVisible({ timeout: 180_000 });
- await expect(page.getByText('Random').first()).toBeVisible();
+ test('shows seeded rooms after login', async ({ app }) => {
+ await expect(app.room('General')).toBeVisible();
+ await expect(app.room('Random')).toBeVisible();
});
- test('opens a room and renders its timeline', async ({ page }) => {
- await page.goto('/');
- await dismissDeviceBanner(page);
+ test('opens a room and renders its timeline', async ({ app, page }) => {
+ await app.room('General').click();
- await page.getByText('General').first().click();
- await expect(page.getByText('Welcome to the test room.')).toBeVisible({ timeout: 180_000 });
+ await expect(page.getByText('Welcome to the test room.')).toBeVisible();
await expect(page.getByText('Layout baseline seed message.')).toBeVisible();
});
- test('matches the shell layout baseline', async ({ page }) => {
+ // `app` is requested so the fixture loads the shell before the snapshot.
+ test('matches the shell layout baseline', async ({ app, page }) => {
test.skip(!containerised, 'run via pnpm test:e2e:docker');
-
- await page.goto('/');
- await expect(page.getByText('General').first()).toBeVisible({ timeout: 180_000 });
- await dismissDeviceBanner(page);
+ await expect(app.room('General')).toBeVisible();
await page.evaluate(async () => {
await document.fonts.ready;
From cb7e8c08aeab1a74863eb691e1341bdbb57295d0 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 11:59:05 +0200
Subject: [PATCH 18/43] refactor: always enable mobile gestures and delete the
setting
---
playwright.config.ts | 7 +--
src/app/components/SwipeableChatWrapper.tsx | 7 ++-
.../components/SwipeableMessageWrapper.tsx | 7 +--
.../components/SwipeableOverlayWrapper.tsx | 7 +--
.../attachment-sheet/AttachmentSheet.tsx | 5 +-
src/app/components/page/MobileNavDrawer.tsx | 5 +-
src/app/components/page/Page.tsx | 5 +-
src/app/features/settings/general/General.tsx | 19 +-------
src/app/pages/MobileFriendly.tsx | 26 +---------
src/app/pages/Router.tsx | 48 ++++---------------
src/app/pages/client/ClientLayout.tsx | 5 +-
src/app/state/settings.ts | 2 -
12 files changed, 27 insertions(+), 116 deletions(-)
diff --git a/playwright.config.ts b/playwright.config.ts
index b52db5abcd..fdd721401a 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -20,7 +20,7 @@ export default defineConfig({
toHaveScreenshot: { maxDiffPixelRatio: 0.01 },
},
use: {
- baseURL: 'http://localhost:8080',
+ baseURL: 'http://localhost:8081',
storageState: 'tests/e2e/.auth/state.json',
trace: 'on-first-retry',
},
@@ -37,8 +37,9 @@ export default defineConfig({
webServer: {
// 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.
- command: 'pnpm run build && pnpm exec vite preview --port 8080 --strictPort',
- url: 'http://localhost:8080',
+ // 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,
diff --git a/src/app/components/SwipeableChatWrapper.tsx b/src/app/components/SwipeableChatWrapper.tsx
index d0c95801a1..5d7d239b82 100644
--- a/src/app/components/SwipeableChatWrapper.tsx
+++ b/src/app/components/SwipeableChatWrapper.tsx
@@ -17,7 +17,6 @@ export function SwipeableChatWrapper({
onOpenMembers,
onReply,
}: SwipeableChatWrapperProps) {
- const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures');
const [rightSwipeAction] = useSetting(settingsAtom, 'rightSwipeAction');
const x = useMotionValue(0);
const containerRef = useRef(null);
@@ -57,16 +56,16 @@ export function SwipeableChatWrapper({
useLayoutEffect(() => {
const element = containerRef.current;
- if (!drawer || !element || !mobileGestures || !mobileOrTablet()) return undefined;
+ if (!drawer || !element || !mobileOrTablet()) return undefined;
return drawer.registerChatSwipe(element, {
move,
end: ({ distanceX, velocityX }) => finish(true, distanceX, velocityX),
cancel: () => finish(false),
});
- }, [drawer, finish, mobileGestures, move]);
+ }, [drawer, finish, move]);
- if (!mobileGestures || !mobileOrTablet()) {
+ if (!mobileOrTablet()) {
return (
- settings.mobileGestures &&
- mobileOrTablet() &&
- settings.rightSwipeAction !== RightSwipeAction.Members,
- [settings.mobileGestures, settings.rightSwipeAction]
+ () => mobileOrTablet() && settings.rightSwipeAction !== RightSwipeAction.Members,
+ [settings.rightSwipeAction]
);
if (!isSwipeToReplyEnabled || !onReply) {
diff --git a/src/app/components/SwipeableOverlayWrapper.tsx b/src/app/components/SwipeableOverlayWrapper.tsx
index 87244a37ed..3ca276f729 100644
--- a/src/app/components/SwipeableOverlayWrapper.tsx
+++ b/src/app/components/SwipeableOverlayWrapper.tsx
@@ -1,8 +1,6 @@
import type { ReactNode } from 'react';
import { animate, motion, useMotionValue } from 'framer-motion';
import { useDrag } from '@use-gesture/react';
-import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
import { mobileOrTablet } from '$utils/user-agent';
interface SwipeableOverlayWrapperProps {
@@ -16,7 +14,6 @@ export function SwipeableOverlayWrapper({
onClose,
direction,
}: SwipeableOverlayWrapperProps) {
- const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures');
const x = useMotionValue(0);
const bind = useDrag(
@@ -28,7 +25,7 @@ export function SwipeableOverlayWrapper({
}
}
- if (!mobileGestures || !mobileOrTablet()) return;
+ if (!mobileOrTablet()) return;
event.stopPropagation();
@@ -68,7 +65,7 @@ export function SwipeableOverlayWrapper({
}
);
- if (!mobileGestures || !mobileOrTablet()) {
+ if (!mobileOrTablet()) {
return (
(null);
const skipReturnFocusRef = useRef(false);
@@ -69,7 +66,7 @@ export function AttachmentSheet({
return true;
}, open);
- const gesturesEnabled = mobileGestures && mobileOrTablet();
+ const gesturesEnabled = mobileOrTablet();
const bind = useDrag(
({ first, active, offset: [, oy], velocity: [, vy], direction: [, dy], event }) => {
diff --git a/src/app/components/page/MobileNavDrawer.tsx b/src/app/components/page/MobileNavDrawer.tsx
index 3cd0b84a30..104da6a8aa 100644
--- a/src/app/components/page/MobileNavDrawer.tsx
+++ b/src/app/components/page/MobileNavDrawer.tsx
@@ -10,8 +10,6 @@ import {
} from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
-import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
import { lastVisitedRoomAtom } from '$state/room/lastRoom';
import { useReducedMotion } from 'framer-motion';
import {
@@ -57,7 +55,6 @@ type ActiveTouchGesture = {
/** Sliding mobile drawer with one touch coordinator and a GPU-transformed panel track. */
export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDrawerProps) {
- const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures');
const reduceMotion = useReducedMotion();
const location = useLocation();
const navigate = useNavigate();
@@ -343,7 +340,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
onTouchStartCapture={(event) => {
const viewport = viewportRef.current;
const touch = event.touches[0];
- if (!mobileGestures || !viewport || !touch) return;
+ if (!viewport || !touch) return;
if (event.touches.length !== 1) {
finishGesture(true);
return;
diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx
index ae4ab593a8..4b43f7b258 100644
--- a/src/app/components/page/Page.tsx
+++ b/src/app/components/page/Page.tsx
@@ -3,8 +3,6 @@ import { Box, Header, Line, Scroll, Text, as } from 'folds';
import classNames from 'classnames';
import { ContainerColor } from '$styles/ContainerColor.css';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
-import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
import { MobileNavDrawer } from './MobileNavDrawer';
import * as css from './style.css';
@@ -18,10 +16,9 @@ type PageRootProps = {
export function PageRoot({ nav, rail, bottomNav, children, mobileDrawer = true }: PageRootProps) {
const screenSize = useScreenSizeContext();
- const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures');
const isMobile = screenSize === ScreenSize.Mobile;
- if (isMobile && mobileGestures && mobileDrawer) {
+ if (isMobile && mobileDrawer) {
return (
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index fb0ca47e0f..3c8d06bac2 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -963,32 +963,15 @@ function SelectRightSwipeAction({ disabled }: Readonly<{ disabled?: boolean }>)
}
function Gestures({ isMobile }: Readonly<{ isMobile: boolean }>) {
- const [mobileGestures, setMobileGestures] = useSetting(settingsAtom, 'mobileGestures');
-
return (
Gestures {!isMobile && '(Mobile Only)'}
-
-
- }
- />
-
}
+ after={}
/>
diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx
index 772e3513b3..e59d2f8115 100644
--- a/src/app/pages/MobileFriendly.tsx
+++ b/src/app/pages/MobileFriendly.tsx
@@ -1,8 +1,6 @@
import type { ReactNode } from 'react';
import { useMatch } from 'react-router-dom';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
-import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
import {
DIRECT_PATH,
EXPLORE_PATH,
@@ -40,7 +38,6 @@ export function MobileFriendlySidebarNav({ children }: MobileFriendlyClientNavPr
export function MobileFriendlyBottomNav({ children }: MobileFriendlyClientNavProps) {
const screenSize = useScreenSizeContext();
- const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures');
const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true });
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true });
const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true });
@@ -52,7 +49,7 @@ export function MobileFriendlyBottomNav({ children }: MobileFriendlyClientNavPro
homeMatch || directMatch || spaceMatch || inboxMatch || navigateMatch || profileMatch;
if (
screenSize !== ScreenSize.Mobile ||
- (mobileGestures && !inboxMatch && !navigateMatch && !profileMatch) ||
+ (!inboxMatch && !navigateMatch && !profileMatch) ||
!onBarDestination ||
settingsMatch
) {
@@ -61,24 +58,3 @@ export function MobileFriendlyBottomNav({ children }: MobileFriendlyClientNavPro
return children;
}
-type MobileFriendlyPageNavProps = {
- path: string;
- children: ReactNode;
-};
-export function MobileFriendlyPageNav({ path, children }: MobileFriendlyPageNavProps) {
- const screenSize = useScreenSizeContext();
- const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures');
- const exactPath = useMatch({
- path,
- caseSensitive: true,
- end: true,
- });
-
- // With mobile gestures on, the list stays mounted so MobileNavDrawer can reveal it as a
- // co-present panel. Without gestures, fall back to the route-based single-view behavior.
- if (screenSize === ScreenSize.Mobile && !mobileGestures && !exactPath) {
- return null;
- }
-
- return children;
-}
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index bc2a15cb94..657d13da97 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -104,11 +104,7 @@ const PublicRooms = lazy(() =>
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
import { WelcomePage } from './client/WelcomePage';
import { SidebarNav } from './client/SidebarNav';
-import {
- MobileFriendlyPageNav,
- MobileFriendlySidebarNav,
- MobileFriendlyBottomNav,
-} from './MobileFriendly';
+import { MobileFriendlySidebarNav, MobileFriendlyBottomNav } from './MobileFriendly';
import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
import { TauriDeepLinkBridge } from './TauriDeepLinkBridge';
@@ -293,15 +289,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
- bottomNav={}
- nav={
-
-
-
- }
- >
+ } bottomNav={} nav={}>
}
@@ -323,15 +311,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
- bottomNav={}
- nav={
-
-
-
- }
- >
+ } bottomNav={} nav={}>
}
@@ -354,11 +334,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
bottomNav={}
- nav={
-
-
-
- }
+ nav={}
>
@@ -398,11 +374,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
nav={
-
- {null}}>
-
-
-
+ {null}}>
+
+
}
>
@@ -465,11 +439,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={
- {null}}>
-
-
-
+ {null}}>
+
+
}
>
diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx
index a3c93d6fe9..01b5bcbf99 100644
--- a/src/app/pages/client/ClientLayout.tsx
+++ b/src/app/pages/client/ClientLayout.tsx
@@ -2,8 +2,6 @@ import type { ReactNode } from 'react';
import { Box } from 'folds';
import { matchPath, useLocation } from 'react-router-dom';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
-import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
import { SETTINGS_PATH } from '../paths';
import { isShallowRoute } from './shallowRoute';
@@ -14,12 +12,11 @@ type ClientLayoutProps = {
export function ClientLayout({ nav, children }: ClientLayoutProps) {
const location = useLocation();
const screenSize = useScreenSizeContext();
- const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures');
const fullPageSettings =
Boolean(matchPath(SETTINGS_PATH, location.pathname)) &&
!isShallowRoute(location.pathname, location.state, screenSize);
- const railInDrawer = screenSize === ScreenSize.Mobile && mobileGestures;
+ const railInDrawer = screenSize === ScreenSize.Mobile;
return (
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index d556df3de8..55f4b832c6 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -204,7 +204,6 @@ export interface Settings {
// Sable features!
sendPresence: boolean;
- mobileGestures: boolean;
rightSwipeAction: RightSwipeAction;
hideMembershipInReadOnly: boolean;
useRightBubbles: boolean;
@@ -383,7 +382,6 @@ export const defaultSettings: Settings = {
// Sable features!
sendPresence: true,
- mobileGestures: true,
rightSwipeAction: RightSwipeAction.Reply,
hideMembershipInReadOnly: true,
useRightBubbles: false,
From f4349af9bcd002bacbfd94b2ea4830de3aa44e64 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 12:04:07 +0200
Subject: [PATCH 19/43] refactor: give ModalOverlay mobile presentation and
android back handling
---
src/app/components/Modal500.tsx | 57 +++----------------
.../components/modal-overlay/ModalOverlay.tsx | 55 ++++++++++++++++--
2 files changed, 60 insertions(+), 52 deletions(-)
diff --git a/src/app/components/Modal500.tsx b/src/app/components/Modal500.tsx
index 62fa97c5a7..a4951efad0 100644
--- a/src/app/components/Modal500.tsx
+++ b/src/app/components/Modal500.tsx
@@ -1,61 +1,22 @@
import type { ReactNode } from 'react';
import { useRef } from 'react';
-import FocusTrap from 'focus-trap-react';
-import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
-import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
-import { stopPropagation } from '$utils/keyboard';
-import { useDismissOnBack } from '$utils/androidBack';
+import { Modal } from 'folds';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type Modal500Props = {
requestClose: () => void;
children: ReactNode;
};
+
+/** A size-500 modal on desktop that fills the viewport on a phone. */
export function Modal500({ requestClose, children }: Modal500Props) {
const modalRef = useRef(null);
- const screenSize = useScreenSizeContext();
-
- // Android back closes the overlay instead of navigating away.
- useDismissOnBack(requestClose);
-
- if (screenSize === ScreenSize.Mobile) {
- return (
-
-
-
- {children}
-
-
-
- );
- }
return (
- }>
-
- modalRef.current ?? document.body,
- clickOutsideDeactivates: true,
- onDeactivate: requestClose,
- escapeDeactivates: stopPropagation,
- }}
- >
-
- {children}
-
-
-
-
+
+
+ {children}
+
+
);
}
diff --git a/src/app/components/modal-overlay/ModalOverlay.tsx b/src/app/components/modal-overlay/ModalOverlay.tsx
index bb39d3c657..b5a21230ae 100644
--- a/src/app/components/modal-overlay/ModalOverlay.tsx
+++ b/src/app/components/modal-overlay/ModalOverlay.tsx
@@ -1,22 +1,69 @@
-import type { ReactNode } from 'react';
+import type { ComponentProps, MutableRefObject, ReactNode } from 'react';
import FocusTrap from 'focus-trap-react';
import { Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
+import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { stopPropagation } from '$utils/keyboard';
+import { useDismissOnBack } from '$utils/androidBack';
+
+type FocusTrapOptions = ComponentProps['focusTrapOptions'];
type ModalOverlayProps = {
open?: boolean;
requestClose: () => void;
+ /** Set false for overlays that must be dismissed deliberately, not by a stray click. */
+ dismissOnClickOutside?: boolean;
+ initialFocus?: FocusTrapOptions['initialFocus'];
+ /** `fullscreen` drops the centred modal on phones and fills the viewport instead. */
+ mobile?: 'centred' | 'fullscreen';
+ /** The modal element, used as the focus fallback and as the fullscreen wrapper. */
+ contentRef?: MutableRefObject;
children: ReactNode;
};
-export function ModalOverlay({ open = true, requestClose, children }: ModalOverlayProps) {
+export function ModalOverlay({
+ open = true,
+ requestClose,
+ dismissOnClickOutside = true,
+ initialFocus = false,
+ mobile = 'centred',
+ contentRef,
+ children,
+}: ModalOverlayProps) {
+ const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
+
+ // Android back closes the overlay instead of navigating away.
+ useDismissOnBack(requestClose, open);
+
+ if (open && isMobile && mobile === 'fullscreen') {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ }
+
return (
}>
contentRef?.current ?? document.body,
+ clickOutsideDeactivates: dismissOnClickOutside,
onDeactivate: requestClose,
escapeDeactivates: stopPropagation,
}}
From 70c7c309fd8e3ba20f9d77e0f83bc8cd1783d633 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 12:08:49 +0200
Subject: [PATCH 20/43] refactor: share one PromptDialog across the prompt
components
---
.../DirectInvitePrompt.tsx | 88 ++++++--------
.../join-address-prompt/JoinAddressPrompt.tsx | 95 ++++++---------
.../knock-room-prompt/KnockRoomPrompt.tsx | 115 +++++++-----------
.../leave-room-prompt/LeaveRoomPrompt.tsx | 77 +++++-------
.../leave-space-prompt/LeaveSpacePrompt.tsx | 108 +++++++---------
.../components/modal-overlay/PromptDialog.tsx | 43 +++++++
6 files changed, 234 insertions(+), 292 deletions(-)
create mode 100644 src/app/components/modal-overlay/PromptDialog.tsx
diff --git a/src/app/components/direct-invite-prompt/DirectInvitePrompt.tsx b/src/app/components/direct-invite-prompt/DirectInvitePrompt.tsx
index ead5938dfb..805cbb0acc 100644
--- a/src/app/components/direct-invite-prompt/DirectInvitePrompt.tsx
+++ b/src/app/components/direct-invite-prompt/DirectInvitePrompt.tsx
@@ -1,6 +1,5 @@
-import { Box, Button, Dialog, Header, IconButton, Spinner, Text, color, config } from 'folds';
-import { composerIcon, X } from '$components/icons/phosphor';
-import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
+import { Box, Button, Spinner, Text, color, config } from 'folds';
+import { PromptDialog } from '$components/modal-overlay/PromptDialog';
type DirectInvitePromptProps = {
onCancel: () => void;
@@ -18,58 +17,39 @@ export function DirectInvitePrompt({
convertError,
}: DirectInvitePromptProps) {
return (
-
-
-
-
- Invite another Member
-
-
- {composerIcon(X)}
-
-
-
-
-
- This is a Direct Message room, intended for a conversation between two persons.
- Would you like to convert it into a group chat before continuing?
+
+
+
+
+ This is a Direct Message room, intended for a conversation between two persons.
+ Would you like to convert it into a group chat before continuing?
+
+ {convertError && (
+
+ Failed to convert direct message to room! {convertError}
- {convertError && (
-
- Failed to convert direct message to room! {convertError}
-
- )}
-
-
- : undefined
- }
- aria-disabled={converting}
- >
-
- {converting ? 'Converting...' : 'Convert to Group Chat and Invite'}
-
-
-
- Invite to Direct Message anyway
-
-
- Cancel
-
-
+ )}
-
-
+
+ : undefined}
+ aria-disabled={converting}
+ >
+
+ {converting ? 'Converting...' : 'Convert to Group Chat and Invite'}
+
+
+
+ Invite to Direct Message anyway
+
+
+ Cancel
+
+
+
+
);
}
diff --git a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx
index f1eefb05a4..ee293bcb39 100644
--- a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx
+++ b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx
@@ -1,10 +1,9 @@
import type { FormEventHandler } from 'react';
import { useState } from 'react';
-import { Dialog, Header, config, Box, Text, IconButton, Button, Input, color } from 'folds';
-import { composerIcon, X } from '$components/icons/phosphor';
+import { config, Box, Text, Button, Input, color } from 'folds';
import { isRoomAlias, isRoomId } from '$utils/matrix';
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '$plugins/matrix-to';
-import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
+import { PromptDialog } from '$components/modal-overlay/PromptDialog';
type JoinAddressProps = {
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
@@ -47,60 +46,44 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
};
return (
-
-
-
-
- Join with Address
-
-
- {composerIcon(X)}
-
-
-
-
-
- Enter public address to join the community. Addresses looks like:
-
-
- #community:server
- https://matrix.to/#/#community:server
- https://matrix.to/#/!xYzAj?via=server
+
+
+
+
+ Enter public address to join the community. Addresses looks like:
+
+
+ #community:server
+ https://matrix.to/#/#community:server
+ https://matrix.to/#/!xYzAj?via=server
+
+
+
+ Address
+
+ {invalid && (
+
+ Invalid Address
-
-
- Address
-
- {invalid && (
-
- Invalid Address
-
- )}
-
-
- Open
-
+ )}
-
-
+
+ Open
+
+
+
);
}
diff --git a/src/app/components/knock-room-prompt/KnockRoomPrompt.tsx b/src/app/components/knock-room-prompt/KnockRoomPrompt.tsx
index c31a027048..8718dde05a 100644
--- a/src/app/components/knock-room-prompt/KnockRoomPrompt.tsx
+++ b/src/app/components/knock-room-prompt/KnockRoomPrompt.tsx
@@ -1,24 +1,12 @@
import type { FormEventHandler } from 'react';
import { useCallback, useEffect } from 'react';
-import {
- Dialog,
- Header,
- config,
- Box,
- Text,
- IconButton,
- Input,
- color,
- Button,
- Spinner,
-} from 'folds';
+import { config, Box, Text, Input, color, Button, Spinner } from 'folds';
import type { MatrixError } from '$types/matrix-sdk';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
-import { composerIcon, X } from '$components/icons/phosphor';
import { createDebugLogger } from '$utils/debugLogger';
-import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
+import { PromptDialog } from '$components/modal-overlay/PromptDialog';
const debugLog = createDebugLogger('KnockRoomPrompt');
@@ -57,67 +45,50 @@ export function KnockRoomPrompt({ roomId, via, onDone, onCancel }: KnockRoomProp
}, [knockState, onDone, roomId]);
return (
-
-
-
-
- Knock On Room
-
-
- {composerIcon(X)}
-
-
-
-
-
- Request to join this room. You can optionally leave a reason for the moderators.
+
+
+
+
+ Request to join this room. You can optionally leave a reason for the moderators.
+
+
+
+ Reason{' '}
+
+ (Optional)
+
-
-
- Reason{' '}
-
- (Optional)
-
+
+ {knockState.status === AsyncStatus.Error && (
+
+ Failed to knock! {knockState.error.message}
-
- {knockState.status === AsyncStatus.Error && (
-
- Failed to knock! {knockState.error.message}
-
- )}
-
+ )}
-
- ) : undefined
- }
- aria-disabled={
- knockState.status === AsyncStatus.Loading || knockState.status === AsyncStatus.Success
- }
- >
-
- {knockState.status === AsyncStatus.Loading ? 'Knocking...' : 'Knock'}
-
-
-
-
+
+ ) : undefined
+ }
+ aria-disabled={
+ knockState.status === AsyncStatus.Loading || knockState.status === AsyncStatus.Success
+ }
+ >
+
+ {knockState.status === AsyncStatus.Loading ? 'Knocking...' : 'Knock'}
+
+
+
+
);
}
diff --git a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
index 9a457f5a76..5befb23154 100644
--- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
+++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
@@ -1,10 +1,10 @@
import { useCallback, useEffect } from 'react';
-import { Dialog, Header, config, Box, Text, IconButton, color, Button, Spinner } from 'folds';
+import { config, Box, Text, color, Button, Spinner } from 'folds';
import type { MatrixError } from '$types/matrix-sdk';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
-import { composerIcon, X } from '$components/icons/phosphor';
-import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
+
+import { PromptDialog } from '$components/modal-overlay/PromptDialog';
import { createDebugLogger } from '$utils/debugLogger';
const debugLog = createDebugLogger('LeaveRoomPrompt');
@@ -36,51 +36,34 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
}, [leaveState, onDone, roomId]);
return (
-
-
-
-
- Leave Room
-
-
- {composerIcon(X)}
-
-
-
-
- Are you sure you want to leave this room?
- {leaveState.status === AsyncStatus.Error && (
-
- Failed to leave room! {leaveState.error.message}
-
- )}
-
-
- ) : undefined
- }
- aria-disabled={
- leaveState.status === AsyncStatus.Loading || leaveState.status === AsyncStatus.Success
- }
- >
-
- {leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
+
+
+
+ Are you sure you want to leave this room?
+ {leaveState.status === AsyncStatus.Error && (
+
+ Failed to leave room! {leaveState.error.message}
-
+ )}
-
-
+
+ ) : undefined
+ }
+ aria-disabled={
+ leaveState.status === AsyncStatus.Loading || leaveState.status === AsyncStatus.Success
+ }
+ >
+
+ {leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
+
+
+
+
);
}
diff --git a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx
index ecec11fa25..6abdcc854c 100644
--- a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx
+++ b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx
@@ -1,12 +1,11 @@
import { useCallback, useEffect, useMemo } from 'react';
-import { Dialog, Header, config, Box, Text, IconButton, color, Button, Spinner } from 'folds';
+import { config, Box, Text, color, Button, Spinner } from 'folds';
import type { MatrixError } from '$types/matrix-sdk';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
-import { composerIcon, X } from '$components/icons/phosphor';
import { getJoinedSpaceChildrenSummary, getRecursiveSpaceLeaveOrder } from '$utils/room';
import { rateLimitedActions } from '$utils/matrix';
-import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
+import { PromptDialog } from '$components/modal-overlay/PromptDialog';
type LeaveSpacePromptProps = {
roomId: string;
@@ -77,70 +76,53 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
}, [leaveState, leaveAllState, onDone]);
return (
-
-
-
-
- Leave Space
-
-
- {composerIcon(X)}
-
-
-
-
- Are you sure you want to leave this space?
- {joinedChildrenCount > 0 && (
-
- {formatJoinedContentsMessage(roomCount, subspaceCount)}
-
- )}
- {leaveState.status === AsyncStatus.Error && (
-
- Failed to leave space! {leaveState.error.message}
-
- )}
- {leaveAllState.status === AsyncStatus.Error && (
-
- Failed to leave space, rooms, and subspaces! {leaveAllState.error.message}
-
- )}
-
-
+
+
+
+ Are you sure you want to leave this space?
+ {joinedChildrenCount > 0 && (
+
+ {formatJoinedContentsMessage(roomCount, subspaceCount)}
+
+ )}
+ {leaveState.status === AsyncStatus.Error && (
+
+ Failed to leave space! {leaveState.error.message}
+
+ )}
+ {leaveAllState.status === AsyncStatus.Error && (
+
+ Failed to leave space, rooms, and subspaces! {leaveAllState.error.message}
+
+ )}
+
+
+ leaveSpace()}
+ before={leaving ? : undefined}
+ aria-disabled={isBusy || leaveState.status === AsyncStatus.Success}
+ >
+ {leaving ? 'Leaving...' : 'Leave Space Only'}
+
+ {joinedChildrenCount > 0 && (
leaveSpace()}
- before={leaving ? : undefined}
- aria-disabled={isBusy || leaveState.status === AsyncStatus.Success}
+ fill="Soft"
+ onClick={() => leaveAll()}
+ before={
+ leavingAll ? : undefined
+ }
+ aria-disabled={isBusy || leaveAllState.status === AsyncStatus.Success}
>
- {leaving ? 'Leaving...' : 'Leave Space Only'}
+
+ {formatRecursiveLeaveLabel(leavingAll, roomCount, subspaceCount)}
+
- {joinedChildrenCount > 0 && (
- leaveAll()}
- before={
- leavingAll ? : undefined
- }
- aria-disabled={isBusy || leaveAllState.status === AsyncStatus.Success}
- >
-
- {formatRecursiveLeaveLabel(leavingAll, roomCount, subspaceCount)}
-
-
- )}
-
+ )}
-
-
+
+
);
}
diff --git a/src/app/components/modal-overlay/PromptDialog.tsx b/src/app/components/modal-overlay/PromptDialog.tsx
new file mode 100644
index 0000000000..f4b63984fa
--- /dev/null
+++ b/src/app/components/modal-overlay/PromptDialog.tsx
@@ -0,0 +1,43 @@
+import type { ReactNode } from 'react';
+import { Box, Dialog, Header, IconButton, Text, config } from 'folds';
+import { composerIcon, X } from '$components/icons/phosphor';
+import { ModalOverlay } from './ModalOverlay';
+
+type PromptDialogProps = {
+ title: string;
+ requestClose: () => void;
+ /** Prompts that must be answered deliberately opt out of click-outside dismiss. */
+ dismissOnClickOutside?: boolean;
+ children: ReactNode;
+};
+
+/** A titled dialog with a close button, over a dimmed backdrop. */
+export function PromptDialog({
+ title,
+ requestClose,
+ dismissOnClickOutside,
+ children,
+}: PromptDialogProps) {
+ return (
+
+
+
+
+ {title}
+
+
+ {composerIcon(X)}
+
+
+ {children}
+
+
+ );
+}
From 90ab2377532cc0ef8f9d61dac7d80ed7b58eb5a2 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 12:21:13 +0200
Subject: [PATCH 21/43] refactor: present anchored menus as bottom sheets on
mobile
---
src/app/components/ResponsiveMenu.tsx | 95 ++++++++++++
.../image-viewer/ImageViewer.test.tsx | 5 +
.../components/image-viewer/ImageViewer.tsx | 105 +++++++------
src/app/features/space-nav/SpaceNavItem.tsx | 23 +--
src/app/pages/client/SidebarNav.tsx | 142 ++++++++----------
src/app/pages/client/sidebar/SpaceTabs.tsx | 82 ++++------
6 files changed, 248 insertions(+), 204 deletions(-)
create mode 100644 src/app/components/ResponsiveMenu.tsx
diff --git a/src/app/components/ResponsiveMenu.tsx b/src/app/components/ResponsiveMenu.tsx
new file mode 100644
index 0000000000..73fb5da4c9
--- /dev/null
+++ b/src/app/components/ResponsiveMenu.tsx
@@ -0,0 +1,95 @@
+import type { ReactNode } from 'react';
+import type { RectCords } from 'folds';
+import { PopOut } from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
+import { stopPropagation } from '$utils/keyboard';
+import { MobileSwipeDownModal } from './MobileSwipeDownModal';
+
+type DragHandlers = {
+ onTouchStart: (e: React.TouchEvent) => void;
+ onTouchMove: (e: React.TouchEvent) => void;
+ onTouchEnd: () => void;
+};
+
+/** Given the sheet's drag handle on mobile, and nothing on desktop. */
+type MenuRenderer = (dragHandle: ReactNode, dragHandlers: DragHandlers | undefined) => ReactNode;
+
+type ResponsiveMenuProps = {
+ anchor: RectCords | undefined;
+ requestClose: () => void;
+ menu: ReactNode | MenuRenderer;
+ /** The element the menu hangs off on desktop. */
+ children?: ReactNode;
+ id?: string;
+ position?: ComponentPosition;
+ align?: ComponentAlign;
+ offset?: number;
+ alignOffset?: number;
+};
+
+type ComponentPosition = 'Top' | 'Right' | 'Bottom' | 'Left';
+type ComponentAlign = 'Start' | 'Center' | 'End';
+
+/**
+ * A menu that hangs off its trigger on desktop and rises as a bottom sheet on
+ * mobile, where a popout anchored to a small target is hard to hit and easy to
+ * dismiss by accident.
+ */
+export function ResponsiveMenu({
+ anchor,
+ requestClose,
+ menu,
+ children,
+ id,
+ position = 'Bottom',
+ align = 'End',
+ offset,
+ alignOffset,
+}: ResponsiveMenuProps) {
+ const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
+
+ if (isMobile) {
+ return (
+ <>
+ {children}
+ {anchor && (
+
+ {(dragHandle, dragHandlers) =>
+ typeof menu === 'function' ? menu(dragHandle, dragHandlers) : menu
+ }
+
+ )}
+ >
+ );
+ }
+
+ return (
+ evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ {typeof menu === 'function' ? menu(null, undefined) : menu}
+
+ }
+ >
+ {children}
+
+ );
+}
diff --git a/src/app/components/image-viewer/ImageViewer.test.tsx b/src/app/components/image-viewer/ImageViewer.test.tsx
index e05ac0cea7..f83fec24fb 100644
--- a/src/app/components/image-viewer/ImageViewer.test.tsx
+++ b/src/app/components/image-viewer/ImageViewer.test.tsx
@@ -34,6 +34,11 @@ vi.mock('file-saver', () => ({
},
}));
+vi.mock('$hooks/useScreenSize', () => ({
+ ScreenSize: { Desktop: 'Desktop', Tablet: 'Tablet', Mobile: 'Mobile' },
+ useScreenSizeContext: () => 'Desktop',
+}));
+
describe('ImageViewer', () => {
it('downloads media without passing a media token argument', async () => {
downloadMedia.mockResolvedValue(new Blob(['image']));
diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx
index f1ca2fdf83..a3fb45f798 100644
--- a/src/app/components/image-viewer/ImageViewer.tsx
+++ b/src/app/components/image-viewer/ImageViewer.tsx
@@ -8,7 +8,6 @@ import {
IconButton,
Menu,
MenuItem,
- PopOut,
type RectCords,
Text,
as,
@@ -27,6 +26,7 @@ import {
sizedIcon,
} from '$components/icons/phosphor';
import { useImageGestures } from '$hooks/useImageGestures';
+import { useMobileLongPress } from '$hooks/useMobileLongPress';
import { useDismissOnBack } from '$utils/androidBack';
import { useSetting } from '$state/hooks/settings';
import { isPixelatedRendering, settingsAtom } from '$state/settings';
@@ -34,10 +34,9 @@ import { downloadMedia } from '$utils/matrix';
import * as css from './ImageViewer.css';
import type { IImageInfo } from '$types/matrix/common';
import { CheckerboardIcon, CopyIcon, DownloadIcon } from '@phosphor-icons/react';
-import FocusTrap from 'focus-trap-react';
-import { stopPropagation } from '$utils/keyboard';
import { copyImageToClipboard } from '$utils/dom';
import { getDownloadFilename, saveFileToDevice } from '$utils/download';
+import { ResponsiveMenu } from '$components/ResponsiveMenu';
export type ImageViewerProps = {
alt: string;
@@ -108,11 +107,27 @@ export const ImageViewer = as<'div', ImageViewerProps>(
const [menuAnchor, setMenuAnchor] = useState();
+ const {
+ onTouchStart,
+ onTouchEnd,
+ onTouchMove,
+ onTouchCancel,
+ firedRef: longPressFiredRef,
+ } = useMobileLongPress(() => {
+ setMenuAnchor({ x: 0, y: 0, width: 0, height: 0 });
+ });
+
const handleContextMenu: MouseEventHandler = (evt) => {
if (evt.altKey || !window.getSelection()?.isCollapsed) return;
const tag = (evt.target as HTMLElement).tagName;
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
+ if (longPressFiredRef.current) {
+ evt.preventDefault();
+ longPressFiredRef.current = false;
+ return;
+ }
+
evt.preventDefault();
setMenuAnchor({
x: evt.clientX,
@@ -124,53 +139,45 @@ export const ImageViewer = as<'div', ImageViewerProps>(
return (
<>
- setMenuAnchor(undefined)}
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
offset={menuAnchor?.width === 0 ? 0 : undefined}
- content={
- setMenuAnchor(undefined),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
+ menu={
+
}
/>
(
style={{ overflow: 'hidden', touchAction: 'none', cursor }}
onPointerDown={onPointerDown}
onContextMenu={handleContextMenu}
+ onTouchStart={onTouchStart}
+ onTouchEnd={onTouchEnd}
+ onTouchMove={onTouchMove}
+ onTouchCancel={onTouchCancel}
>
();
-
const matrixRoomName = useRoomName(room);
const roomName = matrixRoomName;
const navigate = useNavigate();
- const handleContextMenu: MouseEventHandler = (evt) => {
- evt.preventDefault();
- setMenuAnchor({
- x: evt.clientX,
- y: evt.clientY,
- width: 0,
- height: 0,
- });
- };
-
const handleNavItemClick: MouseEventHandler = () => {
navigate(linkPath);
};
@@ -41,14 +27,7 @@ export function SpaceNavItem({ room, selected, linkPath, hideText }: SpaceNavIte
return (
-
+
- {menuAnchor && (
- setMenuAnchor(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
- isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
- }
- />
- )}
+ setMenuAnchor(undefined)}
+ menu={
+
+ }
+ />
diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx
index f229fb9157..31ea3b3d7c 100644
--- a/src/app/pages/client/sidebar/SpaceTabs.tsx
+++ b/src/app/pages/client/sidebar/SpaceTabs.tsx
@@ -13,7 +13,6 @@ import {
Line,
Menu,
MenuItem,
- PopOut,
Text,
config,
toRem,
@@ -44,7 +43,6 @@ import {
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
-import FocusTrap from 'focus-trap-react';
import {
useOrphanSpaces,
useRecursiveChildScopeFactory,
@@ -87,7 +85,6 @@ import { roomToUnreadAtom } from '$state/room/roomToUnread';
import { markAsRead } from '$utils/notifications';
import { copyToClipboard } from '$utils/dom';
import { shareText } from '$utils/share';
-import { stopPropagation } from '$utils/keyboard';
import { getMatrixToRoom } from '$plugins/matrix-to';
import { getViaServers } from '$plugins/via-servers';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
@@ -102,6 +99,7 @@ import { lastVisitedSpaceIdAtom } from '$state/room/lastSpace';
import { useMobileTapActivation } from '$hooks/useMobileTapActivation';
import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
import { useOpenRoomSettings } from '$state/hooks/roomSettings';
+import { ResponsiveMenu } from '$components/ResponsiveMenu';
type SpaceMenuProps = {
room: Room;
@@ -611,32 +609,19 @@ function SpaceTab({
count={unread.highlight > 0 ? unread.highlight : unread.total}
/>
)}
- {menuAnchor && (
- setMenuAnchor(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
- isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
- escapeDeactivates: stopPropagation,
- }}
- >
- setMenuAnchor(undefined)}
- onUnpin={onUnpin}
- />
-
- }
- />
- )}
+ setMenuAnchor(undefined)}
+ menu={
+ setMenuAnchor(undefined)}
+ onUnpin={onUnpin}
+ />
+ }
+ />
)}
@@ -994,31 +979,20 @@ export function SpaceTabs({ scrollRef }: Readonly) {
if (sidebarItems.length === 0) return null;
return (
<>
- {folderMenuState && (
- setFolderMenuState(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
- isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
- escapeDeactivates: stopPropagation,
- }}
- >
- setFolderMenuState(undefined)}
- onRename={() => setRenameTargetFolder(folderMenuState.folder)}
- />
-
- }
- />
- )}
+ setFolderMenuState(undefined)}
+ menu={
+ folderMenuState && (
+ setFolderMenuState(undefined)}
+ onRename={() => setRenameTargetFolder(folderMenuState.folder)}
+ />
+ )
+ }
+ />
{renameTargetFolder && (
Date: Sat, 25 Jul 2026 12:21:13 +0200
Subject: [PATCH 22/43] refactor: collapse duplicate select-one menus onto
SettingMenuSelector
---
src/app/components/MemberSortMenu.tsx | 14 +--
src/app/components/MembershipFilterMenu.tsx | 48 ---------
.../common-settings/members/Members.tsx | 15 +--
src/app/features/room/MembersDrawer.tsx | 9 +-
.../notifications/SystemNotification.tsx | 101 +-----------------
5 files changed, 23 insertions(+), 164 deletions(-)
delete mode 100644 src/app/components/MembershipFilterMenu.tsx
diff --git a/src/app/components/MemberSortMenu.tsx b/src/app/components/MemberSortMenu.tsx
index e25569cfd8..dcf6321550 100644
--- a/src/app/components/MemberSortMenu.tsx
+++ b/src/app/components/MemberSortMenu.tsx
@@ -1,16 +1,18 @@
import FocusTrap from 'focus-trap-react';
import { config, Menu, MenuItem, Text } from 'folds';
import { stopPropagation } from '$utils/keyboard';
-import { useMemberSortMenu } from '$hooks/useMemberSort';
-type MemberSortMenuProps = {
+type MemberMenuItem = {
+ name: string;
+};
+
+type MemberMenuListProps = {
+ items: MemberMenuItem[];
requestClose: () => void;
selected: number;
onSelect: (index: number) => void;
};
-export function MemberSortMenu({ selected, onSelect, requestClose }: MemberSortMenuProps) {
- const memberSortMenu = useMemberSortMenu();
-
+export function MemberMenuList({ items, selected, onSelect, requestClose }: MemberMenuListProps) {
return (
- }
- />
- >
- );
-}
-
function NotificationTransportOverrideInput({
focusId,
title,
From a43a3825455cb8cebcefa1f5d9d834970c87f0cc Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 12:39:39 +0200
Subject: [PATCH 23/43] refactor: migrate hand-rolled overlays onto
ModalOverlay
---
src/app/components/LogoutDialogOverlay.tsx | 14 +
.../components/theme/ThemeMigrationBanner.tsx | 153 +++++-----
src/app/features/settings/Settings.tsx | 33 +--
.../cosmetics/ThemeCatalogOnboarding.tsx | 135 ++++-----
.../settings/cosmetics/ThemeImportModal.tsx | 269 ++++++++----------
.../features/settings/devices/DeviceTile.tsx | 35 +--
.../DeregisterPushNotifications.tsx | 90 +++---
.../features/widgets/IntegrationManager.tsx | 161 +++++------
src/app/pages/client/profile/Profile.tsx | 34 +--
src/app/pages/client/sidebar/UserMenuTab.tsx | 76 +++--
10 files changed, 404 insertions(+), 596 deletions(-)
create mode 100644 src/app/components/LogoutDialogOverlay.tsx
diff --git a/src/app/components/LogoutDialogOverlay.tsx b/src/app/components/LogoutDialogOverlay.tsx
new file mode 100644
index 0000000000..933f14247e
--- /dev/null
+++ b/src/app/components/LogoutDialogOverlay.tsx
@@ -0,0 +1,14 @@
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
+import { LogoutDialog } from '$components/LogoutDialog';
+
+type LogoutDialogOverlayProps = {
+ requestClose: () => void;
+};
+
+export function LogoutDialogOverlay({ requestClose }: LogoutDialogOverlayProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/components/theme/ThemeMigrationBanner.tsx b/src/app/components/theme/ThemeMigrationBanner.tsx
index 5d65b5eff9..9da153cdf2 100644
--- a/src/app/components/theme/ThemeMigrationBanner.tsx
+++ b/src/app/components/theme/ThemeMigrationBanner.tsx
@@ -1,17 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
-import FocusTrap from 'focus-trap-react';
-import {
- Box,
- Button,
- config,
- Dialog,
- Header,
- IconButton,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Text,
-} from 'folds';
+import { Box, Button, config, Dialog, Header, IconButton, Text } from 'folds';
import { sizedIcon, X } from '$components/icons/phosphor';
import { useStore } from 'jotai/react';
@@ -19,7 +7,7 @@ import { useOptionalClientConfig } from '$hooks/useClientConfig';
import { useSetting } from '$state/hooks/settings';
import { trimTrailingSlash } from '$utils/common';
import { defaultSettings, settingsAtom } from '$state/settings';
-import { stopPropagation } from '$utils/keyboard';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
import { usePatchSettings } from '$features/settings/cosmetics/themeSettingsPatch';
import { DEFAULT_THEME_CATALOG_BASE } from '../../theme/catalogDefaults';
@@ -81,82 +69,71 @@ export function ThemeMigrationBanner() {
if (!visible) return null;
return (
- }>
-
-
+
+
-
-
+
+ Update your theme selection
+
+
+
+ {sizedIcon(X, '100')}
+
+
+
+
+ Older bundled color themes are no longer included in the app. Migrate to the same looks
+ from the official catalog (downloaded and cached on this device), or dismiss this
+ reminder.
+
+ {error && (
+
+ {error}
+
+ )}
+
+
-
-
- Update your theme selection
-
-
-
- {sizedIcon(X, '100')}
-
-
-
-
- Older bundled color themes are no longer included in the app. Migrate to the same
- looks from the official catalog (downloaded and cached on this device), or dismiss
- this reminder.
-
- {error && (
-
- {error}
-
- )}
-
-
- {busy ? 'Migrating…' : 'Migrate'}
-
-
- Not now
-
-
-
-
-
-
-
+ {busy ? 'Migrating…' : 'Migrate'}
+
+
+ Not now
+
+
+
+
+
);
}
diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx
index adea0fc813..31f8d98a94 100644
--- a/src/app/features/settings/Settings.tsx
+++ b/src/app/features/settings/Settings.tsx
@@ -1,19 +1,7 @@
import { useMemo, useState } from 'react';
import type { ComponentType } from 'react';
import { DesktopIcon, type IconProps } from '@phosphor-icons/react';
-import {
- Avatar,
- Box,
- Button,
- config,
- IconButton,
- MenuItem,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Text,
-} from 'folds';
-import FocusTrap from 'focus-trap-react';
+import { Avatar, Box, Button, config, IconButton, MenuItem, Text } from 'folds';
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '$components/page';
import { SettingsSectionHeader } from '$components/page/style.css';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
@@ -24,8 +12,7 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { UserAvatar } from '$components/user-avatar';
import { nameInitials } from '$utils/common';
import { UseStateProvider } from '$components/UseStateProvider';
-import { stopPropagation } from '$utils/keyboard';
-import { LogoutDialog } from '$components/LogoutDialog';
+import { LogoutDialogOverlay } from '$components/LogoutDialogOverlay';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import {
@@ -356,21 +343,7 @@ export function Settings({
>
Logout
- {logout && (
- }>
-
- setLogout(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
- setLogout(false)} />
-
-
-
- )}
+ {logout && setLogout(false)} />}
>
)}
diff --git a/src/app/features/settings/cosmetics/ThemeCatalogOnboarding.tsx b/src/app/features/settings/cosmetics/ThemeCatalogOnboarding.tsx
index 133a9c64f2..77cc284751 100644
--- a/src/app/features/settings/cosmetics/ThemeCatalogOnboarding.tsx
+++ b/src/app/features/settings/cosmetics/ThemeCatalogOnboarding.tsx
@@ -1,20 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
-import FocusTrap from 'focus-trap-react';
-import {
- Box,
- Button,
- config,
- Dialog,
- Header,
- IconButton,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Text,
-} from 'folds';
+import { Box, Button, config, Dialog, Header, IconButton, Text } from 'folds';
import { menuIcon, X } from '$components/icons/phosphor';
-import { stopPropagation } from '$utils/keyboard';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
type ThemeCatalogOnboardingProps = {
open: boolean;
@@ -43,74 +31,65 @@ export function ThemeCatalogOnboarding({ open, onEnable, onDecline }: ThemeCatal
onDecline();
}, [onDecline]);
+ if (!open) return null;
+
return (
- }>
-
-
+
+
+
+ {menuIcon(X)}
+
+
+
+
+ Load themes from the official Sable theme catalog on GitHub? You can browse previews,
+ save favorites locally, and sync them with light and dark mode. If you choose not to,
+ you can keep using the built-in Light and Dark themes only.
+
+
+
+ Yes, use the catalog
+
+
-
- Remote themes
-
-
- {menuIcon(X)}
-
-
-
-
- Load themes from the official Sable theme catalog on GitHub? You can browse
- previews, save favorites locally, and sync them with light and dark mode. If you
- choose not to, you can keep using the built-in Light and Dark themes only.
-
-
-
- Yes, use the catalog
-
-
- No, built-in themes only
-
-
-
-
-
-
-
+ No, built-in themes only
+
+
+
+
+
);
}
diff --git a/src/app/features/settings/cosmetics/ThemeImportModal.tsx b/src/app/features/settings/cosmetics/ThemeImportModal.tsx
index b1a41f5f67..c746eaedea 100644
--- a/src/app/features/settings/cosmetics/ThemeImportModal.tsx
+++ b/src/app/features/settings/cosmetics/ThemeImportModal.tsx
@@ -1,19 +1,5 @@
import { type ChangeEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import FocusTrap from 'focus-trap-react';
-import {
- Box,
- Button,
- config,
- Dialog,
- Header,
- IconButton,
- Input,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Text,
- toRem,
-} from 'folds';
+import { Box, Button, config, Dialog, Header, IconButton, Input, Text, toRem } from 'folds';
import { menuIcon, X } from '$components/icons/phosphor';
import { useSetting } from '$state/hooks/settings';
@@ -22,7 +8,7 @@ import {
type ThemeRemoteFavorite,
type ThemeRemoteTweakFavorite,
} from '$state/settings';
-import { stopPropagation } from '$utils/keyboard';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
import { SequenceCardStyle } from '$features/settings/styles.css';
import { SequenceCard } from '$components/sequence-card';
@@ -198,146 +184,135 @@ export function ThemeImportModal({ open, onClose }: ThemeImportModalProps) {
if (!open) return null;
return (
- }>
-
-
+
+
-
-
- Paste a link to a .sable.css file, or paste CSS / upload a file.
- Files whose first metadata block uses @sable-tweak are saved as
- tweaks (applied on top of your current theme and turned on immediately). Themes use{' '}
- @sable-theme. If theme CSS includes fullThemeUrl{' '}
- and that URL loads, it is used; otherwise the theme is stored only on this device.
-
- Choose .css file
+
+ {
+ handleImportTheme().catch(() => undefined);
+ }}
>
-
- {importFileName && uploadedFileCss !== null && (
-
-
- Loaded file: {importFileName}.
-
- {
- setUploadedFileCss(null);
- setImportFileName(undefined);
- if (importFileRef.current) importFileRef.current.value = '';
- }}
- >
- Clear file
-
-
- )}
-
-
-
- importFileRef.current?.click()}
- >
- Choose .css file
-
- {
- handleImportTheme().catch(() => undefined);
- }}
- >
- {importBusy ? 'Importing…' : 'Import'}
-
-
- {importError && (
-
- {importError}
-
- )}
-
+ {importBusy ? 'Importing…' : 'Import'}
+
-
-
-
-
+ {importError && (
+
+ {importError}
+
+ )}
+
+
+
+
);
}
diff --git a/src/app/features/settings/devices/DeviceTile.tsx b/src/app/features/settings/devices/DeviceTile.tsx
index 90473005fd..0371a698f1 100644
--- a/src/app/features/settings/devices/DeviceTile.tsx
+++ b/src/app/features/settings/devices/DeviceTile.tsx
@@ -1,30 +1,15 @@
import type { FormEventHandler, ReactNode } from 'react';
import { useCallback, useEffect, useState } from 'react';
-import {
- Box,
- Text,
- IconButton,
- Chip,
- Input,
- Button,
- color,
- Spinner,
- toRem,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
-} from 'folds';
+import { Box, Text, IconButton, Chip, Input, Button, color, Spinner, toRem } from 'folds';
import { CaretDown, CaretRight, chipIcon, Trash } from '$components/icons/phosphor';
import type { CryptoApi, IMyDevice, MatrixError } from '$types/matrix-sdk';
-import FocusTrap from 'focus-trap-react';
import { SettingTile } from '$components/setting-tile';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '$utils/time';
import { BreakWord } from '$styles/Text.css';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { SequenceCard } from '$components/sequence-card';
-import { LogoutDialog } from '$components/LogoutDialog';
-import { stopPropagation } from '$utils/keyboard';
+import { LogoutDialogOverlay } from '$components/LogoutDialogOverlay';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import { SequenceCardStyle } from '$features/settings/styles.css';
@@ -205,21 +190,7 @@ export function DeviceLogoutBtn() {
setPrompt(true)}>
Logout
- {prompt && (
- }>
-
-
-
-
-
-
- )}
+ {prompt && }
>
);
}
diff --git a/src/app/features/settings/notifications/DeregisterPushNotifications.tsx b/src/app/features/settings/notifications/DeregisterPushNotifications.tsx
index e27715ba2a..a59db2c48c 100644
--- a/src/app/features/settings/notifications/DeregisterPushNotifications.tsx
+++ b/src/app/features/settings/notifications/DeregisterPushNotifications.tsx
@@ -1,20 +1,7 @@
import { useCallback, useState } from 'react';
-import FocusTrap from 'focus-trap-react';
-import {
- Box,
- Button,
- color,
- config,
- Dialog,
- Header,
- IconButton,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Spinner,
- Text,
-} from 'folds';
+import { Box, Button, color, config, Dialog, Header, IconButton, Spinner, Text } from 'folds';
import { menuIcon, X } from '$components/icons/phosphor';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
import { useAtom } from 'jotai';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useClientConfig } from '../../../hooks/useClientConfig';
@@ -36,47 +23,38 @@ type ConfirmDeregisterDialogProps = {
function ConfirmDeregisterDialog({ onClose, onConfirm, isLoading }: ConfirmDeregisterDialogProps) {
return (
- }>
-
-
-
-
-
- Reset All Push Notifications
-
-
- {menuIcon(X)}
-
-
-
-
- This will remove push notifications from all your sessions and devices. This action
- cannot be undone. Are you sure you want to continue?
-
-
- }
- >
- Reset All
-
-
- Cancel
-
-
-
-
-
-
-
+
+
+
+
+ Reset All Push Notifications
+
+
+ {menuIcon(X)}
+
+
+
+
+ This will remove push notifications from all your sessions and devices. This action
+ cannot be undone. Are you sure you want to continue?
+
+
+ }
+ >
+ Reset All
+
+
+ Cancel
+
+
+
+
+
);
}
diff --git a/src/app/features/widgets/IntegrationManager.tsx b/src/app/features/widgets/IntegrationManager.tsx
index a098ba516e..c4dde5794d 100644
--- a/src/app/features/widgets/IntegrationManager.tsx
+++ b/src/app/features/widgets/IntegrationManager.tsx
@@ -1,18 +1,9 @@
import { useEffect, useRef, useState } from 'react';
-import {
- Box,
- Header,
- IconButton,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Spinner,
- Text,
-} from 'folds';
+import { Box, Header, IconButton, Spinner, Text } from 'folds';
import { composerIcon, X } from '$components/icons/phosphor';
-import FocusTrap from 'focus-trap-react';
import type { Room } from '$types/matrix-sdk';
+import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
import { useIntegrationManager, buildIntegrationManagerUrl } from '$hooks/useIntegrationManager';
import * as css from './IntegrationManager.css';
@@ -60,90 +51,80 @@ export function IntegrationManager({ room, open, onClose }: IntegrationManagerPr
}, [open]);
return (
- }>
-
-
-
-
-
-
-
- Integration Manager
-
-
-
-
- {composerIcon(X)}
-
-
-
-
-
-
- {loading && (
-
-
-
- Connecting to integration manager...
-
-
- )}
+
+
+
+
+
+
+ Integration Manager
+
+
+
+
+ {composerIcon(X)}
+
+
+
+
+
+
+ {loading && (
+
+
+
+ Connecting to integration manager...
+
+
+ )}
- {error && (
-
-
- Failed to connect: {error}
-
-
- )}
+ {error && (
+
+
+ Failed to connect: {error}
+
+
+ )}
- {!loading && !error && !manager && (
-
+ {!loading && !error && !manager && (
+
+
+ No integration manager available for this homeserver.
+
+
+ )}
+
+ {!loading && !error && iframeSrc && (
+ <>
+ {!iframeLoaded && (
+
+
- No integration manager available for this homeserver.
+ Loading...
)}
-
- {!loading && !error && iframeSrc && (
- <>
- {!iframeLoaded && (
-
-
-
- Loading...
-
-
- )}
-
-
-
-
-
+
)}
{confirmSignOutSession && (
-
}>
-
- setConfirmSignOutSession(undefined),
- clickOutsideDeactivates: true,
+ setConfirmSignOutSession(undefined)}>
+
+
-
-
+
+
+ Are you sure you want to sign out of {confirmSignOutSession.userId}?
+
+
+ {
+ handleSignOut(confirmSignOutSession);
+ setConfirmSignOutSession(undefined);
}}
- variant="Surface"
- size="500"
>
-
- Sign out
-
-
-
-
- Are you sure you want to sign out of {confirmSignOutSession.userId}?
-
-
- {
- handleSignOut(confirmSignOutSession);
- setConfirmSignOutSession(undefined);
- }}
- >
- Sign out
-
- setConfirmSignOutSession(undefined)}>
- Cancel
-
-
-
-
-
-
-
+
Sign out
+
+
setConfirmSignOutSession(undefined)}>
+ Cancel
+
+
+
+
+
)}
>
);
From e701007316a896cd2eaeed43fa47be90487a9abd Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 12:39:39 +0200
Subject: [PATCH 24/43] refactor: split ClientNonUIFeatures into feature
modules
---
src/app/pages/client/ClientNonUIFeatures.tsx | 1157 +----------------
.../client/client-non-ui/GlobalBanners.tsx | 17 +
.../pages/client/client-non-ui/appearance.tsx | 41 +
.../pages/client/client-non-ui/badging.tsx | 110 ++
.../client/client-non-ui/notifications.tsx | 847 ++++++++++++
src/app/pages/client/client-non-ui/sync.tsx | 33 +
.../pages/client/client-non-ui/telemetry.tsx | 111 ++
7 files changed, 1187 insertions(+), 1129 deletions(-)
create mode 100644 src/app/pages/client/client-non-ui/GlobalBanners.tsx
create mode 100644 src/app/pages/client/client-non-ui/appearance.tsx
create mode 100644 src/app/pages/client/client-non-ui/badging.tsx
create mode 100644 src/app/pages/client/client-non-ui/notifications.tsx
create mode 100644 src/app/pages/client/client-non-ui/sync.tsx
create mode 100644 src/app/pages/client/client-non-ui/telemetry.tsx
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 8e8fccc112..1fb7cbed0a 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -1,1142 +1,45 @@
-import { useAtom, useAtomValue, useSetAtom } from 'jotai';
-import * as Sentry from '@sentry/react';
-import { type as osType } from '@tauri-apps/plugin-os';
-import { invoke } from '@tauri-apps/api/core';
-import { setTrayBadge } from '$generated/tauri/commands';
import type { ReactNode } from 'react';
-import { useCallback, useEffect, useRef, useState } from 'react';
-import { useLocation, useNavigate } from 'react-router-dom';
-import { resolveSection } from '$pages/pathUtils';
-import type { RoomEventHandlerMap } from '$types/matrix-sdk';
-import { getPresenceSyncManager } from '$client/initMatrix';
-import {
- MatrixEvent,
- MatrixEventEvent,
- MsgType,
- RoomEvent,
- SetPresence,
- SyncState,
- EventType,
-} from '$types/matrix-sdk';
-import { roomToUnreadAtom } from '$state/room/roomToUnread';
-import LogoSVG from '$public/res/svg/logo.svg';
-import LogoUnreadSVG from '$public/res/svg/unread.svg';
-import LogoHighlightSVG from '$public/res/svg/highlight.svg';
-import NotificationSound from '$public/sound/notification.ogg';
-import InviteSound from '$public/sound/invite.ogg';
-import { notificationPermission, setFavicon } from '$utils/dom';
-import {
- getTauriNotificationsApi,
- isAndroidTauri,
- isDesktopTauri,
- isIosTauri,
- isNativeNotificationTauri,
- sendNativeTauriNotification,
-} from '$features/settings/notifications/TauriNotificationsApiClient';
-import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
import { IconSizesProvider } from '$components/icons/phosphor';
-import { nicknamesAtom } from '$state/nicknames';
-import { mDirectAtom } from '$state/mDirectList';
-import { allInvitesAtom } from '$state/room-list/inviteList';
-import { usePreviousValue } from '$hooks/usePreviousValue';
-import { useMatrixClient } from '$hooks/useMatrixClient';
-import {
- getMemberDisplayName,
- getNotificationType,
- getStateEvent,
- isDMRoom,
- isNotificationEvent,
-} from '$utils/room';
-import { NotificationType, type RoomToUnread } from '$types/matrix/room';
-import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix';
-import { useSelectedRoom } from '$hooks/router/useSelectedRoom';
-import { useInboxNotificationsSelected } from '$hooks/router/useRouteSelected';
-import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { ShareTargetFeature } from '$features/share-target/ShareTargetFeature';
-import { registrationAtom } from '$state/serviceWorkerRegistration';
-import {
- pendingNotificationAtom,
- inAppBannerAtom,
- activeSessionIdAtom,
- sessionsAtom,
-} from '$state/sessions';
-import {
- buildRoomMessageNotification,
- resolveNotificationPreviewText,
-} from '$utils/notificationStyle';
-import { mobileOrTablet } from '$utils/user-agent';
-import { createDebugLogger } from '$utils/debugLogger';
-import { showToast } from '$state/toast';
-import {
- nativeNotificationRepliesAtom,
- nativeNotificationReplyInFlightAtom,
- enqueueNativeNotificationReplyAtom,
- removeNativeNotificationReplyAtom,
- parseNativeNotificationReply,
- NATIVE_REPLY_EXPIRY_MS,
-} from '$state/nativeNotificationReplies';
-import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom';
-import { NotificationBanner } from '$components/notification-banner';
-import { ThemeMigrationBanner } from '$components/theme/ThemeMigrationBanner';
-import { TelemetryConsentBanner } from '$components/telemetry-consent';
import { useIncomingCallSignaling } from '$hooks/useCallSignaling';
import { MatrixRTCSessionProvider } from '$hooks/useMatrixRTCSession';
-import { lastVisitedRoomAtom } from '$state/room/lastRoom';
-import { useSettingsSyncEffect } from '$hooks/useSettingsSync';
-import { resolveIncomingCallFromNotificationData } from '$features/call/callNotificationBridge';
-import { isIncomingCallSuppressed } from '$features/call/callIncomingIngress';
-import { incomingCallAtom, mutedCallRoomIdAtom } from '$state/callEmbed';
-import { getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';
import { DesktopUpdater } from './DesktopUpdater';
import { WebUpdater } from './WebUpdater';
import { NotificationTransportRuntimeFeature } from '$features/settings/notifications/NotificationTransportRuntimeFeature';
-import { UnverifiedNoticeBanner } from '$components/unverified-notice';
-import { GlobalBannerRenderer } from '$components/global-banner/GlobalBannerRenderer';
-import { getRenderableMediaUrlStats } from '$hooks/useRenderableMediaUrl';
-
-const pushRelayLog = createDebugLogger('push-relay');
-
-function clearMediaSessionQuickly(): void {
- if (!('mediaSession' in navigator)) return;
- // iOS registers the lock screen media player as a side-effect of
- // HTMLAudioElement.play(). We delay slightly so iOS has finished updating
- // the media session before we clear it — clearing too early is a no-op.
- // We only clear if no real in-app media (video/audio in a room) has since
- // registered meaningful metadata; if it has, leave it alone.
- setTimeout(() => {
- if (navigator.mediaSession.metadata !== null) return;
- navigator.mediaSession.playbackState = 'none';
- }, 500);
-}
-
-function SystemEmojiFeature() {
- const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
-
- if (twitterEmoji) {
- document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
- } else {
- document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED');
- }
-
- return null;
-}
-
-function PageZoomFeature() {
- const [pageZoom] = useSetting(settingsAtom, 'pageZoom');
-
- if (pageZoom === 100) {
- document.documentElement.style.removeProperty('font-size');
- } else {
- document.documentElement.style.setProperty('font-size', `calc(1em * ${pageZoom / 100})`);
- }
-
- return null;
-}
-
-// Sum only leaf rooms (from === null); space entries aggregate their children
-// and would double-count.
-function getUnreadTotals(roomToUnread: RoomToUnread) {
- let total = 0;
- let highlightTotal = 0;
- let notification = false;
- let highlight = false;
- roomToUnread.forEach((unread) => {
- if (unread.from === null) {
- total += unread.total;
- highlightTotal += unread.highlight;
- }
- if (unread.total > 0) {
- notification = true;
- }
- if (unread.highlight > 0) {
- highlight = true;
- }
- });
- return { total, highlightTotal, notification, highlight };
-}
-
-// Updates document.title with the mention (highlight) count. Total unread
-// is too noisy for a tab title; the OS app badge already uses the same
-// highlightTotal value for consistency.
-function PageTitleUpdater() {
- const roomToUnread = useAtomValue(roomToUnreadAtom);
-
- useEffect(() => {
- const { highlightTotal } = getUnreadTotals(roomToUnread);
- document.title = highlightTotal > 0 ? `(${highlightTotal}) Sable Client` : 'Sable Client';
- }, [roomToUnread]);
-
- return null;
-}
-
-function FaviconUpdater() {
- const roomToUnread = useAtomValue(roomToUnreadAtom);
- const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
- const [faviconForMentionsOnly] = useSetting(settingsAtom, 'faviconForMentionsOnly');
- const registration = useAtomValue(registrationAtom);
-
- useEffect(() => {
- const { total, highlightTotal, notification, highlight } = getUnreadTotals(roomToUnread);
-
- if (highlight) {
- setFavicon(LogoHighlightSVG);
- } else if (!faviconForMentionsOnly && notification) {
- setFavicon(LogoUnreadSVG);
- } else {
- setFavicon(LogoSVG);
- }
- try {
- // Only badge with highlight (mention) counts — total unread is too noisy
- // for an OS-level app badge.
- if (isNativeNotificationTauri()) {
- const badgeCount = highlightTotal > 0 ? highlightTotal : undefined;
- if (osType() === 'linux') {
- // Linux has no taskbar badge; the count goes on the tray icon instead.
- setTrayBadge({ count: badgeCount ?? null }).catch(() => {});
- } else {
- import('@tauri-apps/api/window')
- .then(({ getCurrentWindow }) => getCurrentWindow().setBadgeCount(badgeCount))
- .catch(() => {});
- }
- } else if (highlightTotal > 0) {
- navigator.setAppBadge(highlightTotal);
- } else {
- navigator.clearAppBadge();
- }
- if (usePushNotifications && registration) {
- if (total === 0) {
- // All rooms read — clear every notification.
- registration.getNotifications().then((notifs) => notifs.forEach((n) => n.close()));
- } else {
- // Dismiss notifications for individual rooms that are now fully read.
- registration.getNotifications().then((notifs) => {
- notifs.forEach((n) => {
- const notifRoomId = n.data?.room_id;
- if (!notifRoomId) return;
- const roomUnread = roomToUnread.get(notifRoomId);
- if (!roomUnread || (roomUnread.total === 0 && roomUnread.highlight === 0)) {
- n.close();
- }
- });
- });
- }
- }
- } catch {
- // Likely Firefox/Gecko-based and doesn't support badging API
- }
- }, [roomToUnread, usePushNotifications, registration, faviconForMentionsOnly]);
-
- return null;
-}
-
-function InviteNotifications() {
- const audioRef = useRef(null);
- const invites = useAtomValue(allInvitesAtom);
- const perviousInviteLen = usePreviousValue(invites.length, 0);
- const mx = useMatrixClient();
-
- const navigate = useNavigate();
- const [showSystemNotifications] = useSetting(settingsAtom, 'useSystemNotifications');
- const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
- const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
- const [backgroundNotificationSounds] = useSetting(settingsAtom, 'backgroundNotificationSounds');
-
- const notify = useCallback(
- (count: number) => {
- const body = `You have ${count} new invitation request.`;
- if (isNativeNotificationTauri()) {
- sendNativeTauriNotification({
- title: 'Invitation',
- body,
- silent: true,
- group: 'matrix_messages',
- extra: { type: 'invite' },
- }).catch(() => {});
- return;
- }
- const noti = new window.Notification('Invitation', {
- icon: LogoSVG,
- badge: LogoSVG,
- body,
- silent: true,
- });
-
- noti.addEventListener('click', () => {
- if (!window.closed) navigate(getInboxInvitesPath());
- noti.close();
- });
- },
- [navigate]
- );
-
- const playSound = useCallback(() => {
- if (isAndroidTauri() || isIosTauri()) {
- invoke('play_notification_sound', { kind: 'invite' }).catch(() => {});
- return;
- }
- const audioElement = audioRef.current;
- audioElement?.play()?.catch(() => {});
- clearMediaSessionQuickly();
- }, []);
-
- useEffect(() => {
- if (invites.length <= perviousInviteLen || mx.getSyncState() !== SyncState.Syncing) return;
-
- // SW push (via Sygnal) handles invite notifications when the app is backgrounded.
- if (document.visibilityState !== 'visible' && usePushNotifications) return;
-
- // OS notification for invites — desktop, plus iOS while foregrounded (testing).
- if (
- (!mobileOrTablet() || isIosTauri()) &&
- showSystemNotifications &&
- (isNativeNotificationTauri() || notificationPermission('granted'))
- ) {
- try {
- notify(invites.length - perviousInviteLen);
- } catch {
- // window.Notification may be unavailable in sandboxed environments.
- }
- }
- const tabVisible = document.visibilityState === 'visible';
- if (notificationSound && (tabVisible || backgroundNotificationSounds)) {
- playSound();
- }
- }, [
- mx,
- invites,
- perviousInviteLen,
- showSystemNotifications,
- usePushNotifications,
- notificationSound,
- backgroundNotificationSounds,
- notify,
- playSound,
- ]);
-
- return (
- // oxlint-disable-next-line jsx-a11y/media-has-caption
-
- );
-}
-
-function MessageNotifications() {
- const audioRef = useRef(null);
- const notifiedEventsRef = useRef(new Set());
- // Record mount time so we can distinguish live events from historical backfill
- // on sliding sync proxies that don't set num_live (which causes liveEvent=false
- // for all events, including actually-new messages).
- const clientStartTimeRef = useRef(Date.now());
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
- const [showNotifications] = useSetting(settingsAtom, 'useInAppNotifications');
- const [showSystemNotifications] = useSetting(settingsAtom, 'useSystemNotifications');
- const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
- const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
- const [backgroundNotificationSounds] = useSetting(settingsAtom, 'backgroundNotificationSounds');
- const [showMessageContent] = useSetting(settingsAtom, 'showMessageContentInNotifications');
- const [showEncryptedMessageContent] = useSetting(
- settingsAtom,
- 'showMessageContentInEncryptedNotifications'
- );
- const nicknames = useAtomValue(nicknamesAtom);
- const nicknamesRef = useRef(nicknames);
- nicknamesRef.current = nicknames;
- const mDirects = useAtomValue(mDirectAtom);
- const mDirectsRef = useRef(mDirects);
- mDirectsRef.current = mDirects;
-
- const setPending = useSetAtom(pendingNotificationAtom);
- const setInAppBanner = useSetAtom(inAppBannerAtom);
- const selectedRoomId = useSelectedRoom();
- const notificationSelected = useInboxNotificationsSelected();
-
- const playSound = useCallback(() => {
- if (isAndroidTauri() || isIosTauri()) {
- invoke('play_notification_sound', { kind: 'notification' }).catch(() => {});
- return;
- }
- const audioElement = audioRef.current;
- audioElement?.play()?.catch(() => {});
- clearMediaSessionQuickly();
- }, []);
-
- useEffect(() => {
- const pushProcessor = mx.pushProcessor;
- // Track encrypted events that should skip focus check when decrypted (because we
- // already checked focus when the encrypted event arrived, and want to use that
- // original state rather than re-checking after decryption completes).
- const skipFocusCheckEvents = new Set();
- // Tracks when each event first arrived so we can measure notification delivery latency
- const notifyTimerMap = new Map();
-
- const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = (
- mEvent,
- room,
- _toStartOfTimeline,
- _removed,
- data
- ) => {
- if (mx.getSyncState() !== SyncState.Syncing) return;
-
- const eventId = mEvent.getId();
- // Record event arrival time once per eventId (re-entry via handleDecrypted must not reset it)
- if (eventId && !notifyTimerMap.has(eventId)) {
- notifyTimerMap.set(eventId, performance.now());
- }
- const shouldSkipFocusCheck = eventId && skipFocusCheckEvents.has(eventId);
- if (!shouldSkipFocusCheck) {
- if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected))
- return;
- }
-
- // Older sliding sync proxies (e.g. matrix-sliding-sync) omit num_live,
- // which causes every event to arrive with fromCache=true and therefore
- // liveEvent=false — silently blocking all notifications. Fall back to an
- // age check: treat the event as potentially live only when it was sent
- // within 60 s of this component mounting (tight enough to avoid phantom
- // notifications for pre-existing unread messages, generous enough for
- // messages that arrived during a brief offline window).
- // Additionally, skip the event if the user already has a read receipt
- // covering it (message was read on another device before this session).
- const isHistoricalEvent =
- !data.liveEvent &&
- (mEvent.getTs() < clientStartTimeRef.current - 60 * 1000 ||
- (!!room && room.hasUserReadEvent(mx.getSafeUserId(), mEvent.getId()!)));
-
- // For encrypted events that haven't been decrypted yet, wait for decryption
- // before processing the notification. The SDK's Timeline re-emission after
- // decryption comes with data.liveEvent=false which would wrongly block it.
- if (mEvent.getType() === 'm.room.encrypted' && mEvent.isEncrypted()) {
- if (eventId) {
- // Mark this event to skip focus check when decrypted, so we use the focus
- // state from when the encrypted event originally arrived, not when it decrypts.
- skipFocusCheckEvents.add(eventId);
- }
-
- const handleDecrypted = () => {
- // After decryption, run the notification logic with the decrypted event
- handleTimelineEvent(mEvent, room, undefined, true, data);
- // Clean up the skip-focus marker
- if (eventId) {
- skipFocusCheckEvents.delete(eventId);
- }
- };
- mEvent.once(MatrixEventEvent.Decrypted, handleDecrypted);
- return;
- }
-
- if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) {
- return;
- }
-
- const notificationType = getNotificationType(mx, room.roomId);
- if (notificationType === NotificationType.Mute) {
- return;
- }
-
- const sender = mEvent.getSender();
- if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return;
-
- // Deduplicate: don't show a second banner if this event fires twice
- // (e.g., decrypted events re-emitted by the SDK).
- if (notifiedEventsRef.current.has(eventId)) return;
-
- // Check if this is a DM using multiple signals for robustness
- const isDM = isDMRoom(room, mDirectsRef.current);
-
- // Measure total notification delivery latency (includes decryption wait for E2EE events)
- const arrivalMs = notifyTimerMap.get(eventId);
- if (arrivalMs !== undefined) {
- Sentry.metrics.distribution(
- 'sable.notification.delivery_ms',
- performance.now() - arrivalMs,
- {
- attributes: {
- encrypted: String(mEvent.isEncrypted()),
- dm: String(isDM),
- },
- }
- );
- notifyTimerMap.delete(eventId);
- }
- const pushActions = pushProcessor.actionsForEvent(mEvent);
-
- // For DMs with "All Messages" or "Default" notification settings:
- // Always notify even if push rules fail to match due to sliding sync limitations.
- // For "Mention & Keywords": respect the push rule (only notify if it matches).
- const shouldForceDMNotification =
- isDM && notificationType !== NotificationType.MentionsAndKeywords;
- const shouldNotify = pushActions?.notify || shouldForceDMNotification;
-
- // If we shouldn't notify based on rules/settings, skip everything
- if (!shouldNotify) return;
-
- const loudByRule = Boolean(pushActions.tweaks?.sound);
- const isHighlightByRule = Boolean(pushActions.tweaks?.highlight);
-
- // With sliding sync we only load m.room.member/$ME in required_state, so
- // PushProcessor cannot evaluate the room_member_count == 2 condition on
- // .m.rule.room_one_to_one. That rule therefore fails to match, and DM
- // messages fall through to .m.rule.message which carries no sound tweak —
- // leaving loudByRule=false. Treat known DMs as inherently loud so that
- // the OS notification and badge are consistent with the DM context.
- const isLoud = loudByRule || isDM;
-
- // Record as notified to prevent duplicate banners (e.g. re-emitted decrypted events).
- notifiedEventsRef.current.add(eventId);
- if (notifiedEventsRef.current.size > 200) {
- const first = notifiedEventsRef.current.values().next().value;
- if (first) notifiedEventsRef.current.delete(first);
- }
-
- // On desktop: fire an OS notification whenever system notifications are
- // enabled and permission is granted — regardless of whether the window is
- // focused. When the window is also visible the in-app banner fires too,
- // mirroring the behaviour of apps like Discord.
- // The whole block is wrapped in try/catch: window.Notification() can throw
- // in sandboxed environments, browsers with DnD active, or Electron — and
- // an uncaught exception here would abort the handler before setInAppBanner
- // is reached, causing in-app notifications to silently vanish too.
- if (
- (!mobileOrTablet() || isIosTauri()) &&
- showSystemNotifications &&
- (isNativeNotificationTauri() || notificationPermission('granted'))
- ) {
- try {
- const isEncryptedRoom = !!getStateEvent(room, EventType.RoomEncryption);
- const avatarMxc =
- room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
- const osPayload = buildRoomMessageNotification({
- roomName: room.name ?? 'Unknown',
- roomAvatar: avatarMxc
- ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
- : undefined,
- username:
- getMemberDisplayName(room, sender, nicknamesRef.current) ??
- getMxIdLocalPart(sender) ??
- sender,
- previewText: resolveNotificationPreviewText({
- content: mEvent.getContent(),
- eventType: mEvent.getType(),
- isEncryptedRoom,
- showMessageContent,
- showEncryptedMessageContent,
- }),
- silent: !notificationSound || !isLoud,
- eventId,
- });
- if (isNativeNotificationTauri()) {
- const extra: Record = { type: mEvent.getType(), room_id: room.roomId };
- if (eventId) extra.event_id = eventId;
- const userId = mx.getUserId();
- if (userId) extra.user_id = userId;
- sendNativeTauriNotification({
- title: osPayload.title,
- body: osPayload.options.body,
- silent: osPayload.options.silent ?? false,
- extra,
- actionTypeId: 'sable-message',
- group: 'matrix_messages',
- }).catch(() => {});
- } else {
- const noti = new window.Notification(osPayload.title, osPayload.options);
- const { roomId } = room;
- noti.addEventListener('click', () => {
- window.focus();
- setPending({
- roomId,
- eventId,
- targetSessionId: mx.getUserId() ?? undefined,
- });
- noti.close();
- });
- }
- } catch {
- // window.Notification unavailable or blocked (sandboxed context, DnD, etc.)
- }
- }
-
- const tabVisible = document.visibilityState === 'visible';
- if (notificationSound && isLoud && (tabVisible || backgroundNotificationSounds)) {
- playSound();
- }
-
- // In-app banner requires a visible tab.
- if (!tabVisible) return;
-
- // Page is visible — show the themed in-app notification banner.
- // For non-DM rooms, only show banner for highlighted messages (mentions/keywords).
- // For DMs, show banner for all messages.
- if (showNotifications && (isHighlightByRule || isDM)) {
- const avatarMxc =
- room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
- const roomAvatar = avatarMxc
- ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
- : undefined;
- const resolvedSenderName =
- getMemberDisplayName(room, sender, nicknamesRef.current) ??
- getMxIdLocalPart(sender) ??
- sender;
- // Events reaching here are already decrypted (m.room.encrypted is skipped
- // above). Pass isEncryptedRoom:false so the preview always shows the actual
- // message body when showMessageContent is enabled.
- const previewText = resolveNotificationPreviewText({
- content: mEvent.getContent(),
- eventType: mEvent.getType(),
- isEncryptedRoom: false,
- showMessageContent,
- showEncryptedMessageContent,
- });
-
- const payload = buildRoomMessageNotification({
- roomName: room.name ?? 'Unknown',
- roomAvatar,
- username: resolvedSenderName,
- previewText,
- silent: !notificationSound || !isLoud,
- eventId,
- });
- const { roomId } = room;
- const capturedEventId = eventId;
- const capturedUserId = mx.getUserId() ?? undefined;
- const canonicalAlias = room.getCanonicalAlias();
- const serverName = canonicalAlias?.split(':')[1] ?? room.roomId.split(':')[1] ?? undefined;
- setInAppBanner({
- id: eventId,
- title: payload.title,
- roomName: room.name ?? undefined,
- serverName,
- senderName: resolvedSenderName,
- body: previewText,
- room,
- event: mEvent,
- icon: roomAvatar,
- onClick: () => {
- window.focus();
- setPending({
- roomId,
- eventId: capturedEventId,
- targetSessionId: capturedUserId,
- });
- },
- });
- }
- };
- mx.on(RoomEvent.Timeline, handleTimelineEvent);
- return () => {
- mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
- };
- }, [
- mx,
- notificationSound,
- backgroundNotificationSounds,
- notificationSelected,
- showNotifications,
- showSystemNotifications,
- showMessageContent,
- showEncryptedMessageContent,
- usePushNotifications,
- playSound,
- setInAppBanner,
- setPending,
- selectedRoomId,
- useAuthentication,
- ]);
-
- return (
- // oxlint-disable-next-line jsx-a11y/media-has-caption
-
- );
-}
-
-function PrivacyBlurFeature() {
- const [blurMedia] = useSetting(settingsAtom, 'privacyBlur');
- const [blurAvatars] = useSetting(settingsAtom, 'privacyBlurAvatars');
- const [blurEmotes] = useSetting(settingsAtom, 'privacyBlurEmotes');
-
- useEffect(() => {
- document.body.classList.toggle('sable-blur-media', blurMedia);
- document.body.classList.toggle('sable-blur-avatars', blurAvatars);
- document.body.classList.toggle('sable-blur-emotes', blurEmotes);
- }, [blurMedia, blurAvatars, blurEmotes]);
-
- return null;
-}
+import {
+ InviteNotifications,
+ MessageNotifications,
+ HandleNotificationClick,
+ SyncNotificationSettingsWithServiceWorker,
+ HandleDecryptPushEvent,
+ NativeNotificationClickRouting,
+ NativeNotificationActionRouting,
+} from './client-non-ui/notifications';
+import { PageTitleUpdater, FaviconUpdater } from './client-non-ui/badging';
+import {
+ SystemEmojiFeature,
+ PageZoomFeature,
+ PrivacyBlurFeature,
+} from './client-non-ui/appearance';
+import {
+ HealthMonitor,
+ SentryRoomContextFeature,
+ SentryTagsFeature,
+} from './client-non-ui/telemetry';
+import {
+ SlidingSyncActiveRoomSubscriber,
+ PresenceFeature,
+ SettingsSyncFeature,
+} from './client-non-ui/sync';
+import { GlobalBanners } from './client-non-ui/GlobalBanners';
-// Periodically emits memory-health gauges so Sentry dashboards can surface
-// unbounded growth (e.g. blob cache never evicted, stale inflight requests).
-function HealthMonitor() {
- useEffect(() => {
- const id = window.setInterval(() => {
- const { cacheSize, inflightCount } = getRenderableMediaUrlStats();
- Sentry.metrics.gauge('sable.media.blob_cache_size', cacheSize);
- if (inflightCount > 0) {
- Sentry.metrics.gauge('sable.media.inflight_requests', inflightCount);
- if (inflightCount >= 10) {
- Sentry.addBreadcrumb({
- category: 'media',
- message: `High inflight request count: ${inflightCount}`,
- level: 'warning',
- data: { inflight_count: inflightCount },
- });
- }
- }
- }, 60_000);
- return () => window.clearInterval(id);
- }, []);
- return null;
-}
+export { HandleNotificationClick };
type ClientNonUIFeaturesProps = {
children: ReactNode;
};
-export function HandleNotificationClick() {
- const setPending = useSetAtom(pendingNotificationAtom);
- const setActiveSessionId = useSetAtom(activeSessionIdAtom);
- const setIncomingCall = useSetAtom(incomingCallAtom);
- const mutedRoomId = useAtomValue(mutedCallRoomIdAtom);
- const [incomingVoiceRoomCallSoundEnabled] = useSetting(
- settingsAtom,
- 'incomingVoiceRoomCallSoundEnabled'
- );
- const mDirects = useAtomValue(mDirectAtom);
- const navigate = useNavigate();
-
- useEffect(() => {
- if (!('serviceWorker' in navigator)) return undefined;
-
- const handleMessage = (ev: MessageEvent) => {
- const { data } = ev;
- if (!data || data.type !== 'notificationClick') return;
-
- const { userId, roomId, eventId, isInvite } = data as {
- userId?: string;
- roomId?: string;
- eventId?: string;
- isInvite?: boolean;
- };
-
- if (userId) setActiveSessionId(userId);
-
- if (isInvite) {
- navigate(getInboxInvitesPath());
- return;
- }
-
- if (!roomId) return;
- setPending({ roomId, eventId, targetSessionId: userId });
-
- const incomingCall = resolveIncomingCallFromNotificationData(
- data as Record,
- mDirects.has(roomId)
- );
- if (
- incomingCall &&
- !isIncomingCallSuppressed(incomingCall, mutedRoomId, incomingVoiceRoomCallSoundEnabled)
- ) {
- setIncomingCall(incomingCall);
- }
- };
-
- navigator.serviceWorker.addEventListener('message', handleMessage);
- return () => navigator.serviceWorker.removeEventListener('message', handleMessage);
- }, [
- mDirects,
- mutedRoomId,
- navigate,
- setActiveSessionId,
- setIncomingCall,
- setPending,
- incomingVoiceRoomCallSoundEnabled,
- ]);
-
- return null;
-}
-
-function SyncNotificationSettingsWithServiceWorker() {
- const [showMessageContent] = useSetting(settingsAtom, 'showMessageContentInNotifications');
- const [showEncryptedMessageContent] = useSetting(
- settingsAtom,
- 'showMessageContentInEncryptedNotifications'
- );
- const [clearNotificationsOnRead] = useSetting(settingsAtom, 'clearNotificationsOnRead');
-
- useEffect(() => {
- if (!('serviceWorker' in navigator)) return undefined;
-
- const postVisibility = () => {
- const visible = document.visibilityState === 'visible';
- const msg = { type: 'setAppVisible', visible };
- navigator.serviceWorker.controller?.postMessage(msg);
- navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg));
- };
-
- // Report initial visibility immediately, then track changes.
- postVisibility();
- document.addEventListener('visibilitychange', postVisibility);
- return () => document.removeEventListener('visibilitychange', postVisibility);
- }, []);
-
- useEffect(() => {
- if (!('serviceWorker' in navigator)) return;
- // notificationSoundEnabled is intentionally excluded: push notification sound
- // is governed by the push rule's tweakSound alone (OS/Sygnal handles it).
- // The in-app sound setting only controls the in-page
{hasBanner && (
From dd84e9fd3058d46178cc53bf2b3c1952351f5ab1 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 15:10:25 +0200
Subject: [PATCH 29/43] refactor: fold General.tsx selectors onto
SettingMenuSelector
---
src/app/features/settings/general/General.tsx | 275 ++----------------
1 file changed, 30 insertions(+), 245 deletions(-)
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index 3c8d06bac2..3c035cd13b 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -45,8 +45,9 @@ import {
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { PageContent } from '$components/page';
import { SequenceCard } from '$components/sequence-card';
+import { SettingMenuSelector } from '$components/setting-menu-selector';
import { useSetting } from '$state/hooks/settings';
-import type { DateFormat, EditorButtonId, MessageSpacing, CaptionPosition } from '$state/settings';
+import type { DateFormat, EditorButtonId } from '$state/settings';
import { MessageLayout, RightSwipeAction, settingsAtom } from '$state/settings';
import { SettingTile } from '$components/setting-tile';
import { KeySymbol } from '$utils/key-symbol';
@@ -689,276 +690,60 @@ function Editor() {
}
function SelectMessageLayout() {
- const [menuCords, setMenuCords] = useState();
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
const messageLayoutItems = useMessageLayoutItems();
- const handleMenu: MouseEventHandler = (evt) => {
- setMenuCords(evt.currentTarget.getBoundingClientRect());
- };
-
- const handleSelect = (layout: MessageLayout) => {
- setMessageLayout(layout);
- setMenuCords(undefined);
- };
-
return (
- <>
-
-
- {messageLayoutItems.find((i) => i.layout === messageLayout)?.name ?? messageLayout}
-
-
- setMenuCords(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
- isKeyBackward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
- {messageLayoutItems.map((item) => (
-
- ))}
-
-
-
- }
- />
- >
+ ({ value: item.layout, label: item.name }))}
+ onSelect={setMessageLayout}
+ offset={5}
+ />
);
}
function SelectCaptionPosition() {
- const [menuCords, setMenuCords] = useState();
const [captionPosition, setCaptionPosition] = useSetting(settingsAtom, 'captionPosition');
const captionPositionItems = useCaptionPositionItems();
- const handleMenu: MouseEventHandler = (evt) => {
- setMenuCords(evt.currentTarget.getBoundingClientRect());
- };
-
- const handleSelect = (position: CaptionPosition) => {
- setCaptionPosition(position);
- setMenuCords(undefined);
- };
-
return (
- <>
-
-
- {captionPositionItems.find((i) => i.layout === captionPosition)?.name ?? captionPosition}
-
-
- setMenuCords(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
- isKeyBackward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
- {captionPositionItems.map((item) => (
-
- ))}
-
-
-
- }
- />
- >
+ ({ value: item.layout, label: item.name }))}
+ onSelect={setCaptionPosition}
+ offset={5}
+ />
);
}
function SelectMessageSpacing() {
- const [menuCords, setMenuCords] = useState();
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const messageSpacingItems = useMessageSpacingItems();
- const handleMenu: MouseEventHandler = (evt) => {
- setMenuCords(evt.currentTarget.getBoundingClientRect());
- };
-
- const handleSelect = (layout: MessageSpacing) => {
- setMessageSpacing(layout);
- setMenuCords(undefined);
- };
-
return (
- <>
-
-
- {messageSpacingItems.find((i) => i.spacing === messageSpacing)?.name ?? messageSpacing}
-
-
- setMenuCords(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
- isKeyBackward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
- {messageSpacingItems.map((item) => (
-
- ))}
-
-
-
- }
- />
- >
+ ({ value: item.spacing, label: item.name }))}
+ onSelect={setMessageSpacing}
+ offset={5}
+ />
);
}
function SelectRightSwipeAction({ disabled }: Readonly<{ disabled?: boolean }>) {
- const [menuCords, setMenuCords] = useState();
const [action, setAction] = useSetting(settingsAtom, 'rightSwipeAction');
- const options = [
- { id: RightSwipeAction.Reply, name: 'Reply to Message' },
- { id: RightSwipeAction.Members, name: 'Open Member List' },
- ];
-
- const handleMenu: MouseEventHandler = (evt) => {
- setMenuCords(evt.currentTarget.getBoundingClientRect());
- };
-
- const handleSelect = (val: RightSwipeAction) => {
- setAction(val);
- setMenuCords(undefined);
- };
-
return (
- <>
-
- {options.find((o) => o.id === action)?.name ?? action}
-
- setMenuCords(undefined),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
- {options.map((option) => (
-
- ))}
-
-
-
- }
- />
- >
+
);
}
From 86ab28ab587a96a02982782169035c527d9df4f7 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 15:31:17 +0200
Subject: [PATCH 30/43] fix: restore focus trapping in mobile menus and honour
escape opt-out in modals
---
src/app/components/ResponsiveMenu.tsx | 44 ++++++++++---------
.../components/modal-overlay/ModalOverlay.tsx | 12 +++--
src/app/hooks/useMenuAnchor.ts | 9 +++-
3 files changed, 40 insertions(+), 25 deletions(-)
diff --git a/src/app/components/ResponsiveMenu.tsx b/src/app/components/ResponsiveMenu.tsx
index 1b336b1d27..5bfae104b8 100644
--- a/src/app/components/ResponsiveMenu.tsx
+++ b/src/app/components/ResponsiveMenu.tsx
@@ -61,26 +61,41 @@ export function ResponsiveMenu({
// Null outside a provider, where desktop is the safe assumption.
const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile;
+ const isKeyForward = (evt: KeyboardEvent) =>
+ evt.key === 'ArrowDown' || (arrowNavigation === 'both' && evt.key === 'ArrowRight');
+ const isKeyBackward = (evt: KeyboardEvent) =>
+ evt.key === 'ArrowUp' || (arrowNavigation === 'both' && evt.key === 'ArrowLeft');
+
+ const focusTrapOptions = {
+ initialFocus,
+ fallbackFocus: () => document.body,
+ returnFocusOnDeactivate,
+ onDeactivate: requestClose,
+ clickOutsideDeactivates: true,
+ isKeyForward,
+ isKeyBackward,
+ escapeDeactivates: stopPropagation,
+ };
+
if (isMobile) {
return (
<>
{children}
{anchor && (
- {(dragHandle, dragHandlers) =>
- typeof menu === 'function' ? menu(dragHandle, dragHandlers) : menu
- }
+ {(dragHandle, dragHandlers) => (
+
+
+ {typeof menu === 'function' ? menu(dragHandle, dragHandlers) : menu}
+
+
+ )}
)}
>
);
}
- const isKeyForward = (evt: KeyboardEvent) =>
- evt.key === 'ArrowDown' || (arrowNavigation === 'both' && evt.key === 'ArrowRight');
- const isKeyBackward = (evt: KeyboardEvent) =>
- evt.key === 'ArrowUp' || (arrowNavigation === 'both' && evt.key === 'ArrowLeft');
-
return (
document.body,
- returnFocusOnDeactivate,
- onDeactivate: requestClose,
- clickOutsideDeactivates: true,
- isKeyForward,
- isKeyBackward,
- escapeDeactivates: stopPropagation,
- }}
- >
+
{typeof menu === 'function' ? menu(null, undefined) : menu}
}
diff --git a/src/app/components/modal-overlay/ModalOverlay.tsx b/src/app/components/modal-overlay/ModalOverlay.tsx
index b5a21230ae..8967be3d3a 100644
--- a/src/app/components/modal-overlay/ModalOverlay.tsx
+++ b/src/app/components/modal-overlay/ModalOverlay.tsx
@@ -1,7 +1,7 @@
import type { ComponentProps, MutableRefObject, ReactNode } from 'react';
import FocusTrap from 'focus-trap-react';
import { Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
-import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
+import { ScreenSize, useScreenSizeOptionally } from '$hooks/useScreenSize';
import { stopPropagation } from '$utils/keyboard';
import { useDismissOnBack } from '$utils/androidBack';
@@ -17,6 +17,8 @@ type ModalOverlayProps = {
mobile?: 'centred' | 'fullscreen';
/** The modal element, used as the focus fallback and as the fullscreen wrapper. */
contentRef?: MutableRefObject;
+ /** Set false for flows that Escape must not abort, such as device verification. */
+ escapeDeactivates?: FocusTrapOptions['escapeDeactivates'];
children: ReactNode;
};
@@ -27,9 +29,11 @@ export function ModalOverlay({
initialFocus = false,
mobile = 'centred',
contentRef,
+ escapeDeactivates = stopPropagation,
children,
}: ModalOverlayProps) {
- const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
+ // Null outside a provider, where desktop is the safe assumption.
+ const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile;
// Android back closes the overlay instead of navigating away.
useDismissOnBack(requestClose, open);
@@ -40,7 +44,7 @@ export function ModalOverlay({
@@ -65,7 +69,7 @@ export function ModalOverlay({
fallbackFocus: () => contentRef?.current ?? document.body,
clickOutsideDeactivates: dismissOnClickOutside,
onDeactivate: requestClose,
- escapeDeactivates: stopPropagation,
+ escapeDeactivates,
}}
>
{children}
diff --git a/src/app/hooks/useMenuAnchor.ts b/src/app/hooks/useMenuAnchor.ts
index 51ee72db04..ab7fe0d85d 100644
--- a/src/app/hooks/useMenuAnchor.ts
+++ b/src/app/hooks/useMenuAnchor.ts
@@ -47,7 +47,14 @@ export function useMenuAnchor(): MenuAnchor
if (triggerRef.current) openAt(triggerRef.current);
});
- const onClick: MouseEventHandler = useCallback((evt) => openAt(evt.currentTarget), [openAt]);
+ const onClick: MouseEventHandler = useCallback(
+ (evt) => {
+ // The long-press timer already opened it; the synthetic click would toggle it shut.
+ if (longPress.firedRef.current) return;
+ openAt(evt.currentTarget);
+ },
+ [longPress, openAt]
+ );
const onContextMenu: MouseEventHandler = useCallback(
(evt) => {
From 77fb621ab83320cded8dd595fb76a40ee03a7e7f Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 15:31:18 +0200
Subject: [PATCH 31/43] fix: apply nicknames to thread mentions and route room
avatars through the tauri media rewrite
---
src/app/features/room/ThreadDrawer.tsx | 5 +-
src/app/utils/matrix.ts | 72 ++------------------------
src/app/utils/mediaUrl.ts | 66 +++++++++++++++++++++++
src/app/utils/room.ts | 8 ++-
4 files changed, 75 insertions(+), 76 deletions(-)
create mode 100644 src/app/utils/mediaUrl.ts
diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx
index 90da2e83cd..e487f74368 100644
--- a/src/app/features/room/ThreadDrawer.tsx
+++ b/src/app/features/room/ThreadDrawer.tsx
@@ -596,9 +596,8 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
evt.preventDefault();
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) return;
- const localNicknames = undefined; // will be resolved via getMemberDisplayName in editor
const name =
- getMemberDisplayName(room, userId, localNicknames) ?? getMxIdLocalPart(userId) ?? userId;
+ getMemberDisplayName(room, userId, nicknames) ?? getMxIdLocalPart(userId) ?? userId;
editor.insertNode(
createMentionElement(
userId,
@@ -609,7 +608,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
ReactEditor.focus(editor);
moveCursor(editor);
},
- [mx, room, editor]
+ [mx, room, editor, nicknames]
);
const handleReplyClick: MouseEventHandler = useCallback(
diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts
index 12c6ddc115..df72ca00a0 100644
--- a/src/app/utils/matrix.ts
+++ b/src/app/utils/matrix.ts
@@ -1,6 +1,6 @@
import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { decryptAttachment } from 'browser-encrypt-attachment';
-import { Channel, convertFileSrc, invoke, isTauri } from '@tauri-apps/api/core';
+import { Channel, invoke, isTauri } from '@tauri-apps/api/core';
import type {
AccountDataEvents,
EventTimelineSet,
@@ -28,21 +28,11 @@ import { encryptAttachmentStreaming } from '$utils/attachmentCrypto';
import { getEventReactions, getStateEvent } from './room';
import { getReactionContent } from './messageReaction';
import { matchMxId, validMxId } from './mxIdHelper';
-import {
- fetchMediaBlob,
- getCurrentMediaSessionScope,
- type MediaTransportOptions,
-} from './mediaTransport';
+
+export { mxcUrlToHttp, rewriteAuthenticatedMediaUrl } from './mediaUrl';
+import { fetchMediaBlob, type MediaTransportOptions } from './mediaTransport';
const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/;
-const TAURI_MEDIA_CACHE_VERSION = '__sable_media_cache=3';
-const TAURI_MEDIA_PATH_PREFIXES = [
- '/_matrix/client/v1/media/',
- '/_matrix/media/v3/download/',
- '/_matrix/media/v3/thumbnail/',
- '/_matrix/media/r0/download/',
- '/_matrix/media/r0/thumbnail/',
-];
export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName);
@@ -482,60 +472,6 @@ export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string):
);
};
-export const rewriteAuthenticatedMediaUrl = (httpUrl: string | null): string | null => {
- if (!httpUrl) return null;
- if (!isTauri()) return httpUrl;
- const sourceUrl = httpUrl.startsWith('sable-media://')
- ? httpUrl.slice('sable-media://'.length)
- : httpUrl;
- let parsedUrl: URL;
- try {
- parsedUrl = new URL(sourceUrl);
- } catch {
- return httpUrl;
- }
- if (
- (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') ||
- parsedUrl.origin === 'null' ||
- !TAURI_MEDIA_PATH_PREFIXES.some((path) => parsedUrl.pathname.startsWith(path))
- ) {
- return httpUrl;
- }
- if (httpUrl.includes(TAURI_MEDIA_CACHE_VERSION)) return httpUrl;
- const mediaUrl = httpUrl.startsWith('sable-media://')
- ? httpUrl
- : convertFileSrc(httpUrl, 'sable-media');
- // Session-scoped so the cacheable response is never shared across accounts.
- const sessionScope = encodeURIComponent(getCurrentMediaSessionScope());
- const separator = mediaUrl.includes('?') ? '&' : '?';
- return `${mediaUrl}${separator}${TAURI_MEDIA_CACHE_VERSION}&__sable_media_session=${sessionScope}`;
-};
-
-export const mxcUrlToHttp = (
- mx: MatrixClient,
- mxcUrl: string,
- useAuthentication?: boolean,
- width?: number,
- height?: number,
- resizeMethod?: string,
- allowDirectLinks?: boolean
-): string | null => {
- const httpUrl = mx.mxcUrlToHttp(
- mxcUrl.replace(/^["']|["']$/g, ''),
- width,
- height,
- resizeMethod,
- allowDirectLinks,
- undefined,
- useAuthentication
- );
-
- if (httpUrl && isTauri()) {
- return rewriteAuthenticatedMediaUrl(httpUrl);
- }
- return httpUrl;
-};
-
export const downloadMedia = async (src: string, options?: MediaTransportOptions): Promise =>
fetchMediaBlob(src, options);
diff --git a/src/app/utils/mediaUrl.ts b/src/app/utils/mediaUrl.ts
new file mode 100644
index 0000000000..418becff20
--- /dev/null
+++ b/src/app/utils/mediaUrl.ts
@@ -0,0 +1,66 @@
+import { convertFileSrc, isTauri } from '@tauri-apps/api/core';
+import type { MatrixClient } from '$types/matrix-sdk';
+import { getCurrentMediaSessionScope } from './mediaTransport';
+
+const TAURI_MEDIA_CACHE_VERSION = '__sable_media_cache=3';
+const TAURI_MEDIA_PATH_PREFIXES = [
+ '/_matrix/client/v1/media/',
+ '/_matrix/media/v3/download/',
+ '/_matrix/media/v3/thumbnail/',
+ '/_matrix/media/r0/download/',
+ '/_matrix/media/r0/thumbnail/',
+];
+
+export const rewriteAuthenticatedMediaUrl = (httpUrl: string | null): string | null => {
+ if (!httpUrl) return null;
+ if (!isTauri()) return httpUrl;
+ const sourceUrl = httpUrl.startsWith('sable-media://')
+ ? httpUrl.slice('sable-media://'.length)
+ : httpUrl;
+ let parsedUrl: URL;
+ try {
+ parsedUrl = new URL(sourceUrl);
+ } catch {
+ return httpUrl;
+ }
+ if (
+ (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') ||
+ parsedUrl.origin === 'null' ||
+ !TAURI_MEDIA_PATH_PREFIXES.some((path) => parsedUrl.pathname.startsWith(path))
+ ) {
+ return httpUrl;
+ }
+ if (httpUrl.includes(TAURI_MEDIA_CACHE_VERSION)) return httpUrl;
+ const mediaUrl = httpUrl.startsWith('sable-media://')
+ ? httpUrl
+ : convertFileSrc(httpUrl, 'sable-media');
+ // Session-scoped so the cacheable response is never shared across accounts.
+ const sessionScope = encodeURIComponent(getCurrentMediaSessionScope());
+ const separator = mediaUrl.includes('?') ? '&' : '?';
+ return `${mediaUrl}${separator}${TAURI_MEDIA_CACHE_VERSION}&__sable_media_session=${sessionScope}`;
+};
+
+export const mxcUrlToHttp = (
+ mx: MatrixClient,
+ mxcUrl: string,
+ useAuthentication?: boolean,
+ width?: number,
+ height?: number,
+ resizeMethod?: string,
+ allowDirectLinks?: boolean
+): string | null => {
+ const httpUrl = mx.mxcUrlToHttp(
+ mxcUrl.replace(/^["']|["']$/g, ''),
+ width,
+ height,
+ resizeMethod,
+ allowDirectLinks,
+ undefined,
+ useAuthentication
+ );
+
+ if (httpUrl && isTauri()) {
+ return rewriteAuthenticatedMediaUrl(httpUrl);
+ }
+ return httpUrl;
+};
diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts
index 4a0abc756c..938af94626 100644
--- a/src/app/utils/room.ts
+++ b/src/app/utils/room.ts
@@ -26,6 +26,7 @@ import {
import type { IRoomCreateContent, RoomToParents, UnreadInfo } from '$types/matrix/room';
import { NotificationType } from '$types/matrix/room';
import { getMxIdLocalPart } from '$utils/matrix';
+import { mxcUrlToHttp } from './mediaUrl';
export const getStateEvent = (
room: Room,
@@ -566,8 +567,7 @@ export const getRoomAvatarUrl = (
): string | undefined => {
const mxcUrl = room.getMxcAvatarUrl();
return mxcUrl
- ? (mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ??
- undefined)
+ ? (mxcUrlToHttp(mx, mxcUrl, useAuthentication, size, size, 'crop') ?? undefined)
: undefined;
};
@@ -583,9 +583,7 @@ export const getDirectRoomAvatarUrl = (
return getRoomAvatarUrl(mx, room, size, useAuthentication);
}
- return (
- mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined
- );
+ return mxcUrlToHttp(mx, mxcUrl, useAuthentication, size, size, 'crop') ?? undefined;
};
export const trimReplyFromBody = (body: string): string => {
From ddd5edc1905c2c074f73a6c903e51180c5f2792d Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 15:50:41 +0200
Subject: [PATCH 32/43] fix: apply inline image sizing in threads, collapse the
duplicated setting option lists
---
src/app/components/emoji-board/EmojiBoard.tsx | 113 +-----------
.../components/emoji-board/useGifSearch.ts | 104 +++++++++++
.../components/setting-menu-selector/index.ts | 1 +
.../setting-menu-selector/options.ts | 60 ++++++
.../common-settings/appearance/Appearance.tsx | 25 +--
src/app/features/room/ThreadDrawer.tsx | 9 +
.../cosmetics/ThemeCatalogSettings.tsx | 19 +-
.../features/settings/cosmetics/Themes.tsx | 174 +++---------------
.../general/CallSoundSettings.test.tsx | 19 +-
src/app/features/settings/general/General.tsx | 22 +--
src/app/hooks/useCaptionPosition.ts | 30 ---
src/app/hooks/useMessageLayout.ts | 26 ---
src/app/hooks/useMessageSpacing.ts | 38 ----
src/app/hooks/usePanelSizes.ts | 41 -----
src/app/hooks/useShowRoomIcon.ts | 57 ------
15 files changed, 238 insertions(+), 500 deletions(-)
create mode 100644 src/app/components/emoji-board/useGifSearch.ts
create mode 100644 src/app/components/setting-menu-selector/options.ts
delete mode 100644 src/app/hooks/useCaptionPosition.ts
delete mode 100644 src/app/hooks/useMessageLayout.ts
delete mode 100644 src/app/hooks/useMessageSpacing.ts
delete mode 100644 src/app/hooks/usePanelSizes.ts
delete mode 100644 src/app/hooks/useShowRoomIcon.ts
diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx
index 897043eec6..9d6e446fe4 100644
--- a/src/app/components/emoji-board/EmojiBoard.tsx
+++ b/src/app/components/emoji-board/EmojiBoard.tsx
@@ -22,7 +22,6 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { useRecentEmoji } from '$hooks/useRecentEmoji';
import { isUserId, mxcUrlToHttp } from '$utils/matrix';
import { editableActiveElement, targetFromEvent } from '$utils/dom';
-import { fetch } from '$utils/fetch';
import type { UseAsyncSearchOptions } from '$hooks/useAsyncSearch';
import { useAsyncSearch } from '$hooks/useAsyncSearch';
import { useDebounce } from '$hooks/useDebounce';
@@ -60,7 +59,7 @@ import {
} from './components';
import type { GifData } from './types';
import { EmojiBoardTab, EmojiType } from './types';
-import { useClientConfig } from '$hooks/useClientConfig';
+import { useGifSearch } from './useGifSearch';
import { useFavoriteGifs } from '$hooks/useFavoriteGifs';
/* oxlint-disable typescript/no-explicit-any */
@@ -510,110 +509,12 @@ export function EmojiBoard({
const searchedItems = emojiResult?.items.slice(0, 100);
const searchedGifItems = gifResult?.items.slice(0, 100) ?? favoriteGifs.toReversed();
- function useGifSearch() {
- const [gifs, setGifs] = useState<{
- gifs: GifData[];
- favorites: GifData[];
- }>({
- gifs: [],
- favorites: favoriteGifs,
- });
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const clientConfig = useClientConfig();
- const klipyApiKey = clientConfig.gifs?.klipyApiKey ?? '';
-
- const parseKlipyResult = useCallback((klipyResult: any): GifData => {
- const SIZE_LIMIT = 3 * 1024 * 1024; // 3MB
-
- const formats = klipyResult.file || {};
- const preview = formats.xs.gif || formats.sm.gif || formats.md.gif;
-
- // Start with full resolution GIF
- let fullRes = formats.hd.gif;
- // If full res is too large and medium exists, use medium instead
- if (fullRes && fullRes.size > SIZE_LIMIT && formats.md) {
- fullRes = formats.md.gif;
- }
-
- // Fallback if no suitable format found
- if (!fullRes) {
- fullRes = formats.md || preview;
- }
-
- // Get dimensions from the selected full resolution format
- const width = fullRes?.width || preview?.width || 0;
- const height = fullRes?.height || preview?.height || 0;
-
- return {
- id: klipyResult.id,
- title: klipyResult.title || 'GIF',
- url: fullRes?.url || '',
- preview_url: preview?.url || fullRes?.url || '',
- width,
- height,
- size: fullRes?.size || preview?.size || 0,
- mimetype: 'image/gif',
- };
- }, []);
-
- const searchGifs = useCallback(
- async (query: string) => {
- if (!showGifPicker) {
- return;
- }
-
- const trimmedQuery = query.trim();
-
- setLoading(true);
- setError(null);
-
- gifSearch(trimmedQuery);
-
- try {
- const url = new URL('https://api.klipy.com');
- url.pathname = `/api/v1/${klipyApiKey}/gifs/search`;
- url.searchParams.set('q', trimmedQuery);
- url.searchParams.set('per_page', '50'); // TODO: infinite scroll?
-
- const response = await fetch(url.toString());
-
- if (response.status === 200) {
- const data = await response.json();
- const results = data.data.data as any[] | undefined;
-
- if (results) {
- const gifData: GifData[] = results.map(parseKlipyResult);
- setGifs((old) => ({
- ...old,
- gifs: gifData,
- }));
- } else {
- setGifs((old) => ({
- ...old,
- gifs: [],
- }));
- }
- } else {
- throw new Error(`HTTP ${response.status}`);
- }
- } catch {
- setError('Failed to search GIFs');
- setGifs((old) => ({
- ...old,
- gifs: [],
- }));
- } finally {
- setLoading(false);
- }
- },
- [parseKlipyResult, klipyApiKey]
- );
-
- return { gifs, loading, error, searchGifs };
- }
-
- const { gifs, loading: gifsLoading, error: gifsError, searchGifs } = useGifSearch();
+ const {
+ gifs,
+ loading: gifsLoading,
+ error: gifsError,
+ searchGifs,
+ } = useGifSearch(favoriteGifs, showGifPicker, gifSearch);
const [emojiGroupItems, stickerGroupItems, gifGroupItems] = useGroups(tab, imagePacks, gifs);
const [showFavoritesOnly, setShowFavoritesOnly] = useState(true);
const groupsByTab = {
diff --git a/src/app/components/emoji-board/useGifSearch.ts b/src/app/components/emoji-board/useGifSearch.ts
new file mode 100644
index 0000000000..7210c0ffe1
--- /dev/null
+++ b/src/app/components/emoji-board/useGifSearch.ts
@@ -0,0 +1,104 @@
+import { useCallback, useState } from 'react';
+import type { AsyncSearchHandler } from '$utils/AsyncSearch';
+import { fetch } from '$utils/fetch';
+import { useClientConfig } from '$hooks/useClientConfig';
+import type { GifData } from './types';
+
+/* oxlint-disable typescript/no-explicit-any */
+// TODO: type klipy api properly
+
+const SIZE_LIMIT = 3 * 1024 * 1024;
+
+const parseKlipyResult = (klipyResult: any): GifData => {
+ const formats = klipyResult.file || {};
+ const preview = formats.xs.gif || formats.sm.gif || formats.md.gif;
+
+ // Start with full resolution GIF
+ let fullRes = formats.hd.gif;
+ // If full res is too large and medium exists, use medium instead
+ if (fullRes && fullRes.size > SIZE_LIMIT && formats.md) {
+ fullRes = formats.md.gif;
+ }
+
+ // Fallback if no suitable format found
+ if (!fullRes) {
+ fullRes = formats.md || preview;
+ }
+
+ // Get dimensions from the selected full resolution format
+ const width = fullRes?.width || preview?.width || 0;
+ const height = fullRes?.height || preview?.height || 0;
+
+ return {
+ id: klipyResult.id,
+ title: klipyResult.title || 'GIF',
+ url: fullRes?.url || '',
+ preview_url: preview?.url || fullRes?.url || '',
+ width,
+ height,
+ size: fullRes?.size || preview?.size || 0,
+ mimetype: 'image/gif',
+ };
+};
+
+export function useGifSearch(
+ favoriteGifs: GifData[],
+ showGifPicker: boolean,
+ gifSearch: AsyncSearchHandler
+) {
+ const [gifs, setGifs] = useState<{ gifs: GifData[]; favorites: GifData[] }>({
+ gifs: [],
+ favorites: favoriteGifs,
+ });
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const clientConfig = useClientConfig();
+ const klipyApiKey = clientConfig.gifs?.klipyApiKey ?? '';
+
+ const searchGifs = useCallback(
+ async (query: string) => {
+ if (!showGifPicker) {
+ return;
+ }
+
+ const trimmedQuery = query.trim();
+
+ setLoading(true);
+ setError(null);
+
+ gifSearch(trimmedQuery);
+
+ try {
+ const url = new URL('https://api.klipy.com');
+ url.pathname = `/api/v1/${klipyApiKey}/gifs/search`;
+ url.searchParams.set('q', trimmedQuery);
+ url.searchParams.set('per_page', '50'); // TODO: infinite scroll?
+
+ const response = await fetch(url.toString());
+
+ if (response.status === 200) {
+ const data = await response.json();
+ const results = data.data.data as any[] | undefined;
+
+ setGifs((old) => ({
+ ...old,
+ gifs: results ? results.map(parseKlipyResult) : [],
+ }));
+ } else {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ } catch {
+ setError('Failed to search GIFs');
+ setGifs((old) => ({
+ ...old,
+ gifs: [],
+ }));
+ } finally {
+ setLoading(false);
+ }
+ },
+ [klipyApiKey, showGifPicker, gifSearch]
+ );
+
+ return { gifs, loading, error, searchGifs };
+}
diff --git a/src/app/components/setting-menu-selector/index.ts b/src/app/components/setting-menu-selector/index.ts
index 6aaeff0de5..e83905502e 100644
--- a/src/app/components/setting-menu-selector/index.ts
+++ b/src/app/components/setting-menu-selector/index.ts
@@ -1 +1,2 @@
export * from './SettingMenuSelector';
+export * from './options';
diff --git a/src/app/components/setting-menu-selector/options.ts b/src/app/components/setting-menu-selector/options.ts
new file mode 100644
index 0000000000..ba91229bf4
--- /dev/null
+++ b/src/app/components/setting-menu-selector/options.ts
@@ -0,0 +1,60 @@
+import type { MessageSpacing } from '$state/settings';
+import { CaptionPosition, MessageLayout, ShowRoomIcon } from '$state/settings';
+import type { SettingMenuOption } from './SettingMenuSelector';
+
+export const MESSAGE_LAYOUT_OPTIONS: SettingMenuOption[] = [
+ { value: MessageLayout.Modern, label: 'Modern' },
+ { value: MessageLayout.Compact, label: 'Compact' },
+ { value: MessageLayout.Bubble, label: 'Bubble' },
+];
+
+export const MESSAGE_SPACING_OPTIONS: SettingMenuOption[] = [
+ { value: '0', label: 'None' },
+ { value: '100', label: 'Ultra Small' },
+ { value: '200', label: 'Extra Small' },
+ { value: '300', label: 'Small' },
+ { value: '400', label: 'Normal' },
+ { value: '500', label: 'Large' },
+];
+
+export const CAPTION_POSITION_OPTIONS: SettingMenuOption[] = [
+ { value: CaptionPosition.Above, label: 'Above' },
+ { value: CaptionPosition.Inline, label: 'Inline' },
+ { value: CaptionPosition.Hidden, label: 'Hidden' },
+ { value: CaptionPosition.Below, label: 'Below' },
+];
+
+export const SHOW_ROOM_ICON_OPTIONS: SettingMenuOption[] = [
+ { value: ShowRoomIcon.Always, label: 'Always' },
+ { value: ShowRoomIcon.Strict, label: 'Sometimes' },
+ { value: ShowRoomIcon.Smart, label: 'Collapsed' },
+ { value: ShowRoomIcon.Never, label: 'Never' },
+];
+
+/** Sentinel for rooms that follow the account wide icon setting. */
+export const SHOW_ROOM_ICON_DEFAULT = 'default';
+export type ShowRoomIconValue = ShowRoomIcon | typeof SHOW_ROOM_ICON_DEFAULT;
+
+export const PER_ROOM_SHOW_ROOM_ICON_OPTIONS: SettingMenuOption[] = [
+ { value: SHOW_ROOM_ICON_DEFAULT, label: 'Default' },
+ ...SHOW_ROOM_ICON_OPTIONS,
+];
+
+export type PanelSizeKey =
+ | 'roomSidebarWidth'
+ | 'memberSidebarWidth'
+ | 'threadSidebarWidth'
+ | 'threadRootHeight'
+ | 'vcmsgSidebarWidth'
+ | 'widgetSidebarWidth'
+ | 'roomBannerHeight';
+
+export const PANEL_SIZE_OPTIONS: SettingMenuOption[] = [
+ { value: 'roomSidebarWidth', label: 'Room Panel Width' },
+ { value: 'memberSidebarWidth', label: 'Member Panel Width' },
+ { value: 'threadSidebarWidth', label: 'Thread Panel Width' },
+ { value: 'threadRootHeight', label: 'Thread Root Height' },
+ { value: 'vcmsgSidebarWidth', label: 'VoiceCall Msg Panel Width' },
+ { value: 'widgetSidebarWidth', label: 'Widget Panel Width' },
+ { value: 'roomBannerHeight', label: 'Room Banner Height' },
+];
diff --git a/src/app/features/common-settings/appearance/Appearance.tsx b/src/app/features/common-settings/appearance/Appearance.tsx
index 88f7261f32..71995291e9 100644
--- a/src/app/features/common-settings/appearance/Appearance.tsx
+++ b/src/app/features/common-settings/appearance/Appearance.tsx
@@ -5,39 +5,30 @@ import { SettingTile } from '$components/setting-tile';
import { useRoom } from '$hooks/useRoom';
import { SequenceCardStyle } from '$features/common-settings/styles.css';
-import { useShowPerRoomRoomIcon } from '$hooks/useShowRoomIcon';
import { useSetting } from '$state/hooks/settings';
-import type { ShowRoomIcon } from '$state/settings';
import { settingsAtom } from '$state/settings';
import {
+ PER_ROOM_SHOW_ROOM_ICON_OPTIONS,
+ SHOW_ROOM_ICON_DEFAULT,
SettingMenuSelector,
- type SettingMenuOption,
-} from '$components/setting-menu-selector/SettingMenuSelector';
-
-const DEFAULT_LAYOUT = 'default';
-type LayoutValue = ShowRoomIcon | typeof DEFAULT_LAYOUT;
+ type ShowRoomIconValue,
+} from '$components/setting-menu-selector';
export function SelectShowPerRoomRoomIcon({ roomId }: { roomId: string }) {
- const showRoomIconItems = useShowPerRoomRoomIcon();
const [showRoomIconArray, setShowRoomIconArray] = useSetting(settingsAtom, 'perRoomShowRoomIcon');
const showRoomIcon = showRoomIconArray?.find((item) => item.roomId === roomId)?.display;
- const handleSelect = (position: LayoutValue) => {
+ const handleSelect = (position: ShowRoomIconValue) => {
let newShowRoomIconArray = showRoomIconArray.filter((item) => item.roomId !== roomId);
- if (position !== DEFAULT_LAYOUT)
+ if (position !== SHOW_ROOM_ICON_DEFAULT)
newShowRoomIconArray = [...newShowRoomIconArray, { roomId, display: position }];
setShowRoomIconArray(newShowRoomIconArray);
};
- const options: SettingMenuOption[] = showRoomIconItems.map((item) => ({
- value: item.layout ?? DEFAULT_LAYOUT,
- label: item.name,
- }));
-
return (
(
diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx
index e487f74368..ccc11b6fdc 100644
--- a/src/app/features/room/ThreadDrawer.tsx
+++ b/src/app/features/room/ThreadDrawer.tsx
@@ -167,6 +167,11 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
const [showInteractiveMap] = useSetting(settingsAtom, 'showInteractiveMap');
const [showEncInteractiveMap] = useSetting(settingsAtom, 'showEncInteractiveMap');
const showMaps = room.hasEncryptionStateEvent() ? showEncInteractiveMap : showInteractiveMap;
+ const [incomingInlineImagesDefaultHeight] = useSetting(
+ settingsAtom,
+ 'incomingInlineImagesDefaultHeight'
+ );
+ const [incomingInlineImagesMaxHeight] = useSetting(settingsAtom, 'incomingInlineImagesMaxHeight');
// Memoized parsing options
const linkifyOpts = useMemo(
@@ -200,6 +205,8 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
handleMentionClick: mentionClickHandler,
nicknames,
autoplayEmojis,
+ incomingInlineImagesDefaultHeight,
+ incomingInlineImagesMaxHeight,
replaceTextNode: buildAbbrReplaceTextNode(abbrMap, linkifyOpts),
}),
[
@@ -212,6 +219,8 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
nicknames,
settingsLinkBaseUrl,
autoplayEmojis,
+ incomingInlineImagesDefaultHeight,
+ incomingInlineImagesMaxHeight,
abbrMap,
]
);
diff --git a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx
index 0111c6ec5a..cf48769131 100644
--- a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx
+++ b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx
@@ -100,12 +100,12 @@ export type LocalTweakRow = ThemeRemoteTweakFavorite & {
tags: string[];
};
-export type ThemeCatalogSettingsMode = 'full' | 'local' | 'chat' | 'remote' | 'appearance';
+export type ThemeCatalogSettingsMode = 'local' | 'appearance';
export { usePatchSettings } from './themeSettingsPatch';
type ThemeCatalogSettingsProps = {
- mode?: ThemeCatalogSettingsMode;
+ mode: ThemeCatalogSettingsMode;
onBrowseOpenChange?: (open: boolean) => void;
};
@@ -226,10 +226,7 @@ function CatalogTweakCard({
);
}
-export function ThemeCatalogSettings({
- mode = 'full',
- onBrowseOpenChange,
-}: ThemeCatalogSettingsProps) {
+export function ThemeCatalogSettings({ mode, onBrowseOpenChange }: ThemeCatalogSettingsProps) {
const clientConfig = useClientConfig();
const patchSettings = usePatchSettings();
const queryClient = useQueryClient();
@@ -250,12 +247,10 @@ export function ThemeCatalogSettings({
}
}, [browseOpen, isAppearanceMode, onBrowseOpenChange]);
- const isRemoteMode = mode === 'remote' || mode === 'full' || (isAppearanceMode && browseOpen);
- const isChatMode = mode === 'chat' || mode === 'full' || (isAppearanceMode && !browseOpen);
- const showAssignmentChrome =
- mode === 'full' || mode === 'local' || (isAppearanceMode && !browseOpen);
- const showSavedLibrary =
- (mode === 'full' || mode === 'local' || isAppearanceMode) && !(isAppearanceMode && browseOpen);
+ const isRemoteMode = isAppearanceMode && browseOpen;
+ const isChatMode = isAppearanceMode && !browseOpen;
+ const showAssignmentChrome = mode === 'local' || (isAppearanceMode && !browseOpen);
+ const showSavedLibrary = !(isAppearanceMode && browseOpen);
const [getFavorites] = useSetting(settingsAtom, 'themeRemoteFavorites');
const [favorites, setFavorites] = useState(getFavorites ? getFavorites : []);
diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx
index 45fed9e558..231dbf3775 100644
--- a/src/app/features/settings/cosmetics/Themes.tsx
+++ b/src/app/features/settings/cosmetics/Themes.tsx
@@ -1,23 +1,16 @@
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
import { type MouseEventHandler, useEffect, useMemo, useState } from 'react';
-import {
- Box,
- Button,
- Chip,
- config,
- Input,
- Menu,
- MenuItem,
- PopOut,
- Switch,
- Text,
- toRem,
- type RectCords,
-} from 'folds';
-import { CaretDown, composerIcon, menuIcon } from '$components/icons/phosphor';
+import { Box, Chip, config, Input, Switch, Text, toRem } from 'folds';
+import { CaretDown, menuIcon } from '$components/icons/phosphor';
import { isKeyHotkey } from 'is-hotkey';
-import { SettingMenuSelector, type SettingMenuOption } from '$components/setting-menu-selector';
+import {
+ PANEL_SIZE_OPTIONS,
+ SHOW_ROOM_ICON_OPTIONS,
+ SettingMenuSelector,
+ type PanelSizeKey,
+ type SettingMenuOption,
+} from '$components/setting-menu-selector';
import { SequenceCard } from '$components/sequence-card';
import { SettingTile } from '$components/setting-tile';
import {
@@ -33,11 +26,6 @@ import { ShowRoomIcon } from '$state/settings';
import { settingsAtom } from '$state/settings';
import { SequenceCardStyle } from '$features/settings/styles.css';
import { ThemeAppearanceSection } from './ThemeAppearanceSection';
-import { stopPropagation } from '$utils/keyboard';
-import FocusTrap from 'focus-trap-react';
-import { useShowRoomIcon } from '$hooks/useShowRoomIcon';
-import type { PanelSizetItem } from '$hooks/usePanelSizes';
-import { usePanelSizeItems } from '$hooks/usePanelSizes';
import { SelectShowPerRoomRoomIcon } from '$features/common-settings/appearance/Appearance';
const clampIncomingInlineImageHeight = (n: number) => Math.max(1, Math.min(4096, n));
@@ -575,76 +563,18 @@ function PanelSelector({
sidebarSelector,
setSidebarSelector,
}: {
- sidebarSelector: string;
- setSidebarSelector: (arg0: string) => void;
+ sidebarSelector: PanelSizeKey;
+ setSidebarSelector: (key: PanelSizeKey) => void;
}) {
- const [menuCords, setMenuCords] = useState();
- const panelSizeItems = usePanelSizeItems();
-
- const handleMenu: MouseEventHandler = (evt) => {
- setMenuCords(evt.currentTarget.getBoundingClientRect());
- };
-
- const handleSelect = (position: PanelSizetItem) => {
- setSidebarSelector(position.layout);
- setMenuCords(undefined);
- };
-
return (
- <>
-
-
- {panelSizeItems.find((i) => i.layout === sidebarSelector)?.name ?? sidebarSelector}
-
-
- setMenuCords(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
- isKeyBackward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
- {panelSizeItems.map((item) => (
-
- ))}
-
-
-
- }
- />
- >
+
);
}
-function SidebarWidth({ sidebarSelector }: { sidebarSelector: string }) {
+function SidebarWidth({ sidebarSelector }: { sidebarSelector: PanelSizeKey }) {
const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth');
const [memberSidebarWidth, setMemberSidebarWidth] = useSetting(
settingsAtom,
@@ -742,72 +672,14 @@ function SidebarWidth({ sidebarSelector }: { sidebarSelector: string }) {
}
function SelectShowRoomIcon() {
- const [menuCords, setMenuCords] = useState();
const [showRoomIcon, setShowRoomIcon] = useSetting(settingsAtom, 'showRoomIcon');
- const showRoomIconItems = useShowRoomIcon();
-
- const handleMenu: MouseEventHandler = (evt) => {
- setMenuCords(evt.currentTarget.getBoundingClientRect());
- };
-
- const handleSelect = (position?: ShowRoomIcon) => {
- if (!position) return;
- setShowRoomIcon(position);
- setMenuCords(undefined);
- };
return (
- <>
-
-
- {showRoomIconItems.find((i) => i.layout === showRoomIcon)?.name ?? showRoomIcon}
-
-
- setMenuCords(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
- isKeyBackward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
- {showRoomIconItems.map((item) => (
-
- ))}
-
-
-
- }
- />
- >
+
);
}
export function Appearance({
@@ -815,7 +687,7 @@ export function Appearance({
}: {
onThemeBrowserOpenChange?: (open: boolean) => void;
} = {}) {
- const [sidebarSelector, setSidebarSelector] = useState('roomSidebarWidth');
+ const [sidebarSelector, setSidebarSelector] = useState('roomSidebarWidth');
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
const [customDMCards, setCustomDMCards] = useSetting(settingsAtom, 'customDMCards');
const [showEasterEggs, setShowEasterEggs] = useSetting(settingsAtom, 'showEasterEggs');
diff --git a/src/app/features/settings/general/CallSoundSettings.test.tsx b/src/app/features/settings/general/CallSoundSettings.test.tsx
index 16bf0ca049..a3c1207df8 100644
--- a/src/app/features/settings/general/CallSoundSettings.test.tsx
+++ b/src/app/features/settings/general/CallSoundSettings.test.tsx
@@ -3,15 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useSetting } from '$state/hooks/settings';
import { CallSoundSettings } from './CallSoundSettings';
-vi.mock('$state/settings', () => ({
- CALL_TONE_IDS: ['sable-default', 'classic-soft', 'minimal-ping', 'silent', 'custom'],
- settingsAtom: {},
- getSettings: () => ({
- iconCompactSizePx: 16,
- iconInlineSizePx: 20,
- iconToolbarSizePx: 24,
- iconEmptySizePx: 32,
- }),
+vi.mock(import('$state/settings'), async (importOriginal) => ({
+ ...(await importOriginal()),
+ settingsAtom: {} as never,
+ getSettings: () =>
+ ({
+ iconCompactSizePx: 16,
+ iconInlineSizePx: 20,
+ iconToolbarSizePx: 24,
+ iconEmptySizePx: 32,
+ }) as never,
}));
vi.mock('$state/hooks/settings', () => ({
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index 3c035cd13b..5f4be2bcb5 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -45,7 +45,12 @@ import {
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { PageContent } from '$components/page';
import { SequenceCard } from '$components/sequence-card';
-import { SettingMenuSelector } from '$components/setting-menu-selector';
+import {
+ CAPTION_POSITION_OPTIONS,
+ MESSAGE_LAYOUT_OPTIONS,
+ MESSAGE_SPACING_OPTIONS,
+ SettingMenuSelector,
+} from '$components/setting-menu-selector';
import { useSetting } from '$state/hooks/settings';
import type { DateFormat, EditorButtonId } from '$state/settings';
import { MessageLayout, RightSwipeAction, settingsAtom } from '$state/settings';
@@ -53,9 +58,6 @@ import { SettingTile } from '$components/setting-tile';
import { KeySymbol } from '$utils/key-symbol';
import { isMacOS, mobileOrTablet } from '$utils/user-agent';
import { stopPropagation } from '$utils/keyboard';
-import { useMessageLayoutItems } from '$hooks/useMessageLayout';
-import { useCaptionPositionItems } from '$hooks/useCaptionPosition';
-import { useMessageSpacingItems } from '$hooks/useMessageSpacing';
import { useDateFormatItems } from '$hooks/useDateFormat';
import { SequenceCardStyle } from '$features/settings/styles.css';
import { sessionsAtom, activeSessionIdAtom } from '$state/sessions';
@@ -691,41 +693,35 @@ function Editor() {
function SelectMessageLayout() {
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
- const messageLayoutItems = useMessageLayoutItems();
return (
({ value: item.layout, label: item.name }))}
+ options={MESSAGE_LAYOUT_OPTIONS}
onSelect={setMessageLayout}
- offset={5}
/>
);
}
function SelectCaptionPosition() {
const [captionPosition, setCaptionPosition] = useSetting(settingsAtom, 'captionPosition');
- const captionPositionItems = useCaptionPositionItems();
return (
({ value: item.layout, label: item.name }))}
+ options={CAPTION_POSITION_OPTIONS}
onSelect={setCaptionPosition}
- offset={5}
/>
);
}
function SelectMessageSpacing() {
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
- const messageSpacingItems = useMessageSpacingItems();
return (
({ value: item.spacing, label: item.name }))}
+ options={MESSAGE_SPACING_OPTIONS}
onSelect={setMessageSpacing}
- offset={5}
/>
);
}
diff --git a/src/app/hooks/useCaptionPosition.ts b/src/app/hooks/useCaptionPosition.ts
deleted file mode 100644
index 195c6fc64f..0000000000
--- a/src/app/hooks/useCaptionPosition.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { useMemo } from 'react';
-import { CaptionPosition } from '$state/settings';
-
-export type MessageLayoutItem = {
- name: string;
- layout: CaptionPosition;
-};
-
-export const useCaptionPositionItems = (): MessageLayoutItem[] =>
- useMemo(
- () => [
- {
- layout: CaptionPosition.Above,
- name: 'Above',
- },
- {
- layout: CaptionPosition.Inline,
- name: 'Inline',
- },
- {
- layout: CaptionPosition.Hidden,
- name: 'Hidden',
- },
- {
- layout: CaptionPosition.Below,
- name: 'Below',
- },
- ],
- []
- );
diff --git a/src/app/hooks/useMessageLayout.ts b/src/app/hooks/useMessageLayout.ts
deleted file mode 100644
index cf81aafb94..0000000000
--- a/src/app/hooks/useMessageLayout.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { useMemo } from 'react';
-import { MessageLayout } from '$state/settings';
-
-export type MessageLayoutItem = {
- name: string;
- layout: MessageLayout;
-};
-
-export const useMessageLayoutItems = (): MessageLayoutItem[] =>
- useMemo(
- () => [
- {
- layout: MessageLayout.Modern,
- name: 'Modern',
- },
- {
- layout: MessageLayout.Compact,
- name: 'Compact',
- },
- {
- layout: MessageLayout.Bubble,
- name: 'Bubble',
- },
- ],
- []
- );
diff --git a/src/app/hooks/useMessageSpacing.ts b/src/app/hooks/useMessageSpacing.ts
deleted file mode 100644
index 812ddfc393..0000000000
--- a/src/app/hooks/useMessageSpacing.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { useMemo } from 'react';
-import type { MessageSpacing } from '$state/settings';
-
-export type MessageSpacingItem = {
- name: string;
- spacing: MessageSpacing;
-};
-
-export const useMessageSpacingItems = (): MessageSpacingItem[] =>
- useMemo(
- () => [
- {
- spacing: '0',
- name: 'None',
- },
- {
- spacing: '100',
- name: 'Ultra Small',
- },
- {
- spacing: '200',
- name: 'Extra Small',
- },
- {
- spacing: '300',
- name: 'Small',
- },
- {
- spacing: '400',
- name: 'Normal',
- },
- {
- spacing: '500',
- name: 'Large',
- },
- ],
- []
- );
diff --git a/src/app/hooks/usePanelSizes.ts b/src/app/hooks/usePanelSizes.ts
deleted file mode 100644
index 3ff64d7852..0000000000
--- a/src/app/hooks/usePanelSizes.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { useMemo } from 'react';
-
-export type PanelSizetItem = {
- layout: string;
- name: string;
-};
-
-export const usePanelSizeItems = (): PanelSizetItem[] =>
- useMemo(
- () => [
- {
- layout: 'roomSidebarWidth',
- name: 'Room Panel Width',
- },
- {
- layout: 'memberSidebarWidth',
- name: 'Member Panel Width',
- },
- {
- layout: 'threadSidebarWidth',
- name: 'Thread Panel Width',
- },
- {
- layout: 'threadRootHeight',
- name: 'Thread Root Height',
- },
- {
- layout: 'vcmsgSidebarWidth',
- name: 'VoiceCall Msg Panel Width',
- },
- {
- layout: 'widgetSidebarWidth',
- name: 'Widget Panel Width',
- },
- {
- layout: 'roomBannerHeight',
- name: 'Room Banner Height',
- },
- ],
- []
- );
diff --git a/src/app/hooks/useShowRoomIcon.ts b/src/app/hooks/useShowRoomIcon.ts
deleted file mode 100644
index 5e4c9a76b2..0000000000
--- a/src/app/hooks/useShowRoomIcon.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { useMemo } from 'react';
-import { ShowRoomIcon } from '$state/settings';
-
-export type MessageLayoutItem = {
- layout?: ShowRoomIcon;
- name: string;
-};
-
-export const useShowRoomIcon = (): MessageLayoutItem[] =>
- useMemo(
- () => [
- {
- layout: ShowRoomIcon.Always,
- name: 'Always',
- },
- {
- layout: ShowRoomIcon.Strict,
- name: 'Sometimes',
- },
- {
- layout: ShowRoomIcon.Smart,
- name: 'Collapsed',
- },
- {
- layout: ShowRoomIcon.Never,
- name: 'Never',
- },
- ],
- []
- );
-
-export const useShowPerRoomRoomIcon = (): MessageLayoutItem[] =>
- useMemo(
- () => [
- {
- layout: undefined,
- name: 'Default',
- },
- {
- layout: ShowRoomIcon.Always,
- name: 'Always',
- },
- {
- layout: ShowRoomIcon.Strict,
- name: 'Sometimes',
- },
- {
- layout: ShowRoomIcon.Smart,
- name: 'Collapsed',
- },
- {
- layout: ShowRoomIcon.Never,
- name: 'Never',
- },
- ],
- []
- );
From 7c86018be71c78e16dc921c397e79428933a9ac4 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 15:53:02 +0200
Subject: [PATCH 33/43] fix: pass cached profile data and guard failed-send
actions in threads
---
src/app/features/room/ThreadDrawer.tsx | 16 ++++--
src/app/hooks/timeline/useTimelineActions.ts | 59 +++++++++++---------
2 files changed, 45 insertions(+), 30 deletions(-)
diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx
index ccc11b6fdc..eedc147cec 100644
--- a/src/app/features/room/ThreadDrawer.tsx
+++ b/src/app/features/room/ThreadDrawer.tsx
@@ -5,6 +5,7 @@ import { Chats, composerIcon, X } from '$components/icons/phosphor';
import type { IEvent, Room, CryptoBackend } from '$types/matrix-sdk';
import {
Direction,
+ EventStatus,
MatrixEvent,
MatrixEventEvent,
ReceiptType,
@@ -13,6 +14,8 @@ import {
ThreadEvent,
EventType,
} from '$types/matrix-sdk';
+import { useSpaceOptionally } from '$hooks/useSpace';
+import { buildCachedProfilePayload } from '$hooks/timeline/useTimelineActions';
import { useAtomValue, useSetAtom, useStore } from 'jotai';
import { ReactEditor } from 'slate-react';
import type { HTMLReactParserOptions } from 'html-react-parser';
@@ -253,6 +256,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
// User profile popup
const openUserRoomProfile = useOpenUserRoomProfile();
+ const optionalSpace = useSpaceOptionally();
// Thread timeline data for useProcessedTimeline
const thread = room.getThread(threadRootId);
@@ -592,12 +596,14 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
if (!userId) return;
openUserRoomProfile(
room.roomId,
- undefined,
+ optionalSpace?.roomId,
userId,
- evt.currentTarget.getBoundingClientRect()
+ evt.currentTarget.getBoundingClientRect(),
+ undefined,
+ buildCachedProfilePayload(getGlobalProfile(userId))
);
},
- [room, openUserRoomProfile]
+ [room, optionalSpace, openUserRoomProfile, getGlobalProfile]
);
const handleUsernameClick: MouseEventHandler = useCallback(
@@ -701,13 +707,15 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
const handleResend = useCallback(
(event: MatrixEvent) => {
- mx.resendEvent(event, room);
+ if (event.getAssociatedStatus() !== EventStatus.NOT_SENT) return;
+ mx.resendEvent(event, room).catch(() => undefined);
},
[mx, room]
);
const handleDeleteFailedSend = useCallback(
(event: MatrixEvent) => {
+ if (event.getAssociatedStatus() !== EventStatus.NOT_SENT) return;
mx.cancelPendingEvent(event);
},
[mx]
diff --git a/src/app/hooks/timeline/useTimelineActions.ts b/src/app/hooks/timeline/useTimelineActions.ts
index 1200cc82fd..b3a3ecff57 100644
--- a/src/app/hooks/timeline/useTimelineActions.ts
+++ b/src/app/hooks/timeline/useTimelineActions.ts
@@ -11,6 +11,38 @@ import { extractReplyDraftBody, getMemberDisplayName, resolveReplyDraftTarget }
import { createMentionElement, moveCursor } from '$components/editor';
import * as prefix from '$unstable/prefixes';
+/**
+ * The profile popup reads name, avatar and the identity fields off the room
+ * member, so the cached copies would shadow fresher state event data.
+ */
+export const buildCachedProfilePayload = (cachedData: UserProfile | undefined) => {
+ const cleanExtended = cachedData?.extended ? { ...cachedData.extended } : undefined;
+
+ if (cleanExtended) {
+ delete cleanExtended[prefix.MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME];
+ delete cleanExtended[prefix.MATRIX_SABLE_UNSTABLE_PROFILE_BIOGRAPHY_PROPERTY_NAME];
+ delete cleanExtended[prefix.MATRIX_COMMET_UNSTABLE_PROFILE_BIO_PROPERTY_NAME];
+ delete cleanExtended[prefix.MATRIX_COMMET_UNSTABLE_PROFILE_STATUS_PROPERTY_NAME];
+ delete cleanExtended[prefix.MATRIX_UNSTABLE_PROFILE_TIMEZONE_PROPERTY_NAME];
+ delete cleanExtended[prefix.MATRIX_STABLE_PROFILE_TIMEZONE_PROPERTY_NAME];
+ delete cleanExtended[prefix.MATRIX_UNSTABLE_PROFILE_BANNER_PROPERTY_NAME];
+ delete cleanExtended[prefix.MATRIX_SABLE_UNSTABLE_NAME_COLOR_PROPERTY_NAME];
+ delete cleanExtended[prefix.MATRIX_SABLE_UNSTABLE_NAME_COLOR_DARK_PROPERTY_NAME];
+ delete cleanExtended[prefix.MATRIX_SABLE_UNSTABLE_NAME_COLOR_LIGHT_PROPERTY_NAME];
+ delete cleanExtended.avatar_url;
+ delete cleanExtended.displayname;
+ delete cleanExtended[prefix.MATRIX_SABLE_UNSTABLE_ANIMAL_IDENTITY_HAS_CAT_PROPERTY_NAME];
+ delete cleanExtended[prefix.MATRIX_SABLE_UNSTABLE_ANIMAL_IDENTITY_IS_CAT_PROPERTY_NAME];
+ }
+
+ return {
+ pronouns: cachedData?.pronouns,
+ bio: cachedData?.bio,
+ timezone: cachedData?.timezone,
+ extended: cleanExtended,
+ };
+};
+
export interface UseTimelineActionsOptions {
room: Room;
mx: MatrixClient;
@@ -65,38 +97,13 @@ export function useTimelineActions({
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) return;
- const cachedData = getGlobalProfile(userId);
- const cleanExtended = cachedData?.extended ? { ...cachedData.extended } : undefined;
-
- if (cleanExtended) {
- delete cleanExtended[prefix.MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME];
- delete cleanExtended[prefix.MATRIX_SABLE_UNSTABLE_PROFILE_BIOGRAPHY_PROPERTY_NAME];
- delete cleanExtended[prefix.MATRIX_COMMET_UNSTABLE_PROFILE_BIO_PROPERTY_NAME];
- delete cleanExtended[prefix.MATRIX_COMMET_UNSTABLE_PROFILE_STATUS_PROPERTY_NAME];
- delete cleanExtended[prefix.MATRIX_UNSTABLE_PROFILE_TIMEZONE_PROPERTY_NAME];
- delete cleanExtended[prefix.MATRIX_STABLE_PROFILE_TIMEZONE_PROPERTY_NAME];
- delete cleanExtended[prefix.MATRIX_UNSTABLE_PROFILE_BANNER_PROPERTY_NAME];
- delete cleanExtended[prefix.MATRIX_SABLE_UNSTABLE_NAME_COLOR_PROPERTY_NAME];
- delete cleanExtended[prefix.MATRIX_SABLE_UNSTABLE_NAME_COLOR_DARK_PROPERTY_NAME];
- delete cleanExtended[prefix.MATRIX_SABLE_UNSTABLE_NAME_COLOR_LIGHT_PROPERTY_NAME];
- delete cleanExtended.avatar_url;
- delete cleanExtended.displayname;
- delete cleanExtended[prefix.MATRIX_SABLE_UNSTABLE_ANIMAL_IDENTITY_HAS_CAT_PROPERTY_NAME];
- delete cleanExtended[prefix.MATRIX_SABLE_UNSTABLE_ANIMAL_IDENTITY_IS_CAT_PROPERTY_NAME];
- }
-
openUserRoomProfile(
room.roomId,
spaceId,
userId,
evt.currentTarget.getBoundingClientRect(),
undefined,
- {
- pronouns: cachedData?.pronouns,
- bio: cachedData?.bio,
- timezone: cachedData?.timezone,
- extended: cleanExtended,
- }
+ buildCachedProfilePayload(getGlobalProfile(userId))
);
},
[room.roomId, spaceId, openUserRoomProfile, getGlobalProfile]
From cbb7761f3af4f7a4334c4e1324b9cb3dace15bb8 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 15:55:11 +0200
Subject: [PATCH 34/43] refactor: type the klipy search response instead of
disabling no-explicit-any
---
src/app/components/emoji-board/EmojiBoard.tsx | 3 -
.../components/emoji-board/useGifSearch.ts | 60 ++++++++++---------
2 files changed, 33 insertions(+), 30 deletions(-)
diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx
index 9d6e446fe4..5e90ed7fd9 100644
--- a/src/app/components/emoji-board/EmojiBoard.tsx
+++ b/src/app/components/emoji-board/EmojiBoard.tsx
@@ -62,9 +62,6 @@ import { EmojiBoardTab, EmojiType } from './types';
import { useGifSearch } from './useGifSearch';
import { useFavoriteGifs } from '$hooks/useFavoriteGifs';
-/* oxlint-disable typescript/no-explicit-any */
-// TODO: type klipy api properly
-
const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group';
diff --git a/src/app/components/emoji-board/useGifSearch.ts b/src/app/components/emoji-board/useGifSearch.ts
index 7210c0ffe1..e4072a2ad0 100644
--- a/src/app/components/emoji-board/useGifSearch.ts
+++ b/src/app/components/emoji-board/useGifSearch.ts
@@ -4,39 +4,45 @@ import { fetch } from '$utils/fetch';
import { useClientConfig } from '$hooks/useClientConfig';
import type { GifData } from './types';
-/* oxlint-disable typescript/no-explicit-any */
-// TODO: type klipy api properly
-
const SIZE_LIMIT = 3 * 1024 * 1024;
-const parseKlipyResult = (klipyResult: any): GifData => {
- const formats = klipyResult.file || {};
- const preview = formats.xs.gif || formats.sm.gif || formats.md.gif;
+type KlipyFile = {
+ url?: string;
+ width?: number;
+ height?: number;
+ size?: number;
+};
- // Start with full resolution GIF
- let fullRes = formats.hd.gif;
- // If full res is too large and medium exists, use medium instead
- if (fullRes && fullRes.size > SIZE_LIMIT && formats.md) {
- fullRes = formats.md.gif;
- }
+/** Klipy serves each size as a bag of encodings; we only ever want the gif. */
+type KlipyFormat = { gif?: KlipyFile };
- // Fallback if no suitable format found
- if (!fullRes) {
- fullRes = formats.md || preview;
- }
+type KlipyResult = {
+ id?: string;
+ title?: string;
+ file?: Partial>;
+};
+
+type KlipySearchResponse = { data?: { data?: KlipyResult[] } };
+
+const parseKlipyResult = (klipyResult: KlipyResult): GifData => {
+ const formats = klipyResult.file ?? {};
+ const preview = formats.xs?.gif ?? formats.sm?.gif ?? formats.md?.gif;
- // Get dimensions from the selected full resolution format
- const width = fullRes?.width || preview?.width || 0;
- const height = fullRes?.height || preview?.height || 0;
+ // Full resolution, dropped to medium when it would be too large to send.
+ let fullRes = formats.hd?.gif;
+ if (fullRes?.size && fullRes.size > SIZE_LIMIT && formats.md?.gif) {
+ fullRes = formats.md.gif;
+ }
+ fullRes ??= formats.md?.gif ?? preview;
return {
- id: klipyResult.id,
+ id: klipyResult.id ?? '',
title: klipyResult.title || 'GIF',
- url: fullRes?.url || '',
- preview_url: preview?.url || fullRes?.url || '',
- width,
- height,
- size: fullRes?.size || preview?.size || 0,
+ url: fullRes?.url ?? '',
+ preview_url: preview?.url ?? fullRes?.url ?? '',
+ width: fullRes?.width ?? preview?.width ?? 0,
+ height: fullRes?.height ?? preview?.height ?? 0,
+ size: fullRes?.size ?? preview?.size ?? 0,
mimetype: 'image/gif',
};
};
@@ -77,8 +83,8 @@ export function useGifSearch(
const response = await fetch(url.toString());
if (response.status === 200) {
- const data = await response.json();
- const results = data.data.data as any[] | undefined;
+ const data = (await response.json()) as KlipySearchResponse;
+ const results = data.data?.data;
setGifs((old) => ({
...old,
From f57e50878283a6b851c989181d41a84fc14f54f6 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 15:57:44 +0200
Subject: [PATCH 35/43] fix: keep favourite gifs live instead of snapshotting
them into state
---
.../components/emoji-board/useGifSearch.ts | 22 ++++++++-----------
1 file changed, 9 insertions(+), 13 deletions(-)
diff --git a/src/app/components/emoji-board/useGifSearch.ts b/src/app/components/emoji-board/useGifSearch.ts
index e4072a2ad0..dc7402a666 100644
--- a/src/app/components/emoji-board/useGifSearch.ts
+++ b/src/app/components/emoji-board/useGifSearch.ts
@@ -1,4 +1,4 @@
-import { useCallback, useState } from 'react';
+import { useCallback, useMemo, useState } from 'react';
import type { AsyncSearchHandler } from '$utils/AsyncSearch';
import { fetch } from '$utils/fetch';
import { useClientConfig } from '$hooks/useClientConfig';
@@ -52,10 +52,7 @@ export function useGifSearch(
showGifPicker: boolean,
gifSearch: AsyncSearchHandler
) {
- const [gifs, setGifs] = useState<{ gifs: GifData[]; favorites: GifData[] }>({
- gifs: [],
- favorites: favoriteGifs,
- });
+ const [searchResults, setSearchResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const clientConfig = useClientConfig();
@@ -86,19 +83,13 @@ export function useGifSearch(
const data = (await response.json()) as KlipySearchResponse;
const results = data.data?.data;
- setGifs((old) => ({
- ...old,
- gifs: results ? results.map(parseKlipyResult) : [],
- }));
+ setSearchResults(results ? results.map(parseKlipyResult) : []);
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch {
setError('Failed to search GIFs');
- setGifs((old) => ({
- ...old,
- gifs: [],
- }));
+ setSearchResults([]);
} finally {
setLoading(false);
}
@@ -106,5 +97,10 @@ export function useGifSearch(
[klipyApiKey, showGifPicker, gifSearch]
);
+ const gifs = useMemo(
+ () => ({ gifs: searchResults, favorites: favoriteGifs }),
+ [searchResults, favoriteGifs]
+ );
+
return { gifs, loading, error, searchGifs };
}
From b430fa5ee8a2c46380075fe7af228e044ef91895 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 16:05:35 +0200
Subject: [PATCH 36/43] feat: add a bottom-sheet mobile presentation to
ModalOverlay
---
.../modal-overlay/ModalOverlay.test.tsx | 36 +++++++++++++++++++
.../components/modal-overlay/ModalOverlay.tsx | 32 +++++++++++++++--
2 files changed, 66 insertions(+), 2 deletions(-)
create mode 100644 src/app/components/modal-overlay/ModalOverlay.test.tsx
diff --git a/src/app/components/modal-overlay/ModalOverlay.test.tsx b/src/app/components/modal-overlay/ModalOverlay.test.tsx
new file mode 100644
index 0000000000..7cdfa324d3
--- /dev/null
+++ b/src/app/components/modal-overlay/ModalOverlay.test.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize';
+import { ModalOverlay } from './ModalOverlay';
+
+const renderAt = (screenSize: ScreenSize, mobile: 'centred' | 'sheet' | 'fullscreen') =>
+ render(
+
+ void>()} mobile={mobile}>
+ Confirm
+
+
+ );
+
+describe('ModalOverlay mobile presentation', () => {
+ it('presents a sheet as a labelled dialog on mobile', () => {
+ renderAt(ScreenSize.Mobile, 'sheet');
+
+ expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
+ expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
+ });
+
+ it('ignores the sheet presentation on desktop', () => {
+ renderAt(ScreenSize.Desktop, 'sheet');
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
+ });
+
+ it('keeps the centred presentation free of the sheet wrapper on mobile', () => {
+ renderAt(ScreenSize.Mobile, 'centred');
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
+ });
+});
diff --git a/src/app/components/modal-overlay/ModalOverlay.tsx b/src/app/components/modal-overlay/ModalOverlay.tsx
index 8967be3d3a..c36709c012 100644
--- a/src/app/components/modal-overlay/ModalOverlay.tsx
+++ b/src/app/components/modal-overlay/ModalOverlay.tsx
@@ -4,6 +4,7 @@ import { Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
import { ScreenSize, useScreenSizeOptionally } from '$hooks/useScreenSize';
import { stopPropagation } from '$utils/keyboard';
import { useDismissOnBack } from '$utils/androidBack';
+import { MobileSwipeDownModal } from '$components/MobileSwipeDownModal';
type FocusTrapOptions = ComponentProps['focusTrapOptions'];
@@ -13,8 +14,12 @@ type ModalOverlayProps = {
/** Set false for overlays that must be dismissed deliberately, not by a stray click. */
dismissOnClickOutside?: boolean;
initialFocus?: FocusTrapOptions['initialFocus'];
- /** `fullscreen` drops the centred modal on phones and fills the viewport instead. */
- mobile?: 'centred' | 'fullscreen';
+ /**
+ * How the overlay presents on phones. `centred` is the interruptive alert, for
+ * destructive confirms. `sheet` rises from the bottom, for non-destructive action
+ * lists. `fullscreen` fills the viewport, for forms and large surfaces.
+ */
+ mobile?: 'centred' | 'sheet' | 'fullscreen';
/** The modal element, used as the focus fallback and as the fullscreen wrapper. */
contentRef?: MutableRefObject;
/** Set false for flows that Escape must not abort, such as device verification. */
@@ -38,6 +43,29 @@ export function ModalOverlay({
// Android back closes the overlay instead of navigating away.
useDismissOnBack(requestClose, open);
+ if (open && isMobile && mobile === 'sheet') {
+ return (
+
+ {(dragHandle) => (
+ contentRef?.current ?? document.body,
+ clickOutsideDeactivates: dismissOnClickOutside,
+ onDeactivate: requestClose,
+ escapeDeactivates,
+ }}
+ >
+
+ {dragHandle}
+ {children}
+
+
+ )}
+
+ );
+ }
+
if (open && isMobile && mobile === 'fullscreen') {
return (
From 17e957ed03da951a67e17156d4a130de61802398 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 16:08:41 +0200
Subject: [PATCH 37/43] Revert "feat: add a bottom-sheet mobile presentation to
ModalOverlay"
This reverts commit b430fa5ee8a2c46380075fe7af228e044ef91895.
---
.../modal-overlay/ModalOverlay.test.tsx | 36 -------------------
.../components/modal-overlay/ModalOverlay.tsx | 32 ++---------------
2 files changed, 2 insertions(+), 66 deletions(-)
delete mode 100644 src/app/components/modal-overlay/ModalOverlay.test.tsx
diff --git a/src/app/components/modal-overlay/ModalOverlay.test.tsx b/src/app/components/modal-overlay/ModalOverlay.test.tsx
deleted file mode 100644
index 7cdfa324d3..0000000000
--- a/src/app/components/modal-overlay/ModalOverlay.test.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
-import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize';
-import { ModalOverlay } from './ModalOverlay';
-
-const renderAt = (screenSize: ScreenSize, mobile: 'centred' | 'sheet' | 'fullscreen') =>
- render(
-
- void>()} mobile={mobile}>
- Confirm
-
-
- );
-
-describe('ModalOverlay mobile presentation', () => {
- it('presents a sheet as a labelled dialog on mobile', () => {
- renderAt(ScreenSize.Mobile, 'sheet');
-
- expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
- expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
- });
-
- it('ignores the sheet presentation on desktop', () => {
- renderAt(ScreenSize.Desktop, 'sheet');
-
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
- });
-
- it('keeps the centred presentation free of the sheet wrapper on mobile', () => {
- renderAt(ScreenSize.Mobile, 'centred');
-
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
- });
-});
diff --git a/src/app/components/modal-overlay/ModalOverlay.tsx b/src/app/components/modal-overlay/ModalOverlay.tsx
index c36709c012..8967be3d3a 100644
--- a/src/app/components/modal-overlay/ModalOverlay.tsx
+++ b/src/app/components/modal-overlay/ModalOverlay.tsx
@@ -4,7 +4,6 @@ import { Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
import { ScreenSize, useScreenSizeOptionally } from '$hooks/useScreenSize';
import { stopPropagation } from '$utils/keyboard';
import { useDismissOnBack } from '$utils/androidBack';
-import { MobileSwipeDownModal } from '$components/MobileSwipeDownModal';
type FocusTrapOptions = ComponentProps['focusTrapOptions'];
@@ -14,12 +13,8 @@ type ModalOverlayProps = {
/** Set false for overlays that must be dismissed deliberately, not by a stray click. */
dismissOnClickOutside?: boolean;
initialFocus?: FocusTrapOptions['initialFocus'];
- /**
- * How the overlay presents on phones. `centred` is the interruptive alert, for
- * destructive confirms. `sheet` rises from the bottom, for non-destructive action
- * lists. `fullscreen` fills the viewport, for forms and large surfaces.
- */
- mobile?: 'centred' | 'sheet' | 'fullscreen';
+ /** `fullscreen` drops the centred modal on phones and fills the viewport instead. */
+ mobile?: 'centred' | 'fullscreen';
/** The modal element, used as the focus fallback and as the fullscreen wrapper. */
contentRef?: MutableRefObject;
/** Set false for flows that Escape must not abort, such as device verification. */
@@ -43,29 +38,6 @@ export function ModalOverlay({
// Android back closes the overlay instead of navigating away.
useDismissOnBack(requestClose, open);
- if (open && isMobile && mobile === 'sheet') {
- return (
-
- {(dragHandle) => (
- contentRef?.current ?? document.body,
- clickOutsideDeactivates: dismissOnClickOutside,
- onDeactivate: requestClose,
- escapeDeactivates,
- }}
- >
-
- {dragHandle}
- {children}
-
-
- )}
-
- );
- }
-
if (open && isMobile && mobile === 'fullscreen') {
return (
From b79711499b890bbbe08085e8e2116e733994621a Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 16:19:11 +0200
Subject: [PATCH 38/43] refactor: drop unused overlay props and migrate the
escape-opt-out overlays onto ModalOverlay
---
src/app/components/DeviceVerification.tsx | 110 +++++++-----------
src/app/components/IncomingCallModal.tsx | 42 ++-----
src/app/components/ResponsiveMenu.tsx | 20 +---
.../components/modal-overlay/ModalOverlay.tsx | 6 +-
.../components/modal-overlay/PromptDialog.tsx | 11 +-
.../settings/devices/Verification.tsx | 44 +++----
src/app/hooks/useMenuAnchor.ts | 8 --
7 files changed, 75 insertions(+), 166 deletions(-)
diff --git a/src/app/components/DeviceVerification.tsx b/src/app/components/DeviceVerification.tsx
index fe0f1e53fb..0ec686025d 100644
--- a/src/app/components/DeviceVerification.tsx
+++ b/src/app/components/DeviceVerification.tsx
@@ -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,
@@ -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}`,
@@ -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);
@@ -261,55 +245,47 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
}, [phase]);
return (
- }>
-
-
-
-
-
- Device Verification
-
-
- {composerIcon(X)}
-
-
-
- {phase === VerificationPhase.Requested &&
- (request.initiatedByMe ? (
-
- ) : (
-
- ))}
- {phase === VerificationPhase.Ready &&
- (request.initiatedByMe ? (
-
- ) : (
-
- ))}
- {phase === VerificationPhase.Started &&
- (request.verifier ? (
-
- ) : (
-
- ))}
- {phase === VerificationPhase.Done && }
- {phase === VerificationPhase.Cancelled && (
-
- )}
-
-
-
-
-
+
+
+
+
+ Device Verification
+
+
+ {composerIcon(X)}
+
+
+
+ {phase === VerificationPhase.Requested &&
+ (request.initiatedByMe ? (
+
+ ) : (
+
+ ))}
+ {phase === VerificationPhase.Ready &&
+ (request.initiatedByMe ? (
+
+ ) : (
+
+ ))}
+ {phase === VerificationPhase.Started &&
+ (request.verifier ? (
+
+ ) : (
+
+ ))}
+ {phase === VerificationPhase.Done && }
+ {phase === VerificationPhase.Cancelled && }
+
+
+
);
}
diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx
index 5f8b332835..1723802b2d 100644
--- a/src/app/components/IncomingCallModal.tsx
+++ b/src/app/components/IncomingCallModal.tsx
@@ -1,18 +1,4 @@
-import {
- Avatar,
- Box,
- Button,
- color,
- Dialog,
- Header,
- IconButton,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Text,
- config,
- toRem,
-} from 'folds';
+import { Avatar, Box, Button, color, Dialog, Header, IconButton, Text, config, toRem } from 'folds';
import { useMemo, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { Room } from '$types/matrix-sdk';
import { useMatrixClient } from '$hooks/useMatrixClient';
@@ -24,7 +10,6 @@ import { getMxIdLocalPart } from '$utils/matrix';
import { getMemberDisplayName, getRoomAvatarUrl } from '$utils/room';
import { webRTCSupported } from '$utils/rtc';
import { useRoomNavigate } from '$hooks/useRoomNavigate';
-import FocusTrap from 'focus-trap-react';
import * as Sentry from '@sentry/react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import {
@@ -35,7 +20,6 @@ import {
type IncomingCall,
} from '$state/callEmbed';
import { createDebugLogger } from '$utils/debugLogger';
-import { useDismissOnBack } from '$utils/androidBack';
import { dismissSystemCallNotifications } from '$features/call/callNotificationBridge';
import { getIncomingCallBlockers } from '$features/call/getIncomingCallBlockers';
import { RoomAvatar } from './room-avatar';
@@ -50,6 +34,7 @@ import {
User,
sizedIcon,
} from '$components/icons/phosphor';
+import { ModalOverlay } from './modal-overlay/ModalOverlay';
const debugLog = createDebugLogger('IncomingCall');
@@ -340,26 +325,13 @@ export function IncomingCallModal() {
const close = () => setIncomingCall(null);
- // Android back dismisses the incoming call modal instead of navigating away.
- useDismissOnBack(close, !!incomingCall && !!room);
-
if (!incomingCall || !room) return null;
return (
- }>
-
-
-
-
-
-
-
-
+
+
+
+
+
);
}
diff --git a/src/app/components/ResponsiveMenu.tsx b/src/app/components/ResponsiveMenu.tsx
index 5bfae104b8..d11cb1fb5f 100644
--- a/src/app/components/ResponsiveMenu.tsx
+++ b/src/app/components/ResponsiveMenu.tsx
@@ -1,4 +1,4 @@
-import type { ComponentProps, ReactNode } from 'react';
+import type { ReactNode } from 'react';
import type { RectCords } from 'folds';
import { PopOut } from 'folds';
import FocusTrap from 'focus-trap-react';
@@ -6,15 +6,16 @@ import { ScreenSize, useScreenSizeOptionally } from '$hooks/useScreenSize';
import { stopPropagation } from '$utils/keyboard';
import { MobileSwipeDownModal } from './MobileSwipeDownModal';
-type FocusTrapOptions = ComponentProps['focusTrapOptions'];
-
type DragHandlers = {
onTouchStart: (e: React.TouchEvent) => void;
onTouchMove: (e: React.TouchEvent) => void;
onTouchEnd: () => void;
};
-/** Given the sheet's drag handle on mobile, and nothing on desktop. */
+/**
+ * Given the sheet's drag handle on mobile, and nothing on desktop. Only
+ * `RoomViewHeader` needs this; every other menu passes a plain node.
+ */
type MenuRenderer = (dragHandle: ReactNode, dragHandlers: DragHandlers | undefined) => ReactNode;
type ComponentPosition = 'Top' | 'Right' | 'Bottom' | 'Left';
@@ -26,15 +27,11 @@ type ResponsiveMenuProps = {
menu: ReactNode | MenuRenderer;
/** The element the menu hangs off on desktop. */
children?: ReactNode;
- id?: string;
position?: ComponentPosition;
align?: ComponentAlign;
offset?: number;
- alignOffset?: number;
/** Set true for menus whose trigger should regain focus when they close. */
returnFocusOnDeactivate?: boolean;
- /** Menus containing a search field want their input focused on open. */
- initialFocus?: FocusTrapOptions['initialFocus'];
/** `both` also maps Left/Right, for menus laid out horizontally. */
arrowNavigation?: 'vertical' | 'both';
};
@@ -49,13 +46,10 @@ export function ResponsiveMenu({
requestClose,
menu,
children,
- id,
position = 'Bottom',
align = 'End',
offset,
- alignOffset,
returnFocusOnDeactivate = false,
- initialFocus = false,
arrowNavigation = 'vertical',
}: ResponsiveMenuProps) {
// Null outside a provider, where desktop is the safe assumption.
@@ -67,7 +61,7 @@ export function ResponsiveMenu({
evt.key === 'ArrowUp' || (arrowNavigation === 'both' && evt.key === 'ArrowLeft');
const focusTrapOptions = {
- initialFocus,
+ initialFocus: false,
fallbackFocus: () => document.body,
returnFocusOnDeactivate,
onDeactivate: requestClose,
@@ -98,13 +92,11 @@ export function ResponsiveMenu({
return (
{typeof menu === 'function' ? menu(null, undefined) : menu}
diff --git a/src/app/components/modal-overlay/ModalOverlay.tsx b/src/app/components/modal-overlay/ModalOverlay.tsx
index 8967be3d3a..cf961d715b 100644
--- a/src/app/components/modal-overlay/ModalOverlay.tsx
+++ b/src/app/components/modal-overlay/ModalOverlay.tsx
@@ -12,7 +12,6 @@ type ModalOverlayProps = {
requestClose: () => void;
/** Set false for overlays that must be dismissed deliberately, not by a stray click. */
dismissOnClickOutside?: boolean;
- initialFocus?: FocusTrapOptions['initialFocus'];
/** `fullscreen` drops the centred modal on phones and fills the viewport instead. */
mobile?: 'centred' | 'fullscreen';
/** The modal element, used as the focus fallback and as the fullscreen wrapper. */
@@ -26,7 +25,6 @@ export function ModalOverlay({
open = true,
requestClose,
dismissOnClickOutside = true,
- initialFocus = false,
mobile = 'centred',
contentRef,
escapeDeactivates = stopPropagation,
@@ -43,7 +41,7 @@ export function ModalOverlay({
contentRef?.current ?? document.body,
clickOutsideDeactivates: dismissOnClickOutside,
onDeactivate: requestClose,
diff --git a/src/app/components/modal-overlay/PromptDialog.tsx b/src/app/components/modal-overlay/PromptDialog.tsx
index f4b63984fa..f4fc43055f 100644
--- a/src/app/components/modal-overlay/PromptDialog.tsx
+++ b/src/app/components/modal-overlay/PromptDialog.tsx
@@ -6,20 +6,13 @@ import { ModalOverlay } from './ModalOverlay';
type PromptDialogProps = {
title: string;
requestClose: () => void;
- /** Prompts that must be answered deliberately opt out of click-outside dismiss. */
- dismissOnClickOutside?: boolean;
children: ReactNode;
};
/** A titled dialog with a close button, over a dimmed backdrop. */
-export function PromptDialog({
- title,
- requestClose,
- dismissOnClickOutside,
- children,
-}: PromptDialogProps) {
+export function PromptDialog({ title, requestClose, children }: PromptDialogProps) {
return (
-
+
)}
{open && (
- }>
-
-
-
-
-
-
+
+
+
)}
>
);
@@ -334,19 +326,13 @@ export function DeviceVerificationOptions() {
}
/>
{reset && (
- }>
-
-
-
-
-
-
+
+
+
)}
>
);
diff --git a/src/app/hooks/useMenuAnchor.ts b/src/app/hooks/useMenuAnchor.ts
index ab7fe0d85d..abde37e7e0 100644
--- a/src/app/hooks/useMenuAnchor.ts
+++ b/src/app/hooks/useMenuAnchor.ts
@@ -1,7 +1,6 @@
import type { MouseEventHandler, TouchEvent as ReactTouchEvent } from 'react';
import { useCallback, useRef, useState } from 'react';
import type { RectCords } from 'folds';
-import { getMouseEventCords } from '$utils/dom';
import { useMobileLongPress } from './useMobileLongPress';
type TriggerProps = {
@@ -18,8 +17,6 @@ export type MenuAnchor = {
close: () => void;
/** Anchors the menu to an element's box. */
openAt: (element: HTMLElement) => void;
- /** Anchors the menu to the pointer, for right-click on a large surface. */
- openAtPointer: (event: MouseEvent) => void;
/** Click, right-click and long-press wiring for the trigger element. */
triggerProps: TriggerProps;
};
@@ -39,10 +36,6 @@ export function useMenuAnchor(): MenuAnchor
setAnchor((current) => (current ? undefined : cords));
}, []);
- const openAtPointer = useCallback((event: MouseEvent) => {
- setAnchor((current) => (current ? undefined : getMouseEventCords(event)));
- }, []);
-
const longPress = useMobileLongPress(() => {
if (triggerRef.current) openAt(triggerRef.current);
});
@@ -78,7 +71,6 @@ export function useMenuAnchor(): MenuAnchor
anchor,
close,
openAt,
- openAtPointer,
triggerProps: {
onClick,
onContextMenu,
From 56befb56aafcdb33bf270c30053d615dce813676 Mon Sep 17 00:00:00 2001
From: Erwan Leboucher
Date: Sat, 25 Jul 2026 16:28:15 +0200
Subject: [PATCH 39/43] refactor: route every avatar through one helper and
fold the date format selector
---
src/app/components/IncomingCallModal.test.tsx | 5 +-
src/app/components/IncomingCallModal.tsx | 7 +-
.../autocomplete/UserMentionAutocomplete.tsx | 6 +-
.../components/event-history/EventHistory.tsx | 6 +-
.../components/event-readers/EventReaders.tsx | 14 +---
src/app/components/member-tile/MemberTile.tsx | 6 +-
.../setting-menu-selector/options.ts | 11 ++-
src/app/features/room-nav/RoomNavUser.tsx | 6 +-
src/app/features/room/MembersDrawer.tsx | 6 +-
.../room/poll-modals/PollResponses.tsx | 14 +---
.../room/reaction-viewer/ReactionViewer.tsx | 14 +---
src/app/features/settings/general/General.tsx | 81 +++----------------
src/app/hooks/useDateFormat.ts | 34 --------
src/app/utils/room.ts | 23 ++++--
14 files changed, 59 insertions(+), 174 deletions(-)
delete mode 100644 src/app/hooks/useDateFormat.ts
diff --git a/src/app/components/IncomingCallModal.test.tsx b/src/app/components/IncomingCallModal.test.tsx
index 2f1874df5f..c85c7c173e 100644
--- a/src/app/components/IncomingCallModal.test.tsx
+++ b/src/app/components/IncomingCallModal.test.tsx
@@ -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',
}));
diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx
index 1723802b2d..d7d190cc81 100644
--- a/src/app/components/IncomingCallModal.tsx
+++ b/src/app/components/IncomingCallModal.tsx
@@ -7,7 +7,7 @@ import { useRoomName } from '$hooks/useRoomMeta';
import { useCallEmbed } from '$hooks/useCallEmbed';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { getMxIdLocalPart } from '$utils/matrix';
-import { getMemberDisplayName, getRoomAvatarUrl } from '$utils/room';
+import { getAvatarUrl, getMemberDisplayName, getRoomAvatarUrl } from '$utils/room';
import { webRTCSupported } from '$utils/rtc';
import { useRoomNavigate } from '$hooks/useRoomNavigate';
import * as Sentry from '@sentry/react';
@@ -62,10 +62,7 @@ export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCa
getMxIdLocalPart(incomingCall.senderId) ??
incomingCall.senderId;
const callerAvatarMxc = room.getMember(incomingCall.senderId)?.getMxcAvatarUrl();
- const callerAvatarUrl = callerAvatarMxc
- ? (mx.mxcUrlToHttp(callerAvatarMxc, 96, 96, 'crop', undefined, undefined, useAuthentication) ??
- undefined)
- : undefined;
+ const callerAvatarUrl = getAvatarUrl(mx, callerAvatarMxc, 96, useAuthentication);
const isRingNotification = incomingCall.notificationType === 'ring';
const isDirectRing = incomingCall.isDirect && incomingCall.notificationType === 'ring';
diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx
index 73013b3085..5082e94257 100644
--- a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx
+++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx
@@ -12,7 +12,7 @@ import { useAsyncSearch } from '$hooks/useAsyncSearch';
import { onTabPress } from '$utils/keyboard';
import { useKeyDown } from '$hooks/useKeyDown';
import { getMxIdLocalPart, isUserId } from '$utils/matrix';
-import { getMemberDisplayName, getMemberSearchStr } from '$utils/room';
+import { getAvatarUrl, getMemberDisplayName, getMemberSearchStr } from '$utils/room';
import { UserAvatar } from '$components/user-avatar';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
@@ -175,9 +175,7 @@ export function UserMentionAutocomplete({
) : (
autoCompleteMembers.map((roomMember) => {
const avatarMxcUrl = roomMember.getMxcAvatarUrl();
- const avatarUrl = avatarMxcUrl
- ? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication)
- : undefined;
+ const avatarUrl = getAvatarUrl(mx, avatarMxcUrl, 32, useAuthentication);
return (