feat(settings): replace Modal500 with /settings route plus mobile bottom-up horseshoe sheet overlaying DM list via clip-path mask

This commit is contained in:
heaven 2026-05-13 00:01:26 +03:00
parent c6bb66958d
commit 635fb91022
40 changed files with 1478 additions and 333 deletions

View file

@ -90,6 +90,8 @@
"menu_emojis_stickers": "Emojis & Stickers", "menu_emojis_stickers": "Emojis & Stickers",
"menu_developer_tools": "Developer Tools", "menu_developer_tools": "Developer Tools",
"menu_about": "About", "menu_about": "About",
"drag_to_close": "Drag down to close",
"close": "Close settings",
"logout": "Logout", "logout": "Logout",
"logout_confirm": "You're about to log out. Are you sure?", "logout_confirm": "You're about to log out. Are you sure?",
"logout_failed": "Failed to logout! {{message}}", "logout_failed": "Failed to logout! {{message}}",

View file

@ -90,6 +90,8 @@
"menu_emojis_stickers": "Эмодзи и стикеры", "menu_emojis_stickers": "Эмодзи и стикеры",
"menu_developer_tools": "Инструменты разработчика", "menu_developer_tools": "Инструменты разработчика",
"menu_about": "О приложении", "menu_about": "О приложении",
"drag_to_close": "Потянуть вниз чтобы закрыть",
"close": "Закрыть настройки",
"logout": "Выйти", "logout": "Выйти",
"logout_confirm": "Вы собираетесь выйти из аккаунта. Вы уверены?", "logout_confirm": "Вы собираетесь выйти из аккаунта. Вы уверены?",
"logout_failed": "Не удалось выйти! {{message}}", "logout_failed": "Не удалось выйти! {{message}}",

View file

