feat(stream-header): pin chats curtain over static pager header on drag-up with per-tab atom, native-only rubber-band gesture and pinned-aware horseshoe sheet coordination
This commit is contained in:
parent
4a9d5f6384
commit
0422a9832f
16 changed files with 618 additions and 79 deletions
|
|
@ -357,7 +357,16 @@ export function MobileTabsPager() {
|
||||||
onSelectChannels={onSelectChannels}
|
onSelectChannels={onSelectChannels}
|
||||||
onSelectBots={onSelectBots}
|
onSelectBots={onSelectBots}
|
||||||
/>
|
/>
|
||||||
<div className={css.strip} style={stripStyle}>
|
{/* `data-pager-pane="true"` flags everything inside the strip so
|
||||||
|
per-pane background paints (PageNav-inner surface,
|
||||||
|
MobileSettings / ChannelsWorkspace appBody, StreamHeader
|
||||||
|
stage + header) collapse to transparent in pager mode. With
|
||||||
|
both the strip and the static header at z-auto in pagerRoot,
|
||||||
|
DOM order puts the strip on top — and with the strip's bg
|
||||||
|
layers transparent, the static header tabs show through every
|
||||||
|
pixel the curtain isn't covering. See `pagerStaticHeader` in
|
||||||
|
style.css.ts for the full overlay contract. */}
|
||||||
|
<div className={css.strip} style={stripStyle} data-pager-pane="true">
|
||||||
<MobilePagerPaneProvider value={directPaneInfo}>
|
<MobilePagerPaneProvider value={directPaneInfo}>
|
||||||
<PaneSlot isActive={directPaneInfo.isActive}>
|
<PaneSlot isActive={directPaneInfo.isActive}>
|
||||||
<Direct />
|
<Direct />
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,22 @@ import React, { useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { Box, Icon, IconButton, Icons } from 'folds';
|
import { Box, Icon, IconButton, Icons } from 'folds';
|
||||||
import { mobilePagerCurtainAtom } from '../../state/mobilePagerHeader';
|
import {
|
||||||
|
curtainPinnedByTabAtom,
|
||||||
|
mobileHorseshoeActiveAtom,
|
||||||
|
mobilePagerCurtainAtom,
|
||||||
|
} from '../../state/mobilePagerHeader';
|
||||||
import { Segment } from '../stream-header/Segment';
|
import { Segment } from '../stream-header/Segment';
|
||||||
import * as streamHeaderCss from '../stream-header/StreamHeader.css';
|
import * as streamHeaderCss from '../stream-header/StreamHeader.css';
|
||||||
import * as css from './style.css';
|
import * as css from './style.css';
|
||||||
|
|
||||||
|
// Positive z-index applied to the static pager header when any
|
||||||
|
// horseshoe sheet is geometrically active (drag in flight or sheet
|
||||||
|
// open). Any positive value beats the strip's `z-index: auto`
|
||||||
|
// stacking context in pagerRoot, so `z: 1` is sufficient; the constant
|
||||||
|
// just makes the intent explicit at the call site.
|
||||||
|
const PAGER_HEADER_ELEVATED_Z = 1;
|
||||||
|
|
||||||
type Tab = 'direct' | 'channels' | 'bots';
|
type Tab = 'direct' | 'channels' | 'bots';
|
||||||
|
|
||||||
// Must match the `INLINE_FORM_ID` local constant in
|
// Must match the `INLINE_FORM_ID` local constant in
|
||||||
|
|
@ -71,8 +82,53 @@ export function MobileTabsPagerHeader({
|
||||||
const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]);
|
const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]);
|
||||||
const iconsDisabled = curtainControls === null;
|
const iconsDisabled = curtainControls === null;
|
||||||
|
|
||||||
|
// The static header does NOT translate to follow the curtain. It
|
||||||
|
// stays put; the curtain physically rises ABOVE it via z-stack — see
|
||||||
|
// the «curtain-overlay invariants» comment in style.css.ts on
|
||||||
|
// pagerStaticHeader for the bg / z-order contract.
|
||||||
|
//
|
||||||
|
// Z-elevation while a horseshoe sheet is GEOMETRICALLY active: the
|
||||||
|
// MobileSettings / ChannelsWorkspace container paints
|
||||||
|
// `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark theme) across the
|
||||||
|
// entire pane to drive the carve cut-out the moment `expandedPx > 0`.
|
||||||
|
// Without elevation that void bleeds up through the transparent
|
||||||
|
// strip-stack into the safe-top + tabsRow zone, turning the system-
|
||||||
|
// tray strip + tabs black. Bumping the static header into a positive
|
||||||
|
// z-index puts it ABOVE the strip's stacking context (positive z
|
||||||
|
// beats z:auto stacking contexts per CSS painting order), covering
|
||||||
|
// the void in its own y-band with SurfaceVariant bg + visible tabs.
|
||||||
|
//
|
||||||
|
// The atom tracks the GEOMETRIC signal (`expandedPx > 0`), not the
|
||||||
|
// sheet-open atoms, so elevation lands on the FIRST frame of drag —
|
||||||
|
// not 80 px later when the user crosses the commit threshold. The
|
||||||
|
// horseshoes' appBody flips to opaque in lockstep (same signal),
|
||||||
|
// containing the void to the bottom carve everywhere below the
|
||||||
|
// static header.
|
||||||
|
//
|
||||||
|
// Pinned-overrides-elevation: when the active pane's curtain is
|
||||||
|
// pinned the curtain itself contains the void — it covers everything
|
||||||
|
// from `y = safe-top` downward inside the strip's stacking context,
|
||||||
|
// and the opaque appBody (also flipped on `horseshoeActive`) covers
|
||||||
|
// the safe-top band above the curtain. Re-elevating the static
|
||||||
|
// header in that state would visibly «slice» the pinned curtain in
|
||||||
|
// the safe-top + tabsRow band, popping tabs back over what the user
|
||||||
|
// explicitly pulled up to cover. So we suppress elevation whenever
|
||||||
|
// the active tab's pin is set — preserves the «pinned hides tabs»
|
||||||
|
// invariant across sheet open/drag.
|
||||||
|
//
|
||||||
|
// The curtain pin gesture is suppressed while either sheet is open
|
||||||
|
// (see `StreamHeader.gestureDisabled`), so this elevation never
|
||||||
|
// races with a pin-in-progress drag.
|
||||||
|
const horseshoeActive = useAtomValue(mobileHorseshoeActiveAtom);
|
||||||
|
const pinnedByTab = useAtomValue(curtainPinnedByTabAtom);
|
||||||
|
const activePinned = !!pinnedByTab[activeTab];
|
||||||
|
const elevated = horseshoeActive && !activePinned;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.pagerStaticHeader}>
|
<div
|
||||||
|
className={css.pagerStaticHeader}
|
||||||
|
style={elevated ? { zIndex: PAGER_HEADER_ELEVATED_Z } : undefined}
|
||||||
|
>
|
||||||
<div className={streamHeaderCss.tabsRow}>
|
<div className={streamHeaderCss.tabsRow}>
|
||||||
<div className={streamHeaderCss.tabsCluster}>
|
<div className={streamHeaderCss.tabsCluster}>
|
||||||
<Segment
|
<Segment
|
||||||
|
|
|
||||||
|
|
@ -39,23 +39,81 @@ export const pagerRoot = style({
|
||||||
color: color.Background.OnContainer,
|
color: color.Background.OnContainer,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Shared static tabs row painted ABOVE the strip. Reserves the
|
// Shared static tabs row painted BEHIND the strip in DOM order.
|
||||||
// status-bar safe-area inset via padding-top so the segments + icons
|
// Reserves the status-bar safe-area inset via padding-top so the
|
||||||
// sit just below the system status bar, and so the backdrop colour
|
// segments + icons sit just below the system status bar, and so the
|
||||||
// extends through the inset zone (matching the per-pane PageNav's
|
// backdrop colour extends through the inset zone (matching the per-pane
|
||||||
// own `paddingTop: var(--vojo-safe-top)` so there's no visible band
|
// PageNav's own `paddingTop: var(--vojo-safe-top)` so there's no
|
||||||
// boundary at the inset edge).
|
// visible band boundary at the inset edge).
|
||||||
//
|
//
|
||||||
// `z-index: 10` keeps this above the strip and any in-pane curtain
|
// Curtain-overlay invariants (why no z-index here at rest):
|
||||||
// (curtain is `z-index: 2` within its pane's stacking context, which
|
//
|
||||||
// in turn lives inside the strip with z-index auto = 0 in pagerRoot's
|
// The chats curtain must visually rise ABOVE this header when the
|
||||||
// context — so 10 reliably wins).
|
// user pulls it up to the «pinned» snap — like a real blind sliding
|
||||||
|
// over the segments rather than the segments moving. The curtain
|
||||||
|
// lives inside each pane > stage with `z: 2` in stage's local
|
||||||
|
// stacking context. The stage / pane stack inside the swipe `strip`,
|
||||||
|
// which creates its own stacking context via `transform`. To let the
|
||||||
|
// curtain visually surface above this static header we:
|
||||||
|
//
|
||||||
|
// (a) leave both elements at `z-index: auto` in pagerRoot's
|
||||||
|
// stacking context (this block has no `zIndex` AT REST), so
|
||||||
|
// painting order falls back to DOM order. `pagerStaticHeader`
|
||||||
|
// is rendered BEFORE `strip` in MobileTabsPager — so the
|
||||||
|
// strip (and everything inside it that paints opaquely) paints
|
||||||
|
// on top.
|
||||||
|
//
|
||||||
|
// (b) tag the strip with `data-pager-pane="true"`. All per-pane
|
||||||
|
// background paints (PageNav-inner surface, MobileSettings/
|
||||||
|
// ChannelsWorkspace appBody, StreamHeader stage + header)
|
||||||
|
// become transparent under that selector, so the static
|
||||||
|
// header tabs show through every transparent layer of the
|
||||||
|
// strip until the curtain — the only remaining opaque element
|
||||||
|
// — covers them by being positioned at top: 0 (pinned snap).
|
||||||
|
//
|
||||||
|
// Breaking (a) or (b) re-introduces the «paravozik» regression where
|
||||||
|
// the tabs visually slide with the curtain. See git history for the
|
||||||
|
// user-feedback trail.
|
||||||
|
//
|
||||||
|
// Conditional z-elevation (horseshoe-active override, suppressed by pin):
|
||||||
|
//
|
||||||
|
// When a horseshoe sheet (Settings or workspace switcher) is
|
||||||
|
// geometrically active — i.e. `expandedPx > 0`, which covers both
|
||||||
|
// the in-flight drag and the committed-open state — the wrapping
|
||||||
|
// container paints `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark
|
||||||
|
// theme) across the entire pane so the carve at the sheet's top
|
||||||
|
// reads as a dark seam. With the transparent strip stack from (b),
|
||||||
|
// that void would bleed up through the safe-top + tabsRow zone,
|
||||||
|
// turning the system-tray strip + tabs solid black.
|
||||||
|
//
|
||||||
|
// `MobileTabsPagerHeader.tsx` bumps this element to a positive
|
||||||
|
// `zIndex` (inline style, driven by `mobileHorseshoeActiveAtom`)
|
||||||
|
// from the first frame of drag. Positive z beats the strip's
|
||||||
|
// `z: auto` stacking context, putting the static header back on
|
||||||
|
// top in the safe-top + tabsRow band — the void is contained to
|
||||||
|
// the carve area, tabs stay visible. The horseshoe's `appBody`
|
||||||
|
// flips back to opaque on the same signal so the void doesn't
|
||||||
|
// bleed into the chip-area band between the static header and
|
||||||
|
// the curtain top either. The curtain pin gesture is gated off
|
||||||
|
// in the same state (see `StreamHeader.gestureDisabled`) so no
|
||||||
|
// pin can race the elevation flip.
|
||||||
|
//
|
||||||
|
// Pinned-override: when the active pane's curtain is pinned, the
|
||||||
|
// curtain itself sits at the top of the stage (z:2 inside the
|
||||||
|
// strip's stacking ctx) and covers everything from `y = safe-top`
|
||||||
|
// downward — including the tabsRow band. Above the curtain
|
||||||
|
// (y=0..safe-top) the opaque appBody contains the void. Elevating
|
||||||
|
// the static header in that state would visibly slice the pinned
|
||||||
|
// curtain in the tabsRow band, popping tabs over what the user
|
||||||
|
// explicitly pulled up to cover. So `MobileTabsPagerHeader`
|
||||||
|
// suppresses elevation whenever `curtainPinnedByTabAtom[activeTab]`
|
||||||
|
// is true — preserves the «pinned hides tabs» invariant across
|
||||||
|
// sheet open/drag without re-introducing the void leak.
|
||||||
export const pagerStaticHeader = style({
|
export const pagerStaticHeader = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 10,
|
|
||||||
paddingTop: 'var(--vojo-safe-top, 0px)',
|
paddingTop: 'var(--vojo-safe-top, 0px)',
|
||||||
// The wrapped tabsRow has its own height of TABS_ROW_PX via the
|
// The wrapped tabsRow has its own height of TABS_ROW_PX via the
|
||||||
// stream-header recipe; we don't set a fixed height here so the
|
// stream-header recipe; we don't set a fixed height here so the
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,14 @@ import {
|
||||||
|
|
||||||
// Stage. Position-relative anchor. The header itself paints the
|
// Stage. Position-relative anchor. The header itself paints the
|
||||||
// light-blue backdrop; the curtain is layered ABOVE it via z-index.
|
// light-blue backdrop; the curtain is layered ABOVE it via z-index.
|
||||||
|
//
|
||||||
|
// In pager mode the bg collapses to transparent so the pager's static
|
||||||
|
// header (sitting behind the strip in DOM order) shows through every
|
||||||
|
// pixel the curtain isn't covering. See
|
||||||
|
// `mobile-tabs-pager/style.css.ts::pagerStaticHeader` for the full
|
||||||
|
// curtain-overlay contract. The strip is tagged
|
||||||
|
// `data-pager-pane="true"` in MobileTabsPager.tsx, which gates this
|
||||||
|
// selector.
|
||||||
export const stage = style({
|
export const stage = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
@ -19,11 +27,22 @@ export const stage = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
backgroundColor: color.SurfaceVariant.Container,
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
selectors: {
|
||||||
|
'[data-pager-pane="true"] &': {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Header — always-rendered strip carrying tabs row + (optional) chip
|
// Header — always-rendered strip carrying tabs row + (optional) chip
|
||||||
// reveal area + (optional) active form. The curtain slides on top of
|
// reveal area + (optional) active form. The curtain slides on top of
|
||||||
// the area BELOW the tabs row to cover/reveal those children.
|
// the area BELOW the tabs row to cover/reveal those children.
|
||||||
|
//
|
||||||
|
// In pager mode the bg collapses to transparent for the same reason as
|
||||||
|
// `stage` above — let the static pager header show through where the
|
||||||
|
// curtain isn't. Chips have their own pill bg and the inline form is
|
||||||
|
// composed of folds-styled inputs with their own backgrounds, so the
|
||||||
|
// peek/form snaps stay visually opaque without this layer.
|
||||||
export const header = style({
|
export const header = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|
@ -35,6 +54,11 @@ export const header = style({
|
||||||
// everything below the tabs row when raised.
|
// everything below the tabs row when raised.
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
backgroundColor: color.SurfaceVariant.Container,
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
selectors: {
|
||||||
|
'[data-pager-pane="true"] &': {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tabs row. Stays fully visible regardless of curtain position
|
// Tabs row. Stays fully visible regardless of curtain position
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,16 @@ import React, {
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMatch, useNavigate } from 'react-router-dom';
|
import { useMatch, useNavigate } from 'react-router-dom';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { Box, Icon, IconButton, Icons, toRem } from 'folds';
|
import { Box, Icon, IconButton, Icons, toRem } from 'folds';
|
||||||
import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
|
import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
|
||||||
import { isNativePlatform } from '../../utils/capacitor';
|
import { isNativePlatform } from '../../utils/capacitor';
|
||||||
import { useBotPresets } from '../../features/bots/catalog';
|
import { useBotPresets } from '../../features/bots/catalog';
|
||||||
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
|
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
|
||||||
import { MobilePagerCurtainControls, mobilePagerCurtainAtom } from '../../state/mobilePagerHeader';
|
import { MobilePagerCurtainControls, mobilePagerCurtainAtom } from '../../state/mobilePagerHeader';
|
||||||
|
import { settingsSheetAtom } from '../../state/settingsSheet';
|
||||||
|
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
|
||||||
import * as css from './StreamHeader.css';
|
import * as css from './StreamHeader.css';
|
||||||
import { CHIP_ROW_PX, TABS_ROW_PX } from './geometry';
|
|
||||||
import { Segment } from './Segment';
|
import { Segment } from './Segment';
|
||||||
import { Chip } from './Chip';
|
import { Chip } from './Chip';
|
||||||
import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState';
|
import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState';
|
||||||
|
|
@ -42,9 +43,17 @@ type StreamHeaderProps = {
|
||||||
// so the on-screen keyboard's viewport resize doesn't push them up
|
// so the on-screen keyboard's viewport resize doesn't push them up
|
||||||
// over the form (see commit 14ed080).
|
// over the form (see commit 14ed080).
|
||||||
bottomPinned?: ReactNode;
|
bottomPinned?: ReactNode;
|
||||||
|
// Stable identifier used to persist the curtain's pinned overlay
|
||||||
|
// across listing-pane remounts (the user taps into a Room and back,
|
||||||
|
// which unmounts the listing pane). When provided, pin state is
|
||||||
|
// stored in `curtainPinnedByTabAtom[pinKey]`; without it, pin lives
|
||||||
|
// in a local useState that resets on unmount. Listing surfaces
|
||||||
|
// wired into the mobile pager (Direct / Channels / Bots) all pass
|
||||||
|
// a key; other consumers can omit it.
|
||||||
|
pinKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeaderProps) {
|
export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: StreamHeaderProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const bots = useBotPresets();
|
const bots = useBotPresets();
|
||||||
|
|
@ -66,13 +75,37 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader
|
||||||
const inPagerMode = pagerPane !== null;
|
const inPagerMode = pagerPane !== null;
|
||||||
const isActivePagerPane = pagerPane?.isActive ?? false;
|
const isActivePagerPane = pagerPane?.isActive ?? false;
|
||||||
|
|
||||||
const curtain = useCurtainState();
|
const curtain = useCurtainState(pinKey);
|
||||||
|
|
||||||
|
// Suppress the curtain gesture whenever the user is interacting with
|
||||||
|
// something else that would otherwise race the pin path:
|
||||||
|
//
|
||||||
|
// * Settings sheet open (DirectSelfRow-originated bottom sheet) —
|
||||||
|
// a drag-up on the still-visible list above the sheet would
|
||||||
|
// mutate the pin atom underneath the sheet and the user would
|
||||||
|
// see an unexpected pinned curtain on dismissal.
|
||||||
|
// * Workspace switcher sheet open — same shape, on the Channels
|
||||||
|
// workspace surface.
|
||||||
|
// * Inactive pager pane — the strip clips offscreen panes so they
|
||||||
|
// shouldn't receive touches in practice, but bind defense-in-
|
||||||
|
// depth so a stray pointer event on a translateX'd pane never
|
||||||
|
// pins someone else's tab.
|
||||||
|
//
|
||||||
|
// Mirrors `MobileTabsPager.gestureDisabled` which suppresses the
|
||||||
|
// pager's OWN horizontal-swipe gesture under the same conditions.
|
||||||
|
const settingsSheetOpen = !!useAtomValue(settingsSheetAtom);
|
||||||
|
const workspaceSheetOpen = !!useAtomValue(channelsWorkspaceSheetAtom);
|
||||||
|
const offscreenPagerPane = inPagerMode && !isActivePagerPane;
|
||||||
|
const gestureDisabled = settingsSheetOpen || workspaceSheetOpen || offscreenPagerPane;
|
||||||
|
|
||||||
useCurtainGesture({
|
useCurtainGesture({
|
||||||
scrollRef,
|
scrollRef,
|
||||||
snap: curtain.snap,
|
snap: curtain.snap,
|
||||||
|
pinned: curtain.pinned,
|
||||||
|
setPinned: curtain.setPinned,
|
||||||
setLiveDrag: curtain.setLiveDrag,
|
setLiveDrag: curtain.setLiveDrag,
|
||||||
commit: curtain.commit,
|
commit: curtain.commit,
|
||||||
|
disabled: gestureDisabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isActive = isFormSnap(curtain.snap);
|
const isActive = isFormSnap(curtain.snap);
|
||||||
|
|
@ -111,7 +144,15 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader
|
||||||
// delta. React-driven (no inline DOM writes), so finger-tracking and
|
// delta. React-driven (no inline DOM writes), so finger-tracking and
|
||||||
// commit happen in the same render pipeline and there's no
|
// commit happen in the same render pipeline and there's no
|
||||||
// intermediate "snap back, then animate" flash on release.
|
// intermediate "snap back, then animate" flash on release.
|
||||||
const curtainTop = snapTopPx(curtain.snap, curtain.formHeightPx) + curtain.liveDragPx;
|
//
|
||||||
|
// When `pinned` is true the local snap (kept at {closed, peek,
|
||||||
|
// form-*}) is overridden — the curtain rests at y = 0 inside the
|
||||||
|
// stage (= y = safe-top in viewport), covering the tabs row. The
|
||||||
|
// global pinned atom shares this state across every listing tab so
|
||||||
|
// swiping between Direct / Channels / Bots preserves the lock.
|
||||||
|
const curtainTop = curtain.pinned
|
||||||
|
? 0 + curtain.liveDragPx
|
||||||
|
: snapTopPx(curtain.snap, curtain.formHeightPx) + curtain.liveDragPx;
|
||||||
|
|
||||||
// After the curtain settles at `closed`, unmount any lingering form.
|
// After the curtain settles at `closed`, unmount any lingering form.
|
||||||
// Guarded so unrelated transitionend events (e.g. children's own
|
// Guarded so unrelated transitionend events (e.g. children's own
|
||||||
|
|
@ -347,5 +388,3 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { TABS_ROW_PX, CHIP_ROW_PX };
|
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,26 @@
|
||||||
// Dragging UP raises the curtain back over the header.
|
// Dragging UP raises the curtain back over the header.
|
||||||
//
|
//
|
||||||
// Snap stops (curtain.top, px):
|
// Snap stops (curtain.top, px):
|
||||||
|
// pinned = 0 (curtain sits flush at top of the stage, tabs row
|
||||||
|
// covered; the safe-top status-bar strip above the
|
||||||
|
// stage stays painted by the surrounding context —
|
||||||
|
// see «pinned visual contract» below)
|
||||||
// closed = TABS_ROW_PX
|
// closed = TABS_ROW_PX
|
||||||
// peek = TABS_ROW_PX + 2·CHIP_ROW_PX + CHIP_GAP_PX
|
// peek = TABS_ROW_PX + 2·CHIP_ROW_PX + CHIP_GAP_PX
|
||||||
// + CURTAIN_BREATHER_PX
|
// + CURTAIN_BREATHER_PX
|
||||||
// form:* = TABS_ROW_PX + formHeight + CURTAIN_BREATHER_PX
|
// form:* = TABS_ROW_PX + formHeight + CURTAIN_BREATHER_PX
|
||||||
|
//
|
||||||
|
// Pinned visual contract: at `pinned` the curtain's top edge lands at
|
||||||
|
// y = safe-top in viewport coords (because the stage starts after the
|
||||||
|
// PageNav / appBody padding-top: var(--vojo-safe-top)). The system tray
|
||||||
|
// strip stays painted by appBody / PageNav-inner / MobileTabsPager's
|
||||||
|
// static header — all of which use `SurfaceVariant.Container` for that
|
||||||
|
// zone, so the colour is continuous across surfaces. The curtain MUST
|
||||||
|
// NOT extend into the safe-top zone (otherwise system text is covered)
|
||||||
|
// and MUST NOT add internal padding-top (otherwise the chat list grows
|
||||||
|
// visually taller). The clamp on the up-drag (= -TABS_ROW_PX) enforces
|
||||||
|
// the first invariant; we deliberately do not add any padding inside
|
||||||
|
// the curtain to enforce the second.
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Tabs row height. Always visible above the curtain.
|
// Tabs row height. Always visible above the curtain.
|
||||||
|
|
@ -69,3 +85,35 @@ export const DIRECTION_DEAD_ZONE_PX = 10;
|
||||||
export const COMMIT_THRESHOLD = 0.9;
|
export const COMMIT_THRESHOLD = 0.9;
|
||||||
// Pull-up distance (raw finger px) required to close an active form.
|
// Pull-up distance (raw finger px) required to close an active form.
|
||||||
export const ACTIVE_CLOSE_THRESHOLD_PX = 100;
|
export const ACTIVE_CLOSE_THRESHOLD_PX = 100;
|
||||||
|
|
||||||
|
// Total vertical CURTAIN travel for the closed ↔ pinned gesture.
|
||||||
|
// Equals the tabs row height because pinning lifts the curtain by
|
||||||
|
// exactly that distance (from y = TABS_ROW_PX down to y = 0 inside
|
||||||
|
// the stage).
|
||||||
|
export const PIN_TRAVEL_PX = TABS_ROW_PX;
|
||||||
|
|
||||||
|
// Rubber-band attenuation for the pin / unpin drag. Finger → curtain
|
||||||
|
// motion is scaled by this factor so the curtain feels heavier than
|
||||||
|
// the finger — a deliberate gate against accidental drag-up.
|
||||||
|
//
|
||||||
|
// User-confirmed intent: «вверх дотянуть нужно явно» — pinning must be
|
||||||
|
// an obvious sustained pull, not a casual flick. With 0.45 the user
|
||||||
|
// must drag ~142px of finger to slide the curtain across the full
|
||||||
|
// PIN_TRAVEL_PX (64px); combined with the very-high commit threshold
|
||||||
|
// below the minimum committing pull is ~135px, which is well above
|
||||||
|
// any plausible accidental scroll-attempt at scrollTop=0.
|
||||||
|
//
|
||||||
|
// Same factor applies to unpin (drag down from pinned) so both
|
||||||
|
// directions feel consistent — neither commits on a casual gesture.
|
||||||
|
export const PIN_RUBBER_BAND = 0.45;
|
||||||
|
|
||||||
|
// Commit threshold for pin / unpin. Tuned very high (≈95%) so the
|
||||||
|
// user must drag the curtain almost-all-the-way to the cap before
|
||||||
|
// release for the snap to flip. Anything shorter reads as accidental
|
||||||
|
// and springs back to the previous resting snap.
|
||||||
|
//
|
||||||
|
// The geometric meaning: after rubber-band, lastDelta ranges 0 …
|
||||||
|
// ±PIN_TRAVEL_PX (clamped). The commit fires when |lastDelta| ≥
|
||||||
|
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, i.e. the curtain visually
|
||||||
|
// reached at least 95% of the snap distance.
|
||||||
|
export const PIN_COMMIT_THRESHOLD = 0.95;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import {
|
||||||
COMMIT_THRESHOLD,
|
COMMIT_THRESHOLD,
|
||||||
DIRECTION_DEAD_ZONE_PX,
|
DIRECTION_DEAD_ZONE_PX,
|
||||||
PEEK_TRAVEL_PX,
|
PEEK_TRAVEL_PX,
|
||||||
|
PIN_COMMIT_THRESHOLD,
|
||||||
|
PIN_RUBBER_BAND,
|
||||||
|
PIN_TRAVEL_PX,
|
||||||
RUBBER_BAND,
|
RUBBER_BAND,
|
||||||
} from './geometry';
|
} from './geometry';
|
||||||
import { CurtainSnap, isFormSnap } from './useCurtainState';
|
import { CurtainSnap, isFormSnap } from './useCurtainState';
|
||||||
|
|
@ -17,6 +20,15 @@ type Args = {
|
||||||
// Current snap stop. Read at touchstart to decide gesture meaning.
|
// Current snap stop. Read at touchstart to decide gesture meaning.
|
||||||
// Mirrored into a ref so the listener (bound once) reads fresh values.
|
// Mirrored into a ref so the listener (bound once) reads fresh values.
|
||||||
snap: CurtainSnap;
|
snap: CurtainSnap;
|
||||||
|
// Per-pane pinned overlay. When true the curtain renders at the top
|
||||||
|
// regardless of `snap`, and the gesture interprets a drag-down as
|
||||||
|
// unpin instead of peek-reveal. Also mirrored into a ref for the
|
||||||
|
// bound-once listener.
|
||||||
|
pinned: boolean;
|
||||||
|
// Setter for the pinned overlay; called on release once the user's
|
||||||
|
// up-drag (from closed) or down-drag (from pinned) past the commit
|
||||||
|
// threshold qualifies the gesture.
|
||||||
|
setPinned: (next: boolean) => void;
|
||||||
// Setter for the live drag delta during touchmove. The hook reads
|
// Setter for the live drag delta during touchmove. The hook reads
|
||||||
// `liveDragPx` from the parent state too, so React drives the
|
// `liveDragPx` from the parent state too, so React drives the
|
||||||
// curtain's `top` re-render — no direct DOM writes, no inline-vs-
|
// curtain's `top` re-render — no direct DOM writes, no inline-vs-
|
||||||
|
|
@ -24,25 +36,61 @@ type Args = {
|
||||||
setLiveDrag: (px: number, dragging: boolean) => void;
|
setLiveDrag: (px: number, dragging: boolean) => void;
|
||||||
// Commit a new snap stop on release.
|
// Commit a new snap stop on release.
|
||||||
commit: (next: CurtainSnap) => void;
|
commit: (next: CurtainSnap) => void;
|
||||||
|
// Suppress gesture binding entirely. Used to gate pinning when a
|
||||||
|
// bottom sheet (Settings, workspace switcher) is open — otherwise
|
||||||
|
// a drag-up on the visible list above the sheet would mutate the
|
||||||
|
// pin atom underneath the sheet and the user would see an unexpected
|
||||||
|
// pinned curtain on sheet dismissal. Also gated when this pane is
|
||||||
|
// inactive inside the swipe pager so stray touches on offscreen
|
||||||
|
// panes can't mutate any tab's pin.
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Touch-gesture driver for the curtain. Native-only: on web/PC the
|
// Touch-gesture driver for the curtain. Native-only: on web/PC the
|
||||||
// listeners aren't attached at all.
|
// listeners aren't attached at all.
|
||||||
//
|
//
|
||||||
// Peek path: drag down from `closed` rubber-bands the live delta and
|
// Peek path: drag down from `closed` (not pinned) rubber-bands the live
|
||||||
// on release past the threshold commits to `peek` (both chips
|
// delta and on release past the threshold commits to `peek` (both
|
||||||
// revealed in one motion). Drag UP from `peek` retreats to `closed`.
|
// chips revealed in one motion). Drag UP from `peek` retreats to
|
||||||
|
// `closed`.
|
||||||
|
//
|
||||||
|
// Pin path: drag UP from `closed` (not pinned) tracks the finger 1:1
|
||||||
|
// with a hard clamp at -PIN_TRAVEL_PX (curtain stops flush above the
|
||||||
|
// tabs row — never enters the system-tray safe-top zone). On release
|
||||||
|
// past `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` flips `pinned = true`.
|
||||||
|
//
|
||||||
|
// Unpin path: drag DOWN while `pinned = true` tracks 1:1 clamped at
|
||||||
|
// +PIN_TRAVEL_PX. On release past the same threshold flips
|
||||||
|
// `pinned = false`. The user-confirmed UX choice is single-stop —
|
||||||
|
// unpin lands at `closed` only; peek requires a separate gesture.
|
||||||
//
|
//
|
||||||
// Form-close path: drag UP from a form snap tracks the finger 1:1; on
|
// Form-close path: drag UP from a form snap tracks the finger 1:1; on
|
||||||
// release past `ACTIVE_CLOSE_THRESHOLD_PX` commits to `closed`.
|
// release past `ACTIVE_CLOSE_THRESHOLD_PX` commits to `closed`.
|
||||||
export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args): void {
|
export function useCurtainGesture({
|
||||||
|
scrollRef,
|
||||||
|
snap,
|
||||||
|
pinned,
|
||||||
|
setPinned,
|
||||||
|
setLiveDrag,
|
||||||
|
commit,
|
||||||
|
disabled,
|
||||||
|
}: Args): void {
|
||||||
// Mirror snap into a ref so the listener — bound once via useEffect —
|
// Mirror snap into a ref so the listener — bound once via useEffect —
|
||||||
// always reads the freshest value without re-attaching.
|
// always reads the freshest value without re-attaching.
|
||||||
const snapRef = useRef<CurtainSnap>(snap);
|
const snapRef = useRef<CurtainSnap>(snap);
|
||||||
snapRef.current = snap;
|
snapRef.current = snap;
|
||||||
|
// Same mirror trick for the global pinned overlay so the listener
|
||||||
|
// sees fresh values without re-binding on every render.
|
||||||
|
const pinnedRef = useRef<boolean>(pinned);
|
||||||
|
pinnedRef.current = pinned;
|
||||||
|
// Stable setter ref so the bound-once listener can call the latest
|
||||||
|
// setPinned without us needing to add it to the effect deps.
|
||||||
|
const setPinnedRef = useRef(setPinned);
|
||||||
|
setPinnedRef.current = setPinned;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isNativePlatform()) return undefined;
|
if (!isNativePlatform()) return undefined;
|
||||||
|
if (disabled) return undefined;
|
||||||
const list = scrollRef.current;
|
const list = scrollRef.current;
|
||||||
if (!list) return undefined;
|
if (!list) return undefined;
|
||||||
|
|
||||||
|
|
@ -92,6 +140,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
||||||
const delta = e.touches[0].clientY - startY;
|
const delta = e.touches[0].clientY - startY;
|
||||||
const deltaX = startX !== null ? e.touches[0].clientX - startX : 0;
|
const deltaX = startX !== null ? e.touches[0].clientX - startX : 0;
|
||||||
const currentSnap = snapRef.current;
|
const currentSnap = snapRef.current;
|
||||||
|
const currentPinned = pinnedRef.current;
|
||||||
|
|
||||||
// Resolve a direction once the finger crosses the dead-zone.
|
// Resolve a direction once the finger crosses the dead-zone.
|
||||||
if (direction === null) {
|
if (direction === null) {
|
||||||
|
|
@ -110,21 +159,27 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
||||||
}
|
}
|
||||||
direction = delta > 0 ? 'down' : 'up';
|
direction = delta > 0 ? 'down' : 'up';
|
||||||
|
|
||||||
// Direction guards: nothing higher than `closed`; nothing
|
// Direction guards:
|
||||||
// lower than `peek`; form snaps only close (up).
|
// - pinned ⇒ only DOWN (unpin); UP would try to push past
|
||||||
if (currentSnap === 'closed' && direction === 'up') {
|
// y = -safe-top into the system-tray zone, which we never
|
||||||
|
// want.
|
||||||
|
// - peek ⇒ only UP (close); DOWN has nowhere lower to go.
|
||||||
|
// - form ⇒ only UP (close); DOWN reads as a list scroll.
|
||||||
|
// - closed + UP ⇒ pin path (new — used to bail).
|
||||||
|
// - closed + DOWN ⇒ peek path (existing).
|
||||||
|
if (currentPinned && direction === 'up') {
|
||||||
startX = null;
|
startX = null;
|
||||||
startY = null;
|
startY = null;
|
||||||
direction = null;
|
direction = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentSnap === 'peek' && direction === 'down') {
|
if (!currentPinned && currentSnap === 'peek' && direction === 'down') {
|
||||||
startX = null;
|
startX = null;
|
||||||
startY = null;
|
startY = null;
|
||||||
direction = null;
|
direction = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isFormSnap(currentSnap) && direction === 'down') {
|
if (!currentPinned && isFormSnap(currentSnap) && direction === 'down') {
|
||||||
startX = null;
|
startX = null;
|
||||||
startY = null;
|
startY = null;
|
||||||
direction = null;
|
direction = null;
|
||||||
|
|
@ -135,11 +190,27 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
||||||
engaged = true;
|
engaged = true;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (isFormSnap(currentSnap)) {
|
if (currentPinned) {
|
||||||
|
// Unpin: finger moves DOWN (delta > 0). Rubber-banded by
|
||||||
|
// PIN_RUBBER_BAND so the curtain feels heavy — same factor as
|
||||||
|
// the pin path below for symmetry. Clamped at +PIN_TRAVEL_PX
|
||||||
|
// so the curtain doesn't visually descend past its `closed`
|
||||||
|
// resting top during the drag.
|
||||||
|
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta * PIN_RUBBER_BAND));
|
||||||
|
} else if (isFormSnap(currentSnap)) {
|
||||||
// Form close: finger moves UP (delta < 0). Track 1:1, capped
|
// Form close: finger moves UP (delta < 0). Track 1:1, capped
|
||||||
// at 0 so an accidental downward jitter doesn't push the
|
// at 0 so an accidental downward jitter doesn't push the
|
||||||
// curtain below its resting position.
|
// curtain below its resting position.
|
||||||
lastDelta = Math.min(0, delta);
|
lastDelta = Math.min(0, delta);
|
||||||
|
} else if (currentSnap === 'closed' && direction === 'up') {
|
||||||
|
// Pin: finger moves UP (delta < 0). Rubber-banded by
|
||||||
|
// PIN_RUBBER_BAND — the curtain moves at ≈45% of finger speed
|
||||||
|
// so the user has to commit a deliberate sustained pull to
|
||||||
|
// close the gap. Clamped at -PIN_TRAVEL_PX so it never enters
|
||||||
|
// the system-tray safe-top zone. See geometry's «pinned visual
|
||||||
|
// contract» and `PIN_RUBBER_BAND` rationale for the full
|
||||||
|
// invariant.
|
||||||
|
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta * PIN_RUBBER_BAND));
|
||||||
} else {
|
} else {
|
||||||
// Peek: rubber-banded BOTH directions. Down (delta > 0) reveals
|
// Peek: rubber-banded BOTH directions. Down (delta > 0) reveals
|
||||||
// more chips; up (delta < 0) retreats toward `closed`. Bounds
|
// more chips; up (delta < 0) retreats toward `closed`. Bounds
|
||||||
|
|
@ -158,12 +229,32 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSnap = snapRef.current;
|
const currentSnap = snapRef.current;
|
||||||
|
const currentPinned = pinnedRef.current;
|
||||||
let next: CurtainSnap = currentSnap;
|
let next: CurtainSnap = currentSnap;
|
||||||
|
let nextPinned = currentPinned;
|
||||||
|
|
||||||
if (isFormSnap(currentSnap)) {
|
if (currentPinned) {
|
||||||
|
// Unpin commit: drag DOWN past ≥ PIN_COMMIT_THRESHOLD of the
|
||||||
|
// full travel flips pinned → false. The user-confirmed UX is
|
||||||
|
// single-stop — unpin lands at `closed`; peek requires a
|
||||||
|
// separate gesture.
|
||||||
|
const progress = lastDelta / PIN_TRAVEL_PX;
|
||||||
|
if (progress >= PIN_COMMIT_THRESHOLD) {
|
||||||
|
nextPinned = false;
|
||||||
|
}
|
||||||
|
} else if (isFormSnap(currentSnap)) {
|
||||||
if (Math.abs(lastDelta) >= ACTIVE_CLOSE_THRESHOLD_PX) {
|
if (Math.abs(lastDelta) >= ACTIVE_CLOSE_THRESHOLD_PX) {
|
||||||
next = 'closed';
|
next = 'closed';
|
||||||
}
|
}
|
||||||
|
} else if (currentSnap === 'closed' && direction === 'up') {
|
||||||
|
// Pin commit: drag UP past ≥ PIN_COMMIT_THRESHOLD of the full
|
||||||
|
// travel flips pinned → true. The user emphasised this must
|
||||||
|
// require «дотянул прям до самого верха» — high threshold
|
||||||
|
// matches that intent.
|
||||||
|
const progress = -lastDelta / PIN_TRAVEL_PX;
|
||||||
|
if (progress >= PIN_COMMIT_THRESHOLD) {
|
||||||
|
nextPinned = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Single-stage peek toggle. Threshold is COMMIT_THRESHOLD of
|
// Single-stage peek toggle. Threshold is COMMIT_THRESHOLD of
|
||||||
// the FULL peek travel — the rubber-banded drag must reach
|
// the FULL peek travel — the rubber-banded drag must reach
|
||||||
|
|
@ -177,7 +268,12 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next !== currentSnap) {
|
if (nextPinned !== currentPinned) {
|
||||||
|
// setPinned() resets liveDragPx + isDragging in the same
|
||||||
|
// batched update — React renders the curtain at the new
|
||||||
|
// pinned-derived top with the snap transition re-enabled.
|
||||||
|
setPinnedRef.current(nextPinned);
|
||||||
|
} else if (next !== currentSnap) {
|
||||||
// commit() also resets liveDragPx + isDragging to 0/false in
|
// commit() also resets liveDragPx + isDragging to 0/false in
|
||||||
// one batched update — React renders the curtain at the new
|
// one batched update — React renders the curtain at the new
|
||||||
// resting top with the snap transition re-enabled.
|
// resting top with the snap transition re-enabled.
|
||||||
|
|
@ -216,7 +312,10 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
||||||
list.removeEventListener('touchcancel', onTouchCancel);
|
list.removeEventListener('touchcancel', onTouchCancel);
|
||||||
};
|
};
|
||||||
// setLiveDrag/commit are stable useCallbacks; scrollRef is stable.
|
// setLiveDrag/commit are stable useCallbacks; scrollRef is stable.
|
||||||
// `snap` is mirrored via snapRef written above on every render.
|
// `snap`, `pinned` and `setPinned` are mirrored via the refs above
|
||||||
|
// so the listener (bound once per disabled-flip) reads fresh values
|
||||||
|
// without re-attaching every render. `disabled` is the only signal
|
||||||
|
// that needs to tear the listeners down — it goes into the deps.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [scrollRef, setLiveDrag, commit]);
|
}, [scrollRef, setLiveDrag, commit, disabled]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader';
|
||||||
import {
|
import {
|
||||||
CHIP_GAP_PX,
|
CHIP_GAP_PX,
|
||||||
CHIP_ROW_PX,
|
CHIP_ROW_PX,
|
||||||
|
|
@ -17,9 +19,7 @@ export type CurtainSnap =
|
||||||
| 'form-search' // full search form revealed
|
| 'form-search' // full search form revealed
|
||||||
| 'form-chat'; // full new-chat form revealed
|
| 'form-chat'; // full new-chat form revealed
|
||||||
|
|
||||||
export const isFormSnap = (
|
export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' | 'form-chat' =>
|
||||||
snap: CurtainSnap
|
|
||||||
): snap is 'form-search' | 'form-chat' =>
|
|
||||||
snap === 'form-search' || snap === 'form-chat';
|
snap === 'form-search' || snap === 'form-chat';
|
||||||
|
|
||||||
export const isPeekSnap = (snap: CurtainSnap): snap is 'peek' => snap === 'peek';
|
export const isPeekSnap = (snap: CurtainSnap): snap is 'peek' => snap === 'peek';
|
||||||
|
|
@ -31,6 +31,18 @@ export type ActiveForm = 'search' | 'chat' | null;
|
||||||
|
|
||||||
export type CurtainState = {
|
export type CurtainState = {
|
||||||
snap: CurtainSnap;
|
snap: CurtainSnap;
|
||||||
|
// Per-tab pin overlay. Stored in `curtainPinnedByTabAtom` keyed by
|
||||||
|
// the consumer-supplied `pinKey` so the lock survives the route-
|
||||||
|
// driven listing-pane unmount when the user taps into a Room and
|
||||||
|
// back. Each tab keeps its own pin (Direct/Channels/Bots are
|
||||||
|
// independent). If no `pinKey` is provided, the pin lives in a
|
||||||
|
// local useState that resets on unmount — fine for non-listing
|
||||||
|
// surfaces where pinning isn't expected anyway.
|
||||||
|
pinned: boolean;
|
||||||
|
// Setter for the pinned overlay. Called by the gesture hook on
|
||||||
|
// commit (drag-up-from-closed past threshold sets true; drag-down-
|
||||||
|
// while-pinned past threshold sets false).
|
||||||
|
setPinned: (next: boolean) => void;
|
||||||
activeForm: ActiveForm;
|
activeForm: ActiveForm;
|
||||||
// Live finger delta in px. Added to the snap-derived resting top to
|
// Live finger delta in px. Added to the snap-derived resting top to
|
||||||
// compute the curtain's visible top. Stays at 0 when no gesture is
|
// compute the curtain's visible top. Stays at 0 when no gesture is
|
||||||
|
|
@ -80,13 +92,7 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
|
||||||
case 'closed':
|
case 'closed':
|
||||||
return TABS_ROW_PX;
|
return TABS_ROW_PX;
|
||||||
case 'peek':
|
case 'peek':
|
||||||
return (
|
return TABS_ROW_PX + CHIP_ROW_PX + CHIP_GAP_PX + CHIP_ROW_PX + CURTAIN_BREATHER_PX;
|
||||||
TABS_ROW_PX +
|
|
||||||
CHIP_ROW_PX +
|
|
||||||
CHIP_GAP_PX +
|
|
||||||
CHIP_ROW_PX +
|
|
||||||
CURTAIN_BREATHER_PX
|
|
||||||
);
|
|
||||||
case 'form-search':
|
case 'form-search':
|
||||||
case 'form-chat':
|
case 'form-chat':
|
||||||
return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX) + CURTAIN_BREATHER_PX;
|
return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX) + CURTAIN_BREATHER_PX;
|
||||||
|
|
@ -95,21 +101,60 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCurtainState(): CurtainState {
|
export function useCurtainState(pinKey?: string): CurtainState {
|
||||||
const [snap, setSnap] = useState<CurtainSnap>('closed');
|
const [snap, setSnap] = useState<CurtainSnap>('closed');
|
||||||
const [activeForm, setActiveForm] = useState<ActiveForm>(null);
|
const [activeForm, setActiveForm] = useState<ActiveForm>(null);
|
||||||
const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
|
const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
|
||||||
const [liveDragPx, setLiveDragPx] = useState(0);
|
const [liveDragPx, setLiveDragPx] = useState(0);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
// Pin storage split: atom-backed when `pinKey` is supplied (survives
|
||||||
|
// listing-pane remount on Room navigate-back), local useState
|
||||||
|
// fallback when no key is supplied (web/non-listing mounts where
|
||||||
|
// pinning isn't expected).
|
||||||
|
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
|
||||||
|
const [pinnedLocal, setPinnedLocal] = useState(false);
|
||||||
|
const pinned = pinKey ? !!pinnedMap[pinKey] : pinnedLocal;
|
||||||
|
|
||||||
const formMeasureRef = useRef<HTMLDivElement>(null);
|
const formMeasureRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const open = useCallback((form: 'search' | 'chat') => {
|
const setPinned = useCallback(
|
||||||
setActiveForm(form);
|
(next: boolean) => {
|
||||||
setSnap(form === 'search' ? 'form-search' : 'form-chat');
|
if (pinKey) {
|
||||||
setLiveDragPx(0);
|
setPinnedMap((prev) => {
|
||||||
setIsDragging(false);
|
// Compare-and-skip so we don't allocate a fresh object (and
|
||||||
}, []);
|
// re-render every other subscriber of the atom) when nothing
|
||||||
|
// actually changes.
|
||||||
|
if (!!prev[pinKey] === next) return prev;
|
||||||
|
return { ...prev, [pinKey]: next };
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setPinnedLocal(next);
|
||||||
|
}
|
||||||
|
// Drop any in-flight live drag on commit so the curtain renders
|
||||||
|
// at the new pinned-derived top without a residual finger offset.
|
||||||
|
setLiveDragPx(0);
|
||||||
|
setIsDragging(false);
|
||||||
|
},
|
||||||
|
[pinKey, setPinnedMap]
|
||||||
|
);
|
||||||
|
|
||||||
|
const open = useCallback(
|
||||||
|
(form: 'search' | 'chat') => {
|
||||||
|
setActiveForm(form);
|
||||||
|
setSnap(form === 'search' ? 'form-search' : 'form-chat');
|
||||||
|
setLiveDragPx(0);
|
||||||
|
setIsDragging(false);
|
||||||
|
// Safety net: clear pin so the form is visible. In practice the
|
||||||
|
// static pager header's icons (the only call site of open()) are
|
||||||
|
// covered by the curtain when pinned, so the user can't trigger
|
||||||
|
// this directly — but a future programmatic open() or a per-pane
|
||||||
|
// tabsRow that escapes the pager-mode visibility:hidden gate
|
||||||
|
// would otherwise mount the form behind the still-pinned curtain
|
||||||
|
// at curtainTop=0 and the user would see an invisible form.
|
||||||
|
setPinned(false);
|
||||||
|
},
|
||||||
|
[setPinned]
|
||||||
|
);
|
||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
setSnap('closed');
|
setSnap('closed');
|
||||||
|
|
@ -180,6 +225,8 @@ export function useCurtainState(): CurtainState {
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
snap,
|
snap,
|
||||||
|
pinned,
|
||||||
|
setPinned,
|
||||||
activeForm,
|
activeForm,
|
||||||
liveDragPx,
|
liveDragPx,
|
||||||
isDragging,
|
isDragging,
|
||||||
|
|
@ -193,6 +240,8 @@ export function useCurtainState(): CurtainState {
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
snap,
|
snap,
|
||||||
|
pinned,
|
||||||
|
setPinned,
|
||||||
activeForm,
|
activeForm,
|
||||||
liveDragPx,
|
liveDragPx,
|
||||||
isDragging,
|
isDragging,
|
||||||
|
|
|
||||||
|
|
@ -19,16 +19,26 @@ export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
|
||||||
// (PageNav's inner column for the Direct route).
|
// (PageNav's inner column for the Direct route).
|
||||||
//
|
//
|
||||||
// `marginTop: -var(--vojo-safe-top)` extends the container UP over the
|
// `marginTop: -var(--vojo-safe-top)` extends the container UP over the
|
||||||
// status-bar safe-top zone reserved by `PageNav` via `padding-top`. With
|
// status-bar safe-top zone reserved by `PageNav` via `padding-top`,
|
||||||
// this offset the wrapped StreamHeader's `curtain` (which positions
|
// and the compensating `paddingTop: var(--vojo-safe-top)` on `appBody`
|
||||||
// `top: <negative>` when dragged past `closed`) can paint into the
|
// keeps the wrapped DM list anchored at the same visual Y as before
|
||||||
// status-bar zone — without it, both `overflow: hidden` here and the
|
// the shift. The combination has two load-bearing effects:
|
||||||
// `clipPath` on `appBody` would clip those pixels at the bottom of
|
//
|
||||||
// the status-bar strip and the strip would stay uncovered, breaking
|
// (1) The settings-sheet clip-path mask on `appBody` carves rounded
|
||||||
// parity with Bots / ChannelsRoot (where StreamHeader is a direct
|
// BL/BR into an opaque surface that already paints THROUGH the
|
||||||
// child of PageNav and no such wrapper clip exists). The compensating
|
// status-bar strip — without the upward extension the carve
|
||||||
// `padding-top` lives on `appBody` so the wrapped DM list / tabs row
|
// would visibly stop at the bottom of the system-tray strip.
|
||||||
// stay visually anchored at the same Y as before the shift.
|
// (2) `appBody`'s bg paints the safe-top strip itself in the same
|
||||||
|
// `SurfaceVariant.Container` tone as `PageNav-inner` / the
|
||||||
|
// pager's static header, giving the system-tray text a
|
||||||
|
// consistent backdrop across surfaces.
|
||||||
|
//
|
||||||
|
// Note: the curtain itself NEVER paints into the safe-top zone — the
|
||||||
|
// pin gesture's hard clamp at `-PIN_TRAVEL_PX = -TABS_ROW_PX` stops
|
||||||
|
// the curtain at `top: 0` of the stage (= `y = safe-top` in viewport),
|
||||||
|
// preserving the system-tray strip. See
|
||||||
|
// `components/stream-header/geometry.ts::PIN_TRAVEL_PX` and the
|
||||||
|
// «pinned visual contract» comment for the full invariant.
|
||||||
export const container = style({
|
export const container = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,14 @@
|
||||||
|
|
||||||
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { settingsSheetAtom } from '../../state/settingsSheet';
|
import { settingsSheetAtom } from '../../state/settingsSheet';
|
||||||
import { useCloseSettingsSheet, useOpenSettingsSheet } from '../../state/hooks/settingsSheet';
|
import { useCloseSettingsSheet, useOpenSettingsSheet } from '../../state/hooks/settingsSheet';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { HorseshoeEnabledContext } from '../../components/page';
|
import { HorseshoeEnabledContext } from '../../components/page';
|
||||||
|
import { useMobilePagerPane } from '../../components/mobile-tabs-pager/MobilePagerPaneContext';
|
||||||
|
import { mobileHorseshoeActiveAtom } from '../../state/mobilePagerHeader';
|
||||||
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
|
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
|
||||||
import { Settings } from './Settings';
|
import { Settings } from './Settings';
|
||||||
import * as css from './MobileSettingsHorseshoe.css';
|
import * as css from './MobileSettingsHorseshoe.css';
|
||||||
|
|
@ -116,6 +118,15 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
const sheet = useAtomValue(settingsSheetAtom);
|
const sheet = useAtomValue(settingsSheetAtom);
|
||||||
const openSheet = useOpenSettingsSheet();
|
const openSheet = useOpenSettingsSheet();
|
||||||
const closeSheet = useCloseSettingsSheet();
|
const closeSheet = useCloseSettingsSheet();
|
||||||
|
// In pager mode the appBody's SurfaceVariant bg would cover the
|
||||||
|
// pager's static header tabs (which live behind the swipe strip in
|
||||||
|
// DOM order so the chats curtain can rise OVER them like a real
|
||||||
|
// blind). Drop the bg in pager mode — the pager root paints the same
|
||||||
|
// SurfaceVariant tone behind the strip, and the static header owns
|
||||||
|
// the visible safe-top + tabsRow strip. See
|
||||||
|
// `mobile-tabs-pager/style.css.ts::pagerStaticHeader` for the
|
||||||
|
// overlay contract.
|
||||||
|
const inPagerMode = useMobilePagerPane() !== null;
|
||||||
|
|
||||||
const [drag, setDrag] = useState<DragState | null>(null);
|
const [drag, setDrag] = useState<DragState | null>(null);
|
||||||
const [viewportHeight, setViewportHeight] = useState(() =>
|
const [viewportHeight, setViewportHeight] = useState(() =>
|
||||||
|
|
@ -188,6 +199,17 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
const isDragging = drag !== null;
|
const isDragging = drag !== null;
|
||||||
const horseshoeActive = expandedPx > 0;
|
const horseshoeActive = expandedPx > 0;
|
||||||
|
|
||||||
|
// Bridge our local `horseshoeActive` (geometric) signal up to the
|
||||||
|
// pager-shared atom so `MobileTabsPagerHeader` can z-elevate the
|
||||||
|
// static header from the first frame of drag — see the atom's docs
|
||||||
|
// for the «no black flash» rationale. The cleanup writing `false`
|
||||||
|
// covers route unmount mid-drag.
|
||||||
|
const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom);
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileHorseshoeActive(horseshoeActive);
|
||||||
|
return () => setMobileHorseshoeActive(false);
|
||||||
|
}, [horseshoeActive, setMobileHorseshoeActive]);
|
||||||
|
|
||||||
const handleRef = useRef<HTMLDivElement>(null);
|
const handleRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Refs so the always-installed event handlers see the latest state
|
// Refs so the always-installed event handlers see the latest state
|
||||||
|
|
@ -536,6 +558,28 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
clipPath: appBodyClipPath,
|
clipPath: appBodyClipPath,
|
||||||
transition: appBodyTransition,
|
transition: appBodyTransition,
|
||||||
overscrollBehaviorY: 'contain',
|
overscrollBehaviorY: 'contain',
|
||||||
|
// In pager mode the appBody is transparent AT REST so the
|
||||||
|
// static pager header (sitting behind the swipe strip in DOM
|
||||||
|
// order) shows through the tabsRow zone — that's how the
|
||||||
|
// chats curtain visually rises ABOVE the header on pin.
|
||||||
|
//
|
||||||
|
// When the horseshoe is active (drag in flight OR sheet
|
||||||
|
// open) we flip the appBody back to opaque
|
||||||
|
// `SurfaceVariant.Container` (via the CSS class — unsetting
|
||||||
|
// the inline override). This contains the container's
|
||||||
|
// `VOJO_HORSESHOE_VOID_COLOR` paint to the carve cut-out at
|
||||||
|
// the bottom of the appBody. Otherwise the void would bleed
|
||||||
|
// up through every transparent pixel of the chip-area band
|
||||||
|
// between the static header and the curtain top (peek/form
|
||||||
|
// snaps), turning the strip black.
|
||||||
|
//
|
||||||
|
// The static-header z-elevation in `MobileTabsPagerHeader`
|
||||||
|
// tracks the same `mobileHorseshoeActiveAtom` so the static
|
||||||
|
// header z-pops above the now-opaque appBody in the safe-
|
||||||
|
// top + tabsRow band — tabs stay visible. Both gestures
|
||||||
|
// (pin + pager swipe) are gated off while a sheet is open,
|
||||||
|
// so the opaque flip can't race with curtain-show-through.
|
||||||
|
backgroundColor: inPagerMode && !horseshoeActive ? 'transparent' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Box, color, config, toRem } from 'folds';
|
||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router-dom';
|
||||||
import { PageNav, PageNavContent } from '../../../components/page';
|
import { PageNav, PageNavContent } from '../../../components/page';
|
||||||
import { StreamHeader } from '../../../components/stream-header';
|
import { StreamHeader } from '../../../components/stream-header';
|
||||||
|
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
|
||||||
import { useBotPresets } from '../../../features/bots/catalog';
|
import { useBotPresets } from '../../../features/bots/catalog';
|
||||||
import type { BotPreset } from '../../../features/bots/catalog';
|
import type { BotPreset } from '../../../features/bots/catalog';
|
||||||
import { BotCard } from '../../../features/bots/BotCard';
|
import { BotCard } from '../../../features/bots/BotCard';
|
||||||
|
|
@ -24,10 +25,14 @@ export function Bots() {
|
||||||
// only) can recognise list scrollTop=0 and engage the curtain peek.
|
// only) can recognise list scrollTop=0 and engage the curtain peek.
|
||||||
// Icons + click flows work on every platform regardless.
|
// Icons + click flows work on every platform regardless.
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Skip PageNav surface in pager mode — see Direct.tsx for the
|
||||||
|
// rationale; the static header behind the strip owns the visible
|
||||||
|
// safe-top + tabsRow tone.
|
||||||
|
const inPagerMode = useMobilePagerPane() !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNav resizable surface="surfaceVariant">
|
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
|
||||||
<StreamHeader scrollRef={scrollRef}>
|
<StreamHeader scrollRef={scrollRef} pinKey="bots">
|
||||||
<PageNavContent scrollRef={scrollRef}>
|
<PageNavContent scrollRef={scrollRef}>
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
|
||||||
import { useSpace } from '../../../hooks/useSpace';
|
import { useSpace } from '../../../hooks/useSpace';
|
||||||
import { PageNav, PageNavContent } from '../../../components/page';
|
import { PageNav, PageNavContent } from '../../../components/page';
|
||||||
import { StreamHeader } from '../../../components/stream-header';
|
import { StreamHeader } from '../../../components/stream-header';
|
||||||
|
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
|
||||||
import { ChannelsList } from './ChannelsList';
|
import { ChannelsList } from './ChannelsList';
|
||||||
import { ChannelCreateRow } from './ChannelCreateRow';
|
import { ChannelCreateRow } from './ChannelCreateRow';
|
||||||
import { ChannelsLanding } from './ChannelsLanding';
|
import { ChannelsLanding } from './ChannelsLanding';
|
||||||
|
|
@ -25,9 +26,18 @@ import { ACTIVE_SPACE_KEY } from './useActiveSpace';
|
||||||
// the centering to top-aligned. Same idiom as `Direct.tsx::DirectEmpty`.
|
// the centering to top-aligned. Same idiom as `Direct.tsx::DirectEmpty`.
|
||||||
export function ChannelsRootNav() {
|
export function ChannelsRootNav() {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Skip PageNav surface in pager mode so the pager's static header
|
||||||
|
// tabs (sitting behind the swipe strip in DOM order) show through
|
||||||
|
// until covered by the rising curtain. See Direct.tsx for the same
|
||||||
|
// pattern and `mobile-tabs-pager/style.css.ts::pagerStaticHeader`
|
||||||
|
// for the full overlay contract.
|
||||||
|
const inPagerMode = useMobilePagerPane() !== null;
|
||||||
return (
|
return (
|
||||||
<PageNav resizable surface="surfaceVariant">
|
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
|
||||||
<StreamHeader scrollRef={scrollRef}>
|
{/* Shared `pinKey` with the workspace-listing `Channels` below
|
||||||
|
so pin/unpin persists when the user toggles between the
|
||||||
|
landing CTA and a chosen workspace within the Channels tab. */}
|
||||||
|
<StreamHeader scrollRef={scrollRef} pinKey="channels">
|
||||||
<ChannelsLanding />
|
<ChannelsLanding />
|
||||||
</StreamHeader>
|
</StreamHeader>
|
||||||
</PageNav>
|
</PageNav>
|
||||||
|
|
@ -46,6 +56,7 @@ export function ChannelsRootNav() {
|
||||||
export function Channels() {
|
export function Channels() {
|
||||||
const space = useSpace();
|
const space = useSpace();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inPagerMode = useMobilePagerPane() !== null;
|
||||||
|
|
||||||
// Persist URL-driven active space so cold-starts at /channels/ resume on
|
// Persist URL-driven active space so cold-starts at /channels/ resume on
|
||||||
// the same workspace. `useActiveSpace` (in ChannelsLanding) reads the
|
// the same workspace. `useActiveSpace` (in ChannelsLanding) reads the
|
||||||
|
|
@ -60,10 +71,11 @@ export function Channels() {
|
||||||
}, [space.roomId]);
|
}, [space.roomId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNav resizable surface="surfaceVariant">
|
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
|
||||||
<ChannelsWorkspaceHorseshoe space={space}>
|
<ChannelsWorkspaceHorseshoe space={space}>
|
||||||
<StreamHeader
|
<StreamHeader
|
||||||
scrollRef={scrollRef}
|
scrollRef={scrollRef}
|
||||||
|
pinKey="channels"
|
||||||
bottomPinned={
|
bottomPinned={
|
||||||
<>
|
<>
|
||||||
<ChannelCreateRow space={space} />
|
<ChannelCreateRow space={space} />
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,19 @@ export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
|
||||||
// header.
|
// header.
|
||||||
//
|
//
|
||||||
// `marginTop: -var(--vojo-safe-top)` extends the container UP over the
|
// `marginTop: -var(--vojo-safe-top)` extends the container UP over the
|
||||||
// status-bar safe-top zone. Mirror of the same property in
|
// status-bar safe-top zone, and the compensating
|
||||||
// `MobileSettingsHorseshoe.css.ts::container` — without it, the wrapped
|
// `paddingTop: var(--vojo-safe-top)` on `appBody` keeps the wrapped
|
||||||
// StreamHeader's curtain (which positions `top: <negative>` when
|
// channels list anchored at the same visual Y. Mirror of the same
|
||||||
// dragged past `closed`) is clipped by both `overflow: hidden` here
|
// pair in `MobileSettingsHorseshoe.css.ts::container` — purpose is
|
||||||
// and `appBody`'s clipPath at the bottom edge of the status bar, so
|
// (1) the workspace-sheet clip-path mask on `appBody` carves into an
|
||||||
// the lighter `SurfaceVariant.Container` strip from `PageNav-inner`
|
// opaque surface that already paints through the status-bar strip,
|
||||||
// stays uncovered. The compensating `padding-top` lives on `appBody`
|
// and (2) `appBody`'s bg paints the safe-top strip in the same
|
||||||
// so the wrapped channels list / tabs row stay visually anchored.
|
// `SurfaceVariant.Container` tone as the pager's static header so the
|
||||||
|
// system-tray backdrop stays consistent across surfaces.
|
||||||
|
//
|
||||||
|
// The curtain itself never paints into the safe-top zone — pin
|
||||||
|
// gesture clamps it at `top: 0` of the stage, see
|
||||||
|
// `components/stream-header/geometry.ts::PIN_TRAVEL_PX`.
|
||||||
export const container = style({
|
export const container = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
// canonical horseshoe and are not re-litigated here.
|
// canonical horseshoe and are not re-litigated here.
|
||||||
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { channelsWorkspaceSheetAtom } from '../../../state/channelsWorkspaceSheet';
|
import { channelsWorkspaceSheetAtom } from '../../../state/channelsWorkspaceSheet';
|
||||||
|
|
@ -32,6 +32,8 @@ import {
|
||||||
useCloseChannelsWorkspaceSheet,
|
useCloseChannelsWorkspaceSheet,
|
||||||
useOpenChannelsWorkspaceSheet,
|
useOpenChannelsWorkspaceSheet,
|
||||||
} from '../../../state/hooks/channelsWorkspaceSheet';
|
} from '../../../state/hooks/channelsWorkspaceSheet';
|
||||||
|
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
|
||||||
|
import { mobileHorseshoeActiveAtom } from '../../../state/mobilePagerHeader';
|
||||||
import { VOJO_HORSESHOE_VOID_COLOR } from '../../../styles/horseshoe';
|
import { VOJO_HORSESHOE_VOID_COLOR } from '../../../styles/horseshoe';
|
||||||
import { WorkspaceSwitcherSheet } from './WorkspaceSwitcherSheet';
|
import { WorkspaceSwitcherSheet } from './WorkspaceSwitcherSheet';
|
||||||
import * as css from './ChannelsWorkspaceHorseshoe.css';
|
import * as css from './ChannelsWorkspaceHorseshoe.css';
|
||||||
|
|
@ -96,6 +98,11 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [containerHeightPx, setContainerHeightPx] = useState(0);
|
const [containerHeightPx, setContainerHeightPx] = useState(0);
|
||||||
const [drag, setDrag] = useState<DragState | null>(null);
|
const [drag, setDrag] = useState<DragState | null>(null);
|
||||||
|
// In pager mode the appBody bg must be transparent so the static
|
||||||
|
// pager header tabs (behind the swipe strip in DOM order) show
|
||||||
|
// through until covered by the rising chats curtain. See
|
||||||
|
// MobileSettingsHorseshoe.tsx for the matching pattern.
|
||||||
|
const inPagerMode = useMobilePagerPane() !== null;
|
||||||
|
|
||||||
// ResizeObserver on the wrapper — rail height is scoped to THIS
|
// ResizeObserver on the wrapper — rail height is scoped to THIS
|
||||||
// column. PageNav width changes via the resizable handle on desktop
|
// column. PageNav width changes via the resizable handle on desktop
|
||||||
|
|
@ -160,6 +167,17 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
|
||||||
const isDragging = drag !== null;
|
const isDragging = drag !== null;
|
||||||
const horseshoeActive = expandedPx > 0;
|
const horseshoeActive = expandedPx > 0;
|
||||||
|
|
||||||
|
// Bridge our local `horseshoeActive` (geometric) signal up to the
|
||||||
|
// pager-shared atom so `MobileTabsPagerHeader` can z-elevate the
|
||||||
|
// static header from the first frame of drag. Mirror of the same
|
||||||
|
// bridge in `MobileSettingsHorseshoe.tsx`. See the atom's docs in
|
||||||
|
// `state/mobilePagerHeader.ts` for the «no black flash» rationale.
|
||||||
|
const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom);
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileHorseshoeActive(horseshoeActive);
|
||||||
|
return () => setMobileHorseshoeActive(false);
|
||||||
|
}, [horseshoeActive, setMobileHorseshoeActive]);
|
||||||
|
|
||||||
const handleRef = useRef<HTMLDivElement>(null);
|
const handleRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Refs so the always-installed document listeners see the latest
|
// Refs so the always-installed document listeners see the latest
|
||||||
|
|
@ -455,6 +473,14 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
|
||||||
clipPath: appBodyClipPath,
|
clipPath: appBodyClipPath,
|
||||||
transition: appBodyTransition,
|
transition: appBodyTransition,
|
||||||
overscrollBehaviorY: 'contain',
|
overscrollBehaviorY: 'contain',
|
||||||
|
// See MobileSettingsHorseshoe.tsx for the full rationale:
|
||||||
|
// appBody is transparent in pager mode AT REST so the static
|
||||||
|
// pager header shows through, but flips back to its CSS-
|
||||||
|
// class `SurfaceVariant.Container` whenever the horseshoe is
|
||||||
|
// active so the container's `VOJO_HORSESHOE_VOID_COLOR` paint
|
||||||
|
// stays contained to the bottom carve cut-out and doesn't
|
||||||
|
// bleed into the chip-area band above the curtain.
|
||||||
|
backgroundColor: inPagerMode && !horseshoeActive ? 'transparent' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
import { StreamHeader } from '../../../components/stream-header';
|
import { StreamHeader } from '../../../components/stream-header';
|
||||||
import { DirectSelfRow } from './DirectSelfRow';
|
import { DirectSelfRow } from './DirectSelfRow';
|
||||||
import { MobileSettingsHorseshoe } from '../../../features/settings';
|
import { MobileSettingsHorseshoe } from '../../../features/settings';
|
||||||
|
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
|
||||||
|
|
||||||
type ListItem =
|
type ListItem =
|
||||||
| { kind: 'invite'; entry: DirectInviteEntry }
|
| { kind: 'invite'; entry: DirectInviteEntry }
|
||||||
|
|
@ -205,13 +206,22 @@ export function Direct() {
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// In pager mode the PageNav surface bg would cover the pager's
|
||||||
|
// static header tabs (which sit behind the swipe strip in DOM order
|
||||||
|
// so the chats curtain can rise OVER them like a real blind). Skip
|
||||||
|
// the surface paint here in pager mode — the static header behind
|
||||||
|
// owns the visible safe-top + tabsRow tone. See
|
||||||
|
// `mobile-tabs-pager/style.css.ts::pagerStaticHeader` for the
|
||||||
|
// overlay contract.
|
||||||
|
const inPagerMode = useMobilePagerPane() !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNav resizable surface="surfaceVariant">
|
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
|
||||||
{/* MobileSettingsHorseshoe wraps the full DM column on mobile so the
|
{/* MobileSettingsHorseshoe wraps the full DM column on mobile so the
|
||||||
Settings sheet can carve into the bottom of this pane. On non-mobile
|
Settings sheet can carve into the bottom of this pane. On non-mobile
|
||||||
it's a pass-through. */}
|
it's a pass-through. */}
|
||||||
<MobileSettingsHorseshoe>
|
<MobileSettingsHorseshoe>
|
||||||
<StreamHeader scrollRef={scrollRef} bottomPinned={<DirectSelfRow />}>
|
<StreamHeader scrollRef={scrollRef} bottomPinned={<DirectSelfRow />} pinKey="direct">
|
||||||
{noRoomToDisplay ? (
|
{noRoomToDisplay ? (
|
||||||
<DirectEmpty />
|
<DirectEmpty />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -20,3 +20,48 @@ export type MobilePagerCurtainControls = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mobilePagerCurtainAtom = atom<MobilePagerCurtainControls | null>(null);
|
export const mobilePagerCurtainAtom = atom<MobilePagerCurtainControls | null>(null);
|
||||||
|
|
||||||
|
// Per-tab pinned state for the StreamHeader curtain. Each listing
|
||||||
|
// surface (Direct / Channels / Bots) reads/writes by its own stable
|
||||||
|
// `pinKey` so the pin survives the route-driven listing-pane unmount
|
||||||
|
// that happens when the user taps into a Room and back: the atom
|
||||||
|
// outlives any individual StreamHeader instance.
|
||||||
|
//
|
||||||
|
// In-memory only (no localStorage). The pinned state is ephemeral UI
|
||||||
|
// state — user expects a fresh «header visible» default on cold start;
|
||||||
|
// surviving app restarts would be surprising.
|
||||||
|
//
|
||||||
|
// Map shape (keys are arbitrary stable strings, owned by each
|
||||||
|
// StreamHeader consumer):
|
||||||
|
// { direct: true, channels: false, bots: false, ... }
|
||||||
|
// Missing key ⇒ false. Each tab's pin lives independently — pinning
|
||||||
|
// Direct doesn't pin Channels.
|
||||||
|
export const curtainPinnedByTabAtom = atom<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// True while a horseshoe bottom sheet (settings on Direct, workspace
|
||||||
|
// switcher on Channels) is geometrically active — i.e. `expandedPx > 0`,
|
||||||
|
// which covers both the in-flight drag and the committed-open state.
|
||||||
|
//
|
||||||
|
// Source of truth for `MobileTabsPagerHeader`'s z-elevation: when this
|
||||||
|
// is true, the static pager header bumps to a positive z-index so it
|
||||||
|
// paints above the strip's stacking context and covers the safe-top +
|
||||||
|
// tabsRow band with `SurfaceVariant.Container`. Without this, the
|
||||||
|
// container's `VOJO_HORSESHOE_VOID_COLOR` paint that drives the carve
|
||||||
|
// would bleed up through the transparent strip stack into the system-
|
||||||
|
// tray strip.
|
||||||
|
//
|
||||||
|
// Tracking the GEOMETRIC signal (not the sheet-open atoms) is load-
|
||||||
|
// bearing: container bg flips to the void colour the moment a drag-
|
||||||
|
// up on `DirectSelfRow` crosses 0 px, but the sheet atom only commits
|
||||||
|
// after 80 px past the threshold. Driving elevation off the geometry
|
||||||
|
// keeps the static header z-above the void from the first frame of
|
||||||
|
// drag — no «pane goes black then header re-paints light blue» flash.
|
||||||
|
//
|
||||||
|
// Mount exclusivity: in pager mode all three listing surfaces stay
|
||||||
|
// mounted, but only one's horseshoe can have `horseshoeActive=true`
|
||||||
|
// because the pager's own swipe gesture is gated off whenever a sheet
|
||||||
|
// is open and the per-pane drag origin (DirectSelfRow / WorkspaceFooter)
|
||||||
|
// only receives touches on the active pane. Two horseshoes writing
|
||||||
|
// `true` at the same time is therefore unreachable through any user
|
||||||
|
// flow.
|
||||||
|
export const mobileHorseshoeActiveAtom = atom<boolean>(false);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue