From 635fb91022a7aed911f0beb370328192a90355f2 Mon Sep 17 00:00:00 2001 From: heaven Date: Wed, 13 May 2026 00:01:26 +0300 Subject: [PATCH] feat(settings): replace Modal500 with /settings route plus mobile bottom-up horseshoe sheet overlaying DM list via clip-path mask --- public/locales/en.json | 2 + public/locales/ru.json | 2 + src/app/components/page/Page.tsx | 73 ++- src/app/components/page/style.css.ts | 8 + .../settings/MobileSettingsHorseshoe.css.ts | 176 ++++++ .../settings/MobileSettingsHorseshoe.tsx | 558 ++++++++++++++++++ src/app/features/settings/Settings.tsx | 188 ++++-- src/app/features/settings/SettingsScreen.tsx | 129 ++++ src/app/features/settings/about/About.tsx | 10 +- src/app/features/settings/account/Account.tsx | 6 +- .../features/settings/account/ContactInfo.tsx | 2 +- .../settings/account/IgnoredUserList.tsx | 2 +- .../features/settings/account/MatrixId.tsx | 2 +- src/app/features/settings/account/Profile.tsx | 2 +- .../settings/developer-tools/AccountData.tsx | 2 +- .../settings/developer-tools/DevelopTools.tsx | 10 +- .../features/settings/devices/DeviceTile.tsx | 2 +- src/app/features/settings/devices/Devices.tsx | 10 +- .../features/settings/devices/LocalBackup.tsx | 4 +- .../settings/devices/OtherDevices.tsx | 2 +- .../settings/devices/Verification.tsx | 2 +- .../emojis-stickers/EmojisStickers.tsx | 6 +- .../settings/emojis-stickers/GlobalPacks.tsx | 4 +- .../settings/emojis-stickers/UserPack.tsx | 2 +- src/app/features/settings/general/General.tsx | 32 +- src/app/features/settings/index.ts | 2 + .../settings/notifications/AllMessages.tsx | 8 +- .../notifications/KeywordMessages.tsx | 4 +- .../settings/notifications/Notifications.tsx | 8 +- .../notifications/SpecialMessages.tsx | 10 +- .../notifications/SystemNotification.tsx | 8 +- src/app/pages/Router.tsx | 23 + src/app/pages/client/direct/Direct.tsx | 154 ++--- src/app/pages/client/direct/DirectSelfRow.tsx | 181 +++--- src/app/pages/client/sidebar/SettingsTab.tsx | 35 +- .../pages/client/sidebar/UnverifiedTab.tsx | 82 +-- src/app/pages/pathUtils.ts | 8 + src/app/pages/paths.ts | 16 + src/app/state/hooks/settingsSheet.ts | 21 + src/app/state/settingsSheet.ts | 15 + 40 files changed, 1478 insertions(+), 333 deletions(-) create mode 100644 src/app/features/settings/MobileSettingsHorseshoe.css.ts create mode 100644 src/app/features/settings/MobileSettingsHorseshoe.tsx create mode 100644 src/app/features/settings/SettingsScreen.tsx create mode 100644 src/app/state/hooks/settingsSheet.ts create mode 100644 src/app/state/settingsSheet.ts diff --git a/public/locales/en.json b/public/locales/en.json index 3f5a2d9a..10cc2995 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -90,6 +90,8 @@ "menu_emojis_stickers": "Emojis & Stickers", "menu_developer_tools": "Developer Tools", "menu_about": "About", + "drag_to_close": "Drag down to close", + "close": "Close settings", "logout": "Logout", "logout_confirm": "You're about to log out. Are you sure?", "logout_failed": "Failed to logout! {{message}}", diff --git a/public/locales/ru.json b/public/locales/ru.json index 3939bd0b..e7efb3d9 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -90,6 +90,8 @@ "menu_emojis_stickers": "Эмодзи и стикеры", "menu_developer_tools": "Инструменты разработчика", "menu_about": "О приложении", + "drag_to_close": "Потянуть вниз чтобы закрыть", + "close": "Закрыть настройки", "logout": "Выйти", "logout_confirm": "Вы собираетесь выйти из аккаунта. Вы уверены?", "logout_failed": "Не удалось выйти! {{message}}", diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 4e16b8ff..ca923d5e 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -9,7 +9,7 @@ import React, { useRef, useState, } 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 classNames from 'classnames'; import { ContainerColor } from '../../styles/ContainerColor.css'; @@ -117,10 +117,29 @@ export function PageRoot({ nav, children }: PageRootProps) { type ClientDrawerLayoutProps = { children: ReactNode; 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({ size, resizable, + roundedRight, + surface, children, }: ClientDrawerLayoutProps & css.PageNavVariants) { const screenSize = useScreenSizeContext(); @@ -131,6 +150,20 @@ export function PageNav({ return {children}; } + 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 ( ` with a fixed height — // padding there would clip the header content. `--vojo-safe-top` // 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} @@ -362,15 +399,29 @@ export function PageNavContent({ ); } -export const Page = as<'div'>(({ className, ...props }, ref) => ( - -)); +type PageVariantProps = { + // Background surface tone. Default `'Surface'` (Dawn bg2, #0d0e11) + // — the deepest tone used by every sub-page elsewhere in the app. + // `'SurfaceVariant'` (Dawn bg, #181a20) is one notch lighter and + // used by the Settings sub-pages so they read on the same surface + // tone as the Settings menu (which itself uses + // `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) => ( + + ) +); export const PageHeader = as<'div', css.PageHeaderVariants>( ({ className, outlined, balance, ...props }, ref) => ( diff --git a/src/app/components/page/style.css.ts b/src/app/components/page/style.css.ts index 8a60bedb..7c35d4ab 100644 --- a/src/app/components/page/style.css.ts +++ b/src/app/components/page/style.css.ts @@ -79,6 +79,14 @@ export const PageNav = recipe({ '300': { 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: { diff --git a/src/app/features/settings/MobileSettingsHorseshoe.css.ts b/src/app/features/settings/MobileSettingsHorseshoe.css.ts new file mode 100644 index 00000000..dab35247 --- /dev/null +++ b/src/app/features/settings/MobileSettingsHorseshoe.css.ts @@ -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, +}); diff --git a/src/app/features/settings/MobileSettingsHorseshoe.tsx b/src/app/features/settings/MobileSettingsHorseshoe.tsx new file mode 100644 index 00000000..b32239c0 --- /dev/null +++ b/src/app/features/settings/MobileSettingsHorseshoe.tsx @@ -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(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(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(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 ( +
+ {open && portalTarget + ? createPortal( +