@ -9,7 +9,7 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { Box, Header, Line, Scroll, Text, as, toRem } from 'folds'; import { Box, Header, Line, Scroll, Text, as, color, toRem } from 'folds';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import classNames from 'classnames'; import classNames from 'classnames';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
@ -117,10 +117,29 @@ export function PageRoot({ nav, children }: PageRootProps) {
type ClientDrawerLayoutProps = { type ClientDrawerLayoutProps = {
children: ReactNode; children: ReactNode;
resizable?: boolean; resizable?: boolean;
// Round the inner column's right-side corners (TR + BR). Off by
// default — the page-nav right rounding was intentionally dropped
// from the standard pattern in commit 74d32eb. Re-enabled only at
// callsites that explicitly opt in. Currently unused in the tree
// (the Settings nav tried it and was reverted on product feedback),
// kept as a primitive for future nested-horseshoe surfaces that
// want a fully-rounded "island" nav between two voids.
roundedRight?: boolean;
// Background surface tone for the inner column. Default
// `'background'` reads the standard `Background.Container` (Dawn
// bg2 = #0d0e11) — the deepest surface, what every tab's nav uses.
// `'surfaceVariant'` swaps to `color.SurfaceVariant.Container`
// (Dawn bg = #181a20) — the «raised» chat-pane tone used by 1-1
// chats and the composer; visually a step lighter than the DM
// list, marking the Settings nav as a distinct surface without
// jumping outside the Dawn palette.
surface?: 'background' | 'surfaceVariant';
}; };
export function PageNav({ export function PageNav({
size, size,
resizable, resizable,
roundedRight,
surface,
children, children,
}: ClientDrawerLayoutProps & css.PageNavVariants) { }: ClientDrawerLayoutProps & css.PageNavVariants) {
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
@ -131,6 +150,20 @@ export function PageNav({
return <ResizablePageNav>{children}</ResizablePageNav>; return <ResizablePageNav>{children}</ResizablePageNav>;
} }
const radii = toRem(VOJO_HORSESHOE_RADIUS_PX);
const roundedRightStyle =
roundedRight && horseshoe
? { borderTopRightRadius: radii, borderBottomRightRadius: radii }
: undefined;
// Inline `backgroundColor` overrides whatever `PageNavInnerWebHorseshoe`
// sets via vanilla-extract — inline style wins on specificity, so
// we can override the default `Background.Container` without
// touching the recipe.
const surfaceStyle =
surface === 'surfaceVariant'
? { backgroundColor: color.SurfaceVariant.Container }
: undefined;
return ( return (
<Box <Box
grow={isMobile ? 'Yes' : undefined} grow={isMobile ? 'Yes' : undefined}
@ -149,7 +182,11 @@ export function PageNav({
// recipe uses a Folds `<Header size="...">` with a fixed height — // recipe uses a Folds `<Header size="...">` with a fixed height —
// padding there would clip the header content. `--vojo-safe-top` // padding there would clip the header content. `--vojo-safe-top`
// is 0 on web and inside Modal500-hosted dialogs. // is 0 on web and inside Modal500-hosted dialogs.
style={{ paddingTop: 'var(--vojo-safe-top, 0px)' }} style={{
paddingTop: 'var(--vojo-safe-top, 0px)',
...roundedRightStyle,
...surfaceStyle,
}}
> >
{children} {children}
</Box> </Box>
@ -362,15 +399,29 @@ export function PageNavContent({
); );
} }
export const Page = as<'div'>(({ className, ...props }, ref) => ( type PageVariantProps = {
<Box // Background surface tone. Default `'Surface'` (Dawn bg2, #0d0e11)
grow="Yes" // — the deepest tone used by every sub-page elsewhere in the app.
direction="Column" // `'SurfaceVariant'` (Dawn bg, #181a20) is one notch lighter and
className={classNames(ContainerColor({ variant: 'Surface' }), className)} // used by the Settings sub-pages so they read on the same surface
{...props} // tone as the Settings menu (which itself uses
ref={ref} // `surface="surfaceVariant"` on its PageNav). Other variants
/> // (`'Background'`, `'Primary'`, etc.) pass through unchanged in
)); // case a future surface needs a different tone — see folds tokens
// for the full set.
variant?: 'Background' | 'Surface' | 'SurfaceVariant' | 'Primary' | 'Secondary';
};
export const Page = as<'div', PageVariantProps>(
({ className, variant = 'Surface', ...props }, ref) => (
<Box
grow="Yes"
direction="Column"
className={classNames(ContainerColor({ variant }), className)}
{...props}
ref={ref}
/>
)
);
export const PageHeader = as<'div', css.PageHeaderVariants>( export const PageHeader = as<'div', css.PageHeaderVariants>(
({ className, outlined, balance, ...props }, ref) => ( ({ className, outlined, balance, ...props }, ref) => (

View file

@ -79,6 +79,14 @@ export const PageNav = recipe({
'300': { '300': {
width: toRem(222), width: toRem(222),
}, },
// Used by the Settings nav — ~1.43× the regular 300 (~317px =
// 1.3 × 1.1 over 222px). Settings labels are long
// ("Notifications", "Emojis & Stickers", "Developer Tools") and
// the 222px column truncated them; the wider column also gives
// the nested-horseshoe void gap on the right room to breathe.
'350': {
width: toRem(317),
},
}, },
}, },
defaultVariants: { defaultVariants: {

View file

@ -0,0 +1,176 @@
import { style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// Re-exported so the TSX can pick up the constants without crossing
// the vanilla-extract / runtime boundary twice.
export const HORSESHOE_RADIUS_PX = VOJO_HORSESHOE_RADIUS_PX;
export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
// Outer container — `position: relative` anchor for the two absolutely-
// positioned panes (`appBody` and `silhouette`). `overflow: hidden`
// clips anything that overflows the wrapper's bounds and crops the
// rounded carves on both panes against the container's bg (which is
// painted with `VOJO_HORSESHOE_VOID_COLOR` inline when the sheet is
// active, so the carved-out areas read as the same near-black seam
// used everywhere else in the app).
//
// `flex: 1` so the container fills whatever flex slot it's mounted in
// (PageNav's inner column for the Direct route).
export const container = style({
position: 'relative',
display: 'flex',
flex: 1,
flexDirection: 'column',
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
});
// === App body === Holds the wrapped children (the DM list — header,
// scroll content, DirectNewChatRow, DirectSelfRow). Fills the
// container via `inset: 0`. Does NOT translate or shrink — the DM
// list stays exactly where it was in the closed state. Instead, the
// bottom of the pane is masked away by an animated `clip-path:
// inset(...)` with rounded BL/BR corners — the user sees the visible
// top portion of the DM list with a rounded carve at the new bottom
// edge, exactly like the profile horseshoe shows the chat with a
// rounded TOP carve as the panel masks it from above.
//
// Why clip-path rather than flex-shrink + margin-bottom: a flex-
// shrink approach changes the scroll-container's height every render,
// and the DM list's `@tanstack/react-virtual` re-measures items mid-
// gesture. Why not `transform: translateY` either: translating moves
// the whole pane up off the top of the viewport, including the
// DirectStreamHeader the user wants to keep visible. Clip-path leaves
// layout unchanged — the DM list keeps its scroll position, its
// measured heights, and its top items in place; only the bottom edge
// of what's visible gets carved into the void below.
//
// `backgroundColor: Background.Container` is load-bearing: the
// container behind appBody is painted with the void colour (#090909)
// when the sheet is active, and without an opaque bg here the void
// would bleed through every transparent gap between DM list rows.
// The clip-path then carves away the bottom of this opaque pane,
// exposing the container's void colour only in the masked region —
// the only place we want the void to show.
//
// `flex: column` so the children (which expect a flex column parent
// — PageNav uses it) still stack naturally.
export const appBody = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
minWidth: 0,
minHeight: 0,
backgroundColor: color.Background.Container,
willChange: 'clip-path',
});
// === Silhouette === The Settings sheet's surface. Anchored at the
// bottom of the container; its height animates 0 → railHeight as the
// user drags up. Rounded TL/TR carve the top edge against the void
// gap between it and the translated-up appBody.
//
// `overflow: hidden` clips `panelContent` (which is railHeight tall,
// top-anchored) so the visible portion of the panel is just the
// silhouette's current height — the user sees more of the panel
// content reveal from the top as silhouette grows.
//
// Background: `SurfaceVariant.Container` (Dawn bg = #181a20) — the
// chat-pane tone, same as the Settings PageNav inside (set via
// `surface="surfaceVariant"`). Load-bearing: the silhouette's bg
// shows through every panel gap that Settings's PageNav doesn't
// cover — the 20px drag-handle band at the top and the
// `env(safe-area-inset-bottom)` padding inside `panelContent` at the
// gesture-pill strip. With `Background.Container` (#0d0e11) here, the
// user saw dark stripes at both seams on Samsung S24 edge-to-edge;
// matching silhouette to the PageNav tone closes them. Same idea as
// commit 77bb72d which dynamically retunes `--vojo-safe-area-bg`
// while a Room is mounted to keep the system-bar strips and the chat
// surface in lockstep.
export const silhouette = style({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
backgroundColor: color.SurfaceVariant.Container,
willChange: 'height, border-top-left-radius, border-top-right-radius',
});
// Anchored at the TOP of `silhouette` so as silhouette grows from 0
// upward, more of the panel content reveals from the top down. The
// handle and Settings title are at the top of `panelContent`, so they
// are visible at the very first pixel of drag — gives the user
// immediate "I'm opening Settings" feedback.
//
// `padding-bottom: env(safe-area-inset-bottom)` keeps the panel
// content (Settings menu) clear of the Android nav bar in edge-to-edge
// mode. `box-sizing: border-box` makes the inline `height: railHeightPx`
// include the inset, so the panel's measured height includes the
// reserved inset and the bottom of the visible content sits above the
// system icons.
export const panelContent = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
});
// Drag handle band — sits at the TOP of `panelContent` so it appears
// at the top edge of the emerging sheet as the user drags up. Mirror
// of the profile horseshoe handle (20px tall + 36×4 grabber). The
// whole band is the ONLY drag-to-close target — touches on the
// panel body below this strip do NOT initiate drag, so Settings's
// internal scroll works without conflict.
//
// `touchAction: none` ensures the browser doesn't try to scroll
// when the user drags on the handle (the whole gesture is ours).
export const panelHandle = style({
flexShrink: 0,
height: toRem(20),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'grab',
touchAction: 'none',
userSelect: 'none',
selectors: {
'&:active': { cursor: 'grabbing' },
},
});
export const panelHandleBar = style({
width: toRem(36),
height: toRem(4),
borderRadius: toRem(4),
// Darker than the silhouette's SurfaceVariant bg so the grabber
// reads against the panel without competing with content. The
// Dawn `Background.Container` (#0d0e11) is one notch deeper than
// `SurfaceVariant.Container` (#181a20) — same step-down used by
// chat bubbles vs the chat surface.
backgroundColor: color.Background.Container,
});
// Holds the Settings tree. `flex: 1` so it grows to fill the
// remaining `panelContent` height below the handle. No drag listeners
// here — Settings's own internal scroll handles touch events; the
// drag-to-close gesture is reserved for `panelHandle`.
export const panelBody = style({
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
minWidth: 0,
});

View file

@ -0,0 +1,558 @@
// Bottom-up «horseshoe» sheet that wraps the mobile Direct DM list.
// Mirror of `MobileProfileHorseshoe` in features/room — the chat
// there is wrapped by a top-down horseshoe (panel above, chat below
// with a 12px void). Here we invert: the wrapped app body is above,
// the Settings sheet emerges from below, and a 12px void separates
// them in a )|( silhouette.
//
// User-visible behaviour:
//
// • The wrapped app body (DirectStreamHeader → DM list → new-chat
// row → DirectSelfRow) stays exactly where it was — no translate,
// no shrink. The bottom of the visible portion is "masked away"
// by an animated `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx
// Rpx)` with rounded BL/BR carves at the new visible edge. The
// carved area exposes the container's void colour underneath, and
// the silhouette below covers the rest of the masked zone. Why
// not `transform: translateY`: translating moves the TOP of the
// pane out of the viewport (DirectStreamHeader scrolls off-
// screen). Why not `flex-shrink + margin-bottom`: the virtualized
// DM list (`@tanstack/react-virtual`) re-measures items every
// time the scroll container resizes — items above the shrinking
// edge visibly smear. Clip-path leaves layout unchanged: the DM
// list keeps its scroll position, its measured heights, and its
// top items in place; only the bottom edge of what's visible is
// carved into the void.
// • Drag-up origin is `DirectSelfRow` itself, marked with the
// `data-settings-drag-origin` attribute. A document-level
// touchstart / pointerdown listener uses `target.closest()` to
// detect drags starting inside the row, so the row's own click
// handler keeps working (tap without movement → openSheet via
// atom; tap with movement → drag).
// • Drag-to-close origin is ONLY the 20px handle band at the top
// of the sheet. Settings's internal content (menu, sub-pages,
// scrolling list) is NOT drag-sensitive, so the user can scroll
// through Settings without accidentally dismissing the sheet.
//
// State is Jotai-atom-driven (`settingsSheetAtom`) so the sheet
// overlays the DM list without route-swapping it away. The /settings
// URL still exists as a deep-link entry — see `SettingsScreen.tsx`
// for the desktop branch and the mobile-side redirect that sets the
// atom and bounces to /direct/.
//
// NO FocusTrap: focus-trap-react throws when its container has no
// tabbable nodes, which is exactly the case mid-drag before Settings
// has rendered any focusable children. Esc handling is an explicit
// `keydown` listener; touch / pointer driving the gesture means
// focus-trap semantics aren't load-bearing here anyway.
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { settingsSheetAtom } from '../../state/settingsSheet';
import {
useCloseSettingsSheet,
useOpenSettingsSheet,
} from '../../state/hooks/settingsSheet';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { HorseshoeEnabledContext } from '../../components/page';
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
import { Settings } from './Settings';
import * as css from './MobileSettingsHorseshoe.css';
// Data attribute set on `DirectSelfRow` to mark it as the drag-up
// origin for the sheet. Anything with `.closest(SELECTOR)` matching is
// treated as the open-drag handle. Keep in sync with `DirectSelfRow.tsx`.
export const SETTINGS_SHEET_DRAG_ORIGIN_ATTR = 'data-settings-drag-origin';
const DRAG_ORIGIN_SELECTOR = `[${SETTINGS_SHEET_DRAG_ORIGIN_ATTR}]`;
const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
const ANIMATION_MS = 250;
// Drag distance past which release commits (open from DirectSelfRow,
// or close from the panel handle). Mirrors the profile horseshoe's
// 80px so the two gestures feel identical.
const COMMIT_THRESHOLD_PX = 80;
// Fixed sheet height — 2/3 of viewport, what the user signed off on.
// Internal scrolling inside Settings sub-pages handles content
// overflow; no rail re-sizing on menu↔sub-page navigation.
const RAIL_FRACTION = 2 / 3;
// Drag distance over which the radii + void-gap ramp from 0 to their
// full value during finger-drag — same as the profile horseshoe's
// `HORSESHOE_EMERGE_PX`. Matched to `COMMIT_THRESHOLD_PX` so the
// silhouette is fully formed exactly when the gesture qualifies to
// commit.
const HORSESHOE_EMERGE_PX = 80;
// Symmetric cubic in-out — slow start, fast middle, slow finish. Mirror
// of the profile horseshoe's emerge curve (file rationale there). The
// linear ramp from round 1 came out too "snappy" because the rounding
// jumped in within the first ~10px of drag; the cubic keeps the corners
// barely visible until ~40% of the way through the gesture, then
// blossoms around the midpoint. Used only during finger-drag; release
// transitions use the asymmetric VAUL_EASING curve in CSS.
const easeInOutCubic = (t: number): number =>
t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2;
type DragSource = 'directSelfRow' | 'handle';
type DragState = {
source: DragSource;
inputType: 'touch' | 'pointer';
startY: number;
deltaY: number;
};
type MobileSettingsHorseshoeProps = {
children: ReactNode;
};
function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) {
const { t } = useTranslation();
const sheet = useAtomValue(settingsSheetAtom);
const openSheet = useOpenSettingsSheet();
const closeSheet = useCloseSettingsSheet();
const [drag, setDrag] = useState<DragState | null>(null);
const [viewportHeight, setViewportHeight] = useState(() =>
typeof window === 'undefined' ? 800 : window.innerHeight
);
useEffect(() => {
const onResize = () => setViewportHeight(window.innerHeight);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const railHeightPx = Math.round(viewportHeight * RAIL_FRACTION);
const open = !!sheet;
// Entry-animation gate. On cold-start deep-links (push notification
// → /settings → mobile redirect → /direct/ with atom already set),
// the wrapper would otherwise mount with `expandedPx = railHeightPx`
// and skip the slide-up animation entirely (sheet would snap to
// fully open). The pattern: first render uses `expandedPx = 0`
// (`hasEntered = false`), `useLayoutEffect` queues a
// `requestAnimationFrame` that flips `hasEntered` to true on the
// next frame, the re-render goes to the open state, and the CSS
// `transform` transition animates the difference. Harmless when
// the atom is undefined on mount (just one extra render with the
// same closed state).
//
// `hasEnteredRef` mirrors the state for the unmount-cleanup effect
// below — without it, React 18 strict-mode dev would run the
// cleanup before the rAF flips the state and would clear the atom
// mid-rehearsal, breaking deep-link UX in dev.
const [hasEntered, setHasEntered] = useState(false);
const hasEnteredRef = useRef(false);
useLayoutEffect(() => {
const id = requestAnimationFrame(() => {
hasEnteredRef.current = true;
setHasEntered(true);
});
return () => cancelAnimationFrame(id);
}, []);
// Mirror `open` with a delayed unmount so Settings stays in the DOM
// during the 250ms slide-down exit animation. Without this, clearing
// the atom would immediately unmount Settings and the user would see
// an empty panel shrink instead of the menu sliding away with it.
const [keepMounted, setKeepMounted] = useState(open);
useEffect(() => {
if (open) {
setKeepMounted(true);
return undefined;
}
const id = window.setTimeout(() => setKeepMounted(false), ANIMATION_MS);
return () => window.clearTimeout(id);
}, [open]);
// Persist the last opened sheet state so Settings's `initialPage`
// doesn't reset to undefined during the exit animation.
const lastSheetRef = useRef(sheet);
if (sheet) lastSheetRef.current = sheet;
const baseExpanded = open && hasEntered ? railHeightPx : 0;
// `directSelfRow` drag: negative deltaY (finger up) opens the sheet.
// `handle` drag: positive deltaY (finger down) closes the sheet.
// Same formula: panel height = base + (-deltaY) = base - deltaY,
// clamped to [0, railHeight].
const expandedPx = drag
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
: baseExpanded;
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
const isDragging = drag !== null;
const horseshoeActive = expandedPx > 0;
const handleRef = useRef<HTMLDivElement>(null);
// Refs so the always-installed event handlers see the latest state
// without re-subscribing on every render. Same pattern as the
// profile horseshoe.
const dragRef = useRef<DragState | null>(null);
dragRef.current = drag;
const sheetRef = useRef(sheet);
sheetRef.current = sheet;
const openSheetRef = useRef(openSheet);
openSheetRef.current = openSheet;
const closeSheetRef = useRef(closeSheet);
closeSheetRef.current = closeSheet;
// Clear the atom on unmount — tapping a DM row while the sheet is
// open routes to /direct/!roomId, the wrapper unmounts (Direct
// unmounts on route change), and if we don't clear the atom here
// a later return to /direct/ would auto-re-open the sheet (wrong:
// the user moved on).
//
// The `hasEnteredRef` guard skips the dev-mode strict-mode
// rehearsal cleanup (which fires synchronously after the first
// useEffect commit, before the entry-rAF can flip the ref to true).
// Without this, deep-link cold-start in dev would clear the atom
// mid-rehearsal and the sheet would never open. Production runs
// the cleanup only on real unmount, when `hasEnteredRef.current`
// has long since been set.
useEffect(
() => () => {
if (!hasEnteredRef.current) return;
if (sheetRef.current) closeSheetRef.current();
},
[]
);
// Hardware Escape (web only, rare on mobile) → close. Plain keydown
// listener; no FocusTrap (rationale at the file header).
useEffect(() => {
if (!open) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
) {
return;
}
closeSheetRef.current();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open]);
// Drag mechanics — two origin paths:
//
// 1. Document-level touch/pointer on anything matching
// `[data-settings-drag-origin]` (= DirectSelfRow). Listening at
// the document so the row's own onClick handler still fires for
// no-movement taps — touchstart and pointerdown are passive /
// don't preventDefault, so the synthesised click downstream is
// intact.
// 2. Element-level touch/pointer on `handleRef` (the 20px drag bar
// at the top of the open sheet). Only triggers when the sheet
// is open; touches on the panel body itself (Settings content)
// are NOT drag-sensitive so internal scroll works without
// conflict.
useEffect(() => {
const handleEl = handleRef.current;
// CLAMP, not early-return. Reversal of gesture direction must
// drag `deltaY` back toward 0, not leave the stale value. The
// earlier `if (wrong direction) return` branch let a user swipe
// up 100px, change their mind, swipe back to start, and release
// to find the sheet committed open anyway (stale -100 deltaY
// still cleared the threshold on release). Clamping with
// `Math.min/max(0, rawDelta)` is the same idiom the profile
// horseshoe uses on its open-direction header drag — reversal
// cancels the gesture cleanly.
//
// directSelfRow source: open on drag-up → clamp negative;
// downward (rawDelta > 0) collapses to 0 so the panel returns
// to closed if the user reverses.
// handle source: close on drag-down → clamp positive;
// upward (rawDelta < 0) collapses to 0 so the panel doesn't
// try to expand past its full open height.
//
// preventDefault always (when cancelable) — these touches belong
// to our gesture. DM-list scroll lives ABOVE the row; touches on
// those rows don't hit `[data-settings-drag-origin]` so they
// never enter this handler.
const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const rawDelta = clientY - d.startY;
const nextDelta =
d.source === 'directSelfRow' ? Math.min(0, rawDelta) : Math.max(0, rawDelta);
if (e.cancelable) e.preventDefault();
setDrag({ ...d, deltaY: nextDelta });
};
const applyEnd = () => {
const d = dragRef.current;
if (!d) return;
if (d.source === 'directSelfRow' && -d.deltaY > COMMIT_THRESHOLD_PX) {
openSheetRef.current();
} else if (d.source === 'handle' && d.deltaY > COMMIT_THRESHOLD_PX) {
closeSheetRef.current();
}
setDrag(null);
};
const targetIsDragOrigin = (target: EventTarget | null): boolean => {
if (!target || !(target instanceof Element)) return false;
return target.closest(DRAG_ORIGIN_SELECTOR) !== null;
};
// === Touch path ===
const onDocTouchStart = (e: TouchEvent) => {
if (dragRef.current) return;
if (sheetRef.current) return; // sheet is open — handle owns drag
if (!targetIsDragOrigin(e.target)) return;
const touch = e.touches[0];
setDrag({
source: 'directSelfRow',
inputType: 'touch',
startY: touch.clientY,
deltaY: 0,
});
};
const onHandleTouchStart = (e: TouchEvent) => {
if (dragRef.current) return;
if (!sheetRef.current) return;
const touch = e.touches[0];
setDrag({ source: 'handle', inputType: 'touch', startY: touch.clientY, deltaY: 0 });
};
const onTouchMove = (e: TouchEvent) => {
const d = dragRef.current;
if (!d || d.inputType !== 'touch') return;
applyMove(e.touches[0].clientY, e);
};
const onTouchEnd = () => {
const d = dragRef.current;
if (!d || d.inputType !== 'touch') return;
applyEnd();
};
// === Mouse / pen path ===
const onDocPointerDown = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
if (dragRef.current) return;
if (sheetRef.current) return;
if (e.button !== 0) return;
if (!targetIsDragOrigin(e.target)) return;
setDrag({
source: 'directSelfRow',
inputType: 'pointer',
startY: e.clientY,
deltaY: 0,
});
};
const onHandlePointerDown = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
if (dragRef.current) return;
if (!sheetRef.current) return;
if (e.button !== 0) return;
setDrag({ source: 'handle', inputType: 'pointer', startY: e.clientY, deltaY: 0 });
};
const onDocPointerMove = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
const d = dragRef.current;
if (!d || d.inputType !== 'pointer') return;
applyMove(e.clientY, e);
};
const onDocPointerEnd = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
const d = dragRef.current;
if (!d || d.inputType !== 'pointer') return;
applyEnd();
};
document.addEventListener('touchstart', onDocTouchStart, { passive: true });
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd, { passive: true });
document.addEventListener('touchcancel', onTouchEnd, { passive: true });
document.addEventListener('pointerdown', onDocPointerDown);
document.addEventListener('pointermove', onDocPointerMove, { passive: false });
document.addEventListener('pointerup', onDocPointerEnd, { passive: true });
document.addEventListener('pointercancel', onDocPointerEnd, { passive: true });
if (handleEl) {
handleEl.addEventListener('touchstart', onHandleTouchStart, { passive: true });
handleEl.addEventListener('pointerdown', onHandlePointerDown);
}
return () => {
document.removeEventListener('touchstart', onDocTouchStart);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
document.removeEventListener('touchcancel', onTouchEnd);
document.removeEventListener('pointerdown', onDocPointerDown);
document.removeEventListener('pointermove', onDocPointerMove);
document.removeEventListener('pointerup', onDocPointerEnd);
document.removeEventListener('pointercancel', onDocPointerEnd);
if (handleEl) {
handleEl.removeEventListener('touchstart', onHandleTouchStart);
handleEl.removeEventListener('pointerdown', onHandlePointerDown);
}
};
}, []);
// Geometry — radii and void gap ramp through `easeInOutCubic` (slow
// start → fast middle → slow finish) during finger-drag, matching
// the profile horseshoe's emerge curve. Same `HORSESHOE_EMERGE_PX`
// window as the profile horseshoe so the rounding finishes exactly
// when the gesture qualifies to commit. During release (not
// dragging), the values jump to their full target and the CSS
// transition (VAUL_EASING) carries them visually.
//
// The mask: appBody stays in place, its bottom edge is "carved
// away" by `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx Rpx)`
// where BOTTOM = expandedPx + voidGap. The carved zone exposes the
// container's bg (the void colour) above the silhouette; the void
// gap is just `voidGap` pixels of that exposed bg between the
// silhouette's top edge and the clip-path's lower edge.
let horseshoeRamp: number;
if (isDragging) {
horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
} else {
horseshoeRamp = expandedFraction > 0 ? 1 : 0;
}
const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
const appBodyMaskBottomPx = expandedPx + appBodyGapPx;
// `inset()` shorthand: top right bottom left, then `round` followed
// by 4 corner radii in TL TR BR BL order. Only the bottom two carry
// the radius so the visible top portion of appBody has rounded BL/BR
// at the clip boundary. Always emitted (even when all values are 0
// for the closed state) so CSS can transition smoothly between
// closed and open — interpolating between `inset(...)` and an
// `undefined` clip-path would snap rather than animate.
const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`;
const silhouetteTransition = isDragging
? 'none'
: `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;
const containerStyle: React.CSSProperties = {
backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
};
const settingsState = sheet ?? lastSheetRef.current;
const renderSettings = keepMounted || isDragging;
// Render an invisible marker into `#portalContainer` while the sheet
// is open. The global Android-back handler in `useAndroidBackButton`
// checks for `portalContainer.firstChild` and, if present, dispatches
// an `Escape` keydown that our `keydown` effect above catches to
// close the sheet (or, when handled in capture by Settings, to pop
// the active sub-page back to the menu). Without this marker the
// global handler would fall through to `navigate(-1)` and bounce the
// user out of /direct/ entirely. Portal target falls back to body
// when running in tests / SSR where `#portalContainer` isn't
// mounted yet.
const portalTarget =
typeof document !== 'undefined'
? document.getElementById('portalContainer') ?? document.body
: null;
return (
<div className={css.container} style={containerStyle}>
{open && portalTarget
? createPortal(
<div data-vojo-settings-sheet-active="true" aria-hidden="true" style={{ display: 'none' }} />,
portalTarget
)
: null}
<div
className={css.appBody}
style={{
clipPath: appBodyClipPath,
transition: appBodyTransition,
overscrollBehaviorY: 'contain',
}}
>
{children}
</div>
<div
className={css.silhouette}
style={{
height: `${expandedPx}px`,
borderTopLeftRadius: `${silhouetteRadiusPx}px`,
borderTopRightRadius: `${silhouetteRadiusPx}px`,
transition: silhouetteTransition,
visibility: expandedPx > 0 ? 'visible' : 'hidden',
// Reset `--vojo-safe-top` for everything mounted inside the
// sheet. The status-bar inset is reserved by PageNav's inner
// column via `padding-top: var(--vojo-safe-top)` for surfaces
// anchored to the top of the viewport — the sheet itself sits
// below the status bar, so that padding here would add dead
// space above the menu header and (because sub-page `<Page>`
// doesn't have the same padding) misalign the sub-page title
// vs the menu title when the user navigates into a section.
// Same trick Modal500 uses for its centred dialog.
['--vojo-safe-top' as string]: '0px',
}}
// `role="dialog"` alone marks this as a non-modal dialog: the
// assistive-tech announces it as a dialog, but interaction
// outside (DM list above, DirectSelfRow tap-through to a DM)
// remains permitted — matching the actual behaviour. We
// deliberately do NOT set `aria-modal="true"`: that attribute
// claims modality, but we have no focus trap and the wrapped
// app body is fully interactive. Per MDN, claiming
// `aria-modal="true"` without enforcing it misleads
// screen-reader users into thinking outside content is
// unreachable. The profile horseshoe takes the same stance
// (FocusTrap with `clickOutsideDeactivates: false`).
//
// `aria-label` (not `aria-labelledby`): the visible "Settings"
// title lives inside the PageNavHeader, which Settings.tsx
// unmounts when a mobile sub-page is active (so the labelledby
// target would become a dangling reference). A stable
// `aria-label` reads the same string regardless of which inner
// view is mounted.
role="dialog"
aria-label={t('Settings.title')}
>
<div className={css.panelContent} style={{ height: `${railHeightPx}px` }}>
<div
ref={handleRef}
className={css.panelHandle}
aria-label={t('Settings.drag_to_close')}
>
<div className={css.panelHandleBar} />
</div>
<div className={css.panelBody}>
{/* HorseshoeEnabledContext disabled for the embedded
Settings so its OWN PageRoot doesn't draw a second
12px void column inside the mobile sheet. */}
<HorseshoeEnabledContext.Provider value={false}>
{renderSettings && (
<Settings
initialPage={settingsState?.page}
requestClose={() => closeSheetRef.current()}
/>
)}
</HorseshoeEnabledContext.Provider>
</div>
</div>
</div>
</div>
);
}
// Top-level router. On non-mobile we pass through; the desktop /settings
// route renders Settings in the nested-horseshoe right pane (see
// `SettingsScreen.tsx`). The mobile branch owns the drag/atom state in
// a sub-component so we don't run those hooks on desktop renders.
export function MobileSettingsHorseshoe({
children,
}: MobileSettingsHorseshoeProps): React.ReactElement {
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
if (!isMobile) {
return children as React.ReactElement;
}
return <MobileSettingsHorseshoeImpl>{children}</MobileSettingsHorseshoeImpl>;
}

View file

@ -1,9 +1,9 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Avatar,
Box, Box,
Button, Button,
color,
config, config,
Icon, Icon,
IconButton, IconButton,
@ -20,12 +20,6 @@ import { General } from './general';
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page'; import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { Account } from './account'; import { Account } from './account';
import { useUserProfile } from '../../hooks/useUserProfile';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar';
import { nameInitials } from '../../utils/common';
import { Notifications } from './notifications'; import { Notifications } from './notifications';
import { Devices } from './devices'; import { Devices } from './devices';
import { EmojisStickers } from './emojis-stickers'; import { EmojisStickers } from './emojis-stickers';
@ -45,6 +39,34 @@ export enum SettingsPages {
AboutPage, AboutPage,
} }
// Stable string keys for URL deep-links: `/settings?page=devices`.
// Append-only — the enum is local but the param values are part of
// the URL contract (push notifications, bookmarks, external links).
// `as const satisfies` so the value type is a literal union (not
// widened to `Record<string, SettingsPages>`): the lookup
// `SETTINGS_PAGE_PARAM[k]` with `k: keyof typeof SETTINGS_PAGE_PARAM`
// returns a narrow `SettingsPages`, but a lookup with an arbitrary
// `string` is a TS error rather than silently typing as
// `SettingsPages` — closing the type-lie R1 flagged but the index-
// signature version didn't actually fix (tsconfig has no
// `noUncheckedIndexedAccess`).
export const SETTINGS_PAGE_PARAM = {
general: SettingsPages.GeneralPage,
account: SettingsPages.AccountPage,
notifications: SettingsPages.NotificationPage,
devices: SettingsPages.DevicesPage,
emojis: SettingsPages.EmojisStickersPage,
'developer-tools': SettingsPages.DeveloperToolsPage,
about: SettingsPages.AboutPage,
} as const satisfies Record<string, SettingsPages>;
export const SETTINGS_PARAM_DEVICES = 'devices';
// DOM id of the visible "Settings" title in PageNavHeader — referenced
// from `aria-labelledby` on the mobile sheet's `role="dialog"` so
// screen readers announce the same string sighted users see (WAI-ARIA
// APG dialog pattern prefers `aria-labelledby` over `aria-label`).
export const SETTINGS_TITLE_ID = 'vojo-settings-title';
type SettingsMenuItem = { type SettingsMenuItem = {
page: SettingsPages; page: SettingsPages;
nameKey: string; nameKey: string;
@ -79,20 +101,25 @@ type SettingsProps = {
}; };
export function Settings({ initialPage, requestClose }: SettingsProps) { export function Settings({ initialPage, requestClose }: SettingsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const userId = mx.getUserId()!;
const profile = useUserProfile(userId);
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
// `initialPage !== undefined` (not truthy-check) — `GeneralPage`
// happens to be enum value `0`, which the old `if (initialPage)`
// form silently dropped, breaking `/settings?page=general`.
const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => { const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => {
if (initialPage) return initialPage; if (initialPage !== undefined) return initialPage;
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage; return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
}); });
// Re-sync when the parent passes a new `initialPage` (e.g. the
// UnverifiedTab shield-icon shortcut navigating from `/settings` to
// `/settings?page=devices` while Settings stays mounted — without
// this effect the activePage would stay at its previous value and
// the deep-link would be silently ignored). Treats `initialPage`
// as the canonical deep-link entry, not the live state, so any
// user-driven menu click (`setActivePage(...)`) remains untouched
// until a NEW initialPage arrives from props.
useEffect(() => {
if (initialPage !== undefined) setActivePage(initialPage);
}, [initialPage]);
const menuItems = SETTINGS_MENU_ITEMS; const menuItems = SETTINGS_MENU_ITEMS;
const handlePageRequestClose = () => { const handlePageRequestClose = () => {
@ -103,28 +130,64 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
requestClose(); requestClose();
}; };
// Two-level back on mobile: first Escape (or Android hardware back,
// which `useAndroidBackButton` translates into an Escape dispatch)
// pops the active sub-page back to the menu; the next Escape lets the
// wrapper close the sheet entirely. We listen in capture mode so this
// handler runs BEFORE the wrapper's window-level keydown bubble
// listener — when a sub-page is active we `stopImmediatePropagation()`
// so the wrapper's close-sheet handler doesn't also fire.
//
// On desktop the two-level fallback isn't useful (Settings is the
// route — Esc just navigates back), so the handler is mobile-only.
useEffect(() => {
if (screenSize !== ScreenSize.Mobile) return undefined;
if (activePage === undefined) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
) {
return;
}
setActivePage(undefined);
e.stopImmediatePropagation();
};
window.addEventListener('keydown', onKeyDown, { capture: true });
return () => window.removeEventListener('keydown', onKeyDown, { capture: true });
}, [screenSize, activePage]);
return ( return (
<PageRoot <PageRoot
nav={ nav={
screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : ( screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : (
<PageNav size="300"> <PageNav size="350" surface="surfaceVariant">
<PageNavHeader outlined={false}> <PageNavHeader outlined={false}>
<Box grow="Yes" gap="200"> <Box grow="Yes" gap="200" alignItems="Center">
<Avatar size="200" radii="300"> <Text id={SETTINGS_TITLE_ID} size="H4" truncate>
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H6">{nameInitials(displayName)}</Text>}
/>
</Avatar>
<Text size="H4" truncate>
{t('Settings.title')} {t('Settings.title')}
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
{screenSize === ScreenSize.Mobile && ( {screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background"> <IconButton
<Icon src={Icons.Cross} /> onClick={requestClose}
variant="SurfaceVariant"
aria-label={t('Settings.close')}
>
{/* Explicit `color: OnContainer` because the
surrounding PageNavHeader carries
`variant="Background"` (recipe-level) without
an override the Icon would inherit the
Background tone and read as a near-invisible
dark patch against the SurfaceVariant panel
bg. The neutral OnContainer is the same light
text colour used everywhere else on the panel. */}
<Icon src={Icons.Cross} style={{ color: color.SurfaceVariant.OnContainer }} />
</IconButton> </IconButton>
)} )}
</Box> </Box>
@ -132,26 +195,57 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<PageNavContent> <PageNavContent>
<div style={{ flexGrow: 1 }}> <div style={{ flexGrow: 1 }}>
{menuItems.map((item) => ( {menuItems.map((item) => {
<MenuItem const isActive = activePage === item.page;
key={item.nameKey} // The whole PageNav sits on SurfaceVariant.Container
variant="Background" // (the chat-pane tone). Active items step up to
radii="400" // SurfaceVariant.ContainerActive (the raised
aria-pressed={activePage === item.page} // hover/active tone) to read as a subtle raised
before={<Icon src={item.icon} size="100" filled={activePage === item.page} />} // row, and the active icon picks up
onClick={() => setActivePage(item.page)} // `Primary.Main` (Fleet-violet) for a single
> // splash of accent — the same accent the
<Text // DM/Channels/Bots tab underline uses. Text stays
// neutral OnContainer; the weight bump conveys
// the active state to the eye.
return (
<MenuItem
key={item.nameKey}
variant="SurfaceVariant"
radii="400"
aria-pressed={isActive}
before={
<Icon
src={item.icon}
size="100"
filled={isActive}
style={{
color: isActive
? color.Primary.Main
: color.SurfaceVariant.OnContainer,
opacity: isActive ? 1 : 0.7,
}}
/>
}
onClick={() => setActivePage(item.page)}
style={{ style={{
fontWeight: activePage === item.page ? config.fontWeight.W600 : undefined, backgroundColor: isActive
? color.SurfaceVariant.ContainerActive
: 'transparent',
}} }}
size="T300"
truncate
> >
{t(item.nameKey)} <Text
</Text> style={{
</MenuItem> fontWeight: isActive ? config.fontWeight.W600 : undefined,
))} opacity: isActive ? 1 : 0.82,
}}
size="T300"
truncate
>
{t(item.nameKey)}
</Text>
</MenuItem>
);
})}
</div> </div>
</PageNavContent> </PageNavContent>
<Box style={{ padding: config.space.S200 }} shrink="No" direction="Column"> <Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">

View file

@ -0,0 +1,129 @@
// Route-mounted entry point for `/settings`. Three responsibilities:
//
// 1. Pick the surface chrome based on screen size — Settings itself
// provides its own internal PageRoot (settings menu + sub-screen).
// On Desktop we render it inline inside the right pane of the
// outer DIRECT_PATH PageRoot (the route element in Router.tsx) so
// the DM list stays visible on the left and the horseshoe rounded
// TL/BL on the right pane is inherited. On Mobile the route is a
// deep-link entry only — we set the `settingsSheetAtom` and
// redirect to /direct/, where `MobileSettingsHorseshoe` wraps the
// DM list and renders the bottom-up sheet over it (so the user
// sees the )|( silhouette: DM list above, void, sheet below).
//
// 2. Translate URL state into Settings props — `?page=devices` deep-
// links into the Devices sub-screen (used by the UnverifiedTab
// shield-icon shortcut and future deep links — push notifications,
// external onboarding emails). Stable string keys live in
// `SETTINGS_PAGE_PARAM` (Settings.tsx). Unknown values fall through
// to the default landing (mobile menu / desktop GeneralPage).
//
// 3. Define close behaviour — Esc closes via a plain `keydown`
// listener (we explicitly do NOT use focus-trap-react here: the
// desktop Settings surface is non-modal — clicks on the DM list
// should pass through to navigation, not deactivate a trap — and
// focus-trap-react's `onDeactivate` fires on Strict-Mode unmount,
// which would call `navigate(-1)` at the wrong moment).
// Cold-load entries (no in-app history) fall through to /direct/
// so the close action is never a dead-end.
import React, { useCallback, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { getDirectPath } from '../../pages/pathUtils';
import { useOpenSettingsSheet } from '../../state/hooks/settingsSheet';
import { Settings, SETTINGS_PAGE_PARAM, SettingsPages } from './Settings';
const hasPageParam = (key: string): key is keyof typeof SETTINGS_PAGE_PARAM =>
Object.prototype.hasOwnProperty.call(SETTINGS_PAGE_PARAM, key);
// `location.state` payload set by every in-Vojo navigation to /settings
// (SettingsTab, DirectSelfRow, UnverifiedTab). When present, we know
// the previous history entry is an in-app page and `navigate(-1)` is
// safe. When missing — cold-start, deep-link from a push, an external
// bookmark — we explicitly route to `/direct/` instead of risking a
// cross-origin back navigation. Keep this constant in sync with the
// entry points.
export const SETTINGS_FROM_IN_APP_STATE = { vojoFrom: 'in-app' as const };
export function SettingsScreen() {
const screenSize = useScreenSizeContext();
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const openSheet = useOpenSettingsSheet();
const pageParam = searchParams.get('page');
const initialPage: SettingsPages | undefined =
pageParam && hasPageParam(pageParam) ? SETTINGS_PAGE_PARAM[pageParam] : undefined;
// `window.history.length` does NOT distinguish in-SPA navigations
// from cross-origin entries (the user typing `vojo.chat/settings`
// in the address bar). Relying on it was a load-bearing coincidence;
// a determined back-fallback could take the user out of Vojo
// entirely. Instead, every in-app entry to `/settings` passes
// `state: SETTINGS_FROM_IN_APP_STATE` via the router (see
// `SettingsTab.tsx`, `DirectSelfRow.tsx`, `UnverifiedTab.tsx`).
// We read that state here as the safe-`-1` signal; without it we
// route explicitly to `/direct/`.
const fromInApp =
(location.state as { vojoFrom?: string } | null | undefined)?.vojoFrom === 'in-app';
const handleClose = useCallback(() => {
if (fromInApp) {
navigate(-1);
} else {
navigate(getDirectPath(), { replace: true });
}
}, [navigate, fromInApp]);
// Mobile is a deep-link redirect — set the atom (so the bottom-up
// sheet opens with the right initial page) then bounce to /direct/
// where `MobileSettingsHorseshoe` is mounted. `replace: true` so the
// /settings entry doesn't sit in the back-stack (otherwise pressing
// back from /direct/ would bounce back through /settings → loop).
const isMobile = screenSize === ScreenSize.Mobile;
useEffect(() => {
if (!isMobile) return;
openSheet(initialPage);
navigate(getDirectPath(), { replace: true });
}, [isMobile, initialPage, openSheet, navigate]);
// Esc on desktop — explicit keydown listener instead of FocusTrap's
// `escapeDeactivates + onDeactivate`. focus-trap-react's
// `onDeactivate` fires on Strict-Mode mount-cycle unmount and on
// outside clicks, both of which would erroneously trigger close
// here. A plain keydown is exact: Esc → handleClose, nothing else.
useEffect(() => {
if (isMobile) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
// Don't intercept Esc when the user is in a text field — they
// expect Esc to clear input, not close the page.
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
) {
return;
}
handleClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isMobile, handleClose]);
if (isMobile) {
// Render nothing — the useEffect above redirects on mount. A flash
// of empty content is acceptable; the redirect runs synchronously
// in a microtask after mount.
return null;
}
return (
<div style={{ display: 'flex', flex: 1, minWidth: 0, minHeight: 0 }}>
<Settings initialPage={initialPage} requestClose={handleClose} />
</div>
);
}

View file

@ -17,7 +17,7 @@ export function About({ requestClose }: AboutProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
return ( return (
<Page> <Page variant="SurfaceVariant">
<PageHeader outlined={false}> <PageHeader outlined={false}>
<Box grow="Yes" gap="200"> <Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
@ -26,7 +26,7 @@ export function About({ requestClose }: AboutProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="SurfaceVariant" aria-label={t('Settings.close')}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@ -34,7 +34,7 @@ export function About({ requestClose }: AboutProps) {
</PageHeader> </PageHeader>
<Box grow="Yes"> <Box grow="Yes">
<Scroll hideTrack visibility="Hover"> <Scroll hideTrack visibility="Hover">
<PageContent> <PageContent style={{ paddingBottom: '1rem' }}>
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<Box gap="400"> <Box gap="400">
<Box shrink="No"> <Box shrink="No">
@ -58,7 +58,7 @@ export function About({ requestClose }: AboutProps) {
<Text size="L400">{t('Settings.options')}</Text> <Text size="L400">{t('Settings.options')}</Text>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -84,7 +84,7 @@ export function About({ requestClose }: AboutProps) {
<Text size="L400">{t('Settings.credits')}</Text> <Text size="L400">{t('Settings.credits')}</Text>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -13,7 +13,7 @@ type AccountProps = {
export function Account({ requestClose }: AccountProps) { export function Account({ requestClose }: AccountProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Page> <Page variant="SurfaceVariant">
<PageHeader outlined={false}> <PageHeader outlined={false}>
<Box grow="Yes" gap="200"> <Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
@ -22,7 +22,7 @@ export function Account({ requestClose }: AccountProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="SurfaceVariant" aria-label={t('Settings.close')}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@ -30,7 +30,7 @@ export function Account({ requestClose }: AccountProps) {
</PageHeader> </PageHeader>
<Box grow="Yes"> <Box grow="Yes">
<Scroll hideTrack visibility="Hover"> <Scroll hideTrack visibility="Hover">
<PageContent> <PageContent style={{ paddingBottom: '1rem' }}>
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<Profile /> <Profile />
<MatrixId /> <MatrixId />

View file

@ -27,7 +27,7 @@ export function ContactInformation() {
<Text size="L400">{t('Settings.contact_info')}</Text> <Text size="L400">{t('Settings.contact_info')}</Text>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -141,7 +141,7 @@ export function IgnoredUserList() {
</Box> </Box>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -17,7 +17,7 @@ export function MatrixId() {
<Text size="L400">{t('Settings.matrix_id')}</Text> <Text size="L400">{t('Settings.matrix_id')}</Text>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -317,7 +317,7 @@ export function Profile() {
<Text size="L400">{t('Settings.profile')}</Text> <Text size="L400">{t('Settings.profile')}</Text>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -32,7 +32,7 @@ export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataPro
<Text size="L400">{t('Settings.account_data')}</Text> <Text size="L400">{t('Settings.account_data')}</Text>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -44,7 +44,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
} }
return ( return (
<Page> <Page variant="SurfaceVariant">
<PageHeader outlined={false}> <PageHeader outlined={false}>
<Box grow="Yes" gap="200"> <Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
@ -53,7 +53,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="SurfaceVariant" aria-label={t('Settings.close')}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@ -61,13 +61,13 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
</PageHeader> </PageHeader>
<Box grow="Yes"> <Box grow="Yes">
<Scroll hideTrack visibility="Hover"> <Scroll hideTrack visibility="Hover">
<PageContent> <PageContent style={{ paddingBottom: '1rem' }}>
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">{t('Settings.options')}</Text> <Text size="L400">{t('Settings.options')}</Text>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -85,7 +85,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
{developerTools && ( {developerTools && (
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -34,7 +34,7 @@ export function DeviceTilePlaceholder() {
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
style={{ height: toRem(66) }} style={{ height: toRem(66) }}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
/> />

View file

@ -67,7 +67,7 @@ export function Devices({ requestClose }: DevicesProps) {
); );
return ( return (
<Page> <Page variant="SurfaceVariant">
<PageHeader outlined={false}> <PageHeader outlined={false}>
<Box grow="Yes" gap="200"> <Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
@ -76,7 +76,7 @@ export function Devices({ requestClose }: DevicesProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="SurfaceVariant" aria-label={t('Settings.close')}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@ -84,13 +84,13 @@ export function Devices({ requestClose }: DevicesProps) {
</PageHeader> </PageHeader>
<Box grow="Yes"> <Box grow="Yes">
<Scroll hideTrack visibility="Hover"> <Scroll hideTrack visibility="Hover">
<PageContent> <PageContent style={{ paddingBottom: '1rem' }}>
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">{t('Settings.security')}</Text> <Text size="L400">{t('Settings.security')}</Text>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -119,7 +119,7 @@ export function Devices({ requestClose }: DevicesProps) {
{currentDevice ? ( {currentDevice ? (
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -312,7 +312,7 @@ export function LocalBackup() {
<Text size="L400">{t('Settings.local_backup')}</Text> <Text size="L400">{t('Settings.local_backup')}</Text>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -320,7 +320,7 @@ export function LocalBackup() {
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -109,7 +109,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
{authMetadata && ( {authMetadata && (
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -294,7 +294,7 @@ export function DeviceVerificationOptions() {
<> <>
<IconButton <IconButton
aria-pressed={!!menuCords} aria-pressed={!!menuCords}
variant="SurfaceVariant" variant="Background"
size="300" size="300"
radii="300" radii="300"
onClick={handleMenu} onClick={handleMenu}

View file

@ -23,7 +23,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
} }
return ( return (
<Page> <Page variant="SurfaceVariant">
<PageHeader outlined={false}> <PageHeader outlined={false}>
<Box grow="Yes" gap="200"> <Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
@ -32,7 +32,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="SurfaceVariant" aria-label={t('Settings.close')}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@ -40,7 +40,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
</PageHeader> </PageHeader>
<Box grow="Yes"> <Box grow="Yes">
<Scroll hideTrack visibility="Hover"> <Scroll hideTrack visibility="Hover">
<PageContent> <PageContent style={{ paddingBottom: '1rem' }}>
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<UserPack onViewPack={setImagePack} /> <UserPack onViewPack={setImagePack} />
<GlobalPacks onViewPack={setImagePack} /> <GlobalPacks onViewPack={setImagePack} />

View file

@ -221,7 +221,7 @@ function GlobalPackSelector({
{roomToPacks.size === 0 && ( {roomToPacks.size === 0 && (
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -428,7 +428,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
<Text size="L400">{t('Settings.favorite_packs')}</Text> <Text size="L400">{t('Settings.favorite_packs')}</Text>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -36,7 +36,7 @@ export function UserPack({ onViewPack }: UserPackProps) {
<Text size="L400">{t('Settings.default_pack')}</Text> <Text size="L400">{t('Settings.default_pack')}</Text>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -85,25 +85,25 @@ function Appearance() {
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">{t('Settings.appearance')}</Text> <Text size="L400">{t('Settings.appearance')}</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile title={t('Settings.theme')} after={<ThemeSelect />} /> <SettingTile title={t('Settings.theme')} after={<ThemeSelect />} />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile <SettingTile
title={t('Settings.monochrome_mode')} title={t('Settings.monochrome_mode')}
after={<Switch variant="Primary" value={monochromeMode} onChange={setMonochromeMode} />} after={<Switch variant="Primary" value={monochromeMode} onChange={setMonochromeMode} />}
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile <SettingTile
title={t('Settings.twitter_emoji')} title={t('Settings.twitter_emoji')}
after={<Switch variant="Primary" value={twitterEmoji} onChange={setTwitterEmoji} />} after={<Switch variant="Primary" value={twitterEmoji} onChange={setTwitterEmoji} />}
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile title={t('Settings.page_zoom')} after={<PageZoomInput />} /> <SettingTile title={t('Settings.page_zoom')} after={<PageZoomInput />} />
</SequenceCard> </SequenceCard>
</Box> </Box>
@ -119,7 +119,7 @@ function Editor() {
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">{t('Settings.editor')}</Text> <Text size="L400">{t('Settings.editor')}</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile <SettingTile
title={t('Settings.enter_newline')} title={t('Settings.enter_newline')}
description={t('Settings.enter_newline_desc', { description={t('Settings.enter_newline_desc', {
@ -128,13 +128,13 @@ function Editor() {
after={<Switch variant="Primary" value={enterForNewline} onChange={setEnterForNewline} />} after={<Switch variant="Primary" value={enterForNewline} onChange={setEnterForNewline} />}
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile <SettingTile
title={t('Settings.markdown')} title={t('Settings.markdown')}
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />} after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile <SettingTile
title={t('Settings.hide_activity')} title={t('Settings.hide_activity')}
description={t('Settings.hide_activity_desc')} description={t('Settings.hide_activity_desc')}
@ -163,7 +163,7 @@ function Messages() {
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">{t('Settings.messages')}</Text> <Text size="L400">{t('Settings.messages')}</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile <SettingTile
title={t('Settings.hide_membership')} title={t('Settings.hide_membership')}
after={ after={
@ -175,7 +175,7 @@ function Messages() {
} }
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile <SettingTile
title={t('Settings.hide_profile')} title={t('Settings.hide_profile')}
after={ after={
@ -187,7 +187,7 @@ function Messages() {
} }
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile <SettingTile
title={t('Settings.disable_media_auto_load')} title={t('Settings.disable_media_auto_load')}
after={ after={
@ -199,19 +199,19 @@ function Messages() {
} }
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile <SettingTile
title={t('Settings.url_preview')} title={t('Settings.url_preview')}
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />} after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile <SettingTile
title={t('Settings.url_preview_encrypted')} title={t('Settings.url_preview_encrypted')}
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />} after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile <SettingTile
title={t('Settings.show_hidden_events')} title={t('Settings.show_hidden_events')}
after={ after={
@ -229,7 +229,7 @@ type GeneralProps = {
export function General({ requestClose }: GeneralProps) { export function General({ requestClose }: GeneralProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Page> <Page variant="SurfaceVariant">
<PageHeader outlined={false}> <PageHeader outlined={false}>
<Box grow="Yes" gap="200"> <Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
@ -238,7 +238,7 @@ export function General({ requestClose }: GeneralProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="SurfaceVariant" aria-label={t('Settings.close')}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@ -246,7 +246,7 @@ export function General({ requestClose }: GeneralProps) {
</PageHeader> </PageHeader>
<Box grow="Yes"> <Box grow="Yes">
<Scroll hideTrack visibility="Hover"> <Scroll hideTrack visibility="Hover">
<PageContent> <PageContent style={{ paddingBottom: '1rem' }}>
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<Appearance /> <Appearance />
<Editor /> <Editor />

View file

@ -1 +1,3 @@
export * from './Settings'; export * from './Settings';
export * from './SettingsScreen';
export * from './MobileSettingsHorseshoe';

View file

@ -94,7 +94,7 @@ export function AllMessagesNotifications() {
</Box> </Box>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -105,7 +105,7 @@ export function AllMessagesNotifications() {
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -123,7 +123,7 @@ export function AllMessagesNotifications() {
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -134,7 +134,7 @@ export function AllMessagesNotifications() {
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -175,7 +175,7 @@ export function KeywordMessagesNotifications() {
</Box> </Box>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -190,7 +190,7 @@ export function KeywordMessagesNotifications() {
<SequenceCard <SequenceCard
key={pushRule.rule_id} key={pushRule.rule_id}
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -16,7 +16,7 @@ type NotificationsProps = {
export function Notifications({ requestClose }: NotificationsProps) { export function Notifications({ requestClose }: NotificationsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Page> <Page variant="SurfaceVariant">
<PageHeader outlined={false}> <PageHeader outlined={false}>
<Box grow="Yes" gap="200"> <Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
@ -25,7 +25,7 @@ export function Notifications({ requestClose }: NotificationsProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="SurfaceVariant" aria-label={t('Settings.close')}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@ -33,7 +33,7 @@ export function Notifications({ requestClose }: NotificationsProps) {
</PageHeader> </PageHeader>
<Box grow="Yes"> <Box grow="Yes">
<Scroll hideTrack visibility="Hover"> <Scroll hideTrack visibility="Hover">
<PageContent> <PageContent style={{ paddingBottom: '1rem' }}>
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<SystemNotification /> <SystemNotification />
<AllMessagesNotifications /> <AllMessagesNotifications />
@ -43,7 +43,7 @@ export function Notifications({ requestClose }: NotificationsProps) {
<Text size="L400">{t('Settings.block_messages')}</Text> <Text size="L400">{t('Settings.block_messages')}</Text>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -136,7 +136,7 @@ export function SpecialMessagesNotifications() {
</Box> </Box>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -153,7 +153,7 @@ export function SpecialMessagesNotifications() {
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -174,7 +174,7 @@ export function SpecialMessagesNotifications() {
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -191,7 +191,7 @@ export function SpecialMessagesNotifications() {
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -208,7 +208,7 @@ export function SpecialMessagesNotifications() {
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -182,7 +182,7 @@ export function SystemNotification() {
<Text size="L400">{t('Settings.system')}</Text> <Text size="L400">{t('Settings.system')}</Text>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -190,7 +190,7 @@ export function SystemNotification() {
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -202,7 +202,7 @@ export function SystemNotification() {
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -214,7 +214,7 @@ export function SystemNotification() {
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
> >

View file

@ -25,6 +25,7 @@ import {
LOGIN_PATH, LOGIN_PATH,
REGISTER_PATH, REGISTER_PATH,
RESET_PASSWORD_PATH, RESET_PASSWORD_PATH,
SETTINGS_PATH,
SPACE_PATH, SPACE_PATH,
_CREATE_PATH, _CREATE_PATH,
_FEATURED_PATH, _FEATURED_PATH,
@ -80,6 +81,7 @@ import { CreateRoomModalRenderer } from '../features/create-room';
import { Create } from './client/create'; import { Create } from './client/create';
import { CreateSpaceModalRenderer } from '../features/create-space'; import { CreateSpaceModalRenderer } from '../features/create-space';
import { SearchModalRenderer } from '../features/search'; import { SearchModalRenderer } from '../features/search';
import { SettingsScreen } from '../features/settings';
import { getFallbackSession } from '../state/sessions'; import { getFallbackSession } from '../state/sessions';
import { CallEmbedProvider } from '../components/CallEmbedProvider'; import { CallEmbedProvider } from '../components/CallEmbedProvider';
import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications'; import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
@ -395,6 +397,27 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_SERVER_PATH} element={<PublicRooms />} /> <Route path={_SERVER_PATH} element={<PublicRooms />} />
</Route> </Route>
<Route path={CREATE_PATH} element={<Create />} /> <Route path={CREATE_PATH} element={<Create />} />
{/* /settings shares the DIRECT_PATH shell left page-nav stays
the DM list, the right pane swaps the chat outlet for the
Settings UI. The horseshoe rounded TL/BL on the right pane
(commits 363bd9d / 74d32eb) inherits from PageRoot for free.
On mobile MobileFriendlyPageNav hides the DM list since
/settings/ DIRECT_PATH; SettingsScreen renders the
bottom-up horseshoe sheet over the empty outlet area. */}
<Route
path={SETTINGS_PATH}
element={
<PageRoot
nav={
<MobileFriendlyPageNav path={DIRECT_PATH}>
<Direct />
</MobileFriendlyPageNav>
}
>
<SettingsScreen />
</PageRoot>
}
/>
<Route path={USER_LINK_PATH} element={<UserLinkRedirect />} /> <Route path={USER_LINK_PATH} element={<UserLinkRedirect />} />
{/* Legacy /inbox/ tree invites moved inline into the Direct list, {/* Legacy /inbox/ tree invites moved inline into the Direct list,
the Notifications aggregator was removed. Keep the route as a the Notifications aggregator was removed. Keep the route as a

View file

@ -24,6 +24,7 @@ import {
import { DirectStreamHeader } from './DirectStreamHeader'; import { DirectStreamHeader } from './DirectStreamHeader';
import { DirectNewChatRow } from './DirectNewChatRow'; import { DirectNewChatRow } from './DirectNewChatRow';
import { DirectSelfRow } from './DirectSelfRow'; import { DirectSelfRow } from './DirectSelfRow';
import { MobileSettingsHorseshoe } from '../../../features/settings';
type ListItem = type ListItem =
| { kind: 'invite'; entry: DirectInviteEntry } | { kind: 'invite'; entry: DirectInviteEntry }
@ -198,87 +199,98 @@ export function Direct() {
return ( return (
<PageNav resizable> <PageNav resizable>
<DirectStreamHeader /> {/* On mobile, `MobileSettingsHorseshoe` wraps the whole DM
{noRoomToDisplay ? ( list (including DirectSelfRow). The bottom-up Settings sheet
<DirectEmpty /> opens via tap on DirectSelfRow OR drag-up from the row
) : ( (`data-settings-drag-origin` marks the drag-target). The
<PageNavContent scrollRef={scrollRef}> app body STAYS IN PLACE only the bottom is carved away by
<Box direction="Column" gap="300"> an animated `clip-path: inset(...)` with rounded BL/BR; the
<NavCategory> sheet emerges below with rounded TL/TR, separated by a 12px
<div void. On non-mobile the wrapper is a pass-through, so the
style={{ desktop layout is unchanged. */}
position: 'relative', <MobileSettingsHorseshoe>
height: virtualizer.getTotalSize(), <DirectStreamHeader />
}} {noRoomToDisplay ? (
> <DirectEmpty />
{virtualizer.getVirtualItems().map((vItem) => { ) : (
const item = items[vItem.index]; <PageNavContent scrollRef={scrollRef}>
if (!item) return null; <Box direction="Column" gap="300">
<NavCategory>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const item = items[vItem.index];
if (!item) return null;
if (item.kind === 'invite' || item.kind === 'spam-invite') { if (item.kind === 'invite' || item.kind === 'spam-invite') {
const { entry } = item; const { entry } = item;
const selected = selectedRoomId === entry.roomId; const selected = selectedRoomId === entry.roomId;
return (
<VirtualTile
virtualItem={vItem}
key={`invite-${entry.roomId}`}
ref={virtualizer.measureElement}
>
<DirectInviteRow
room={entry.room}
selected={selected}
isSpam={item.kind === 'spam-invite'}
/>
</VirtualTile>
);
}
if (item.kind === 'spam-toggle') {
return (
<VirtualTile
virtualItem={vItem}
key="spam-toggle"
ref={virtualizer.measureElement}
>
<SpamToggleRow
spamCount={item.spamCount}
expanded={item.expanded}
onToggle={() => setSpamExpanded((v) => !v)}
/>
</VirtualTile>
);
}
// kind === 'direct'
const { roomId } = item;
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return ( return (
<VirtualTile <VirtualTile
virtualItem={vItem} virtualItem={vItem}
key={`invite-${entry.roomId}`} key={`direct-${roomId}`}
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
> >
<DirectInviteRow <DmStreamRow
room={entry.room} room={room}
selected={selected} selected={selected}
isSpam={item.kind === 'spam-invite'} linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId
)}
/> />
</VirtualTile> </VirtualTile>
); );
} })}
</div>
if (item.kind === 'spam-toggle') { </NavCategory>
return ( </Box>
<VirtualTile </PageNavContent>
virtualItem={vItem} )}
key="spam-toggle" <DirectNewChatRow />
ref={virtualizer.measureElement} <DirectSelfRow />
> </MobileSettingsHorseshoe>
<SpamToggleRow
spamCount={item.spamCount}
expanded={item.expanded}
onToggle={() => setSpamExpanded((v) => !v)}
/>
</VirtualTile>
);
}
// kind === 'direct'
const { roomId } = item;
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={`direct-${roomId}`}
ref={virtualizer.measureElement}
>
<DmStreamRow
room={room}
selected={selected}
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
</Box>
</PageNavContent>
)}
<DirectNewChatRow />
<DirectSelfRow />
</PageNav> </PageNav>
); );
} }

View file

@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Avatar, Box, Icon, Icons, Text, color, config, toRem } from 'folds'; import { Avatar, Box, Icon, Icons, Text, color, config, toRem } from 'folds';
import { NavButton, NavItem, NavItemContent } from '../../../components/nav'; import { NavButton, NavItem, NavItemContent } from '../../../components/nav';
import { UserAvatar } from '../../../components/user-avatar'; import { UserAvatar } from '../../../components/user-avatar';
@ -8,8 +9,11 @@ import { useUserProfile } from '../../../hooks/useUserProfile';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common'; import { nameInitials } from '../../../utils/common';
import { Settings } from '../../../features/settings'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { Modal500 } from '../../../components/Modal500'; import { useOpenSettingsSheet } from '../../../state/hooks/settingsSheet';
import { SETTINGS_SHEET_DRAG_ORIGIN_ATTR } from '../../../features/settings/MobileSettingsHorseshoe';
import { SETTINGS_FROM_IN_APP_STATE } from '../../../features/settings/SettingsScreen';
import { getSettingsPath } from '../../pathUtils';
const MONO_FONT = '"JetBrains Mono Variable", ui-monospace, monospace'; const MONO_FONT = '"JetBrains Mono Variable", ui-monospace, monospace';
const ROW_MIN_HEIGHT = toRem(68); const ROW_MIN_HEIGHT = toRem(68);
@ -18,100 +22,115 @@ export function DirectSelfRow() {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const navigate = useNavigate();
const screenSize = useScreenSizeContext();
const openSheet = useOpenSettingsSheet();
const userId = mx.getSafeUserId(); const userId = mx.getSafeUserId();
const profile = useUserProfile(userId); const profile = useUserProfile(userId);
const [open, setOpen] = useState(false); // Mobile: open the atom-driven bottom-up sheet so the DM list stays
const handleOpen = () => setOpen(true); // visible above with a void gap (the )|( horseshoe). The
const handleClose = () => setOpen(false); // MobileSettingsHorseshoe wrapper owns the drag/animation; the tap
// just commits to fully-open.
//
// Desktop: navigate to `/settings` — the route renders Settings in
// the right pane of the nested horseshoe (DM list still on the left).
const handleOpen = () => {
if (screenSize === ScreenSize.Mobile) {
openSheet();
} else {
navigate(getSettingsPath(), { state: SETTINGS_FROM_IN_APP_STATE });
}
};
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined; : undefined;
// `data-settings-drag-origin` is the marker the mobile horseshoe
// wrapper uses to detect drag-up gestures starting on this row.
// Document-level touch/pointer listeners in `MobileSettingsHorseshoe`
// check `target.closest('[data-settings-drag-origin]')` so any
// touch landing inside this Box (anywhere on the row) starts the
// open-drag while still leaving the row's own onClick handler
// free to fire when there's no movement (tap → openSheet).
return ( return (
<> <Box
<Box {...{ [SETTINGS_SHEET_DRAG_ORIGIN_ATTR]: true }}
style={{ style={{
padding: `${toRem(6)} ${config.space.S100}`, padding: `${toRem(6)} ${config.space.S100}`,
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}} }}
> >
<NavItem variant="Background" radii="400" style={{ minHeight: ROW_MIN_HEIGHT }}> <NavItem variant="Background" radii="400" style={{ minHeight: ROW_MIN_HEIGHT }}>
<NavButton onClick={handleOpen} aria-label={t('Direct.self_row_preview')}> <NavButton onClick={handleOpen} aria-label={t('Direct.self_row_preview')}>
<NavItemContent> <NavItemContent>
<Box
as="span"
grow="Yes"
alignItems="Center"
gap="300"
style={{
minHeight: ROW_MIN_HEIGHT,
boxSizing: 'border-box',
padding: `${toRem(8)} 0`,
}}
>
<Avatar size="300" radii="400">
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(displayName)}
</Text>
)}
/>
</Avatar>
<Box <Box
as="span" as="span"
direction="Column"
grow="Yes" grow="Yes"
alignItems="Center" gap="100"
gap="300" style={{ minWidth: 0, overflow: 'hidden' }}
style={{
minHeight: ROW_MIN_HEIGHT,
boxSizing: 'border-box',
padding: `${toRem(8)} 0`,
}}
> >
<Avatar size="300" radii="400"> <Box as="span" alignItems="Baseline" gap="100" style={{ minWidth: 0 }}>
<UserAvatar <Text as="span" size="T300" truncate style={{ fontWeight: 600, flexShrink: 1 }}>
userId={userId} {t('Direct.self_row_label')}
src={avatarUrl}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(displayName)}
</Text>
)}
/>
</Avatar>
<Box
as="span"
direction="Column"
grow="Yes"
gap="100"
style={{ minWidth: 0, overflow: 'hidden' }}
>
<Box as="span" alignItems="Baseline" gap="100" style={{ minWidth: 0 }}>
<Text as="span" size="T300" truncate style={{ fontWeight: 600, flexShrink: 1 }}>
{t('Direct.self_row_label')}
</Text>
<span
style={{
fontFamily: MONO_FONT,
fontSize: toRem(11),
color: color.Surface.OnContainer,
opacity: 0.65,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flexShrink: 1,
minWidth: 0,
}}
>
{userId}
</span>
</Box>
<Text as="span" size="T200" truncate style={{ opacity: 0.6 }}>
{t('Direct.self_row_preview')}
</Text> </Text>
<span
style={{
fontFamily: MONO_FONT,
fontSize: toRem(11),
color: color.Surface.OnContainer,
opacity: 0.65,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flexShrink: 1,
minWidth: 0,
}}
>
{userId}
</span>
</Box> </Box>
<Icon <Text as="span" size="T200" truncate style={{ opacity: 0.6 }}>
src={Icons.Setting} {t('Direct.self_row_preview')}
size="100" </Text>
style={{
opacity: 0.55,
flexShrink: 0,
}}
/>
</Box> </Box>
</NavItemContent> <Icon
</NavButton> src={Icons.Setting}
</NavItem> size="100"
</Box> style={{
{open && ( opacity: 0.55,
<Modal500 requestClose={handleClose}> flexShrink: 0,
<Settings requestClose={handleClose} /> }}
</Modal500> />
)} </Box>
</> </NavItemContent>
</NavButton>
</NavItem>
</Box>
); );
} }

View file

@ -1,33 +1,47 @@
import React, { useState } from 'react'; import React from 'react';
import { Text } from 'folds'; import { Text } from 'folds';
import { useMatch, useNavigate } from 'react-router-dom';
import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar'; import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar';
import { UserAvatar } from '../../../components/user-avatar'; import { UserAvatar } from '../../../components/user-avatar';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common'; import { nameInitials } from '../../../utils/common';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Settings } from '../../../features/settings';
import { useUserProfile } from '../../../hooks/useUserProfile'; import { useUserProfile } from '../../../hooks/useUserProfile';
import { Modal500 } from '../../../components/Modal500'; import { SETTINGS_PATH } from '../../paths';
import { getSettingsPath } from '../../pathUtils';
import { SETTINGS_FROM_IN_APP_STATE } from '../../../features/settings/SettingsScreen';
export function SettingsTab() { export function SettingsTab() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const navigate = useNavigate();
const settingsMatch = useMatch({ path: SETTINGS_PATH, caseSensitive: true, end: false });
const userId = mx.getUserId()!; const userId = mx.getUserId()!;
const profile = useUserProfile(userId); const profile = useUserProfile(userId);
const [settings, setSettings] = useState(false);
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined; : undefined;
const openSettings = () => setSettings(true); // Use `replace` when re-tapping the icon from inside /settings (still
const closeSettings = () => setSettings(false); // visible at the top of the sidebar). Push from another route, replace
// from inside Settings, so the back-stack doesn't accumulate
// /settings → /settings → /direct/ chains.
//
// `state: SETTINGS_FROM_IN_APP_STATE` marks the entry as in-app so
// SettingsScreen's close fallback knows `navigate(-1)` is safe (vs
// a cold-loaded /settings where the previous history entry might be
// cross-origin and would bounce the user out of Vojo).
const openSettings = () =>
navigate(getSettingsPath(), {
replace: !!settingsMatch,
state: SETTINGS_FROM_IN_APP_STATE,
});
return ( return (
<SidebarItem active={settings}> <SidebarItem active={!!settingsMatch}>
<SidebarItemTooltip tooltip="User Settings"> <SidebarItemTooltip tooltip="User Settings">
{(triggerRef) => ( {(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} onClick={openSettings}> <SidebarAvatar as="button" ref={triggerRef} onClick={openSettings}>
@ -39,11 +53,6 @@ export function SettingsTab() {
</SidebarAvatar> </SidebarAvatar>
)} )}
</SidebarItemTooltip> </SidebarItemTooltip>
{settings && (
<Modal500 requestClose={closeSettings}>
<Settings requestClose={closeSettings} />
</Modal500>
)}
</SidebarItem> </SidebarItem>
); );
} }

View file

@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React from 'react';
import { Badge, color, Icon, Icons, Text } from 'folds'; import { Badge, color, Icon, Icons, Text } from 'folds';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMatch, useNavigate } from 'react-router-dom';
import { import {
SidebarAvatar, SidebarAvatar,
SidebarItem, SidebarItem,
@ -16,12 +17,16 @@ import {
VerificationStatus, VerificationStatus,
} from '../../../hooks/useDeviceVerificationStatus'; } from '../../../hooks/useDeviceVerificationStatus';
import { useCrossSigningActive } from '../../../hooks/useCrossSigning'; import { useCrossSigningActive } from '../../../hooks/useCrossSigning';
import { Modal500 } from '../../../components/Modal500'; import { SETTINGS_PARAM_DEVICES } from '../../../features/settings';
import { Settings, SettingsPages } from '../../../features/settings'; import { SETTINGS_PATH } from '../../paths';
import { getSettingsPath } from '../../pathUtils';
import { SETTINGS_FROM_IN_APP_STATE } from '../../../features/settings/SettingsScreen';
function UnverifiedIndicator() { function UnverifiedIndicator() {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const navigate = useNavigate();
const settingsMatch = useMatch({ path: SETTINGS_PATH, caseSensitive: true, end: false });
const crypto = mx.getCrypto(); const crypto = mx.getCrypto();
const [devices] = useDeviceList(); const [devices] = useDeviceList();
@ -42,50 +47,45 @@ function UnverifiedIndicator() {
otherDevicesId otherDevicesId
); );
const [settings, setSettings] = useState(false); const openDevices = () =>
const closeSettings = () => setSettings(false); navigate(getSettingsPath(SETTINGS_PARAM_DEVICES), {
replace: !!settingsMatch,
state: SETTINGS_FROM_IN_APP_STATE,
});
const hasUnverified = const hasUnverified =
unverified || (unverifiedDeviceCount !== undefined && unverifiedDeviceCount > 0); unverified || (unverifiedDeviceCount !== undefined && unverifiedDeviceCount > 0);
if (!hasUnverified) return null;
return ( return (
<> <SidebarItem active={!!settingsMatch} className={css.UnverifiedTab}>
{hasUnverified && ( <SidebarItemTooltip
<SidebarItem active={settings} className={css.UnverifiedTab}> tooltip={unverified ? t('Inbox.unverified_device') : t('Inbox.unverified_devices')}
<SidebarItemTooltip >
tooltip={unverified ? t('Inbox.unverified_device') : t('Inbox.unverified_devices')} {(triggerRef) => (
<SidebarAvatar
className={unverified ? css.UnverifiedAvatar : css.UnverifiedOtherAvatar}
as="button"
ref={triggerRef}
outlined
onClick={openDevices}
> >
{(triggerRef) => ( <Icon
<SidebarAvatar style={{ color: unverified ? color.Critical.Main : color.Warning.Main }}
className={unverified ? css.UnverifiedAvatar : css.UnverifiedOtherAvatar} src={Icons.ShieldUser}
as="button" />
ref={triggerRef} </SidebarAvatar>
outlined )}
onClick={() => setSettings(true)} </SidebarItemTooltip>
> {!unverified && unverifiedDeviceCount && unverifiedDeviceCount > 0 && (
<Icon <SidebarItemBadge hasCount>
style={{ color: unverified ? color.Critical.Main : color.Warning.Main }} <Badge variant="Warning" size="400" fill="Solid" radii="Pill" outlined={false}>
src={Icons.ShieldUser} <Text as="span" size="L400">
/> {unverifiedDeviceCount}
</SidebarAvatar> </Text>
)} </Badge>
</SidebarItemTooltip> </SidebarItemBadge>
{!unverified && unverifiedDeviceCount && unverifiedDeviceCount > 0 && (
<SidebarItemBadge hasCount>
<Badge variant="Warning" size="400" fill="Solid" radii="Pill" outlined={false}>
<Text as="span" size="L400">
{unverifiedDeviceCount}
</Text>
</Badge>
</SidebarItemBadge>
)}
</SidebarItem>
)} )}
{settings && ( </SidebarItem>
<Modal500 requestClose={closeSettings}>
<Settings initialPage={SettingsPages.DevicesPage} requestClose={closeSettings} />
</Modal500>
)}
</>
); );
} }

View file

@ -13,6 +13,8 @@ import {
EXPLORE_FEATURED_PATH, EXPLORE_FEATURED_PATH,
EXPLORE_PATH, EXPLORE_PATH,
EXPLORE_SERVER_PATH, EXPLORE_SERVER_PATH,
SETTINGS_PATH,
SettingsPathSearchParams,
HOME_CREATE_PATH, HOME_CREATE_PATH,
HOME_JOIN_PATH, HOME_JOIN_PATH,
HOME_PATH, HOME_PATH,
@ -159,6 +161,12 @@ export const getExploreServerPath = (server: string): string => {
export const getCreatePath = (): string => CREATE_PATH; export const getCreatePath = (): string => CREATE_PATH;
export const getSettingsPath = (page?: string): string => {
if (!page) return SETTINGS_PATH;
const params: SettingsPathSearchParams = { page };
return withSearchParam(SETTINGS_PATH, params as Record<string, string>);
};
export const getBotsPath = (): string => BOTS_PATH; export const getBotsPath = (): string => BOTS_PATH;
export const getBotPath = (botId: string): string => export const getBotPath = (botId: string): string =>
generatePath(BOTS_BOT_PATH, { botId: encodeURIComponent(botId) }); generatePath(BOTS_BOT_PATH, { botId: encodeURIComponent(botId) });

View file

@ -102,3 +102,19 @@ export const CHANNELS_THREAD_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/th
export const SPACE_SETTINGS_PATH = '/space-settings/'; export const SPACE_SETTINGS_PATH = '/space-settings/';
export const ROOM_SETTINGS_PATH = '/room-settings/'; export const ROOM_SETTINGS_PATH = '/room-settings/';
// User-settings as a first-class route. Mounted inside the authed
// tree wrapped in the same PageRoot+Direct shell as `/direct/`, so the
// DM list stays on the left and Settings renders in the right pane as
// a nested horseshoe (menu | 12px void | content). On mobile the route
// is a deep-link entry only — `SettingsScreen` redirects to /direct/
// and sets the `settingsSheetAtom`, which `MobileSettingsHorseshoe`
// inside Direct renders as a bottom-up overlay. Replaces the pre-
// redesign Modal500 dialog opened from SettingsTab / DirectSelfRow /
// UnverifiedTab. The `page` query param deep-links into a specific
// sub-screen — keys defined by `SETTINGS_PAGE_PARAM` in
// `features/settings/Settings.tsx`.
export const SETTINGS_PATH = '/settings/';
export type SettingsPathSearchParams = {
page?: string;
};

View file

@ -0,0 +1,21 @@
import { useCallback } from 'react';
import { useSetAtom } from 'jotai';
import { settingsSheetAtom } from '../settingsSheet';
import { SettingsPages } from '../../features/settings/Settings';
export const useOpenSettingsSheet = (): ((page?: SettingsPages) => void) => {
const setSheet = useSetAtom(settingsSheetAtom);
return useCallback(
(page) => {
setSheet({ page });
},
[setSheet]
);
};
export const useCloseSettingsSheet = (): (() => void) => {
const setSheet = useSetAtom(settingsSheetAtom);
return useCallback(() => {
setSheet(undefined);
}, [setSheet]);
};

View file

@ -0,0 +1,15 @@
import { atom } from 'jotai';
import { SettingsPages } from '../features/settings/Settings';
// Open state for the mobile bottom-up Settings sheet. Mirror of
// `userRoomProfileAtom` (top-down user-card horseshoe). Desktop uses
// the `/settings` route directly; mobile uses this atom so the sheet
// overlays the DM list with a visible void gap, instead of route-
// swapping away from the underlying content. `/settings` deep-links
// on mobile (push notifications, bookmarks) redirect to `/direct/`
// and set this atom so the surface looks identical to a self-row tap.
export type SettingsSheetState = {
page?: SettingsPages;
};
export const settingsSheetAtom = atom<SettingsSheetState | undefined>(undefined);