feat(stream-header): swap the curtain search-peek for a pull-to-refresh dancing mascot with a radar-pulse ripple
This commit is contained in:
parent
717928154b
commit
901bec2e9a
17 changed files with 517 additions and 420 deletions
|
|
@ -446,6 +446,7 @@
|
|||
"segment_dm": "Direct",
|
||||
"segment_channels": "Channels",
|
||||
"segment_bots": "Robots",
|
||||
"refreshing": "Refreshing…",
|
||||
"self_row_label": "You",
|
||||
"self_row_preview": "Settings & profile",
|
||||
"message_me_label": "me",
|
||||
|
|
|
|||
|
|
@ -448,6 +448,7 @@
|
|||
"segment_dm": "Личные",
|
||||
"segment_channels": "Каналы",
|
||||
"segment_bots": "Роботы",
|
||||
"refreshing": "Обновляем…",
|
||||
"self_row_label": "Я",
|
||||
"self_row_preview": "Настройки и профиль",
|
||||
"message_me_label": "я",
|
||||
|
|
|
|||
BIN
public/res/img/mascot-dance.png
Normal file
BIN
public/res/img/mascot-dance.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
BIN
public/res/img/mascot-dance.webm
Normal file
BIN
public/res/img/mascot-dance.webm
Normal file
Binary file not shown.
|
|
@ -6,7 +6,7 @@ import { color } from 'folds';
|
|||
// horizontally; `align-items: stretch` on the parent fills vertically.
|
||||
//
|
||||
// `touch-action: pan-y` lets the browser keep doing native vertical
|
||||
// scroll (DM list virtualizer, curtain peek pull-down) without us
|
||||
// scroll (DM list virtualizer, curtain pull-to-refresh) without us
|
||||
// having to call preventDefault on every move — only the pager's own
|
||||
// listener calls preventDefault, and only after axis-resolve commits
|
||||
// to "horizontal".
|
||||
|
|
@ -93,7 +93,7 @@ export const pagerRoot = style({
|
|||
// 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
|
||||
// bleed into the mascot/form 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.
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Icon, IconSrc } from 'folds';
|
||||
import * as css from './StreamHeader.css';
|
||||
|
||||
type ChipProps = {
|
||||
iconSrc: IconSrc;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
// When the curtain covers the chip its row is `height: 0` /
|
||||
// `overflow: hidden`. We also flip `tabIndex` so keyboard users
|
||||
// can't focus the invisible button on desktop (where peek-drag is
|
||||
// unavailable). Re-enabled when the row is revealed.
|
||||
hidden: boolean;
|
||||
};
|
||||
|
||||
// Pill-shaped reveal button shown when the user drags the curtain down
|
||||
// to a peek stage. Same geometry as the inline form's input bar so the
|
||||
// transition chip → input feels like a content swap, not a layout move.
|
||||
export function Chip({ iconSrc, label, onClick, hidden }: ChipProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={css.chip}
|
||||
tabIndex={hidden ? -1 : 0}
|
||||
aria-hidden={hidden || undefined}
|
||||
>
|
||||
<Icon size="50" src={iconSrc} />
|
||||
<span className={css.chipPlaceholder}>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
85
src/app/components/stream-header/RefreshMascot.tsx
Normal file
85
src/app/components/stream-header/RefreshMascot.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Text } from 'folds';
|
||||
import { isNativePlatform } from '../../utils/capacitor';
|
||||
|
||||
// Dancing-mascot pull-to-refresh asset — the pink "evil mascot" working
|
||||
// out (dumbbells + radar rings), cut to a seamless 2s loop, background
|
||||
// keyed to alpha, VP9. SEPARATE from the auth `mascot.*` (the purple
|
||||
// idle mascot), which is unchanged.
|
||||
import mascotPoster from '../../../../public/res/img/mascot-dance.png';
|
||||
import mascotWebm from '../../../../public/res/img/mascot-dance.webm';
|
||||
import * as css from './StreamHeader.css';
|
||||
|
||||
type RefreshMascotProps = {
|
||||
// Caption under the mascot («Обновляем…»). Passed by StreamHeader so the
|
||||
// copy stays in the i18n layer rather than baked in here.
|
||||
caption: string;
|
||||
// True while the curtain is revealing — or resting at — the refresh card
|
||||
// (refresh snap, or a live down-drag from closed). Only then do we mount
|
||||
// the looping <video>; otherwise (curtain closed, card z-occluded) we
|
||||
// render the still poster so the decoder isn't running behind the
|
||||
// curtain 24/7. The card is z-covered + aria-hidden at rest, and the
|
||||
// poster is the clip's first frame, so the poster↔video swap is
|
||||
// invisible and the dance starts seamlessly on reveal.
|
||||
revealing: boolean;
|
||||
};
|
||||
|
||||
// The card revealed below the tabs row by the curtain's `refresh` snap:
|
||||
// a looping dancing mascot, painted in the header's `SurfaceVariant.
|
||||
// Container` tone. The mascot dances during the pull-to-refresh; no
|
||||
// caption — the animation is the whole affordance (Claude-mobile-app
|
||||
// style).
|
||||
//
|
||||
// We render a plain `<video>` — NOT the auth `mascotSingleton`
|
||||
// currentTime-preservation machinery, which solves a cold-boot remount
|
||||
// problem this surface doesn't have; the two mascot surfaces stay
|
||||
// decoupled by design.
|
||||
//
|
||||
// Native-only: the curtain pull-to-refresh gesture is native-only (web
|
||||
// has no drag), so the card would never be revealed on web — skip
|
||||
// mounting it there entirely.
|
||||
//
|
||||
// `prefers-reduced-motion`: fall back to the still poster so the reveal
|
||||
// honours the OS motion-reduction setting. (Read once at mount; the rest
|
||||
// of the app reads this query one-shot too, and this card is native-only
|
||||
// + aria-hidden, so a mid-session OS toggle not re-evaluating here is
|
||||
// acceptable.)
|
||||
export function RefreshMascot({ caption, revealing }: RefreshMascotProps) {
|
||||
const native = isNativePlatform();
|
||||
|
||||
const reducedMotion = useMemo(
|
||||
() =>
|
||||
typeof window !== 'undefined' &&
|
||||
!!window.matchMedia &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
[]
|
||||
);
|
||||
|
||||
if (!native) return null;
|
||||
|
||||
const showVideo = revealing && !reducedMotion;
|
||||
|
||||
return (
|
||||
<div className={css.mascotCard} aria-hidden="true">
|
||||
{/* The radar-pulse rings live at the header level (StreamHeader), not
|
||||
here — they break out of this «formal» card and ripple across the
|
||||
whole light-blue chrome. This card holds just the mascot + caption. */}
|
||||
{showVideo ? (
|
||||
<video
|
||||
className={css.mascotMedia}
|
||||
src={mascotWebm}
|
||||
poster={mascotPoster}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<img className={css.mascotMedia} src={mascotPoster} alt="" />
|
||||
)}
|
||||
<Text className={css.mascotCaption} size="T200" priority="300">
|
||||
{caption}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { recipe } from '@vanilla-extract/recipes';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import {
|
||||
CHIP_GAP_PX,
|
||||
CURTAIN_BREATHER_PX,
|
||||
CURTAIN_RADIUS_PX,
|
||||
CURTAIN_SNAP_EASING,
|
||||
CURTAIN_SNAP_MS,
|
||||
HANDLE_HEIGHT_PX,
|
||||
MASCOT_CARD_PX,
|
||||
MASCOT_VIDEO_PX,
|
||||
TABS_ROW_PX,
|
||||
WEB_TABS_ROW_PX,
|
||||
} from './geometry';
|
||||
|
|
@ -36,15 +36,16 @@ export const stage = style({
|
|||
},
|
||||
});
|
||||
|
||||
// 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.
|
||||
// Header — always-rendered strip carrying tabs row + (optional)
|
||||
// mascot-refresh card + (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.
|
||||
// curtain isn't. The mascot card carries its own `SurfaceVariant`
|
||||
// backdrop and the inline form is composed of folds-styled inputs with
|
||||
// their own backgrounds, so the refresh/form snaps stay visually opaque
|
||||
// without this layer.
|
||||
export const header = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
|
@ -133,7 +134,7 @@ export const iconsCluster = style({
|
|||
// rendered position — disabled during drag, restored on commit.
|
||||
//
|
||||
// Web variant (`[data-platform="web"]` on `stage`, set by
|
||||
// StreamHeader.tsx when `!isNativePlatform()`): there is no pin/peek
|
||||
// StreamHeader.tsx when `!isNativePlatform()`): there is no pin/refresh
|
||||
// gesture, so the curtain is a purely static slab under the tabs row.
|
||||
// Drop ONLY the «card» rounding (top corners flat). The divider rule
|
||||
// at the seam is owned by `tabsRow.borderBottom` under the same
|
||||
|
|
@ -158,8 +159,7 @@ export const curtain = style({
|
|||
borderTopLeftRadius: toRem(CURTAIN_RADIUS_PX),
|
||||
borderTopRightRadius: toRem(CURTAIN_RADIUS_PX),
|
||||
transition: `top ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}`,
|
||||
// Hint the compositor while the curtain is moving. Cheap since the
|
||||
// curtain is the only element in this stacking context that animates.
|
||||
// Hint the compositor while the curtain is moving.
|
||||
willChange: 'top',
|
||||
selectors: {
|
||||
'[data-platform="web"] &': {
|
||||
|
|
@ -295,62 +295,93 @@ export const segmentDot = recipe({
|
|||
},
|
||||
});
|
||||
|
||||
// Chip row — outer clip-strip. Both rows reveal together when the
|
||||
// user drags the curtain down to the `peek` snap.
|
||||
//
|
||||
// The `marginBottom` math is load-bearing for the snap-top
|
||||
// calculation: the resting `top` of `peek` lands the curtain exactly
|
||||
// where the next row would have begun, so the breather never "steals"
|
||||
// pixels from the next chip's paddingTop. Two different values:
|
||||
// - default (chip-to-chip): `CHIP_GAP_PX` — tighter, so the two
|
||||
// pills read as a related pair when both are revealed.
|
||||
// - `:last-child` (chip-to-curtain): `CURTAIN_BREATHER_PX` — wider,
|
||||
// so the curtain's rounded top has comfortable air above the
|
||||
// chip pill it lands above.
|
||||
export const chipRow = style({
|
||||
height: toRem(56),
|
||||
marginBottom: toRem(CHIP_GAP_PX),
|
||||
paddingLeft: toRem(24),
|
||||
paddingRight: toRem(24),
|
||||
paddingTop: toRem(8),
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
selectors: {
|
||||
'&:last-child': {
|
||||
marginBottom: toRem(CURTAIN_BREATHER_PX),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The chip pill itself.
|
||||
export const chip = style({
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
// Dancing-mascot pull-to-refresh card. Revealed below the tabs row when
|
||||
// the user drags the curtain down to the `refresh` snap. Its fixed
|
||||
// height equals `MASCOT_CARD_PX`, which the curtain's refresh rest-top
|
||||
// (`TABS_ROW_PX + MASCOT_CARD_PX`) lands exactly on — so the card edge
|
||||
// sits at the curtain's rounded top with the content centred above it.
|
||||
export const mascotCard = style({
|
||||
flexShrink: 0,
|
||||
height: toRem(MASCOT_CARD_PX),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: toRem(10),
|
||||
width: '100%',
|
||||
height: toRem(48),
|
||||
padding: `${toRem(8)} ${toRem(14)}`,
|
||||
borderRadius: toRem(20),
|
||||
font: 'inherit',
|
||||
fontSize: toRem(14),
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: color.Background.Container,
|
||||
color: color.Background.OnContainer,
|
||||
WebkitAppearance: 'none',
|
||||
justifyContent: 'center',
|
||||
gap: toRem(2),
|
||||
// Own opaque backdrop so the refresh reveal stays solid in pager mode,
|
||||
// where the `header` bg collapses to transparent (the transparent
|
||||
// mascot video would otherwise show the static pager header through it).
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
});
|
||||
|
||||
export const chipPlaceholder = style({
|
||||
opacity: 0.65,
|
||||
// Radar-pulse overlay. Spans the visible light-blue surface behind the
|
||||
// curtain — up into the safe-top (lifted in via the negative `top`) and
|
||||
// down to the stage bottom — so the ripple washes across the app chrome.
|
||||
// `zIndex: 1` is over the header chrome but under the curtain (`zIndex: 2`),
|
||||
// which occludes whatever's below its top edge wherever it's dragged.
|
||||
// `pointerEvents: none` + low opacity keep the tabs / mascot readable.
|
||||
// Rendered only while revealed + animated (StreamHeader `showPulse`).
|
||||
const pulseKeyframes = keyframes({
|
||||
'0%': { transform: 'translate(-50%, -50%) scale(0.05)', opacity: 0 },
|
||||
'10%': { opacity: 0.26 },
|
||||
'65%': { opacity: 0.13 },
|
||||
'100%': { transform: 'translate(-50%, -50%) scale(1)', opacity: 0 },
|
||||
});
|
||||
|
||||
export const stagePulseLayer = style({
|
||||
position: 'absolute',
|
||||
top: 'calc(-1 * var(--vojo-safe-top, 0px))',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
export const stagePulse = style({
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
// Origin ≈ the mascot centre. The layer top is lifted by the safe-top, so
|
||||
// add it back: safe-top + tabs row + the card's top pad + half the video
|
||||
// box (≈ TABS_ROW_PX + 53 with the current card geometry).
|
||||
top: `calc(var(--vojo-safe-top, 0px) + ${toRem(TABS_ROW_PX + 53)})`,
|
||||
width: toRem(760),
|
||||
height: toRem(760),
|
||||
borderRadius: '50%',
|
||||
border: `${toRem(2)} solid rgb(248, 80, 160)`,
|
||||
opacity: 0,
|
||||
transform: 'translate(-50%, -50%) scale(0.05)',
|
||||
animationName: pulseKeyframes,
|
||||
animationDuration: '4s',
|
||||
animationTimingFunction: 'ease-out',
|
||||
animationIterationCount: 'infinite',
|
||||
});
|
||||
|
||||
// The looping mascot video (or its still poster under reduced-motion),
|
||||
// `object-fit: contain` so the whole dance frame fits the box. The box is
|
||||
// SMALLER than the card height (geometry.ts), so it's centred with
|
||||
// breathing room and never clipped. `pointerEvents: none` so a drag that
|
||||
// starts on the card flows to the curtain body gesture rather than being
|
||||
// eaten by the media element.
|
||||
export const mascotMedia = style({
|
||||
width: toRem(MASCOT_VIDEO_PX),
|
||||
height: toRem(MASCOT_VIDEO_PX),
|
||||
objectFit: 'contain',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
// Caption under the mascot («Обновляем…»). Muted so the dancing mascot
|
||||
// stays the focus.
|
||||
export const mascotCaption = style({
|
||||
opacity: 0.7,
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
// Active form area in the header. Outer is `position: relative`; the
|
||||
// inner mounted form fills it with `top: 0` so the form's first
|
||||
// element (input bar) sits flush at the line where chips would
|
||||
// otherwise live.
|
||||
// inner mounted form fills it with `top: 0` so the form's input bar
|
||||
// sits flush just below the tabs row.
|
||||
export const formArea = style({
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
|
|
@ -362,14 +393,12 @@ export const formInner = style({
|
|||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
// Narrower side padding than the chip rows (24px) so the search form
|
||||
// uses more of the column width — the inset bar read as «narrow».
|
||||
// 12px side padding insets the search bar a little from the column edge.
|
||||
//
|
||||
// Bottom padding is 0 (not 12 like the top): the form's last child is
|
||||
// Bottom padding is 0 (not 8 like the top): the form's last child is
|
||||
// the scrollable result list, and it must run flush into the curtain's
|
||||
// top edge. Any bottom padding here — like the curtain breather the
|
||||
// form snap deliberately drops (see `snapTopPx`) — would reintroduce a
|
||||
// light-blue strip that clips the search results before they reach the
|
||||
// curtain. The 8px top padding stays: it's the gap below the tabs row.
|
||||
// top edge. Any bottom padding here would reintroduce a light-blue strip
|
||||
// that clips the search results before they reach the curtain. The 8px
|
||||
// top padding stays: it's the gap below the tabs row.
|
||||
padding: `${toRem(8)} ${toRem(12)} 0`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ import React, {
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { useMatch, useNavigate } from 'react-router-dom';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { ClientEvent, SyncState } from 'matrix-js-sdk';
|
||||
import { Box, Icon, IconButton, Icons, toRem } from 'folds';
|
||||
import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
|
||||
import { isNativePlatform } from '../../utils/capacitor';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useBotPresets } from '../../features/bots/catalog';
|
||||
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
|
||||
import {
|
||||
|
|
@ -21,13 +23,13 @@ import {
|
|||
StreamHeaderPrimaryAction,
|
||||
mobilePagerCurtainAtom,
|
||||
} from '../../state/mobilePagerHeader';
|
||||
import { TABS_ROW_PX, WEB_TABS_ROW_PX } from './geometry';
|
||||
import { REFRESH_MAX_MS, REFRESH_MIN_MS, TABS_ROW_PX, WEB_TABS_ROW_PX } from './geometry';
|
||||
import { settingsSheetAtom } from '../../state/settingsSheet';
|
||||
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
|
||||
import * as css from './StreamHeader.css';
|
||||
import { Segment } from './Segment';
|
||||
import { Chip } from './Chip';
|
||||
import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState';
|
||||
import { RefreshMascot } from './RefreshMascot';
|
||||
import { isFormSnap, isRefreshSnap, snapTopPx, useCurtainState } from './useCurtainState';
|
||||
import { useCurtainHandleGesture } from './useCurtainHandleGesture';
|
||||
import { useCurtainBodyGesture } from './useCurtainBodyGesture';
|
||||
import { InlineRoomSearch } from './forms/InlineRoomSearch';
|
||||
|
|
@ -62,8 +64,8 @@ type StreamHeaderProps = {
|
|||
// listing share `"channels"` so pin survives the toggle between
|
||||
// empty state and a chosen workspace.
|
||||
pinKey: string;
|
||||
// Optional contextual create action (a Plus button + peek chip). When
|
||||
// omitted (Direct) the header shows no Plus at all — starting a chat
|
||||
// Optional contextual create action (a Plus button in the tabs row).
|
||||
// When omitted (Direct) the header shows no Plus at all — starting a chat
|
||||
// is folded into search, which finds people via the homeserver user
|
||||
// directory. Channels sets this to «create channel» / «create
|
||||
// community» so its Plus launches that flow.
|
||||
|
|
@ -120,12 +122,8 @@ export function StreamHeader({
|
|||
else navigate(BOTS_PATH, navOpts);
|
||||
}, [selectTabInstant, navigate, navOpts]);
|
||||
|
||||
// Peek reveals a single Search chip by default (Direct — new-chat is
|
||||
// gone, people are found through search), or two when a `primaryAction`
|
||||
// adds the tab's contextual create chip (Channels). The count drives
|
||||
// the curtain's peek travel geometry.
|
||||
const chipRows = primaryAction ? 2 : 1;
|
||||
const curtain = useCurtainState(pinKey, chipRows);
|
||||
const curtain = useCurtainState(pinKey);
|
||||
const mx = useMatrixClient();
|
||||
|
||||
// Suppress every curtain gesture whenever the user is interacting
|
||||
// with something else that would otherwise race the pin path:
|
||||
|
|
@ -153,8 +151,8 @@ export function StreamHeader({
|
|||
// * `useCurtainHandleGesture` — the dedicated 32 px drag-handle
|
||||
// at the top of the curtain. Crisp 1:1 finger ↔ curtain. From
|
||||
// closed the gesture is a free-range drag spanning pin↔closed↔
|
||||
// peek in one motion (`closed-free`); other snaps drive single-
|
||||
// destination transitions (unpin / close-peek / form-close).
|
||||
// refresh in one motion (`closed-free`); other snaps drive single-
|
||||
// destination transitions (unpin / close-refresh / form-close).
|
||||
// Engages regardless of whether the chat list is scrollable —
|
||||
// the handle is a distinct surface and never competes with list
|
||||
// scroll. Only rendered on native (`isNativePlatform()`).
|
||||
|
|
@ -190,7 +188,6 @@ export function StreamHeader({
|
|||
snap: curtain.snap,
|
||||
pinned: curtain.pinned,
|
||||
setPinned: curtain.setPinned,
|
||||
peekTravelPx: curtain.peekTravelPx,
|
||||
setLiveDrag: curtain.setLiveDrag,
|
||||
commit: curtain.commit,
|
||||
disabled: gestureDisabled,
|
||||
|
|
@ -203,7 +200,6 @@ export function StreamHeader({
|
|||
scrollRef,
|
||||
snap: curtain.snap,
|
||||
pinned: curtain.pinned,
|
||||
peekTravelPx: curtain.peekTravelPx,
|
||||
setLiveDrag: curtain.setLiveDrag,
|
||||
commit: curtain.commit,
|
||||
disabled: gestureDisabled,
|
||||
|
|
@ -214,6 +210,97 @@ export function StreamHeader({
|
|||
const openSearch = useCallback(() => curtain.open(), [curtain]);
|
||||
const { close } = curtain;
|
||||
|
||||
// Radar-pulse overlay gate. Native-only (the gesture is), silent under
|
||||
// reduced-motion, and shown whenever the refresh card is revealed — at
|
||||
// the `refresh` snap OR while a drag is in flight (`isDragging`).
|
||||
//
|
||||
// We gate on `isDragging` (a stable boolean held true for the whole drag)
|
||||
// NOT on `liveDragPx > 0`: a held finger micro-jitters around the drag
|
||||
// origin, so `liveDragPx` dithers across 0 every frame. With the old
|
||||
// `liveDragPx > 0` term that flipped `showPulse` true/false each frame,
|
||||
// remounting the pulse layer (and the mascot video) → the rings restarted
|
||||
// over and over ("circle spam") and the curtain rattled. `isDragging`
|
||||
// doesn't flip on the drag delta's sign, so the layer stays mounted.
|
||||
const reducedMotion = useMemo(
|
||||
() =>
|
||||
typeof window !== 'undefined' &&
|
||||
!!window.matchMedia &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
[]
|
||||
);
|
||||
const showPulse =
|
||||
isNativePlatform() && !reducedMotion && (isRefreshSnap(curtain.snap) || curtain.isDragging);
|
||||
|
||||
// Pull-to-refresh side-effect. When the curtain commits to the
|
||||
// `refresh` snap, keep the mascot dancing for at least REFRESH_MIN_MS
|
||||
// (so a near-instant result doesn't flash), capped at REFRESH_MAX_MS
|
||||
// (so an offline client can't strand the curtain open), then auto-close.
|
||||
//
|
||||
// Matrix /sync is a continuous long-poll, so on a HEALTHY client there's
|
||||
// nothing to wait for — the state is already current and the next
|
||||
// `Syncing` event may be up to a full timeout away. So we branch on the
|
||||
// current sync state: healthy (Syncing/Prepared) → the pull is a ~1.2s
|
||||
// delight, state already fresh; otherwise (Error/Reconnecting/Catchup/
|
||||
// cold) → `mx.retryImmediately()` forces the next /sync now and we wait
|
||||
// for the recovery `Syncing`/`Prepared` event (same poke ClientRoot uses
|
||||
// on the `online` event). An early up-drag dismiss (`close-refresh`)
|
||||
// flips `refreshing` false and the cleanup cancels the pending timers.
|
||||
//
|
||||
// The "ready to close" signal (`refreshReady`) is split from the actual
|
||||
// close so the close can DEFER while a drag is in flight. Closing during
|
||||
// a drag would zero `liveDragPx` under the finger while the gesture hook
|
||||
// keeps its own `lastDelta`, snapping the curtain ~MASCOT_CARD_PX on the
|
||||
// next touchmove. The separate close effect below gates on
|
||||
// `!curtain.isDragging`, so a deferred close fires the instant a
|
||||
// mid-refresh retract drag releases (spring-back included — no strand).
|
||||
const refreshing = curtain.snap === 'refresh';
|
||||
const [refreshReady, setRefreshReady] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!refreshing) {
|
||||
setRefreshReady(false);
|
||||
return undefined;
|
||||
}
|
||||
let synced = false;
|
||||
let minElapsed = false;
|
||||
const update = () => {
|
||||
if (synced && minElapsed) setRefreshReady(true);
|
||||
};
|
||||
const state = mx.getSyncState();
|
||||
if (state === SyncState.Syncing || state === SyncState.Prepared) {
|
||||
// Already healthy — state is current; just run the minimum dance.
|
||||
synced = true;
|
||||
} else {
|
||||
// Stuck/cold — force the next /sync and wait for recovery below.
|
||||
mx.retryImmediately();
|
||||
}
|
||||
const onSync = (next: SyncState) => {
|
||||
if (next === SyncState.Syncing || next === SyncState.Prepared) {
|
||||
synced = true;
|
||||
update();
|
||||
}
|
||||
};
|
||||
mx.on(ClientEvent.Sync, onSync);
|
||||
const minTimer = window.setTimeout(() => {
|
||||
minElapsed = true;
|
||||
update();
|
||||
}, REFRESH_MIN_MS);
|
||||
// Hard cap: force-ready even if /sync never recovers (offline client).
|
||||
const maxTimer = window.setTimeout(() => setRefreshReady(true), REFRESH_MAX_MS);
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.Sync, onSync);
|
||||
window.clearTimeout(minTimer);
|
||||
window.clearTimeout(maxTimer);
|
||||
};
|
||||
}, [refreshing, mx]);
|
||||
|
||||
// Close once the dance is ready AND no drag is in flight. Re-runs when
|
||||
// `isDragging` flips, so a close deferred during a mid-refresh retract
|
||||
// fires the moment the finger releases (whether it sprang back or not).
|
||||
const refreshDragging = curtain.isDragging;
|
||||
useEffect(() => {
|
||||
if (refreshing && refreshReady && !refreshDragging) close();
|
||||
}, [refreshing, refreshReady, refreshDragging, close]);
|
||||
|
||||
// Memoised controls object so the cleanup's identity check (atom
|
||||
// compare-and-clear) is meaningful — without useMemo a fresh object
|
||||
// would be created on every render and the cleanup of an earlier
|
||||
|
|
@ -246,7 +333,7 @@ export function StreamHeader({
|
|||
// commit happen in the same render pipeline and there's no
|
||||
// intermediate "snap back, then animate" flash on release.
|
||||
//
|
||||
// When `pinned` is true the local snap (kept at {closed, peek,
|
||||
// When `pinned` is true the local snap (kept at {closed, refresh,
|
||||
// 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
|
||||
|
|
@ -256,7 +343,7 @@ export function StreamHeader({
|
|||
// snap by the delta between native and web tabs-row heights. Tabs
|
||||
// row on web is `WEB_TABS_ROW_PX` (= 54px, matching PageHeader on
|
||||
// the right pane); `snapTopPx` is computed against `TABS_ROW_PX`
|
||||
// (= 64px) which stays authoritative for native pin/peek geometry.
|
||||
// (= 64px) which stays authoritative for native pin/refresh geometry.
|
||||
// Subtracting the delta on web realigns the closed/form snaps with
|
||||
// the smaller tabs row without touching the snap-state machine.
|
||||
// Pinned (= 0) doesn't need the offset because the safe-top + native
|
||||
|
|
@ -264,9 +351,7 @@ export function StreamHeader({
|
|||
const platformOffset = isNativePlatform() ? 0 : WEB_TABS_ROW_PX - TABS_ROW_PX;
|
||||
const curtainTop = curtain.pinned
|
||||
? 0 + curtain.liveDragPx
|
||||
: snapTopPx(curtain.snap, curtain.formHeightPx, curtain.peekTravelPx) +
|
||||
platformOffset +
|
||||
curtain.liveDragPx;
|
||||
: snapTopPx(curtain.snap, curtain.formHeightPx) + platformOffset + curtain.liveDragPx;
|
||||
|
||||
// After the curtain settles at `closed`, unmount any lingering form.
|
||||
// Guarded so unrelated transitionend events (e.g. children's own
|
||||
|
|
@ -435,19 +520,14 @@ export function StreamHeader({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Chips vs form ──────────────────────────────────────
|
||||
Mutually exclusive. While a form is mounted (including the
|
||||
curtain's close-snap window before `acknowledgeClosed`), the
|
||||
chips stay unrendered so the form doesn't visually jump
|
||||
from y = TABS_ROW_PX to y = TABS_ROW_PX + 2·CHIP_ROW_PX
|
||||
mid-animation.
|
||||
|
||||
When chips are rendered: always present in their fixed
|
||||
header positions; the curtain occludes them by z-stacking.
|
||||
As the user drags the curtain down, the chips reveal from
|
||||
underneath naturally. `Chip.hidden` only controls keyboard
|
||||
focus (the chip paints normally; the curtain's z-index does
|
||||
the visual hiding). */}
|
||||
{/* ── Mascot-refresh card vs search form ─────────────────
|
||||
Mutually exclusive. While the search form is mounted
|
||||
(including the curtain's close-snap window before
|
||||
`acknowledgeClosed`), the mascot card stays unrendered so the
|
||||
form doesn't visually jump mid-animation. Otherwise the mascot
|
||||
card sits in its fixed header position below the tabs row and
|
||||
the curtain occludes it by z-stacking; dragging the curtain
|
||||
down to `refresh` reveals it from underneath. */}
|
||||
{curtain.activeForm ? (
|
||||
<div
|
||||
id={INLINE_FORM_ID}
|
||||
|
|
@ -463,32 +543,33 @@ export function StreamHeader({
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={css.chipRow}>
|
||||
<Chip
|
||||
iconSrc={Icons.Search}
|
||||
label={t('Search.search')}
|
||||
onClick={openSearch}
|
||||
hidden={curtain.snap !== 'peek'}
|
||||
/>
|
||||
</div>
|
||||
{/* Second chip is the tab's contextual create action
|
||||
(Channels). Direct has none — its new-chat Plus is gone,
|
||||
so the peek reveals a single Search chip. */}
|
||||
{primaryAction && (
|
||||
<div className={css.chipRow}>
|
||||
<Chip
|
||||
iconSrc={primaryAction.iconSrc}
|
||||
label={primaryAction.label}
|
||||
onClick={primaryAction.onClick}
|
||||
hidden={curtain.snap !== 'peek'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
// Dancing-mascot pull-to-refresh card. Rendered in the header
|
||||
// below the tabs row; the curtain z-occludes it at `closed` and
|
||||
// the drag-down reveals it at the `refresh` snap. The Search /
|
||||
// Channels-create chips that used to live here are gone — search
|
||||
// is reached via the tabs-row icon, Channels-create via its
|
||||
// tabs-row Plus.
|
||||
<RefreshMascot
|
||||
caption={t('Direct.refreshing')}
|
||||
revealing={isRefreshSnap(curtain.snap) || curtain.isDragging}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* ── Radar-pulse overlay ────────────────────────────────
|
||||
Concentric pink rings rippling across the light-blue surface behind
|
||||
the curtain (z:1, the curtain at z:2 occludes whatever's below its
|
||||
top edge). 10 rings staggered across the 4s loop for a dense,
|
||||
continuous ripple; `pointerEvents: none` so taps fall through to the
|
||||
tabs. See `stagePulseLayer`. */}
|
||||
{showPulse && (
|
||||
<div className={css.stagePulseLayer}>
|
||||
{[0, 0.4, 0.8, 1.2, 1.6, 2.0, 2.4, 2.8, 3.2, 3.6].map((delay) => (
|
||||
<span key={delay} className={css.stagePulse} style={{ animationDelay: `${delay}s` }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Curtain layer ─────────────────────────────────────
|
||||
Renders ABOVE the header (z-index higher). `top` combines the
|
||||
snap-derived resting position with the live finger drag — one
|
||||
|
|
@ -513,7 +594,7 @@ export function StreamHeader({
|
|||
sit flush against the curtain's rounded top.
|
||||
|
||||
On native the handle hosts the authoritative curtain
|
||||
gesture (pin / unpin / peek / close-peek / form-close)
|
||||
gesture (pin / unpin / refresh / close-refresh / form-close)
|
||||
and stays mounted across snap transitions so the gesture
|
||||
surface is always reachable when there is one to make.
|
||||
|
||||
|
|
|
|||
|
|
@ -219,8 +219,7 @@ export function InlineRoomSearch({ onClose }: Props) {
|
|||
// resting top and the pre-measure fallback in lockstep.
|
||||
style={{ width: '100%', height: toRem(SEARCH_FORM_BASE_PX - 8) }}
|
||||
>
|
||||
{/* ── Input bar (matches chip geometry: h=48 / r=20 / pad 8/14)
|
||||
so the chip → input morph reads as a content crossfade.
|
||||
{/* ── Input bar (h=48 / r=20 / pad 8/14 pill).
|
||||
`width: 100%` + `minWidth: 0` keep the bar a constant full
|
||||
width whether empty or typed — without it the flex row
|
||||
collapses to its content (the search icon) until text widens
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
// Mental model: the chats card is a curtain layered ABOVE the header
|
||||
// (z-index higher). The curtain's `top` is the visible part of the
|
||||
// header below the always-pinned tabs row. When the curtain is fully
|
||||
// closed it sits flush under the tabs row (covering chips + form area
|
||||
// beneath). Dragging it DOWN reveals more of the header from underneath.
|
||||
// Dragging UP raises the curtain back over the header.
|
||||
// closed it sits flush under the tabs row (covering the mascot card +
|
||||
// form area beneath). Dragging it DOWN reveals more of the header from
|
||||
// underneath. 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
|
||||
|
|
@ -14,8 +14,9 @@
|
|||
// 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
|
||||
// refresh = TABS_ROW_PX + MASCOT_CARD_PX (dancing-mascot pull-to-
|
||||
// refresh card fully revealed; a MOMENTARY snap — entering
|
||||
// it pokes /sync and auto-closes once sync returns)
|
||||
// form:* = TABS_ROW_PX + formHeight (NO breather — the search
|
||||
// results clip flush at the curtain's top edge; the
|
||||
// form's own height already reaches the curtain)
|
||||
|
|
@ -52,41 +53,31 @@ export const TABS_ROW_PX = 64;
|
|||
// 1. CSS `[data-platform="web"] &` selectors on `tabsRow` (height
|
||||
// → `WEB_TABS_ROW_PX`, plus the divider as `borderBottom`).
|
||||
// 2. TSX `platformOffset = WEB_TABS_ROW_PX - TABS_ROW_PX` (= -10)
|
||||
// added to `curtainTop` so the closed / peek / form snaps all
|
||||
// added to `curtainTop` so the closed / refresh / form snaps all
|
||||
// ride the reduced tabs row without recomputing `snapTopPx`.
|
||||
export const WEB_TABS_ROW_PX = 54;
|
||||
|
||||
// Each peek-chip row. Reveals one chip's pill (h=48) + 8px top breather.
|
||||
export const CHIP_ROW_PX = 56;
|
||||
|
||||
// Vertical gap BETWEEN two consecutive chip rows. Separate from
|
||||
// `CURTAIN_BREATHER_PX` so the inter-chip spacing can read tighter
|
||||
// than the breather between the last chip and the curtain's rounded
|
||||
// top (the curtain's straight edge against a chip pill needs more
|
||||
// air to avoid feeling «clamped», while two pills sitting in a
|
||||
// vertical stack want to read as a pair).
|
||||
export const CHIP_GAP_PX = 14;
|
||||
// Dancing-mascot pull-to-refresh card. The curtain's `refresh` snap
|
||||
// rests `MASCOT_CARD_PX` below the tabs row, revealing the card: the
|
||||
// mascot above a short caption («Обновляем…»), stacked in a centred
|
||||
// column.
|
||||
//
|
||||
// The mascot fits ENTIRELY inside the card (no clipping). The dance clip
|
||||
// fills ~88% of its 384² frame (the radar rings reach near the edges, so
|
||||
// there's little transparent margin to crop), so the `MASCOT_VIDEO_PX`
|
||||
// square video box is kept SMALLER than the card and rendered
|
||||
// `object-fit: contain`, centred — displayed mascot ≈ 0.88 ×
|
||||
// MASCOT_VIDEO_PX (≈ 84px). `MASCOT_CARD_PX` budgets the box + caption +
|
||||
// gaps; the frame's ~6% margin adds the breathing room. Painted in the
|
||||
// header's `SurfaceVariant.Container` tone.
|
||||
export const MASCOT_VIDEO_PX = 96;
|
||||
export const MASCOT_CARD_PX = 124;
|
||||
|
||||
// Initial estimate for the search form's outer height. The actual
|
||||
// height is measured at runtime via ResizeObserver and adapts to the
|
||||
// available viewport so the form never overflows the chats card.
|
||||
export const SEARCH_FORM_BASE_PX = 360;
|
||||
|
||||
// Breathing strip between the bottom of the revealed peek chip(s) and
|
||||
// the top of the curtain. Painted by the header's `SurfaceVariant.
|
||||
// Container` (light-blue) so the chip / Create button never visually
|
||||
// touches the curtain's rounded top — the user reads a chip that sits
|
||||
// flush with the curtain as «зажатый» rather than two separate
|
||||
// affordances. Applies to the PEEK snap only (via `peekTravelPx` and
|
||||
// the `chipRow` last-child margin).
|
||||
//
|
||||
// Deliberately NOT applied to the form snap: the search results are a
|
||||
// scroll list and must clip flush at the curtain's top edge — a light-
|
||||
// blue strip there reads as «content cut off early», not breathing room
|
||||
// (see `snapTopPx`'s form-search case). Also not applied at `closed`
|
||||
// (nothing to breathe to).
|
||||
export const CURTAIN_BREATHER_PX = 20;
|
||||
|
||||
// Curtain snap transition. Tuned tight for an in-app reveal —
|
||||
// emphasized-decelerate territory.
|
||||
export const CURTAIN_SNAP_MS = 280;
|
||||
|
|
@ -96,35 +87,31 @@ export const CURTAIN_SNAP_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
|
|||
// horseshoe surfaces elsewhere in the app.
|
||||
export const CURTAIN_RADIUS_PX = 24;
|
||||
|
||||
// Total vertical travel of the curtain between `closed` and `peek` —
|
||||
// the resting-top delta between the two snaps. Used as the basis for
|
||||
// the peek-commit threshold: the user must drag (rubber-banded) at
|
||||
// least COMMIT_THRESHOLD × peekTravel before release for the snap to
|
||||
// flip. Anything shorter reads as accidental and springs back.
|
||||
//
|
||||
// The chip count is DYNAMIC per tab: Direct reveals a single chip
|
||||
// (Search — new-chat is gone, people are found through search), while
|
||||
// Channels reveals two (Search + the contextual create action). So the
|
||||
// peek travel is a function of `chipRows`, computed per StreamHeader
|
||||
// from its actual chip count and threaded into the gesture hooks +
|
||||
// `snapTopPx` so the rest position and the commit scale stay in
|
||||
// lockstep (a mismatch would make the curtain overshoot then retract).
|
||||
export const peekTravelPx = (chipRows: number): number =>
|
||||
chipRows * CHIP_ROW_PX + Math.max(0, chipRows - 1) * CHIP_GAP_PX + CURTAIN_BREATHER_PX;
|
||||
// Curtain displacement (px) the user must drag DOWN from closed before
|
||||
// release for the refresh to commit. Absolute (NOT a fraction of the
|
||||
// card height) so the pull-to-refresh trigger distance stays a
|
||||
// comfortable thumb-pull regardless of how tall the mascot card is.
|
||||
// On the rubber-banded body surface (×RUBBER_BAND) the finger pull
|
||||
// needed to reach this displacement is correspondingly longer.
|
||||
export const REFRESH_COMMIT_PX = 88;
|
||||
|
||||
// Back-compat default (two-chip peek). The live value is computed per
|
||||
// StreamHeader via `peekTravelPx(chipRows)`.
|
||||
export const PEEK_TRAVEL_PX = peekTravelPx(2);
|
||||
// Refresh dance timing. The mascot stays revealed for AT LEAST
|
||||
// `REFRESH_MIN_MS` so a near-instant /sync (healthy online client) reads
|
||||
// as an intentional refresh rather than a flicker; it auto-closes once
|
||||
// /sync returns AND that minimum has elapsed. `REFRESH_MAX_MS` caps the
|
||||
// dance so an offline client (sync never returns) can't strand the
|
||||
// curtain open — it closes regardless after the cap.
|
||||
export const REFRESH_MIN_MS = 1200;
|
||||
export const REFRESH_MAX_MS = 8000;
|
||||
|
||||
// Touch gesture tuning. RUBBER_BAND dampens finger→curtain motion so
|
||||
// the chip reveal feels resistive; COMMIT_THRESHOLD is the fraction of
|
||||
// the full peek travel the user must cross on release for the snap to
|
||||
// commit. Tuned high (≈90%) so anything below «дотянул почти до конца»
|
||||
// reads as accidental and snaps back to `closed`.
|
||||
// Touch gesture tuning. RUBBER_BAND dampens finger→curtain motion on
|
||||
// the body surface so the refresh pull feels resistive (physically
|
||||
// «heavier» than the handle's crisp 1:1). DIRECTION_DEAD_ZONE_PX is
|
||||
// the finger travel before a drag resolves to up/down.
|
||||
// ACTIVE_CLOSE_THRESHOLD_PX is the pull-up distance (raw finger px)
|
||||
// required to close an active form OR dismiss the refresh card early.
|
||||
export const RUBBER_BAND = 0.65;
|
||||
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.
|
||||
|
|
@ -139,21 +126,18 @@ export const PIN_TRAVEL_PX = TABS_ROW_PX;
|
|||
// and springs back to the previous resting snap.
|
||||
//
|
||||
// On the handle the up direction is 1:1 with no upper clamp (the
|
||||
// «closed-free» transition spans the full pin↔closed↔peek range in
|
||||
// «closed-free» transition spans the full pin↔closed↔refresh range in
|
||||
// one gesture and the curtain follows the finger off-screen freely);
|
||||
// the committing curtain DISPLACEMENT is still
|
||||
// `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ 61 px — essentially «drag
|
||||
// the curtain across the full tabs-row height». On the body the same
|
||||
// displacement is reached with a longer finger pull because the body
|
||||
// path is rubber-banded (×0.65).
|
||||
// the committing curtain DISPLACEMENT is `PIN_COMMIT_THRESHOLD ×
|
||||
// PIN_TRAVEL_PX` ≈ 61 px — essentially «drag the curtain across the
|
||||
// full tabs-row height». On the body the same displacement is reached
|
||||
// with a longer finger pull because the body path is rubber-banded
|
||||
// (×0.65).
|
||||
//
|
||||
// Unpin's clamp is asymmetric — `pinned-free` lower-bounds the live
|
||||
// delta at 0 (no destination above pinned) but leaves the upper
|
||||
// direction unclamped so the same gesture can carry the curtain
|
||||
// through closed into peek territory in one motion. The handle-only
|
||||
// contract on unpin means the body never resolves to `pinned-free`,
|
||||
// so the no-upper-clamp tolerance only applies on the dedicated
|
||||
// drag-handle.
|
||||
// `pinned-free` (handle-only) lower-bounds the live delta at 0 (no
|
||||
// destination above pinned) and commits unpin at this threshold. The
|
||||
// body never resolves to `pinned-free` — unpin stays a deliberate
|
||||
// drag-handle pull.
|
||||
export const PIN_COMMIT_THRESHOLD = 0.95;
|
||||
|
||||
// Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the
|
||||
|
|
@ -163,7 +147,7 @@ export const PIN_COMMIT_THRESHOLD = 0.95;
|
|||
// decoration and is omitted entirely.
|
||||
//
|
||||
// On native the handle is the AUTHORITATIVE gesture surface —
|
||||
// closed-free / unpin / close-peek / form-close all bind here with
|
||||
// closed-free / unpin / close-refresh / form-close all bind here with
|
||||
// 1:1 finger ↔ curtain tracking, no matter whether the chat list
|
||||
// inside the curtain is scrollable. See `useCurtainHandleGesture`
|
||||
// for the full state machine.
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { StreamHeader } from './StreamHeader';
|
||||
export { TABS_ROW_PX, CHIP_ROW_PX, CURTAIN_SNAP_MS, CURTAIN_SNAP_EASING } from './geometry';
|
||||
export { TABS_ROW_PX, CURTAIN_SNAP_MS, CURTAIN_SNAP_EASING } from './geometry';
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { MutableRefObject, useEffect, useRef } from 'react';
|
|||
import { isNativePlatform } from '../../utils/capacitor';
|
||||
import {
|
||||
ACTIVE_CLOSE_THRESHOLD_PX,
|
||||
COMMIT_THRESHOLD,
|
||||
DIRECTION_DEAD_ZONE_PX,
|
||||
REFRESH_COMMIT_PX,
|
||||
RUBBER_BAND,
|
||||
} from './geometry';
|
||||
import { CurtainSnap, isFormSnap } from './useCurtainState';
|
||||
|
|
@ -30,7 +30,7 @@ type Args = {
|
|||
// DirectSelfRow, WorkspaceFooter). These rows open their own bottom
|
||||
// sheets via vertical drag, so a touch that starts there must NOT
|
||||
// engage the curtain body — otherwise the
|
||||
// user's «pull settings up» gesture would also pin the curtain
|
||||
// user's «pull settings up» gesture would also drive the curtain
|
||||
// and the two motions would visually fight. `null` is fine (the
|
||||
// surface has no bottomPinned content); the contains() check is
|
||||
// optional-chained.
|
||||
|
|
@ -53,18 +53,14 @@ type Args = {
|
|||
// by the touchstart bail) — pin / unpin commits are the handle's
|
||||
// exclusive contract, see «Direction asymmetry» on the hook.
|
||||
pinned: boolean;
|
||||
// Closed→peek travel for this curtain's chip count (1 chip on Direct,
|
||||
// 2 on Channels). The peek commit threshold scales off this so the
|
||||
// gesture matches the actual rest position. Ref-mirrored like `snap`.
|
||||
peekTravelPx: number;
|
||||
// Live drag delta sink — feeds the curtain's `top` via React state,
|
||||
// no direct DOM writes.
|
||||
setLiveDrag: (px: number, dragging: boolean) => void;
|
||||
// Snap commit. `'peek'` fires from closed-free's down-half;
|
||||
// `'closed'` fires from close-peek and form-close. The pin / unpin
|
||||
// Snap commit. `'refresh'` fires from closed-free's down-half;
|
||||
// `'closed'` fires from close-refresh and form-close. The pin / unpin
|
||||
// paths are handle-only and never flip state through this setter
|
||||
// from the body.
|
||||
commit: (next: 'peek' | 'closed') => void;
|
||||
commit: (next: 'refresh' | 'closed') => void;
|
||||
// Suppress gesture binding entirely. Same conditions as the handle
|
||||
// hook — see StreamHeader's `gestureDisabled`.
|
||||
disabled?: boolean;
|
||||
|
|
@ -82,7 +78,7 @@ type Args = {
|
|||
// Why a second surface? On listing surfaces with content that fits in
|
||||
// one screen (empty Direct / Bots / Channels states, the ChannelsLanding
|
||||
// CTA, a workspace with few rooms) the user's natural «pull the curtain
|
||||
// down to peek» gesture happens anywhere on the visible card.
|
||||
// down to refresh» gesture happens anywhere on the visible card.
|
||||
// Restricting all motion to the 32 px handle on these surfaces felt
|
||||
// artificial. On the other hand, surfaces with a scrollable list need
|
||||
// their native vertical scroll preserved — so the body gesture is
|
||||
|
|
@ -92,8 +88,8 @@ type Args = {
|
|||
//
|
||||
// Direction asymmetry — pinning is handle-only, retracting is shared.
|
||||
// The body engages on:
|
||||
// * closed + DOWN → peek (closed-free, down-half only)
|
||||
// * peek + UP → closed (close-peek)
|
||||
// * closed + DOWN → refresh (closed-free, down-half only)
|
||||
// * refresh + UP → closed (close-refresh)
|
||||
// * form-* + UP → closed (form-close)
|
||||
// The body does NOT engage on:
|
||||
// * closed + UP → would be pin via closed-free's up-half. The
|
||||
|
|
@ -101,26 +97,25 @@ type Args = {
|
|||
// the body made it too easy to accidentally
|
||||
// close the directs/channels/bots header by
|
||||
// pinning. Pin must be a deliberate gesture on
|
||||
// the dedicated pin-handle. After close-peek /
|
||||
// the dedicated pin-handle. After close-refresh /
|
||||
// form-close lands at `closed`, the curtain
|
||||
// can only go further up via the handle.
|
||||
// * pinned + DOWN → unpin / peek-from-pinned. Same rationale: the
|
||||
// pin handle owns the unpin contract too, so an
|
||||
// accidental drag on the visible card can't
|
||||
// undo it. Bailed at touchstart (see Pinned
|
||||
// override below).
|
||||
// * pinned + DOWN → unpin. Same rationale: the pin handle owns the
|
||||
// unpin contract too, so an accidental drag on
|
||||
// the visible card can't undo it. Bailed at
|
||||
// touchstart (see Pinned override below).
|
||||
// The asymmetric block on closed+UP is implemented in onTouchMove
|
||||
// after the transition resolves — we only bail closed-free's UP half,
|
||||
// not every upward drag, so close-peek and form-close still engage on
|
||||
// the body.
|
||||
// not every upward drag, so close-refresh and form-close still engage
|
||||
// on the body.
|
||||
//
|
||||
// Dynamics: all transitions use rubber-band 0.65 (= RUBBER_BAND) so
|
||||
// the body drag feels physically «heavier» than the handle's crisp
|
||||
// 1:1 — the user reads the two surfaces as distinct affordances. The
|
||||
// commit math is expressed in CURTAIN displacement (lastDelta), not
|
||||
// raw finger pull, so a body «commit at COMMIT_THRESHOLD ×
|
||||
// PEEK_TRAVEL_PX» visually matches a handle commit at the same point —
|
||||
// only the finger pull needed to get there differs.
|
||||
// raw finger pull, so a body «commit at REFRESH_COMMIT_PX» visually
|
||||
// matches a handle commit at the same point — only the finger pull
|
||||
// needed to get there differs.
|
||||
//
|
||||
// Form-snap override: when a form is mounted, the chat list under it
|
||||
// is mostly hidden but still in DOM with whatever scrollHeight it has.
|
||||
|
|
@ -141,7 +136,6 @@ export function useCurtainBodyGesture({
|
|||
scrollRef,
|
||||
snap,
|
||||
pinned,
|
||||
peekTravelPx,
|
||||
setLiveDrag,
|
||||
commit,
|
||||
disabled,
|
||||
|
|
@ -151,8 +145,6 @@ export function useCurtainBodyGesture({
|
|||
snapRef.current = snap;
|
||||
const pinnedRef = useRef<boolean>(pinned);
|
||||
pinnedRef.current = pinned;
|
||||
const peekTravelPxRef = useRef<number>(peekTravelPx);
|
||||
peekTravelPxRef.current = peekTravelPx;
|
||||
const commitRef = useRef(commit);
|
||||
commitRef.current = commit;
|
||||
const setHandleStateRef = useRef(setHandleState);
|
||||
|
|
@ -193,7 +185,7 @@ export function useCurtainBodyGesture({
|
|||
if (target && handleRef.current?.contains(target)) return;
|
||||
// Hand off to the bottomPinned region (DirectSelfRow,
|
||||
// WorkspaceFooter). Those rows host their own drag-to-open
|
||||
// bottom sheets — engaging the curtain gesture here would pin
|
||||
// bottom sheets — engaging the curtain gesture here would drive
|
||||
// the curtain in parallel with the sheet opening, and the two
|
||||
// motions would visually fight.
|
||||
if (target && bottomPinnedRef.current?.contains(target)) return;
|
||||
|
|
@ -245,7 +237,7 @@ export function useCurtainBodyGesture({
|
|||
direction = delta > 0 ? 'down' : 'up';
|
||||
transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction);
|
||||
if (transition === null) {
|
||||
// (snap, pinned, direction) has no valid motion — peek+down,
|
||||
// (snap, pinned, direction) has no valid motion — refresh+down,
|
||||
// form+down (pinned+up also resolves to null, though the
|
||||
// touchstart pinned-bail already filters every pinned
|
||||
// gesture before we reach here). Bail without preventDefault
|
||||
|
|
@ -259,8 +251,8 @@ export function useCurtainBodyGesture({
|
|||
// Closed-free UP-half bail. closed-free is the only transition
|
||||
// whose upward direction commits to pin — and pin via body is
|
||||
// exactly what the user banned (see «Direction asymmetry» on
|
||||
// the hook). The downward half (closed → peek) stays on body.
|
||||
// close-peek and form-close are also upward, but their commit
|
||||
// the hook). The downward half (closed → refresh) stays on body.
|
||||
// close-refresh and form-close are also upward, but their commit
|
||||
// target is `closed` — they're the «retract» gestures the user
|
||||
// explicitly wants to keep on the body, so they pass through.
|
||||
if (transition === 'closed-free' && direction === 'up') {
|
||||
|
|
@ -292,16 +284,16 @@ export function useCurtainBodyGesture({
|
|||
// want exposed on the body. Rubber-banded 0.65×
|
||||
// displacement matches the «physically heavier» body feel.
|
||||
lastDelta = Math.max(0, delta * RUBBER_BAND);
|
||||
atCommit = lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD;
|
||||
atCommit = lastDelta >= REFRESH_COMMIT_PX;
|
||||
break;
|
||||
case 'close-peek':
|
||||
case 'close-refresh':
|
||||
// Rubber-banded up. No clamp either side — matches the
|
||||
// original list-bound peek feel; a downward jitter past the
|
||||
// peek snap is visually negligible against the rubber-band
|
||||
// damping. Commit target is `closed`; no path into pin
|
||||
// original list-bound retract feel; a downward jitter past
|
||||
// the refresh snap is visually negligible against the rubber-
|
||||
// band damping. Commit target is `closed`; no path into pin
|
||||
// territory (the user's hard rule — pin is handle-only).
|
||||
lastDelta = delta * RUBBER_BAND;
|
||||
atCommit = -lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD;
|
||||
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
|
||||
break;
|
||||
case 'form-close':
|
||||
// Rubber-banded up; capped at 0 so an accidental downward
|
||||
|
|
@ -342,18 +334,18 @@ export function useCurtainBodyGesture({
|
|||
}
|
||||
switch (transition) {
|
||||
case 'closed-free':
|
||||
// Body is DOWN-only — peek is the sole commit target. Pin
|
||||
// Body is DOWN-only — refresh is the sole commit target. Pin
|
||||
// commit lives on the handle (see «Direction asymmetry»
|
||||
// above and the touchmove switch). Below threshold the
|
||||
// curtain springs back to closed.
|
||||
if (lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) {
|
||||
commitRef.current('peek');
|
||||
if (lastDelta >= REFRESH_COMMIT_PX) {
|
||||
commitRef.current('refresh');
|
||||
} else {
|
||||
setLiveDrag(0, false);
|
||||
}
|
||||
break;
|
||||
case 'close-peek':
|
||||
if (-lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) {
|
||||
case 'close-refresh':
|
||||
if (-lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX) {
|
||||
commitRef.current('closed');
|
||||
} else {
|
||||
setLiveDrag(0, false);
|
||||
|
|
|
|||
|
|
@ -2,23 +2,23 @@ import { MutableRefObject, useEffect, useRef } from 'react';
|
|||
import { isNativePlatform } from '../../utils/capacitor';
|
||||
import {
|
||||
ACTIVE_CLOSE_THRESHOLD_PX,
|
||||
COMMIT_THRESHOLD,
|
||||
DIRECTION_DEAD_ZONE_PX,
|
||||
PIN_COMMIT_THRESHOLD,
|
||||
PIN_TRAVEL_PX,
|
||||
REFRESH_COMMIT_PX,
|
||||
} from './geometry';
|
||||
import { CurtainSnap, isFormSnap } from './useCurtainState';
|
||||
import { CurtainSnap, isFormSnap, isRefreshSnap } from './useCurtainState';
|
||||
|
||||
type Args = {
|
||||
// Drag-handle element at the top of the curtain. ALL curtain
|
||||
// gestures bind here — the chat list's scroll viewport is left to
|
||||
// native vertical scroll so finger-down inside the list never races
|
||||
// a pin / peek / form-close path. Mounted as the first flex child
|
||||
// a pin / refresh / form-close path. Mounted as the first flex child
|
||||
// of the curtain in StreamHeader.tsx.
|
||||
handleRef: MutableRefObject<HTMLDivElement | null>;
|
||||
// Current snap stop. Mirrored into a ref so the listener (bound
|
||||
// once per `disabled` flip) reads fresh values without re-attaching.
|
||||
// Every snap participates: closed → pin/peek, peek → close-peek,
|
||||
// Every snap participates: closed → pin/refresh, refresh → close,
|
||||
// form-* → form-close, pinned-overlay → unpin.
|
||||
snap: CurtainSnap;
|
||||
// Per-pane pinned overlay. When true the handle's drag-down path
|
||||
|
|
@ -27,19 +27,15 @@ type Args = {
|
|||
// Setter for the pinned overlay; called on release once the user's
|
||||
// drag past the commit threshold qualifies the gesture.
|
||||
setPinned: (next: boolean) => void;
|
||||
// Closed→peek travel for this curtain's chip count (1 chip on Direct,
|
||||
// 2 on Channels). The peek commit threshold scales off this so the
|
||||
// gesture matches the actual rest position. Ref-mirrored like `snap`.
|
||||
peekTravelPx: number;
|
||||
// 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.
|
||||
setLiveDrag: (px: number, dragging: boolean) => void;
|
||||
// Snap commit. Called on release for peek / close-peek / form-close
|
||||
// Snap commit. Called on release for refresh / close / form-close
|
||||
// (the pin / unpin paths flip `pinned` instead). Narrowed to the
|
||||
// two non-form destinations the hook ever reaches. Also resets
|
||||
// liveDragPx + isDragging atomically inside the parent state.
|
||||
commit: (next: 'peek' | 'closed') => void;
|
||||
commit: (next: 'refresh' | 'closed') => void;
|
||||
// Suppress gesture binding entirely. Used to gate motion when a
|
||||
// bottom sheet is open or when this pane is inactive inside the
|
||||
// swipe pager.
|
||||
|
|
@ -60,28 +56,24 @@ type Args = {
|
|||
// and `useCurtainBodyGesture` for the rubber-banded equivalents.
|
||||
//
|
||||
// `closed-free` is the single free-range transition that spans the
|
||||
// full pin↔closed↔peek vertical range in one gesture. From the closed
|
||||
// snap, neither direction is locked at the dead-zone: the user can
|
||||
// drag up past the safe-top zone OR down through the chip area in
|
||||
// one motion, and the release decides pin / peek / snap-back based
|
||||
// full pin↔closed↔refresh vertical range in one gesture. From the
|
||||
// closed snap, neither direction is locked at the dead-zone: the user
|
||||
// can drag up past the safe-top zone OR down into the refresh card in
|
||||
// one motion, and the release decides pin / refresh / snap-back based
|
||||
// on the final position. The earlier pair of one-shot `pin` and
|
||||
// `peek` transitions used a hard «gate» at the start point (each
|
||||
// `refresh` transitions used a hard «gate» at the start point (each
|
||||
// direction was clamped to one side of 0 once the dead-zone resolved
|
||||
// the direction) and the user reported this as a regression — drag
|
||||
// up, then back down, ran into an invisible wall at the closed
|
||||
// position before peek could engage.
|
||||
// position before the down reveal could engage.
|
||||
//
|
||||
// `pinned-free` is the symmetric free-range transition for the
|
||||
// pinned overlay: from pinned + drag DOWN the curtain follows the
|
||||
// finger all the way through closed into peek territory in one
|
||||
// motion. On release, peek wins if the finger crossed the absolute
|
||||
// peek planka (PIN_TRAVEL_PX + COMMIT_THRESHOLD × PEEK_TRAVEL_PX —
|
||||
// the same visual point peek commits at from closed-free), unpin
|
||||
// wins if at least the unpin threshold was reached, otherwise snap
|
||||
// back to pinned. UP is no-op (no destination above pinned). Only
|
||||
// the handle resolves to `pinned-free` — the body gesture bails at
|
||||
// touchstart while pinned so unpin remains a deliberate handle pull.
|
||||
export type CurtainTransition = 'closed-free' | 'pinned-free' | 'close-peek' | 'form-close';
|
||||
// `pinned-free` is the free-range DOWN transition for the pinned
|
||||
// overlay: from pinned + drag DOWN the curtain follows the finger and
|
||||
// commits unpin (back to closed) past the threshold. UP is no-op (no
|
||||
// destination above pinned). Only the handle resolves to `pinned-free`
|
||||
// — the body gesture bails at touchstart while pinned so unpin remains
|
||||
// a deliberate handle pull.
|
||||
export type CurtainTransition = 'closed-free' | 'pinned-free' | 'close-refresh' | 'form-close';
|
||||
|
||||
// Exhaustive-check helper. Used in the `default` branch of every
|
||||
// switch over `CurtainTransition | null` so that adding a fifth
|
||||
|
|
@ -101,16 +93,15 @@ export const assertNeverCurtainTransition = (_value: never): void => {};
|
|||
// * pinned + UP → no-op (would push the curtain past safe-top
|
||||
// on commit — no destination above pinned).
|
||||
// * pinned + DOWN → pinned-free (HANDLE-only contract — the body
|
||||
// hook bails entirely while pinned so unpin /
|
||||
// peek-from-pinned stays a deliberate handle
|
||||
// pull. See
|
||||
// hook bails entirely while pinned so unpin
|
||||
// stays a deliberate handle pull. See
|
||||
// `useCurtainBodyGesture::onTouchStart`).
|
||||
// * closed (any) → closed-free (single transition spanning the
|
||||
// whole pin↔closed↔peek range; direction at
|
||||
// whole pin↔closed↔refresh range; direction at
|
||||
// the dead-zone matters only for the
|
||||
// horizontal-bail check).
|
||||
// * peek + UP → close-peek (retreat to closed).
|
||||
// * peek + DOWN → no-op (nothing lower to reveal).
|
||||
// * refresh + UP → close-refresh (retreat to closed).
|
||||
// * refresh + DOWN → no-op (nothing lower to reveal).
|
||||
// * form-* + UP → form-close.
|
||||
// * form-* + DOWN → no-op (form is already the lowest snap).
|
||||
export function resolveCurtainTransition(
|
||||
|
|
@ -120,7 +111,7 @@ export function resolveCurtainTransition(
|
|||
): CurtainTransition | null {
|
||||
if (pinned) return direction === 'down' ? 'pinned-free' : null;
|
||||
if (snap === 'closed') return 'closed-free';
|
||||
if (snap === 'peek') return direction === 'up' ? 'close-peek' : null;
|
||||
if (isRefreshSnap(snap)) return direction === 'up' ? 'close-refresh' : null;
|
||||
if (isFormSnap(snap)) return direction === 'up' ? 'form-close' : null;
|
||||
return null;
|
||||
}
|
||||
|
|
@ -130,7 +121,7 @@ export function resolveCurtainTransition(
|
|||
// desktop.
|
||||
//
|
||||
// The handle is the «authoritative» gesture surface — it owns every
|
||||
// transition (closed-free, pinned-free, close-peek, form-close)
|
||||
// transition (closed-free, pinned-free, close-refresh, form-close)
|
||||
// with crisp 1:1 finger ↔ curtain tracking regardless of whether
|
||||
// the chat list inside the curtain is scrollable. The curtain BODY
|
||||
// has a parallel gesture (`useCurtainBodyGesture`) with rubber-
|
||||
|
|
@ -138,12 +129,11 @@ export function resolveCurtainTransition(
|
|||
// no scrollable content — so the user can pull the curtain «from
|
||||
// anywhere» on empty / short lists but a real list-scroll is never
|
||||
// hijacked under their finger. The body is also fully inert while
|
||||
// pinned, so unpin (and unpin → peek overshoot) stays a deliberate
|
||||
// handle pull.
|
||||
// pinned, so unpin stays a deliberate handle pull.
|
||||
//
|
||||
// Design rationale: gestures used to bind to the chat list's scroll
|
||||
// viewport directly, which produced repeating «drag-at-scrollTop=0
|
||||
// hijacks for pin/peek» bugs. Moving every transition onto a
|
||||
// hijacks for pin/reveal» bugs. Moving every transition onto a
|
||||
// dedicated handle (plus an opt-in body surface that bails on
|
||||
// scrollable lists) removes the scroll/gesture race entirely.
|
||||
//
|
||||
|
|
@ -154,25 +144,21 @@ export function resolveCurtainTransition(
|
|||
// * closed-free — NO clamps either side. Finger goes off-
|
||||
// screen up → curtain follows past safe-top;
|
||||
// finger crosses back below the start point →
|
||||
// curtain continues into peek territory in
|
||||
// curtain continues into the refresh card in
|
||||
// the same gesture. Direction-aware commit
|
||||
// on release: pin if pulled UP past
|
||||
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, peek
|
||||
// if pulled DOWN past COMMIT_THRESHOLD ×
|
||||
// PEEK_TRAVEL_PX, else snap back to closed.
|
||||
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, refresh
|
||||
// if pulled DOWN past REFRESH_COMMIT_PX, else
|
||||
// snap back to closed.
|
||||
// * pinned-free — DOWN-only free-range drag from pinned.
|
||||
// Clamped at 0 below (no destination above
|
||||
// pinned), NO upper clamp — the finger can
|
||||
// carry the curtain through closed into
|
||||
// peek territory in one motion. Release
|
||||
// decides peek (lastDelta ≥ PIN_TRAVEL_PX +
|
||||
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX), unpin
|
||||
// (lastDelta ≥ PIN_COMMIT_THRESHOLD ×
|
||||
// PIN_TRAVEL_PX), or snap back to pinned.
|
||||
// * close-peek — capped at 0 below (no transition lower
|
||||
// than peek), NO upper clamp (drag past
|
||||
// pinned). Release commits unpin past
|
||||
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, else
|
||||
// snaps back to pinned.
|
||||
// * close-refresh — capped at 0 below (no transition lower than
|
||||
// the refresh card), NO upper clamp (drag past
|
||||
// closed into safe-top freely). Commit at
|
||||
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX.
|
||||
// ACTIVE_CLOSE_THRESHOLD_PX (absolute).
|
||||
// * form-close — capped at 0 so a downward jitter can't
|
||||
// push the curtain below its form-snap top,
|
||||
// NO upper clamp. Commit at
|
||||
|
|
@ -190,7 +176,6 @@ export function useCurtainHandleGesture({
|
|||
snap,
|
||||
pinned,
|
||||
setPinned,
|
||||
peekTravelPx,
|
||||
setLiveDrag,
|
||||
commit,
|
||||
disabled,
|
||||
|
|
@ -200,8 +185,6 @@ export function useCurtainHandleGesture({
|
|||
snapRef.current = snap;
|
||||
const pinnedRef = useRef<boolean>(pinned);
|
||||
pinnedRef.current = pinned;
|
||||
const peekTravelPxRef = useRef<number>(peekTravelPx);
|
||||
peekTravelPxRef.current = peekTravelPx;
|
||||
const setPinnedRef = useRef(setPinned);
|
||||
setPinnedRef.current = setPinned;
|
||||
const commitRef = useRef(commit);
|
||||
|
|
@ -276,8 +259,8 @@ export function useCurtainHandleGesture({
|
|||
direction = delta > 0 ? 'down' : 'up';
|
||||
transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction);
|
||||
// (snap, pinned, direction) has no valid motion — pinned+up,
|
||||
// peek+down, form+down. Bail so the gesture can be re-armed on
|
||||
// the next touch sequence; no preventDefault is fired so the
|
||||
// refresh+down, form+down. Bail so the gesture can be re-armed
|
||||
// on the next touch sequence; no preventDefault is fired so the
|
||||
// browser keeps any default behaviour (it would be a no-op
|
||||
// here anyway — the handle has touchAction:none in CSS).
|
||||
if (transition === null) {
|
||||
|
|
@ -297,42 +280,37 @@ export function useCurtainHandleGesture({
|
|||
let atCommit = false;
|
||||
switch (transition) {
|
||||
case 'closed-free':
|
||||
// Single free-range drag spanning pin↔closed↔peek. 1:1 with
|
||||
// NO clamps either side: the curtain follows the finger off-
|
||||
// screen upward (past safe-top) and continuously into peek
|
||||
// territory downward in the same gesture. The release decides
|
||||
// pin / peek / snap-back from the final lastDelta.
|
||||
// Single free-range drag spanning pin↔closed↔refresh. 1:1
|
||||
// with NO clamps either side: the curtain follows the finger
|
||||
// off-screen upward (past safe-top) and continuously into the
|
||||
// refresh card downward in the same gesture. The release
|
||||
// decides pin / refresh / snap-back from the final lastDelta.
|
||||
lastDelta = delta;
|
||||
// Direction-aware atCommit so the grabber pill stretches
|
||||
// whichever way the user is committing. Pin and peek are
|
||||
// whichever way the user is committing. Pin and refresh are
|
||||
// sign-exclusive (one branch can't fire simultaneously with
|
||||
// the other) so a simple ternary on `lastDelta` suffices.
|
||||
atCommit =
|
||||
lastDelta <= 0
|
||||
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
|
||||
: lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD;
|
||||
: lastDelta >= REFRESH_COMMIT_PX;
|
||||
break;
|
||||
case 'pinned-free':
|
||||
// 1:1 down from pinned. Clamped at 0 below (a downward
|
||||
// jitter past the start mustn't push the curtain into
|
||||
// safe-top — there's no destination above pinned), NO
|
||||
// upper clamp — the curtain follows the finger through
|
||||
// closed into peek territory in one motion.
|
||||
// safe-top — there's no destination above pinned). Release
|
||||
// commits unpin past the threshold.
|
||||
lastDelta = Math.max(0, delta);
|
||||
// atCommit fires as soon as ANY commit qualifies (the
|
||||
// grabber pill stretches to signal «release works here»);
|
||||
// it stays true past the unpin threshold all the way
|
||||
// through peek, since both are valid landing zones.
|
||||
atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
|
||||
break;
|
||||
case 'close-peek':
|
||||
case 'close-refresh':
|
||||
// 1:1 up; delta is negative. Lower-capped at 0 (a downward
|
||||
// jitter shouldn't push past the peek snap), NO upper clamp
|
||||
// jitter shouldn't push past the refresh snap), NO upper clamp
|
||||
// — the curtain follows the finger off-screen freely in the
|
||||
// safe-top direction, matching the «drag up off-screen from
|
||||
// anywhere» expectation.
|
||||
lastDelta = Math.min(0, delta);
|
||||
atCommit = -lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD;
|
||||
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
|
||||
break;
|
||||
case 'form-close':
|
||||
// Form close: finger moves UP (delta < 0). Track 1:1, capped
|
||||
|
|
@ -375,42 +353,28 @@ export function useCurtainHandleGesture({
|
|||
switch (transition) {
|
||||
case 'closed-free':
|
||||
// Direction-aware commit from the free-range drag. Pin
|
||||
// wins over peek if both somehow qualified (sign-exclusive
|
||||
// wins over refresh if both somehow qualified (sign-exclusive
|
||||
// in practice — lastDelta can't be simultaneously <0 and
|
||||
// >0). Below either threshold, spring back to closed.
|
||||
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
|
||||
setPinnedRef.current(true);
|
||||
} else if (lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) {
|
||||
commitRef.current('peek');
|
||||
} else if (lastDelta >= REFRESH_COMMIT_PX) {
|
||||
commitRef.current('refresh');
|
||||
} else {
|
||||
setLiveDrag(0, false);
|
||||
}
|
||||
break;
|
||||
case 'pinned-free':
|
||||
// Two-tier commit: peek wins if the finger crossed the
|
||||
// absolute peek planka (matches the visual point peek
|
||||
// commits at from closed-free — PIN_TRAVEL_PX to get to
|
||||
// closed + COMMIT_THRESHOLD × PEEK_TRAVEL_PX through the
|
||||
// chip area); otherwise unpin if at least the unpin
|
||||
// threshold was reached; else snap back to pinned.
|
||||
//
|
||||
// The peek branch MUST clear `pinned` before committing
|
||||
// the snap. The curtain's resting top is
|
||||
// `pinned ? 0 : snapTopPx(snap)` — so commit('peek')
|
||||
// alone would set snap='peek' yet leave the curtain
|
||||
// visually at top=0 (the pin overlay wins). Both updates
|
||||
// batch into one render inside this touchend handler.
|
||||
if (lastDelta >= PIN_TRAVEL_PX + COMMIT_THRESHOLD * peekTravelPxRef.current) {
|
||||
setPinnedRef.current(false);
|
||||
commitRef.current('peek');
|
||||
} else if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
|
||||
// Unpin if the finger crossed the unpin threshold; else snap
|
||||
// back to pinned.
|
||||
if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
|
||||
setPinnedRef.current(false);
|
||||
} else {
|
||||
setLiveDrag(0, false);
|
||||
}
|
||||
break;
|
||||
case 'close-peek':
|
||||
if (-lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) {
|
||||
case 'close-refresh':
|
||||
if (-lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX) {
|
||||
commitRef.current('closed');
|
||||
} else {
|
||||
setLiveDrag(0, false);
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader';
|
||||
import { CURTAIN_SNAP_MS, peekTravelPx, SEARCH_FORM_BASE_PX, TABS_ROW_PX } from './geometry';
|
||||
import { CURTAIN_SNAP_MS, MASCOT_CARD_PX, SEARCH_FORM_BASE_PX, TABS_ROW_PX } from './geometry';
|
||||
|
||||
// Discrete snap stops for the curtain. The curtain's resting `top`
|
||||
// is derived from this value plus the live finger drag delta. There
|
||||
// is no separate «active vs peek» mode flag — the snap encodes both.
|
||||
// is no separate «active vs reveal» mode flag — the snap encodes both.
|
||||
export type CurtainSnap =
|
||||
| 'closed' // curtain flush under tabs row; nothing peeking
|
||||
| 'peek' // action chip(s) revealed (Search, plus the tab's create action on Channels)
|
||||
| 'closed' // curtain flush under tabs row; nothing revealed
|
||||
| 'refresh' // dancing-mascot pull-to-refresh card revealed (momentary)
|
||||
| 'form-search'; // full search form revealed
|
||||
|
||||
export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' => snap === 'form-search';
|
||||
|
||||
export const isPeekSnap = (snap: CurtainSnap): snap is 'peek' => snap === 'peek';
|
||||
export const isRefreshSnap = (snap: CurtainSnap): snap is 'refresh' => snap === 'refresh';
|
||||
|
||||
// Whether a form is currently rendered in the header. Stays set during
|
||||
// the curtain's close transition so the form has content to slide
|
||||
// behind; cleared by `acknowledgeClosed` after the snap settles at
|
||||
// `closed`. There is only one form now (search) — new-chat was folded
|
||||
// `closed`. There is only one form (search) — new-chat was folded
|
||||
// into search's People section — so this is a simple on/off flag.
|
||||
export type ActiveForm = 'search' | null;
|
||||
|
||||
|
|
@ -37,8 +37,8 @@ export type CurtainState = {
|
|||
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
|
||||
// in flight. Positive = finger pulled down (peek reveal); negative =
|
||||
// finger pulled up (close gesture).
|
||||
// in flight. Positive = finger pulled down (refresh reveal); negative
|
||||
// = finger pulled up (close gesture).
|
||||
liveDragPx: number;
|
||||
// True while a touch gesture is in flight. Controls the curtain's
|
||||
// `top` transition: disabled while dragging (curtain tracks finger
|
||||
|
|
@ -52,10 +52,6 @@ export type CurtainState = {
|
|||
// watches this to feed `formHeightPx`. Consumer attaches it to the
|
||||
// form's wrapping element.
|
||||
formMeasureRef: React.RefObject<HTMLDivElement>;
|
||||
// Closed→peek travel for this curtain's chip count. Threaded into the
|
||||
// gesture hooks (commit scale) and `snapTopPx` (rest position) so the
|
||||
// two stay in lockstep.
|
||||
peekTravelPx: number;
|
||||
// Open the search form. Sets `snap` and `activeForm` synchronously.
|
||||
open: () => void;
|
||||
// Close the curtain (raise it back to `closed`). Keeps `activeForm`
|
||||
|
|
@ -65,9 +61,9 @@ export type CurtainState = {
|
|||
// Commit a snap stop directly. Used by the touch gesture on release.
|
||||
// Also resets `liveDragPx` and `isDragging` in one batched update.
|
||||
// Narrowed to the two non-form destinations the gesture hooks ever
|
||||
// reach — peek-reveal and close. Form snaps are entered through
|
||||
// reach — refresh-reveal and close. Form snaps are entered through
|
||||
// `open()` which sets `activeForm` synchronously alongside the snap.
|
||||
commit: (next: 'peek' | 'closed') => void;
|
||||
commit: (next: 'refresh' | 'closed') => void;
|
||||
// Setter for the live drag delta — called from the touch gesture on
|
||||
// every touchmove. Updates are batched by React inside event handlers.
|
||||
setLiveDrag: (px: number, dragging: boolean) => void;
|
||||
|
|
@ -77,29 +73,26 @@ export type CurtainState = {
|
|||
acknowledgeClosed: () => void;
|
||||
};
|
||||
|
||||
// Resting `top` (px) of the curtain for a given snap stop, the
|
||||
// currently measured form height (null falls back to the base), and
|
||||
// the chip-count-derived peek travel (`peekTravel`). The peek rest
|
||||
// lands exactly `peekTravel` below the tabs row so the curtain's edge
|
||||
// sits on the boundary below the revealed chip(s) — no «next chip pill
|
||||
// peeking through».
|
||||
// Resting `top` (px) of the curtain for a given snap stop and the
|
||||
// currently measured form height (null falls back to the base).
|
||||
//
|
||||
// The form rests at exactly `TABS_ROW_PX + formHeight` — NO breather
|
||||
// (unlike peek). The search results are a scroll list, and any light-
|
||||
// blue strip between the last visible result and the curtain's top
|
||||
// reads as «content cut off early» rather than as breathing room. So
|
||||
// the form's measured height already reaches the curtain top: the list
|
||||
// clips flush at the curtain's edge. `SEARCH_FORM_BASE_PX` is the
|
||||
// pre-measure fallback and is sized (= the form's full outer height)
|
||||
// so it equals the measured `formH`, avoiding any open-time top jump.
|
||||
// The chip/peek breather lives separately in `peekTravel` and is
|
||||
// untouched.
|
||||
export function snapTopPx(snap: CurtainSnap, formH: number | null, peekTravel: number): number {
|
||||
// The refresh snap rests exactly `MASCOT_CARD_PX` below the tabs row so
|
||||
// the dancing-mascot card is fully revealed below the boundary.
|
||||
//
|
||||
// The form rests at exactly `TABS_ROW_PX + formHeight` — the search
|
||||
// results are a scroll list, and any strip between the last visible
|
||||
// result and the curtain's top reads as «content cut off early» rather
|
||||
// than as breathing room. So the form's measured height already reaches
|
||||
// the curtain top: the list clips flush at the curtain's edge.
|
||||
// `SEARCH_FORM_BASE_PX` is the pre-measure fallback and is sized (= the
|
||||
// form's full outer height) so it equals the measured `formH`, avoiding
|
||||
// any open-time top jump.
|
||||
export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
|
||||
switch (snap) {
|
||||
case 'closed':
|
||||
return TABS_ROW_PX;
|
||||
case 'peek':
|
||||
return TABS_ROW_PX + peekTravel;
|
||||
case 'refresh':
|
||||
return TABS_ROW_PX + MASCOT_CARD_PX;
|
||||
case 'form-search':
|
||||
return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX);
|
||||
default:
|
||||
|
|
@ -107,11 +100,7 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null, peekTravel: n
|
|||
}
|
||||
}
|
||||
|
||||
// `chipRows` = how many peek chips this curtain reveals (1 on Direct =
|
||||
// Search only; 2 on Channels = Search + the contextual create action).
|
||||
// Drives the peek travel so the rest position and gesture commit scale
|
||||
// match the actual chip stack.
|
||||
export function useCurtainState(pinKey: string, chipRows: number): 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);
|
||||
|
|
@ -124,8 +113,6 @@ export function useCurtainState(pinKey: string, chipRows: number): CurtainState
|
|||
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
|
||||
const pinned = !!pinnedMap[pinKey];
|
||||
|
||||
const peekTravel = peekTravelPx(chipRows);
|
||||
|
||||
const formMeasureRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const setPinned = useCallback(
|
||||
|
|
@ -151,8 +138,8 @@ export function useCurtainState(pinKey: string, chipRows: number): CurtainState
|
|||
setLiveDragPx(0);
|
||||
setIsDragging(false);
|
||||
// Safety net: clear pin so the form is visible. In practice the
|
||||
// visible openers (static pager header icons, in-pane chips on
|
||||
// non-pager surfaces) are all covered by the curtain when pinned,
|
||||
// visible opener (the tabs-row Search icon, via the per-pane header
|
||||
// or the static pager header) is covered by the curtain when pinned,
|
||||
// so the user can't trigger this directly — but a future
|
||||
// programmatic open() would otherwise mount the form behind the
|
||||
// still-pinned curtain at curtainTop=0 and present an invisible
|
||||
|
|
@ -166,14 +153,21 @@ export function useCurtainState(pinKey: string, chipRows: number): CurtainState
|
|||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const commit = useCallback((next: 'peek' | 'closed') => {
|
||||
const commit = useCallback((next: 'refresh' | 'closed') => {
|
||||
setSnap(next);
|
||||
setLiveDragPx(0);
|
||||
setIsDragging(false);
|
||||
// `activeForm` is intentionally NOT cleared here — it stays set
|
||||
// so the closing transition has form content beneath the curtain
|
||||
// as it slides up. `acknowledgeClosed` clears it once the snap
|
||||
// settles at `closed`.
|
||||
// A 'refresh' commit reveals the mascot, so drop any lingering form.
|
||||
// Without this, a fast close→pull-to-refresh landed inside the form's
|
||||
// ~280–480ms close-slide window (before `acknowledgeClosed`/the safety
|
||||
// timer clear `activeForm`) would leave `activeForm` set, and the
|
||||
// header's `activeForm ? form : mascot` gate would render the closing
|
||||
// search form in place of the mascot during the dance.
|
||||
//
|
||||
// The 'closed' destination intentionally KEEPS `activeForm` set so the
|
||||
// closing transition has form content beneath the curtain as it slides
|
||||
// up — `acknowledgeClosed` clears it once the snap settles at `closed`.
|
||||
if (next === 'refresh') setActiveForm(null);
|
||||
}, []);
|
||||
|
||||
const setLiveDrag = useCallback((px: number, dragging: boolean) => {
|
||||
|
|
@ -233,7 +227,6 @@ export function useCurtainState(pinKey: string, chipRows: number): CurtainState
|
|||
isDragging,
|
||||
formHeightPx,
|
||||
formMeasureRef,
|
||||
peekTravelPx: peekTravel,
|
||||
open,
|
||||
close,
|
||||
commit,
|
||||
|
|
@ -248,7 +241,6 @@ export function useCurtainState(pinKey: string, chipRows: number): CurtainState
|
|||
liveDragPx,
|
||||
isDragging,
|
||||
formHeightPx,
|
||||
peekTravel,
|
||||
open,
|
||||
close,
|
||||
commit,
|
||||
|
|
|
|||
|
|
@ -569,8 +569,8 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
// 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
|
||||
// up through every transparent pixel of the mascot/form band
|
||||
// between the static header and the curtain top (refresh/form
|
||||
// snaps), turning the strip black.
|
||||
//
|
||||
// The static-header z-elevation in `MobileTabsPagerHeader`
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { IconSrc } from 'folds';
|
|||
|
||||
// Per-tab «primary action» published by a pane's StreamHeader. When
|
||||
// present it replaces the default Plus / «new chat» button in the
|
||||
// static header (and the matching peek-chip in the per-pane StreamHeader).
|
||||
// static header (and renders the matching Plus in the per-pane
|
||||
// StreamHeader's tabs row).
|
||||
// Channels uses this to surface «create channel» (inside a workspace)
|
||||
// or «create community» (on the landing) instead of the DM-creation
|
||||
// path that Direct keeps as default.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue