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}
|
||||
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}>
|
||||
<PaneSlot isActive={directPaneInfo.isActive}>
|
||||
<Direct />
|
||||
|
|
|
|||
|
|
@ -2,11 +2,22 @@ import React, { useCallback } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue } from 'jotai';
|
||||
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 * as streamHeaderCss from '../stream-header/StreamHeader.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';
|
||||
|
||||
// Must match the `INLINE_FORM_ID` local constant in
|
||||
|
|
@ -71,8 +82,53 @@ export function MobileTabsPagerHeader({
|
|||
const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]);
|
||||
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 (
|
||||
<div className={css.pagerStaticHeader}>
|
||||
<div
|
||||
className={css.pagerStaticHeader}
|
||||
style={elevated ? { zIndex: PAGER_HEADER_ELEVATED_Z } : undefined}
|
||||
>
|
||||
<div className={streamHeaderCss.tabsRow}>
|
||||
<div className={streamHeaderCss.tabsCluster}>
|
||||
<Segment
|
||||
|
|
|
|||
|
|
@ -39,23 +39,81 @@ export const pagerRoot = style({
|
|||
color: color.Background.OnContainer,
|
||||
});
|
||||
|
||||
// Shared static tabs row painted ABOVE the strip. Reserves the
|
||||
// status-bar safe-area inset via padding-top so the segments + icons
|
||||
// sit just below the system status bar, and so the backdrop colour
|
||||
// extends through the inset zone (matching the per-pane PageNav's
|
||||
// own `paddingTop: var(--vojo-safe-top)` so there's no visible band
|
||||
// boundary at the inset edge).
|
||||
// Shared static tabs row painted BEHIND the strip in DOM order.
|
||||
// Reserves the status-bar safe-area inset via padding-top so the
|
||||
// segments + icons sit just below the system status bar, and so the
|
||||
// backdrop colour extends through the inset zone (matching the per-pane
|
||||
// PageNav's own `paddingTop: var(--vojo-safe-top)` so there's no
|
||||
// visible band boundary at the inset edge).
|
||||
//
|
||||
// `z-index: 10` keeps this above the strip and any in-pane curtain
|
||||
// (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
|
||||
// context — so 10 reliably wins).
|
||||
// Curtain-overlay invariants (why no z-index here at rest):
|
||||
//
|
||||
// The chats curtain must visually rise ABOVE this header when the
|
||||
// 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({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
paddingTop: 'var(--vojo-safe-top, 0px)',
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ import {
|
|||
|
||||
// Stage. Position-relative anchor. The header itself paints the
|
||||
// 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({
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
|
|
@ -19,11 +27,22 @@ export const stage = style({
|
|||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
selectors: {
|
||||
'[data-pager-pane="true"] &': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Header — always-rendered strip carrying tabs row + (optional) chip
|
||||
// reveal area + (optional) active form. The curtain slides on top of
|
||||
// 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({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
|
@ -35,6 +54,11 @@ export const header = style({
|
|||
// everything below the tabs row when raised.
|
||||
zIndex: 1,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
selectors: {
|
||||
'[data-pager-pane="true"] &': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Tabs row. Stays fully visible regardless of curtain position
|
||||
|
|
|
|||
|
|
@ -9,15 +9,16 @@ import React, {
|
|||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
|
||||
import { isNativePlatform } from '../../utils/capacitor';
|
||||
import { useBotPresets } from '../../features/bots/catalog';
|
||||
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
|
||||
import { MobilePagerCurtainControls, mobilePagerCurtainAtom } from '../../state/mobilePagerHeader';
|
||||
import { settingsSheetAtom } from '../../state/settingsSheet';
|
||||
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
|
||||
import * as css from './StreamHeader.css';
|
||||
import { CHIP_ROW_PX, TABS_ROW_PX } from './geometry';
|
||||
import { Segment } from './Segment';
|
||||
import { Chip } from './Chip';
|
||||
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
|
||||
// over the form (see commit 14ed080).
|
||||
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 navigate = useNavigate();
|
||||
const bots = useBotPresets();
|
||||
|
|
@ -66,13 +75,37 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader
|
|||
const inPagerMode = pagerPane !== null;
|
||||
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({
|
||||
scrollRef,
|
||||
snap: curtain.snap,
|
||||
pinned: curtain.pinned,
|
||||
setPinned: curtain.setPinned,
|
||||
setLiveDrag: curtain.setLiveDrag,
|
||||
commit: curtain.commit,
|
||||
disabled: gestureDisabled,
|
||||
});
|
||||
|
||||
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
|
||||
// commit happen in the same render pipeline and there's no
|
||||
// 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.
|
||||
// Guarded so unrelated transitionend events (e.g. children's own
|
||||
|
|
@ -347,5 +388,3 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TABS_ROW_PX, CHIP_ROW_PX };
|
||||
|
|
|
|||
|
|
@ -9,10 +9,26 @@
|
|||
// Dragging UP raises the curtain back over the header.
|
||||
//
|
||||
// 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
|
||||
// peek = TABS_ROW_PX + 2·CHIP_ROW_PX + CHIP_GAP_PX
|
||||
// + 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.
|
||||
|
|
@ -69,3 +85,35 @@ export const DIRECTION_DEAD_ZONE_PX = 10;
|
|||
export const COMMIT_THRESHOLD = 0.9;
|
||||
// Pull-up distance (raw finger px) required to close an active form.
|
||||
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,
|
||||
DIRECTION_DEAD_ZONE_PX,
|
||||
PEEK_TRAVEL_PX,
|
||||
PIN_COMMIT_THRESHOLD,
|
||||
PIN_RUBBER_BAND,
|
||||
PIN_TRAVEL_PX,
|
||||
RUBBER_BAND,
|
||||
} from './geometry';
|
||||
import { CurtainSnap, isFormSnap } from './useCurtainState';
|
||||
|
|
@ -17,6 +20,15 @@ type Args = {
|
|||
// Current snap stop. Read at touchstart to decide gesture meaning.
|
||||
// Mirrored into a ref so the listener (bound once) reads fresh values.
|
||||
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
|
||||
// `liveDragPx` from the parent state too, so React drives the
|
||||
// curtain's `top` re-render — no direct DOM writes, no inline-vs-
|
||||
|
|
@ -24,25 +36,61 @@ type Args = {
|
|||
setLiveDrag: (px: number, dragging: boolean) => void;
|
||||
// Commit a new snap stop on release.
|
||||
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
|
||||
// listeners aren't attached at all.
|
||||
//
|
||||
// Peek path: drag down from `closed` rubber-bands the live delta and
|
||||
// on release past the threshold commits to `peek` (both chips
|
||||
// revealed in one motion). Drag UP from `peek` retreats to `closed`.
|
||||
// Peek path: drag down from `closed` (not pinned) rubber-bands the live
|
||||
// delta and on release past the threshold commits to `peek` (both
|
||||
// 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
|
||||
// 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 —
|
||||
// always reads the freshest value without re-attaching.
|
||||
const snapRef = useRef<CurtainSnap>(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(() => {
|
||||
if (!isNativePlatform()) return undefined;
|
||||
if (disabled) return undefined;
|
||||
const list = scrollRef.current;
|
||||
if (!list) return undefined;
|
||||
|
||||
|
|
@ -92,6 +140,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
|||
const delta = e.touches[0].clientY - startY;
|
||||
const deltaX = startX !== null ? e.touches[0].clientX - startX : 0;
|
||||
const currentSnap = snapRef.current;
|
||||
const currentPinned = pinnedRef.current;
|
||||
|
||||
// Resolve a direction once the finger crosses the dead-zone.
|
||||
if (direction === null) {
|
||||
|
|
@ -110,21 +159,27 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
|||
}
|
||||
direction = delta > 0 ? 'down' : 'up';
|
||||
|
||||
// Direction guards: nothing higher than `closed`; nothing
|
||||
// lower than `peek`; form snaps only close (up).
|
||||
if (currentSnap === 'closed' && direction === 'up') {
|
||||
// Direction guards:
|
||||
// - pinned ⇒ only DOWN (unpin); UP would try to push past
|
||||
// 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;
|
||||
startY = null;
|
||||
direction = null;
|
||||
return;
|
||||
}
|
||||
if (currentSnap === 'peek' && direction === 'down') {
|
||||
if (!currentPinned && currentSnap === 'peek' && direction === 'down') {
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
return;
|
||||
}
|
||||
if (isFormSnap(currentSnap) && direction === 'down') {
|
||||
if (!currentPinned && isFormSnap(currentSnap) && direction === 'down') {
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
|
|
@ -135,11 +190,27 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
|||
engaged = true;
|
||||
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
|
||||
// at 0 so an accidental downward jitter doesn't push the
|
||||
// curtain below its resting position.
|
||||
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 {
|
||||
// Peek: rubber-banded BOTH directions. Down (delta > 0) reveals
|
||||
// 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 currentPinned = pinnedRef.current;
|
||||
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) {
|
||||
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 {
|
||||
// Single-stage peek toggle. Threshold is COMMIT_THRESHOLD of
|
||||
// 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
|
||||
// one batched update — React renders the curtain at the new
|
||||
// resting top with the snap transition re-enabled.
|
||||
|
|
@ -216,7 +312,10 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
|||
list.removeEventListener('touchcancel', onTouchCancel);
|
||||
};
|
||||
// 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
|
||||
}, [scrollRef, setLiveDrag, commit]);
|
||||
}, [scrollRef, setLiveDrag, commit, disabled]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader';
|
||||
import {
|
||||
CHIP_GAP_PX,
|
||||
CHIP_ROW_PX,
|
||||
|
|
@ -17,9 +19,7 @@ export type CurtainSnap =
|
|||
| 'form-search' // full search form revealed
|
||||
| 'form-chat'; // full new-chat form revealed
|
||||
|
||||
export const isFormSnap = (
|
||||
snap: CurtainSnap
|
||||
): snap is 'form-search' | 'form-chat' =>
|
||||
export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' | 'form-chat' =>
|
||||
snap === 'form-search' || snap === 'form-chat';
|
||||
|
||||
export const isPeekSnap = (snap: CurtainSnap): snap is 'peek' => snap === 'peek';
|
||||
|
|
@ -31,6 +31,18 @@ export type ActiveForm = 'search' | 'chat' | null;
|
|||
|
||||
export type CurtainState = {
|
||||
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;
|
||||
// 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
|
||||
|
|
@ -80,13 +92,7 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
|
|||
case 'closed':
|
||||
return TABS_ROW_PX;
|
||||
case 'peek':
|
||||
return (
|
||||
TABS_ROW_PX +
|
||||
CHIP_ROW_PX +
|
||||
CHIP_GAP_PX +
|
||||
CHIP_ROW_PX +
|
||||
CURTAIN_BREATHER_PX
|
||||
);
|
||||
return TABS_ROW_PX + CHIP_ROW_PX + CHIP_GAP_PX + CHIP_ROW_PX + CURTAIN_BREATHER_PX;
|
||||
case 'form-search':
|
||||
case 'form-chat':
|
||||
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 [activeForm, setActiveForm] = useState<ActiveForm>(null);
|
||||
const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
|
||||
const [liveDragPx, setLiveDragPx] = useState(0);
|
||||
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 open = useCallback((form: 'search' | 'chat') => {
|
||||
const setPinned = useCallback(
|
||||
(next: boolean) => {
|
||||
if (pinKey) {
|
||||
setPinnedMap((prev) => {
|
||||
// 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(() => {
|
||||
setSnap('closed');
|
||||
|
|
@ -180,6 +225,8 @@ export function useCurtainState(): CurtainState {
|
|||
return useMemo(
|
||||
() => ({
|
||||
snap,
|
||||
pinned,
|
||||
setPinned,
|
||||
activeForm,
|
||||
liveDragPx,
|
||||
isDragging,
|
||||
|
|
@ -193,6 +240,8 @@ export function useCurtainState(): CurtainState {
|
|||
}),
|
||||
[
|
||||
snap,
|
||||
pinned,
|
||||
setPinned,
|
||||
activeForm,
|
||||
liveDragPx,
|
||||
isDragging,
|
||||
|
|
|
|||
|
|
@ -19,16 +19,26 @@ export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
|
|||
// (PageNav's inner column for the Direct route).
|
||||
//
|
||||
// `marginTop: -var(--vojo-safe-top)` extends the container UP over the
|
||||
// status-bar safe-top zone reserved by `PageNav` via `padding-top`. With
|
||||
// this offset the wrapped StreamHeader's `curtain` (which positions
|
||||
// `top: <negative>` when dragged past `closed`) can paint into the
|
||||
// status-bar zone — without it, both `overflow: hidden` here and the
|
||||
// `clipPath` on `appBody` would clip those pixels at the bottom of
|
||||
// the status-bar strip and the strip would stay uncovered, breaking
|
||||
// parity with Bots / ChannelsRoot (where StreamHeader is a direct
|
||||
// child of PageNav and no such wrapper clip exists). The compensating
|
||||
// `padding-top` lives on `appBody` so the wrapped DM list / tabs row
|
||||
// stay visually anchored at the same Y as before the shift.
|
||||
// status-bar safe-top zone reserved by `PageNav` via `padding-top`,
|
||||
// and the compensating `paddingTop: var(--vojo-safe-top)` on `appBody`
|
||||
// keeps the wrapped DM list anchored at the same visual Y as before
|
||||
// the shift. The combination has two load-bearing effects:
|
||||
//
|
||||
// (1) The settings-sheet clip-path mask on `appBody` carves rounded
|
||||
// BL/BR into an opaque surface that already paints THROUGH the
|
||||
// status-bar strip — without the upward extension the carve
|
||||
// would visibly stop at the bottom of the system-tray strip.
|
||||
// (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({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
|
|
|
|||
|
|
@ -48,12 +48,14 @@
|
|||
|
||||
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useAtomValue, useSetAtom } 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 { useMobilePagerPane } from '../../components/mobile-tabs-pager/MobilePagerPaneContext';
|
||||
import { mobileHorseshoeActiveAtom } from '../../state/mobilePagerHeader';
|
||||
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
|
||||
import { Settings } from './Settings';
|
||||
import * as css from './MobileSettingsHorseshoe.css';
|
||||
|
|
@ -116,6 +118,15 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
const sheet = useAtomValue(settingsSheetAtom);
|
||||
const openSheet = useOpenSettingsSheet();
|
||||
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 [viewportHeight, setViewportHeight] = useState(() =>
|
||||
|
|
@ -188,6 +199,17 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
const isDragging = drag !== null;
|
||||
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);
|
||||
|
||||
// Refs so the always-installed event handlers see the latest state
|
||||
|
|
@ -536,6 +558,28 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
clipPath: appBodyClipPath,
|
||||
transition: appBodyTransition,
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Box, color, config, toRem } from 'folds';
|
|||
import { useMatch } from 'react-router-dom';
|
||||
import { PageNav, PageNavContent } from '../../../components/page';
|
||||
import { StreamHeader } from '../../../components/stream-header';
|
||||
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
|
||||
import { useBotPresets } from '../../../features/bots/catalog';
|
||||
import type { BotPreset } from '../../../features/bots/catalog';
|
||||
import { BotCard } from '../../../features/bots/BotCard';
|
||||
|
|
@ -24,10 +25,14 @@ export function Bots() {
|
|||
// only) can recognise list scrollTop=0 and engage the curtain peek.
|
||||
// Icons + click flows work on every platform regardless.
|
||||
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 (
|
||||
<PageNav resizable surface="surfaceVariant">
|
||||
<StreamHeader scrollRef={scrollRef}>
|
||||
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
|
||||
<StreamHeader scrollRef={scrollRef} pinKey="bots">
|
||||
<PageNavContent scrollRef={scrollRef}>
|
||||
<Box
|
||||
direction="Column"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
|
|||
import { useSpace } from '../../../hooks/useSpace';
|
||||
import { PageNav, PageNavContent } from '../../../components/page';
|
||||
import { StreamHeader } from '../../../components/stream-header';
|
||||
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
|
||||
import { ChannelsList } from './ChannelsList';
|
||||
import { ChannelCreateRow } from './ChannelCreateRow';
|
||||
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`.
|
||||
export function ChannelsRootNav() {
|
||||
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 (
|
||||
<PageNav resizable surface="surfaceVariant">
|
||||
<StreamHeader scrollRef={scrollRef}>
|
||||
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
|
||||
{/* 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 />
|
||||
</StreamHeader>
|
||||
</PageNav>
|
||||
|
|
@ -46,6 +56,7 @@ export function ChannelsRootNav() {
|
|||
export function Channels() {
|
||||
const space = useSpace();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inPagerMode = useMobilePagerPane() !== null;
|
||||
|
||||
// Persist URL-driven active space so cold-starts at /channels/ resume on
|
||||
// the same workspace. `useActiveSpace` (in ChannelsLanding) reads the
|
||||
|
|
@ -60,10 +71,11 @@ export function Channels() {
|
|||
}, [space.roomId]);
|
||||
|
||||
return (
|
||||
<PageNav resizable surface="surfaceVariant">
|
||||
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
|
||||
<ChannelsWorkspaceHorseshoe space={space}>
|
||||
<StreamHeader
|
||||
scrollRef={scrollRef}
|
||||
pinKey="channels"
|
||||
bottomPinned={
|
||||
<>
|
||||
<ChannelCreateRow space={space} />
|
||||
|
|
|
|||
|
|
@ -17,14 +17,19 @@ export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
|
|||
// header.
|
||||
//
|
||||
// `marginTop: -var(--vojo-safe-top)` extends the container UP over the
|
||||
// status-bar safe-top zone. Mirror of the same property in
|
||||
// `MobileSettingsHorseshoe.css.ts::container` — without it, the wrapped
|
||||
// StreamHeader's curtain (which positions `top: <negative>` when
|
||||
// dragged past `closed`) is clipped by both `overflow: hidden` here
|
||||
// and `appBody`'s clipPath at the bottom edge of the status bar, so
|
||||
// the lighter `SurfaceVariant.Container` strip from `PageNav-inner`
|
||||
// stays uncovered. The compensating `padding-top` lives on `appBody`
|
||||
// so the wrapped channels list / tabs row stay visually anchored.
|
||||
// status-bar safe-top zone, and the compensating
|
||||
// `paddingTop: var(--vojo-safe-top)` on `appBody` keeps the wrapped
|
||||
// channels list anchored at the same visual Y. Mirror of the same
|
||||
// pair in `MobileSettingsHorseshoe.css.ts::container` — purpose is
|
||||
// (1) the workspace-sheet clip-path mask on `appBody` carves into an
|
||||
// opaque surface that already paints through the status-bar strip,
|
||||
// and (2) `appBody`'s bg paints the safe-top strip in the same
|
||||
// `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({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
// canonical horseshoe and are not re-litigated here.
|
||||
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { channelsWorkspaceSheetAtom } from '../../../state/channelsWorkspaceSheet';
|
||||
|
|
@ -32,6 +32,8 @@ import {
|
|||
useCloseChannelsWorkspaceSheet,
|
||||
useOpenChannelsWorkspaceSheet,
|
||||
} 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 { WorkspaceSwitcherSheet } from './WorkspaceSwitcherSheet';
|
||||
import * as css from './ChannelsWorkspaceHorseshoe.css';
|
||||
|
|
@ -96,6 +98,11 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerHeightPx, setContainerHeightPx] = useState(0);
|
||||
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
|
||||
// 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 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);
|
||||
|
||||
// Refs so the always-installed document listeners see the latest
|
||||
|
|
@ -455,6 +473,14 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
|
|||
clipPath: appBodyClipPath,
|
||||
transition: appBodyTransition,
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
import { StreamHeader } from '../../../components/stream-header';
|
||||
import { DirectSelfRow } from './DirectSelfRow';
|
||||
import { MobileSettingsHorseshoe } from '../../../features/settings';
|
||||
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
|
||||
|
||||
type ListItem =
|
||||
| { kind: 'invite'; entry: DirectInviteEntry }
|
||||
|
|
@ -205,13 +206,22 @@ export function Direct() {
|
|||
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 (
|
||||
<PageNav resizable surface="surfaceVariant">
|
||||
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
|
||||
{/* MobileSettingsHorseshoe wraps the full DM column on mobile so the
|
||||
Settings sheet can carve into the bottom of this pane. On non-mobile
|
||||
it's a pass-through. */}
|
||||
<MobileSettingsHorseshoe>
|
||||
<StreamHeader scrollRef={scrollRef} bottomPinned={<DirectSelfRow />}>
|
||||
<StreamHeader scrollRef={scrollRef} bottomPinned={<DirectSelfRow />} pinKey="direct">
|
||||
{noRoomToDisplay ? (
|
||||
<DirectEmpty />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -20,3 +20,48 @@ export type MobilePagerCurtainControls = {
|
|||
};
|
||||
|
||||
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