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:
parent
c6bb66958d
commit
635fb91022
40 changed files with 1478 additions and 333 deletions
|
|
@ -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}}",
|
||||||
|
|
|
||||||
|
|
@ -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}}",
|
||||||
|
|
|
||||||
|
|
@ -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) => (
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
176
src/app/features/settings/MobileSettingsHorseshoe.css.ts
Normal file
176
src/app/features/settings/MobileSettingsHorseshoe.css.ts
Normal 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,
|
||||||
|
});
|
||||||
558
src/app/features/settings/MobileSettingsHorseshoe.tsx
Normal file
558
src/app/features/settings/MobileSettingsHorseshoe.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
129
src/app/features/settings/SettingsScreen.tsx
Normal file
129
src/app/features/settings/SettingsScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
export * from './Settings';
|
export * from './Settings';
|
||||||
|
export * from './SettingsScreen';
|
||||||
|
export * from './MobileSettingsHorseshoe';
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) });
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
||||||
21
src/app/state/hooks/settingsSheet.ts
Normal file
21
src/app/state/hooks/settingsSheet.ts
Normal 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]);
|
||||||
|
};
|
||||||
15
src/app/state/settingsSheet.ts
Normal file
15
src/app/state/settingsSheet.ts
Normal 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);
|
||||||
Loading…
Add table
Reference in a new issue