feat(mobile-tabs-pager): swipe between Direct, Channels and Bots on Capacitor native with static header, 24px gap, atom-bridged action icons and inert offscreen panes
This commit is contained in:
parent
727a53a776
commit
870e13d895
17 changed files with 1342 additions and 139 deletions
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
// Set by MobileTabsPager around each of its three listing panes. Lets
|
||||||
|
// the StreamHeader inside the pane discover (a) that it's mounted in
|
||||||
|
// pager mode at all, and (b) whether it's the currently active pane.
|
||||||
|
//
|
||||||
|
// "Mounted in pager mode" controls whether the per-pane tabs row
|
||||||
|
// renders visibly — when we're in pager mode the tabs row is hidden
|
||||||
|
// via visibility:hidden (the shared static header at pager root paints
|
||||||
|
// the visible tabs + icons), but the row still occupies its
|
||||||
|
// TABS_ROW_PX height so the curtain's snap geometry is unchanged.
|
||||||
|
//
|
||||||
|
// "isActive" controls which pane's curtain is wired to the shared
|
||||||
|
// static header's action icons via `mobilePagerCurtainAtom`.
|
||||||
|
export type MobilePagerPaneInfo = {
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MobilePagerPaneContext = createContext<MobilePagerPaneInfo | null>(null);
|
||||||
|
|
||||||
|
export const MobilePagerPaneProvider = MobilePagerPaneContext.Provider;
|
||||||
|
|
||||||
|
export function useMobilePagerPane(): MobilePagerPaneInfo | null {
|
||||||
|
return useContext(MobilePagerPaneContext);
|
||||||
|
}
|
||||||
48
src/app/components/mobile-tabs-pager/MobileTabsLayout.tsx
Normal file
48
src/app/components/mobile-tabs-pager/MobileTabsLayout.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Outlet, useMatch } from 'react-router-dom';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
|
import { isNativePlatform } from '../../utils/capacitor';
|
||||||
|
import { BOTS_PATH, CHANNELS_PATH, CHANNELS_SPACE_PATH, DIRECT_PATH } from '../../pages/paths';
|
||||||
|
import { MobileTabsPager } from './MobileTabsPager';
|
||||||
|
|
||||||
|
// Router-level wrapper around the three listing tabs (/direct/,
|
||||||
|
// /channels/, /bots/). When all of (mobile breakpoint, Capacitor
|
||||||
|
// native runtime, listing-root URL) hold, we hijack rendering and
|
||||||
|
// mount `MobileTabsPager` directly — the wrapped routes' Outlet is
|
||||||
|
// never read, so their `element` chains stay unmounted. Anywhere else
|
||||||
|
// — non-mobile breakpoints (tablet, desktop), non-Capacitor runtimes
|
||||||
|
// (mobile web, Electron desktop), AND detail URLs nested under any
|
||||||
|
// listing root (/direct/!roomId, /channels/!space/!roomId,
|
||||||
|
// /bots/:botId) — we pass through to `<Outlet/>` and the existing
|
||||||
|
// route tree renders unchanged.
|
||||||
|
//
|
||||||
|
// Channels has TWO listing-root URLs that both activate the pager on
|
||||||
|
// the Channels tab:
|
||||||
|
//
|
||||||
|
// * `/channels/` — landing (empty-state CTA when the user has no
|
||||||
|
// orphan spaces; otherwise pager-internal render of the active
|
||||||
|
// workspace via the persisted active-space resolver).
|
||||||
|
// * `/channels/!space/` — workspace listing for that specific space.
|
||||||
|
// This is what `commitTo('channels')` actually navigates to when
|
||||||
|
// an active space is known, so the user lands directly on the
|
||||||
|
// workspace view without bouncing through `/channels/` and the
|
||||||
|
// `ChannelsLanding` <Navigate> redirect (which previously cut the
|
||||||
|
// pager animation short and made the swipe feel jerky).
|
||||||
|
//
|
||||||
|
// Detail URLs nested under either (`/channels/!space/!roomId`, etc.)
|
||||||
|
// flip both matches to false and fall through to <Outlet/> — Room
|
||||||
|
// renders full-screen on mobile as before.
|
||||||
|
export function MobileTabsLayout() {
|
||||||
|
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||||
|
const native = isNativePlatform();
|
||||||
|
const directRoot = !!useMatch({ path: DIRECT_PATH, end: true });
|
||||||
|
const channelsRoot = !!useMatch({ path: CHANNELS_PATH, end: true });
|
||||||
|
const channelsSpaceRoot = !!useMatch({ path: CHANNELS_SPACE_PATH, end: true });
|
||||||
|
const botsRoot = !!useMatch({ path: BOTS_PATH, end: true });
|
||||||
|
const onListingRoot = directRoot || channelsRoot || channelsSpaceRoot || botsRoot;
|
||||||
|
|
||||||
|
if (!(mobile && native) || !onListingRoot) {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
return <MobileTabsPager />;
|
||||||
|
}
|
||||||
389
src/app/components/mobile-tabs-pager/MobileTabsPager.tsx
Normal file
389
src/app/components/mobile-tabs-pager/MobileTabsPager.tsx
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Outlet, useMatch, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { BOTS_PATH, CHANNELS_PATH, CHANNELS_SPACE_PATH, DIRECT_PATH } from '../../pages/paths';
|
||||||
|
import { useBotPresets } from '../../features/bots/catalog';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
|
import { useOrphanSpaces } from '../../state/hooks/roomList';
|
||||||
|
import {
|
||||||
|
getCanonicalAliasOrRoomId,
|
||||||
|
getCanonicalAliasRoomId,
|
||||||
|
isRoomAlias,
|
||||||
|
} from '../../utils/matrix';
|
||||||
|
import { getChannelsSpacePath } from '../../pages/pathUtils';
|
||||||
|
import { SpaceProvider } from '../../hooks/useSpace';
|
||||||
|
import { Direct } from '../../pages/client/direct';
|
||||||
|
import { Channels, ChannelsRootNav, useActiveSpace } from '../../pages/client/channels';
|
||||||
|
import { Bots } from '../../pages/client/bots';
|
||||||
|
import { ChannelsModeProvider } from '../../hooks/useChannelsMode';
|
||||||
|
import { settingsSheetAtom } from '../../state/settingsSheet';
|
||||||
|
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
|
||||||
|
import { MobilePagerPaneProvider } from './MobilePagerPaneContext';
|
||||||
|
import { MobileTabsPagerHeader } from './MobileTabsPagerHeader';
|
||||||
|
import { useMobileTabsPagerGesture } from './useMobileTabsPagerGesture';
|
||||||
|
import { PAGER_EASING, PAGER_TRANSITION_MS, PANE_GAP_PX } from './geometry';
|
||||||
|
import * as css from './style.css';
|
||||||
|
|
||||||
|
type Tab = 'direct' | 'channels' | 'bots';
|
||||||
|
|
||||||
|
// URL-safe wrapper around decodeURIComponent — matches the same helper
|
||||||
|
// inside `useActiveSpace`. Used here to validate the URL `:spaceIdOrAlias`
|
||||||
|
// param before we decide whether to mount the pager or defer to the
|
||||||
|
// existing route tree's JoinBeforeNavigate fallback.
|
||||||
|
const safeDecode = (raw: string): string | undefined => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(raw);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type PaneSlotProps = {
|
||||||
|
isActive: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wraps a pane's DOM box and toggles the `inert` HTMLElement property
|
||||||
|
// based on the active flag, plus mirrors it into `aria-hidden`.
|
||||||
|
//
|
||||||
|
// `inert` removes the off-screen pane subtree from focus order, click
|
||||||
|
// handling, and the accessibility tree — important so assistive tech
|
||||||
|
// (and stray keyboard focus on devices with hardware keyboards) can't
|
||||||
|
// reach controls that are visually translateX'd out of the viewport.
|
||||||
|
// `aria-hidden` is the long-supported half of the same intent and
|
||||||
|
// alone covers most screen readers. Both are applied for full
|
||||||
|
// portability across AT/browser combinations.
|
||||||
|
//
|
||||||
|
// `inert` is assigned via ref (not as a JSX attr) because React 18.2's
|
||||||
|
// HTMLAttributes typing doesn't include it. The underlying DOM
|
||||||
|
// property is supported by Chromium 102+ / Safari 15.5+ which covers
|
||||||
|
// Capacitor's WebView baseline.
|
||||||
|
function PaneSlot({ isActive, children }: PaneSlotProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) ref.current.inert = !isActive;
|
||||||
|
}, [isActive]);
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={css.pane} aria-hidden={!isActive || undefined}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile + Capacitor horizontal swipe pager. Mounts all three listing
|
||||||
|
// surfaces once, slides between them via CSS transform.
|
||||||
|
//
|
||||||
|
// Visual layout decomposes into a STATIC overlay header at the top
|
||||||
|
// (segments + action icons, painted by `MobileTabsPagerHeader`) and a
|
||||||
|
// translating strip below it. Each pane's StreamHeader still renders
|
||||||
|
// its own tabs row but `visibility: hidden` in pager mode — kept in
|
||||||
|
// DOM only so the curtain's TABS_ROW_PX-based snap geometry is
|
||||||
|
// preserved. Action icons in the static header proxy through
|
||||||
|
// `mobilePagerCurtainAtom`, written by whichever pane is active.
|
||||||
|
//
|
||||||
|
// Channels tab specifics: the pane content depends on whether the
|
||||||
|
// user has at least one joined orphan space. If yes, we render
|
||||||
|
// `<Channels>` (workspace listing) keyed to the active space; if no,
|
||||||
|
// `<ChannelsRootNav>` paints the empty-state CTA. `commitTo('channels')`
|
||||||
|
// navigates to /channels/!{spaceId}/ when an active space is known so
|
||||||
|
// the swipe never bounces through /channels/ + the ChannelsLanding
|
||||||
|
// `<Navigate>` redirect (which previously cut the slide animation
|
||||||
|
// short and made the gesture feel jerky).
|
||||||
|
//
|
||||||
|
// Inter-pane gap: `PANE_GAP_PX` is inserted between adjacent panes
|
||||||
|
// via inline `gap` on the strip. The pagerRoot's SurfaceVariant
|
||||||
|
// backdrop shows through the gap during a swipe, matching the user
|
||||||
|
// request for a light-blue divider colour identical to the header.
|
||||||
|
//
|
||||||
|
// Invalid space URL fall-through: if the URL is `/channels/:alias/`
|
||||||
|
// but `:alias` doesn't resolve to a joined orphan space (deep-link
|
||||||
|
// to a workspace the user isn't in, or a typo), the pager bails out
|
||||||
|
// and renders `<Outlet/>` instead. That delegates to the existing
|
||||||
|
// `/channels/!space/` route element whose `RouteSpaceProvider` shows
|
||||||
|
// `JoinBeforeNavigate`. Without this guard, `useActiveSpace` would
|
||||||
|
// silently fall back to the persisted-or-first-orphan space and the
|
||||||
|
// pager would show a DIFFERENT workspace than the URL claims —
|
||||||
|
// confusing the user and breaking deep-link semantics.
|
||||||
|
export function MobileTabsPager() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const bots = useBotPresets();
|
||||||
|
|
||||||
|
// `end: true` matches the listing-root URL exactly. Detail URLs
|
||||||
|
// (/channels/!space/!room/, /direct/!room, /bots/:botId) flip these
|
||||||
|
// to false — and MobileTabsLayout above us would have rendered
|
||||||
|
// Outlet instead of the pager in that case, so we never see those
|
||||||
|
// states. Channels is matched via EITHER /channels/ (landing) OR
|
||||||
|
// /channels/!space/ (workspace listing) — both keep us on the
|
||||||
|
// channels tab.
|
||||||
|
const channelsRoot = !!useMatch({ path: CHANNELS_PATH, end: true });
|
||||||
|
const channelsSpaceMatch = useMatch({ path: CHANNELS_SPACE_PATH, end: true });
|
||||||
|
const channelsSpaceRoot = !!channelsSpaceMatch;
|
||||||
|
const channelsActive = channelsRoot || channelsSpaceRoot;
|
||||||
|
const botsRoot = !!useMatch({ path: BOTS_PATH, end: true });
|
||||||
|
|
||||||
|
// Active space resolution mirrors ChannelsLanding: URL > localStorage
|
||||||
|
// > first joined orphan. `useOrphanSpaces` filters `allRoomsAtom`
|
||||||
|
// through `isSpace(mx.getRoom) && !roomToParents.has(...)`, so every
|
||||||
|
// entry it returns is BOTH (a) a Space the user has joined and (b)
|
||||||
|
// an orphan (no parent Space). `useActiveSpace` then constrains its
|
||||||
|
// result to that orphan set. Net invariant: if `activeSpaceId` is
|
||||||
|
// defined, `mx.getRoom(activeSpaceId)` is a Space the user is
|
||||||
|
// currently a member of — which is exactly the precondition
|
||||||
|
// `RouteSpaceProvider` would otherwise enforce via
|
||||||
|
// `joinedSpaces.includes(space.roomId)` before mounting Channels.
|
||||||
|
// That's why the pager can mount Channels with a plain
|
||||||
|
// `<SpaceProvider value={...}>` and skip RouteSpaceProvider's
|
||||||
|
// JoinBeforeNavigate fallback path safely — for VALID URL spaces.
|
||||||
|
// Invalid URL spaces hit the early-return below.
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const orphanSpaceIds = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
|
||||||
|
const activeSpaceId = useActiveSpace(orphanSpaceIds);
|
||||||
|
const activeSpace = activeSpaceId ? mx.getRoom(activeSpaceId) : null;
|
||||||
|
|
||||||
|
// Validate the URL `:spaceIdOrAlias` param when on the workspace
|
||||||
|
// route. If the param exists but doesn't resolve to a joined orphan
|
||||||
|
// (deep-link to an unjoined / unknown space), we'll defer to the
|
||||||
|
// original route tree below instead of silently substituting another
|
||||||
|
// workspace.
|
||||||
|
const urlSpaceParam = channelsSpaceMatch?.params.spaceIdOrAlias;
|
||||||
|
const urlSpaceIsValid = useMemo(() => {
|
||||||
|
if (!urlSpaceParam) return true;
|
||||||
|
const decoded = safeDecode(urlSpaceParam);
|
||||||
|
if (!decoded) return false;
|
||||||
|
const resolved = isRoomAlias(decoded) ? getCanonicalAliasRoomId(mx, decoded) : decoded;
|
||||||
|
return resolved !== undefined && orphanSpaceIds.includes(resolved);
|
||||||
|
}, [mx, urlSpaceParam, orphanSpaceIds]);
|
||||||
|
|
||||||
|
const showBots = bots.length > 0 || botsRoot;
|
||||||
|
|
||||||
|
const tabs = useMemo<Tab[]>(() => {
|
||||||
|
const list: Tab[] = ['direct', 'channels'];
|
||||||
|
if (showBots) list.push('bots');
|
||||||
|
return list;
|
||||||
|
}, [showBots]);
|
||||||
|
|
||||||
|
const urlActiveIdx = useMemo(() => {
|
||||||
|
if (botsRoot) {
|
||||||
|
const i = tabs.indexOf('bots');
|
||||||
|
return i >= 0 ? i : 0;
|
||||||
|
}
|
||||||
|
if (channelsActive) {
|
||||||
|
const i = tabs.indexOf('channels');
|
||||||
|
return i >= 0 ? i : 0;
|
||||||
|
}
|
||||||
|
const i = tabs.indexOf('direct');
|
||||||
|
return i >= 0 ? i : 0;
|
||||||
|
}, [tabs, channelsActive, botsRoot]);
|
||||||
|
|
||||||
|
const [dragPx, setDragPxState] = useState(0);
|
||||||
|
const [dragging, setDraggingState] = useState(false);
|
||||||
|
// Stored as a Tab NAME, not an index. The `tabs` array's length and
|
||||||
|
// composition can change at runtime (showBots flipping when the user
|
||||||
|
// navigates onto/off /bots/, or when a bot-config refresh adds a
|
||||||
|
// bot) — a stored index could end up pointing at the wrong tab or
|
||||||
|
// off the end of the array. A stable name lets us re-derive the
|
||||||
|
// index on every render via `tabs.indexOf(...)`.
|
||||||
|
//
|
||||||
|
// Load-bearing: react-router-dom v6's `useNavigate` does NOT auto-
|
||||||
|
// wrap in `React.startTransition` and its router-state update
|
||||||
|
// (`useSyncExternalStore`-backed) is asynchronous relative to React
|
||||||
|
// 18's auto-batching of setState in the same event handler. Without
|
||||||
|
// this pending lock, the commit path `setDragPx(0); setDragging
|
||||||
|
// (false); navigate(...)` can land in two renders — first with
|
||||||
|
// dragPx=0 at the OLD urlActiveIdx (strip snaps back to source tab),
|
||||||
|
// then with the NEW urlActiveIdx (strip animates to target). That
|
||||||
|
// two-stage flicker is exactly the "jerk on release" the user
|
||||||
|
// reported. See
|
||||||
|
// https://github.com/remix-run/react-router/issues/11003 for the
|
||||||
|
// upstream non-batching discussion. Do NOT remove without measuring.
|
||||||
|
// Cleared once the URL catches up (or after a safety timeout — see
|
||||||
|
// the effect below).
|
||||||
|
const [pendingTargetTab, setPendingTargetTab] = useState<Tab | null>(null);
|
||||||
|
|
||||||
|
const setDragPx = useCallback((px: number, drag: boolean) => {
|
||||||
|
setDragPxState(px);
|
||||||
|
setDraggingState(drag);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const destinationFor = useCallback(
|
||||||
|
(tab: Tab): string => {
|
||||||
|
if (tab === 'direct') return DIRECT_PATH;
|
||||||
|
if (tab === 'bots') return BOTS_PATH;
|
||||||
|
if (activeSpaceId) {
|
||||||
|
const alias = getCanonicalAliasOrRoomId(mx, activeSpaceId);
|
||||||
|
return getChannelsSpacePath(alias);
|
||||||
|
}
|
||||||
|
return CHANNELS_PATH;
|
||||||
|
},
|
||||||
|
[mx, activeSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitTo = useCallback(
|
||||||
|
(idx: number) => {
|
||||||
|
const target = tabs[idx];
|
||||||
|
if (!target) return;
|
||||||
|
setDragPxState(0);
|
||||||
|
setDraggingState(false);
|
||||||
|
setPendingTargetTab(target);
|
||||||
|
navigate(destinationFor(target), { replace: true });
|
||||||
|
},
|
||||||
|
[tabs, navigate, destinationFor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSelectDirect = useCallback(() => {
|
||||||
|
const i = tabs.indexOf('direct');
|
||||||
|
if (i >= 0) commitTo(i);
|
||||||
|
}, [tabs, commitTo]);
|
||||||
|
const onSelectChannels = useCallback(() => {
|
||||||
|
const i = tabs.indexOf('channels');
|
||||||
|
if (i >= 0) commitTo(i);
|
||||||
|
}, [tabs, commitTo]);
|
||||||
|
const onSelectBots = useCallback(() => {
|
||||||
|
const i = tabs.indexOf('bots');
|
||||||
|
if (i >= 0) commitTo(i);
|
||||||
|
}, [tabs, commitTo]);
|
||||||
|
|
||||||
|
const pendingTargetIdx = pendingTargetTab !== null ? tabs.indexOf(pendingTargetTab) : -1;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingTargetTab === null) return undefined;
|
||||||
|
// Tab disappeared from the array mid-animation (e.g. /bots/ deep-
|
||||||
|
// link held the Bots tab visible, the user committed to Direct,
|
||||||
|
// and during the slide the bot config became empty so showBots
|
||||||
|
// flipped to false). The stored target no longer maps to any
|
||||||
|
// index — clear immediately so visualIdx falls back to urlActive.
|
||||||
|
if (pendingTargetIdx === -1) {
|
||||||
|
setPendingTargetTab(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (pendingTargetIdx === urlActiveIdx) {
|
||||||
|
setPendingTargetTab(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const id = window.setTimeout(() => setPendingTargetTab(null), PAGER_TRANSITION_MS + 100);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [pendingTargetTab, pendingTargetIdx, urlActiveIdx]);
|
||||||
|
|
||||||
|
const visualIdx = pendingTargetIdx >= 0 ? pendingTargetIdx : urlActiveIdx;
|
||||||
|
const visualDragPx = pendingTargetTab !== null ? 0 : dragPx;
|
||||||
|
|
||||||
|
// Suppress the pager gesture while ANY of:
|
||||||
|
// 1. A horseshoe sheet is open (Settings or workspace switcher).
|
||||||
|
// A horizontal swipe on the sheet body, or on the still-
|
||||||
|
// visible listing above the sheet, would steer the pager into
|
||||||
|
// a sibling tab and unmount the sheet's host.
|
||||||
|
// 2. A commit-slide animation is in flight (pendingTargetTab set).
|
||||||
|
// Starting a new gesture during the 280ms transition would
|
||||||
|
// either jump (because visualDragPx is forced to 0) or commit
|
||||||
|
// relative to a stale urlActiveIdx — same UX hazard React
|
||||||
|
// Navigation's TabView avoids by locking gestures during
|
||||||
|
// transitions.
|
||||||
|
const settingsSheetOpen = !!useAtomValue(settingsSheetAtom);
|
||||||
|
const workspaceSheetOpen = !!useAtomValue(channelsWorkspaceSheetAtom);
|
||||||
|
const gestureDisabled = settingsSheetOpen || workspaceSheetOpen || pendingTargetTab !== null;
|
||||||
|
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
useMobileTabsPagerGesture({
|
||||||
|
rootRef,
|
||||||
|
activeIdx: urlActiveIdx,
|
||||||
|
tabsCount: tabs.length,
|
||||||
|
disabled: gestureDisabled,
|
||||||
|
setDragPx,
|
||||||
|
commitTo,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gap-aware strip transform. Each adjacent pane is offset by an
|
||||||
|
// extra `PANE_GAP_PX` so a swipe past the gap zone exposes the
|
||||||
|
// pagerRoot backdrop colour, matching the static header tone.
|
||||||
|
// Memoised so the inline object identity is stable when dragPx
|
||||||
|
// doesn't change — avoids extra child re-renders when other state
|
||||||
|
// updates (e.g. atom subscription) tick the parent.
|
||||||
|
const stripStyle = useMemo<React.CSSProperties>(
|
||||||
|
() => ({
|
||||||
|
width: `calc(${tabs.length * 100}vw + ${(tabs.length - 1) * PANE_GAP_PX}px)`,
|
||||||
|
transform: `translate3d(calc(${-visualIdx * 100}vw - ${
|
||||||
|
visualIdx * PANE_GAP_PX
|
||||||
|
}px + ${visualDragPx}px), 0, 0)`,
|
||||||
|
transition: dragging ? 'none' : `transform ${PAGER_TRANSITION_MS}ms ${PAGER_EASING}`,
|
||||||
|
gap: `${PANE_GAP_PX}px`,
|
||||||
|
}),
|
||||||
|
[tabs.length, visualIdx, visualDragPx, dragging]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-pane context values memoised separately so each pane's
|
||||||
|
// `useMobilePagerPane()` consumer (the inner StreamHeader) only
|
||||||
|
// re-runs when ITS isActive flag toggles, not every time the parent
|
||||||
|
// re-renders (e.g. on every touchmove during a drag). Without this,
|
||||||
|
// a fresh `{ isActive: bool }` object per render would tick every
|
||||||
|
// pane's context subscription at 60Hz during a swipe.
|
||||||
|
const directIdx = useMemo(() => tabs.indexOf('direct'), [tabs]);
|
||||||
|
const channelsIdx = useMemo(() => tabs.indexOf('channels'), [tabs]);
|
||||||
|
const botsIdx = useMemo(() => tabs.indexOf('bots'), [tabs]);
|
||||||
|
const directPaneInfo = useMemo(
|
||||||
|
() => ({ isActive: urlActiveIdx === directIdx }),
|
||||||
|
[urlActiveIdx, directIdx]
|
||||||
|
);
|
||||||
|
const channelsPaneInfo = useMemo(
|
||||||
|
() => ({ isActive: urlActiveIdx === channelsIdx }),
|
||||||
|
[urlActiveIdx, channelsIdx]
|
||||||
|
);
|
||||||
|
const botsPaneInfo = useMemo(
|
||||||
|
() => ({ isActive: urlActiveIdx === botsIdx }),
|
||||||
|
[urlActiveIdx, botsIdx]
|
||||||
|
);
|
||||||
|
|
||||||
|
// The static header doesn't need useMatch of its own — `urlActiveIdx`
|
||||||
|
// is already the authoritative source of truth for which tab is
|
||||||
|
// active. Map it back to a Tab name and pass down.
|
||||||
|
const activeTab: Tab = tabs[urlActiveIdx] ?? 'direct';
|
||||||
|
|
||||||
|
// Invalid URL space — defer to the existing route tree which
|
||||||
|
// handles unjoined / unknown spaces via JoinBeforeNavigate. All
|
||||||
|
// hooks above must run unconditionally for rules-of-hooks
|
||||||
|
// compliance; this early-return is the first conditional render.
|
||||||
|
if (channelsSpaceRoot && !urlSpaceIsValid) {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className={css.pagerRoot}>
|
||||||
|
<MobileTabsPagerHeader
|
||||||
|
showBots={showBots}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onSelectDirect={onSelectDirect}
|
||||||
|
onSelectChannels={onSelectChannels}
|
||||||
|
onSelectBots={onSelectBots}
|
||||||
|
/>
|
||||||
|
<div className={css.strip} style={stripStyle}>
|
||||||
|
<MobilePagerPaneProvider value={directPaneInfo}>
|
||||||
|
<PaneSlot isActive={directPaneInfo.isActive}>
|
||||||
|
<Direct />
|
||||||
|
</PaneSlot>
|
||||||
|
</MobilePagerPaneProvider>
|
||||||
|
<MobilePagerPaneProvider value={channelsPaneInfo}>
|
||||||
|
<PaneSlot isActive={channelsPaneInfo.isActive}>
|
||||||
|
<ChannelsModeProvider value>
|
||||||
|
{activeSpace ? (
|
||||||
|
<SpaceProvider key={activeSpace.roomId} value={activeSpace}>
|
||||||
|
<Channels />
|
||||||
|
</SpaceProvider>
|
||||||
|
) : (
|
||||||
|
<ChannelsRootNav />
|
||||||
|
)}
|
||||||
|
</ChannelsModeProvider>
|
||||||
|
</PaneSlot>
|
||||||
|
</MobilePagerPaneProvider>
|
||||||
|
{showBots && (
|
||||||
|
<MobilePagerPaneProvider value={botsPaneInfo}>
|
||||||
|
<PaneSlot isActive={botsPaneInfo.isActive}>
|
||||||
|
<Bots />
|
||||||
|
</PaneSlot>
|
||||||
|
</MobilePagerPaneProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx
Normal file
146
src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
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 { Segment } from '../stream-header/Segment';
|
||||||
|
import * as streamHeaderCss from '../stream-header/StreamHeader.css';
|
||||||
|
import * as css from './style.css';
|
||||||
|
|
||||||
|
type Tab = 'direct' | 'channels' | 'bots';
|
||||||
|
|
||||||
|
// Must match the `INLINE_FORM_ID` local constant in
|
||||||
|
// `StreamHeader.tsx`. The shared static header's action icons are
|
||||||
|
// `aria-controls`-linked to the form region that the active pane's
|
||||||
|
// StreamHeader renders inside its curtain — mirroring the original
|
||||||
|
// in-pane buttons' ARIA semantics so assistive tech still announces
|
||||||
|
// the relationship correctly. Keep the two literals in lockstep.
|
||||||
|
const INLINE_FORM_ID = 'stream-header-inline-form';
|
||||||
|
|
||||||
|
type MobileTabsPagerHeaderProps = {
|
||||||
|
showBots: boolean;
|
||||||
|
// Active tab name resolved by the parent pager from the URL. We
|
||||||
|
// accept it as a prop rather than re-running `useMatch` here — the
|
||||||
|
// pager already knows the answer and passing it down keeps the
|
||||||
|
// segment highlight in lock-step with the strip's visual position
|
||||||
|
// (one source of truth = `urlActiveIdx`).
|
||||||
|
activeTab: Tab;
|
||||||
|
onSelectDirect: () => void;
|
||||||
|
onSelectChannels: () => void;
|
||||||
|
onSelectBots: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static shared tabs row painted at the top of MobileTabsPager. Lives
|
||||||
|
// outside the swipe strip so it doesn't translate with the panes —
|
||||||
|
// addresses the user-visible regression where each pane's identical
|
||||||
|
// tabs row sliding underneath felt like "the header is moving" even
|
||||||
|
// though the segments matched at every pixel.
|
||||||
|
//
|
||||||
|
// Reuses `stream-header/StreamHeader.css.ts` classes (`tabsRow`,
|
||||||
|
// `tabsCluster`, `iconsCluster`) so the layout, padding, and segment
|
||||||
|
// styling stay identical to the per-pane tabs row that sits hidden
|
||||||
|
// underneath. The only structural difference is the surrounding
|
||||||
|
// `pagerStaticHeader` wrapper which positions this row absolute at
|
||||||
|
// the top of the pager and reserves the status-bar safe-area inset.
|
||||||
|
//
|
||||||
|
// Segment clicks call the pager's commit callbacks (so the swipe
|
||||||
|
// animation uses the same pendingTargetIdx path as a finger swipe).
|
||||||
|
// Action icons read `mobilePagerCurtainAtom` — the active pane's
|
||||||
|
// StreamHeader writes its curtain controls there, so Plus/Search/X
|
||||||
|
// drive whichever curtain is currently visible.
|
||||||
|
//
|
||||||
|
// ARIA: action icons mirror the original in-pane buttons'
|
||||||
|
// `aria-controls` / `aria-expanded` / `aria-haspopup` relationship to
|
||||||
|
// the form region (`#stream-header-inline-form`). When `iconsDisabled`
|
||||||
|
// (atom not yet populated on initial mount) the buttons report
|
||||||
|
// `aria-disabled` so assistive tech announces the unavailable state
|
||||||
|
// instead of silently failing on activation.
|
||||||
|
export function MobileTabsPagerHeader({
|
||||||
|
showBots,
|
||||||
|
activeTab,
|
||||||
|
onSelectDirect,
|
||||||
|
onSelectChannels,
|
||||||
|
onSelectBots,
|
||||||
|
}: MobileTabsPagerHeaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const curtainControls = useAtomValue(mobilePagerCurtainAtom);
|
||||||
|
const isFormActive = curtainControls?.isFormActive ?? false;
|
||||||
|
const openChat = useCallback(() => curtainControls?.openChat(), [curtainControls]);
|
||||||
|
const openSearch = useCallback(() => curtainControls?.openSearch(), [curtainControls]);
|
||||||
|
const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]);
|
||||||
|
const iconsDisabled = curtainControls === null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.pagerStaticHeader}>
|
||||||
|
<div className={streamHeaderCss.tabsRow}>
|
||||||
|
<div className={streamHeaderCss.tabsCluster}>
|
||||||
|
<Segment
|
||||||
|
active={activeTab === 'direct'}
|
||||||
|
label={t('Direct.segment_dm')}
|
||||||
|
onClick={onSelectDirect}
|
||||||
|
/>
|
||||||
|
<Segment
|
||||||
|
active={activeTab === 'channels'}
|
||||||
|
label={t('Direct.segment_channels')}
|
||||||
|
onClick={onSelectChannels}
|
||||||
|
/>
|
||||||
|
{showBots && (
|
||||||
|
<Segment
|
||||||
|
active={activeTab === 'bots'}
|
||||||
|
label={t('Direct.segment_bots')}
|
||||||
|
onClick={onSelectBots}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Box grow="Yes" />
|
||||||
|
{isFormActive ? (
|
||||||
|
<IconButton
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
fill="None"
|
||||||
|
size="400"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={closeForm}
|
||||||
|
aria-label={t('Direct.close')}
|
||||||
|
aria-controls={INLINE_FORM_ID}
|
||||||
|
aria-expanded
|
||||||
|
disabled={iconsDisabled}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<div className={streamHeaderCss.iconsCluster}>
|
||||||
|
<IconButton
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
fill="None"
|
||||||
|
size="400"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={openChat}
|
||||||
|
aria-label={t('Direct.create_chat')}
|
||||||
|
aria-controls={INLINE_FORM_ID}
|
||||||
|
aria-expanded={false}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
disabled={iconsDisabled}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Plus} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
fill="None"
|
||||||
|
size="400"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={openSearch}
|
||||||
|
aria-label={t('Search.search')}
|
||||||
|
aria-controls={INLINE_FORM_ID}
|
||||||
|
aria-expanded={false}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
disabled={iconsDisabled}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Search} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/app/components/mobile-tabs-pager/geometry.ts
Normal file
40
src/app/components/mobile-tabs-pager/geometry.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
// Mobile horizontal swipe pager — tuning constants.
|
||||||
|
//
|
||||||
|
// The pager is only active on Capacitor + mobile + listing-root URLs
|
||||||
|
// (/direct/, /channels/, /bots/). Everywhere else MobileTabsLayout
|
||||||
|
// passes through to <Outlet/> and these values are inert.
|
||||||
|
|
||||||
|
// Direction-resolve dead-zone (px). The finger must travel at least
|
||||||
|
// this far on either axis before we resolve the gesture as horizontal
|
||||||
|
// (engage the pager) or vertical (bail, let curtain / horseshoes /
|
||||||
|
// scroll take over).
|
||||||
|
export const DEAD_ZONE_PX = 12;
|
||||||
|
|
||||||
|
// Edge-guard band (px). Touchstart inside this strip from the L or R
|
||||||
|
// viewport edge is ignored — that zone belongs to the Android system
|
||||||
|
// back-gesture in edge-to-edge mode, and reacting to it would steal
|
||||||
|
// the back-swipe.
|
||||||
|
export const EDGE_GUARD_PX = 24;
|
||||||
|
|
||||||
|
// Rubber-band attenuation factor applied when the user pulls past the
|
||||||
|
// leftmost or rightmost tab boundary. Soft pull, never commits.
|
||||||
|
export const RUBBER_BAND_FACTOR = 0.35;
|
||||||
|
|
||||||
|
// Commit threshold = max(MIN_COMMIT_PX, viewport_width × COMMIT_FRACTION).
|
||||||
|
// Tuned wide on purpose (≈40% of viewport, floor 150px) so accidental
|
||||||
|
// horizontal jitter on long DM rows doesn't flip tabs.
|
||||||
|
export const MIN_COMMIT_PX = 150;
|
||||||
|
export const COMMIT_FRACTION = 0.4;
|
||||||
|
|
||||||
|
// Snap-back / commit-slide animation. Same curve & duration as the
|
||||||
|
// curtain commit so the two motions feel consistent.
|
||||||
|
export const PAGER_TRANSITION_MS = 280;
|
||||||
|
export const PAGER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
|
||||||
|
|
||||||
|
// Visible gap between adjacent panes inside the strip. Surfaces the
|
||||||
|
// `SurfaceVariant.Container` (pagerRoot's backdrop) during a swipe,
|
||||||
|
// matching the design intent of a light-blue divider between screens.
|
||||||
|
// Roughly 2× the standard horseshoe seam (`VOJO_HORSESHOE_GAP_PX=12`)
|
||||||
|
// — the inter-pane gap reads as a transitional void rather than a
|
||||||
|
// horseshoe surface boundary, so it's tuned wider to feel breathing.
|
||||||
|
export const PANE_GAP_PX = 24;
|
||||||
4
src/app/components/mobile-tabs-pager/index.ts
Normal file
4
src/app/components/mobile-tabs-pager/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// `MobileTabsPager` is intentionally NOT exported — it's mounted only
|
||||||
|
// from `MobileTabsLayout` based on the routed activation conditions,
|
||||||
|
// never directly by route or app code.
|
||||||
|
export { MobileTabsLayout } from './MobileTabsLayout';
|
||||||
87
src/app/components/mobile-tabs-pager/style.css.ts
Normal file
87
src/app/components/mobile-tabs-pager/style.css.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color } from 'folds';
|
||||||
|
|
||||||
|
// Pager root. Sits inside the authed shell's row-flex slot
|
||||||
|
// (ClientLayout → Box grow=Yes), so `flex: 1 1 0` fills the slot
|
||||||
|
// 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
|
||||||
|
// having to call preventDefault on every move — only the pager's own
|
||||||
|
// listener calls preventDefault, and only after axis-resolve commits
|
||||||
|
// to "horizontal".
|
||||||
|
//
|
||||||
|
// `SurfaceVariant.Container` backdrop intentionally shows through
|
||||||
|
// (a) the inter-pane gap during a swipe — the gap colour the user
|
||||||
|
// asked for is "light blue same as the header", which IS this
|
||||||
|
// SurfaceVariant tone — and (b) any sub-pixel rounding seam at rest.
|
||||||
|
export const pagerRoot = style({
|
||||||
|
position: 'relative',
|
||||||
|
flex: '1 1 0',
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
touchAction: 'pan-y',
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
//
|
||||||
|
// `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).
|
||||||
|
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
|
||||||
|
// status-bar inset adds on top naturally.
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Horizontal strip carrying all three panes side-by-side. Width &
|
||||||
|
// transform are computed inline in the JSX (they depend on tabs.length
|
||||||
|
// and visualIdx + visualDragPx, and the gap math couples to them).
|
||||||
|
//
|
||||||
|
// `gap: PANE_GAP_PX` is what makes the inter-pane void visible during
|
||||||
|
// a swipe — the pagerRoot's SurfaceVariant.Container colour shows
|
||||||
|
// through the gap, matching the static header tone exactly.
|
||||||
|
export const strip = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
height: '100%',
|
||||||
|
willChange: 'transform',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Each pane is exactly one viewport wide. CRITICALLY `display: flex;
|
||||||
|
// flex-direction: row` so the nested Folds PageNav (which is a flex
|
||||||
|
// child with `flex-grow: 1` on mobile to override its 256px recipe
|
||||||
|
// width) expands to fill the pane. A column-flex parent here would
|
||||||
|
// leave PageNav at 256px — the bug that ate the previous attempt.
|
||||||
|
//
|
||||||
|
// No paddingTop here: the per-pane StreamHeader still renders its
|
||||||
|
// own tabs row (kept for the curtain's TABS_ROW_PX snap math, just
|
||||||
|
// painted invisible via visibility:hidden), and PageNav's inner
|
||||||
|
// column reserves the status-bar safe-area inset via its own
|
||||||
|
// `paddingTop: var(--vojo-safe-top)`. The static header overlay at
|
||||||
|
// the pager root simply paints OVER the same screen zone, so the
|
||||||
|
// underlying geometry stays identical to non-pager mode.
|
||||||
|
export const pane = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexShrink: 0,
|
||||||
|
width: '100vw',
|
||||||
|
height: '100%',
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
import { MutableRefObject, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
COMMIT_FRACTION,
|
||||||
|
DEAD_ZONE_PX,
|
||||||
|
EDGE_GUARD_PX,
|
||||||
|
MIN_COMMIT_PX,
|
||||||
|
RUBBER_BAND_FACTOR,
|
||||||
|
} from './geometry';
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
// Root element the touch listeners attach to. Touches outside this
|
||||||
|
// element never reach the pager — that's how we keep the gesture
|
||||||
|
// scoped to the listing surface and out of detail routes.
|
||||||
|
rootRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
// Index of the currently active pane. Mirrored into a ref so the
|
||||||
|
// single bound effect reads fresh values without re-attaching.
|
||||||
|
activeIdx: number;
|
||||||
|
// Total number of panes. Used to clamp commit + rubber-band edges.
|
||||||
|
tabsCount: number;
|
||||||
|
// While true the listeners stay bound but every touchstart bails
|
||||||
|
// immediately. Used by the parent to suppress the gesture when an
|
||||||
|
// overlay sheet (settings, workspace switcher) is open — a swipe
|
||||||
|
// there shouldn't navigate sibling tabs.
|
||||||
|
disabled: boolean;
|
||||||
|
// Setter for the live drag delta. The pager component re-renders the
|
||||||
|
// strip transform on every change.
|
||||||
|
setDragPx: (px: number, dragging: boolean) => void;
|
||||||
|
// Commit a tab change. The caller is expected to reset dragPx to 0
|
||||||
|
// AND call navigate(replace) in the same React batch so the strip's
|
||||||
|
// transform jumps from (oldIdx, dragPx) to (newIdx, 0) in one render
|
||||||
|
// — CSS transition then animates the (small) remaining distance
|
||||||
|
// smoothly without an intermediate "snap back" flash.
|
||||||
|
commitTo: (idx: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Horizontal swipe driver for the mobile listing tab pager. Mirrors
|
||||||
|
// the shape of `useCurtainGesture`: single listener bound to the
|
||||||
|
// pager root, refs for the latest snap/index state, axis-resolve in
|
||||||
|
// the dead-zone, rubber-band at boundaries, threshold-commit on
|
||||||
|
// release.
|
||||||
|
//
|
||||||
|
// Conflict resolution with other gestures sharing the same surface
|
||||||
|
// (curtain, MobileSettingsHorseshoe, ChannelsWorkspaceHorseshoe) is
|
||||||
|
// cooperative: every gesture-owner resolves axis at the same dead-
|
||||||
|
// zone (12px) and bails when its own axis doesn't dominate. The pager
|
||||||
|
// wins horizontal; the others win vertical.
|
||||||
|
export function useMobileTabsPagerGesture({
|
||||||
|
rootRef,
|
||||||
|
activeIdx,
|
||||||
|
tabsCount,
|
||||||
|
disabled,
|
||||||
|
setDragPx,
|
||||||
|
commitTo,
|
||||||
|
}: Args): void {
|
||||||
|
const activeRef = useRef(activeIdx);
|
||||||
|
const countRef = useRef(tabsCount);
|
||||||
|
const disabledRef = useRef(disabled);
|
||||||
|
activeRef.current = activeIdx;
|
||||||
|
countRef.current = tabsCount;
|
||||||
|
disabledRef.current = disabled;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!root) return undefined;
|
||||||
|
|
||||||
|
let startX: number | null = null;
|
||||||
|
let startY: number | null = null;
|
||||||
|
let engaged = false;
|
||||||
|
let bailed = false;
|
||||||
|
let lastDragPx = 0;
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
startX = null;
|
||||||
|
startY = null;
|
||||||
|
engaged = false;
|
||||||
|
bailed = false;
|
||||||
|
lastDragPx = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
|
if (disabledRef.current) {
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.touches.length !== 1) {
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = e.touches[0];
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
// Android system back-gesture lives in the L/R edge strip in
|
||||||
|
// edge-to-edge mode. Ignore touches there so we don't fight it.
|
||||||
|
if (t.clientX < EDGE_GUARD_PX || t.clientX > vw - EDGE_GUARD_PX) {
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startX = t.clientX;
|
||||||
|
startY = t.clientY;
|
||||||
|
engaged = false;
|
||||||
|
bailed = false;
|
||||||
|
lastDragPx = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
|
if (e.touches.length !== 1) {
|
||||||
|
// Second finger landed mid-gesture — abort without commit.
|
||||||
|
if (engaged) setDragPx(0, false);
|
||||||
|
reset();
|
||||||
|
bailed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Defensive symmetry with onTouchStart's disabled check: a sheet
|
||||||
|
// opening async between touchstart and touchmove (e.g. atom flip
|
||||||
|
// from a delayed effect) shouldn't let an already-armed pager
|
||||||
|
// gesture commit through.
|
||||||
|
if (disabledRef.current) {
|
||||||
|
if (engaged) setDragPx(0, false);
|
||||||
|
reset();
|
||||||
|
bailed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (startX === null || startY === null || bailed) return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
const dx = t.clientX - startX;
|
||||||
|
const dy = t.clientY - startY;
|
||||||
|
|
||||||
|
if (!engaged) {
|
||||||
|
// Wait for the finger to leave the dead-zone before deciding
|
||||||
|
// who owns the gesture. The pager only engages when |dx|
|
||||||
|
// strictly dominates |dy|; ties go to vertical (curtain +
|
||||||
|
// horseshoe pull-down feels more natural than horizontal
|
||||||
|
// commit for ambiguous gestures).
|
||||||
|
if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) return;
|
||||||
|
if (Math.abs(dy) >= Math.abs(dx)) {
|
||||||
|
bailed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
engaged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const idx = activeRef.current;
|
||||||
|
const count = countRef.current;
|
||||||
|
let drag = dx;
|
||||||
|
// Rubber-band at the leftmost (dx > 0 = trying to go past idx 0)
|
||||||
|
// and rightmost (dx < 0 = trying to go past last idx) boundary.
|
||||||
|
// Soft attenuation — commit threshold can never be reached, so
|
||||||
|
// the spring-back on release lands us back on the current tab.
|
||||||
|
if (idx === 0 && dx > 0) drag = dx * RUBBER_BAND_FACTOR;
|
||||||
|
else if (idx === count - 1 && dx < 0) drag = dx * RUBBER_BAND_FACTOR;
|
||||||
|
// Clamp to ±one viewport so an overshooting swipe doesn't
|
||||||
|
// translate the strip into nonsense territory.
|
||||||
|
drag = Math.max(-vw, Math.min(vw, drag));
|
||||||
|
lastDragPx = drag;
|
||||||
|
setDragPx(drag, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
if (!engaged) {
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Defensive recheck symmetric with onTouchStart / onTouchMove:
|
||||||
|
// an overlay sheet could have opened between the last touchmove
|
||||||
|
// and this touchend (atom flip from a delayed effect, a system
|
||||||
|
// dialog, etc.). Committing under those circumstances would
|
||||||
|
// navigate sibling tabs from beneath the overlay — same hazard
|
||||||
|
// the touchstart/move gates exist to prevent. Spring back
|
||||||
|
// instead.
|
||||||
|
if (disabledRef.current) {
|
||||||
|
setDragPx(0, false);
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const idx = activeRef.current;
|
||||||
|
const count = countRef.current;
|
||||||
|
const threshold = Math.max(MIN_COMMIT_PX, vw * COMMIT_FRACTION);
|
||||||
|
|
||||||
|
let nextIdx = idx;
|
||||||
|
// Negative drag (finger moved left) → next tab to the right.
|
||||||
|
// Positive drag (finger moved right) → previous tab to the left.
|
||||||
|
if (lastDragPx <= -threshold && idx < count - 1) nextIdx = idx + 1;
|
||||||
|
else if (lastDragPx >= threshold && idx > 0) nextIdx = idx - 1;
|
||||||
|
|
||||||
|
if (nextIdx !== idx) {
|
||||||
|
commitTo(nextIdx);
|
||||||
|
} else {
|
||||||
|
// No commit — re-enable transition and animate the strip back
|
||||||
|
// to its resting position at the current tab.
|
||||||
|
setDragPx(0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchCancel = () => {
|
||||||
|
// System cancel (incoming call, scroll-take-over, etc.) — never
|
||||||
|
// commit; just spring back if a drag was in flight.
|
||||||
|
if (engaged) setDragPx(0, false);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
root.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||||
|
root.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||||
|
root.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||||
|
root.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
||||||
|
return () => {
|
||||||
|
root.removeEventListener('touchstart', onTouchStart);
|
||||||
|
root.removeEventListener('touchmove', onTouchMove);
|
||||||
|
root.removeEventListener('touchend', onTouchEnd);
|
||||||
|
root.removeEventListener('touchcancel', onTouchCancel);
|
||||||
|
};
|
||||||
|
// setDragPx / commitTo are stable useCallbacks from the parent;
|
||||||
|
// activeIdx / tabsCount are mirrored via the refs above so the
|
||||||
|
// listener reads fresh values without re-binding on every nav.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [rootRef, setDragPx, commitTo]);
|
||||||
|
}
|
||||||
|
|
@ -9,10 +9,13 @@ import React, {
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMatch, useNavigate } from 'react-router-dom';
|
import { useMatch, useNavigate } from 'react-router-dom';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
import { Box, Icon, IconButton, Icons, toRem } from 'folds';
|
import { Box, Icon, IconButton, Icons, toRem } from 'folds';
|
||||||
import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
|
import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
|
||||||
import { isNativePlatform } from '../../utils/capacitor';
|
import { isNativePlatform } from '../../utils/capacitor';
|
||||||
import { useBotPresets } from '../../features/bots/catalog';
|
import { useBotPresets } from '../../features/bots/catalog';
|
||||||
|
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
|
||||||
|
import { MobilePagerCurtainControls, mobilePagerCurtainAtom } from '../../state/mobilePagerHeader';
|
||||||
import * as css from './StreamHeader.css';
|
import * as css from './StreamHeader.css';
|
||||||
import { CHIP_ROW_PX, TABS_ROW_PX } from './geometry';
|
import { CHIP_ROW_PX, TABS_ROW_PX } from './geometry';
|
||||||
import { Segment } from './Segment';
|
import { Segment } from './Segment';
|
||||||
|
|
@ -52,6 +55,17 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader
|
||||||
const channelsMatch = useMatch({ path: CHANNELS_PATH, caseSensitive: true, end: false });
|
const channelsMatch = useMatch({ path: CHANNELS_PATH, caseSensitive: true, end: false });
|
||||||
const showBotsSegment = bots.length > 0 || !!botsMatch;
|
const showBotsSegment = bots.length > 0 || !!botsMatch;
|
||||||
|
|
||||||
|
// Pager mode wiring. When this StreamHeader is mounted inside
|
||||||
|
// MobileTabsPager, the shared static tabs row at the pager root
|
||||||
|
// owns the visible segments + action icons; our local tabs row is
|
||||||
|
// kept in DOM (preserving the curtain's TABS_ROW_PX-based snap
|
||||||
|
// geometry) but rendered with `visibility: hidden`. Only the
|
||||||
|
// currently active pane writes its curtain controls to
|
||||||
|
// `mobilePagerCurtainAtom` so the shared icons drive THIS curtain.
|
||||||
|
const pagerPane = useMobilePagerPane();
|
||||||
|
const inPagerMode = pagerPane !== null;
|
||||||
|
const isActivePagerPane = pagerPane?.isActive ?? false;
|
||||||
|
|
||||||
const curtain = useCurtainState();
|
const curtain = useCurtainState();
|
||||||
|
|
||||||
useCurtainGesture({
|
useCurtainGesture({
|
||||||
|
|
@ -66,6 +80,33 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader
|
||||||
const openChat = useCallback(() => curtain.open('chat'), [curtain]);
|
const openChat = useCallback(() => curtain.open('chat'), [curtain]);
|
||||||
const { close } = curtain;
|
const { close } = curtain;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// render would never match the atom's current contents.
|
||||||
|
const pagerControls = useMemo<MobilePagerCurtainControls>(
|
||||||
|
() => ({
|
||||||
|
openSearch,
|
||||||
|
openChat,
|
||||||
|
closeForm: close,
|
||||||
|
isFormActive: isActive,
|
||||||
|
}),
|
||||||
|
[openSearch, openChat, close, isActive]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActivePagerPane) return undefined;
|
||||||
|
setPagerCurtain(pagerControls);
|
||||||
|
// Compare-and-clear cleanup: only wipe the atom if it still holds
|
||||||
|
// OUR controls. If another pane became active between this render
|
||||||
|
// and the cleanup (rapid tab switch), it has already overwritten
|
||||||
|
// the atom with its own controls — we must not clobber that.
|
||||||
|
return () => {
|
||||||
|
setPagerCurtain((prev) => (prev === pagerControls ? null : prev));
|
||||||
|
};
|
||||||
|
}, [isActivePagerPane, pagerControls, setPagerCurtain]);
|
||||||
|
|
||||||
// Curtain's `top` is the resting snap position plus the live drag
|
// Curtain's `top` is the resting snap position plus the live drag
|
||||||
// delta. React-driven (no inline DOM writes), so finger-tracking and
|
// delta. React-driven (no inline DOM writes), so finger-tracking and
|
||||||
// commit happen in the same render pipeline and there's no
|
// commit happen in the same render pipeline and there's no
|
||||||
|
|
@ -131,8 +172,27 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader
|
||||||
return (
|
return (
|
||||||
<div className={css.stage}>
|
<div className={css.stage}>
|
||||||
<header className={css.header}>
|
<header className={css.header}>
|
||||||
{/* ── Tabs row + action icons (always visible) ─────────── */}
|
{/* ── Tabs row + action icons (always visible) ───────────
|
||||||
<div className={css.tabsRow}>
|
In pager mode the row stays mounted (curtain snap math
|
||||||
|
depends on its TABS_ROW_PX height) but is painted invisible
|
||||||
|
because the shared static tabs row at the pager root owns
|
||||||
|
the visible chrome. Hit-testing the invisible row is fine —
|
||||||
|
the static header sits above it in z-stack and absorbs
|
||||||
|
taps; even if a tap leaked through, both rows trigger the
|
||||||
|
same navigate() so the user-visible result is identical.
|
||||||
|
|
||||||
|
`aria-hidden` removes the duplicate (per-pane) segments and
|
||||||
|
icons from the accessibility tree so screen readers don't
|
||||||
|
announce three sets of "Direct / Channels / Bots" plus the
|
||||||
|
single visible set from the shared static header. Chromium
|
||||||
|
already prunes `visibility: hidden` nodes from a11y, but
|
||||||
|
making the intent explicit guards against AT/browser
|
||||||
|
variations. */}
|
||||||
|
<div
|
||||||
|
className={css.tabsRow}
|
||||||
|
style={inPagerMode ? { visibility: 'hidden' } : undefined}
|
||||||
|
aria-hidden={inPagerMode || undefined}
|
||||||
|
>
|
||||||
<div className={css.tabsCluster}>
|
<div className={css.tabsCluster}>
|
||||||
<Segment
|
<Segment
|
||||||
active={!!directMatch}
|
active={!!directMatch}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
||||||
const list = scrollRef.current;
|
const list = scrollRef.current;
|
||||||
if (!list) return undefined;
|
if (!list) return undefined;
|
||||||
|
|
||||||
|
let startX: number | null = null;
|
||||||
let startY: number | null = null;
|
let startY: number | null = null;
|
||||||
let direction: 'up' | 'down' | null = null;
|
let direction: 'up' | 'down' | null = null;
|
||||||
let engaged = false;
|
let engaged = false;
|
||||||
|
|
@ -53,6 +54,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
||||||
|
|
||||||
const onTouchStart = (e: TouchEvent) => {
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
if (e.touches.length !== 1) return;
|
if (e.touches.length !== 1) return;
|
||||||
|
startX = e.touches[0].clientX;
|
||||||
startY = e.touches[0].clientY;
|
startY = e.touches[0].clientY;
|
||||||
direction = null;
|
direction = null;
|
||||||
engaged = false;
|
engaged = false;
|
||||||
|
|
@ -61,6 +63,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
||||||
// scroll). Form-close engages regardless of scrollTop (the form
|
// scroll). Form-close engages regardless of scrollTop (the form
|
||||||
// is open, the list scroll is the close target).
|
// is open, the list scroll is the close target).
|
||||||
if (!isFormSnap(snapRef.current) && list.scrollTop !== 0) {
|
if (!isFormSnap(snapRef.current) && list.scrollTop !== 0) {
|
||||||
|
startX = null;
|
||||||
startY = null;
|
startY = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -68,6 +71,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
||||||
const onTouchMove = (e: TouchEvent) => {
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
if (e.touches.length !== 1) {
|
if (e.touches.length !== 1) {
|
||||||
// Second finger landed mid-gesture — abort.
|
// Second finger landed mid-gesture — abort.
|
||||||
|
startX = null;
|
||||||
startY = null;
|
startY = null;
|
||||||
direction = null;
|
direction = null;
|
||||||
if (engaged) setLiveDrag(0, false);
|
if (engaged) setLiveDrag(0, false);
|
||||||
|
|
@ -78,6 +82,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
||||||
if (startY === null) {
|
if (startY === null) {
|
||||||
// Active mode may re-arm startY here if onTouchStart bailed.
|
// Active mode may re-arm startY here if onTouchStart bailed.
|
||||||
if (isFormSnap(snapRef.current)) {
|
if (isFormSnap(snapRef.current)) {
|
||||||
|
startX = e.touches[0].clientX;
|
||||||
startY = e.touches[0].clientY;
|
startY = e.touches[0].clientY;
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -85,26 +90,42 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
||||||
}
|
}
|
||||||
|
|
||||||
const delta = e.touches[0].clientY - startY;
|
const delta = e.touches[0].clientY - startY;
|
||||||
|
const deltaX = startX !== null ? e.touches[0].clientX - startX : 0;
|
||||||
const currentSnap = snapRef.current;
|
const currentSnap = snapRef.current;
|
||||||
|
|
||||||
// Resolve a direction once the finger crosses the dead-zone.
|
// Resolve a direction once the finger crosses the dead-zone.
|
||||||
if (direction === null) {
|
if (direction === null) {
|
||||||
if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return;
|
if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return;
|
||||||
|
// Horizontal-bail: if the finger crosses the dead-zone with
|
||||||
|
// |dx| strictly greater than |dy|, the user is swiping the
|
||||||
|
// mobile tab pager, not pulling the curtain. Drop our tracking
|
||||||
|
// state so the pager owns the gesture; ties still resolve to
|
||||||
|
// vertical (curtain) because pull-down is the more common
|
||||||
|
// intent on the listing surface.
|
||||||
|
if (Math.abs(deltaX) > Math.abs(delta)) {
|
||||||
|
startX = null;
|
||||||
|
startY = null;
|
||||||
|
direction = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
direction = delta > 0 ? 'down' : 'up';
|
direction = delta > 0 ? 'down' : 'up';
|
||||||
|
|
||||||
// Direction guards: nothing higher than `closed`; nothing
|
// Direction guards: nothing higher than `closed`; nothing
|
||||||
// lower than `peek`; form snaps only close (up).
|
// lower than `peek`; form snaps only close (up).
|
||||||
if (currentSnap === 'closed' && direction === 'up') {
|
if (currentSnap === 'closed' && direction === 'up') {
|
||||||
|
startX = null;
|
||||||
startY = null;
|
startY = null;
|
||||||
direction = null;
|
direction = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentSnap === 'peek' && direction === 'down') {
|
if (currentSnap === 'peek' && direction === 'down') {
|
||||||
|
startX = null;
|
||||||
startY = null;
|
startY = null;
|
||||||
direction = null;
|
direction = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isFormSnap(currentSnap) && direction === 'down') {
|
if (isFormSnap(currentSnap) && direction === 'down') {
|
||||||
|
startX = null;
|
||||||
startY = null;
|
startY = null;
|
||||||
direction = null;
|
direction = null;
|
||||||
return;
|
return;
|
||||||
|
|
@ -167,6 +188,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
||||||
setLiveDrag(0, false);
|
setLiveDrag(0, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startX = null;
|
||||||
startY = null;
|
startY = null;
|
||||||
direction = null;
|
direction = null;
|
||||||
engaged = false;
|
engaged = false;
|
||||||
|
|
@ -176,6 +198,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
|
||||||
const onTouchCancel = () => {
|
const onTouchCancel = () => {
|
||||||
// System cancel never commits — always snap back to current snap.
|
// System cancel never commits — always snap back to current snap.
|
||||||
if (engaged) setLiveDrag(0, false);
|
if (engaged) setLiveDrag(0, false);
|
||||||
|
startX = null;
|
||||||
startY = null;
|
startY = null;
|
||||||
direction = null;
|
direction = null;
|
||||||
engaged = false;
|
engaged = false;
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,7 @@ import { createPortal } from 'react-dom';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { settingsSheetAtom } from '../../state/settingsSheet';
|
import { settingsSheetAtom } from '../../state/settingsSheet';
|
||||||
import {
|
import { useCloseSettingsSheet, useOpenSettingsSheet } from '../../state/hooks/settingsSheet';
|
||||||
useCloseSettingsSheet,
|
|
||||||
useOpenSettingsSheet,
|
|
||||||
} from '../../state/hooks/settingsSheet';
|
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { HorseshoeEnabledContext } from '../../components/page';
|
import { HorseshoeEnabledContext } from '../../components/page';
|
||||||
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
|
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
|
||||||
|
|
@ -91,14 +88,21 @@ const HORSESHOE_EMERGE_PX = 80;
|
||||||
// barely visible until ~40% of the way through the gesture, then
|
// barely visible until ~40% of the way through the gesture, then
|
||||||
// blossoms around the midpoint. Used only during finger-drag; release
|
// blossoms around the midpoint. Used only during finger-drag; release
|
||||||
// transitions use the asymmetric VAUL_EASING curve in CSS.
|
// transitions use the asymmetric VAUL_EASING curve in CSS.
|
||||||
const easeInOutCubic = (t: number): number =>
|
const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
|
||||||
t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2;
|
|
||||||
|
|
||||||
type DragSource = 'directSelfRow' | 'handle';
|
type DragSource = 'directSelfRow' | 'handle';
|
||||||
|
|
||||||
|
// Axis dead-zone for horizontal-bail. The finger must travel this far
|
||||||
|
// on either axis before we resolve the gesture as vertical (open/close
|
||||||
|
// the sheet) or horizontal (yield to MobileTabsPager). Same value as
|
||||||
|
// the curtain gesture and the pager itself so all three resolve at the
|
||||||
|
// same threshold and never compete.
|
||||||
|
const AXIS_DEAD_ZONE_PX = 12;
|
||||||
|
|
||||||
type DragState = {
|
type DragState = {
|
||||||
source: DragSource;
|
source: DragSource;
|
||||||
inputType: 'touch' | 'pointer';
|
inputType: 'touch' | 'pointer';
|
||||||
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
deltaY: number;
|
deltaY: number;
|
||||||
};
|
};
|
||||||
|
|
@ -228,9 +232,7 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
if (
|
if (
|
||||||
target &&
|
target &&
|
||||||
(target.tagName === 'INPUT' ||
|
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||||
target.tagName === 'TEXTAREA' ||
|
|
||||||
target.isContentEditable)
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -256,6 +258,21 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEl = handleRef.current;
|
const handleEl = handleRef.current;
|
||||||
|
|
||||||
|
// Per-drag axis-resolution flag. Reset to false in every touchstart /
|
||||||
|
// pointerdown handler, flipped to true in applyMove once the finger
|
||||||
|
// leaves the dead-zone with a vertical-dominant delta. If the finger
|
||||||
|
// leaves the dead-zone horizontally, we drop the drag and yield to
|
||||||
|
// MobileTabsPager instead of opening/closing the sheet.
|
||||||
|
let axisResolved = false;
|
||||||
|
// Sticky flag flipped once a touch series has yielded to the pager
|
||||||
|
// (axis-resolved as horizontal). Without it, a rapid reversal back
|
||||||
|
// toward vertical inside the same touch could re-enter the
|
||||||
|
// axisResolved=false branch — the closure's `axisResolved` is still
|
||||||
|
// false because we returned early — and re-engage the sheet drag,
|
||||||
|
// even though we explicitly handed the gesture off. Reset on every
|
||||||
|
// fresh touchstart / pointerdown.
|
||||||
|
let touchBailed = false;
|
||||||
|
|
||||||
// CLAMP, not early-return. Reversal of gesture direction must
|
// CLAMP, not early-return. Reversal of gesture direction must
|
||||||
// drag `deltaY` back toward 0, not leave the stale value. The
|
// drag `deltaY` back toward 0, not leave the stale value. The
|
||||||
// earlier `if (wrong direction) return` branch let a user swipe
|
// earlier `if (wrong direction) return` branch let a user swipe
|
||||||
|
|
@ -274,15 +291,37 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
// try to expand past its full open height.
|
// try to expand past its full open height.
|
||||||
//
|
//
|
||||||
// preventDefault always (when cancelable) — these touches belong
|
// preventDefault always (when cancelable) — these touches belong
|
||||||
// to our gesture. DM-list scroll lives ABOVE the row; touches on
|
// to our gesture once axis-resolved. DM-list scroll lives ABOVE
|
||||||
// those rows don't hit `[data-settings-drag-origin]` so they
|
// the row; touches on those rows don't hit
|
||||||
// never enter this handler.
|
// `[data-settings-drag-origin]` so they never enter this handler.
|
||||||
const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => {
|
const applyMove = (clientX: number, clientY: number, e: TouchEvent | PointerEvent) => {
|
||||||
|
if (touchBailed) return;
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
const rawDelta = clientY - d.startY;
|
const rawDeltaX = clientX - d.startX;
|
||||||
|
const rawDeltaY = clientY - d.startY;
|
||||||
|
|
||||||
|
if (!axisResolved) {
|
||||||
|
const dxAbs = Math.abs(rawDeltaX);
|
||||||
|
const dyAbs = Math.abs(rawDeltaY);
|
||||||
|
// Stay quiet inside the dead-zone — both the pager and us
|
||||||
|
// need a stable signal before deciding who owns the gesture.
|
||||||
|
if (dxAbs < AXIS_DEAD_ZONE_PX && dyAbs < AXIS_DEAD_ZONE_PX) return;
|
||||||
|
if (dxAbs > dyAbs) {
|
||||||
|
// Horizontal-dominant — the user is steering the pager.
|
||||||
|
// Drop our drag state without preventDefault so the pager's
|
||||||
|
// touchmove handler can engage and own the rest of the touch.
|
||||||
|
// Stick the bail for the remainder of this touch series so a
|
||||||
|
// late vertical reversal can't sneak back into the sheet.
|
||||||
|
touchBailed = true;
|
||||||
|
setDrag(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
axisResolved = true;
|
||||||
|
}
|
||||||
|
|
||||||
const nextDelta =
|
const nextDelta =
|
||||||
d.source === 'directSelfRow' ? Math.min(0, rawDelta) : Math.max(0, rawDelta);
|
d.source === 'directSelfRow' ? Math.min(0, rawDeltaY) : Math.max(0, rawDeltaY);
|
||||||
if (e.cancelable) e.preventDefault();
|
if (e.cancelable) e.preventDefault();
|
||||||
setDrag({ ...d, deltaY: nextDelta });
|
setDrag({ ...d, deltaY: nextDelta });
|
||||||
};
|
};
|
||||||
|
|
@ -309,9 +348,12 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
if (sheetRef.current) return; // sheet is open — handle owns drag
|
if (sheetRef.current) return; // sheet is open — handle owns drag
|
||||||
if (!targetIsDragOrigin(e.target)) return;
|
if (!targetIsDragOrigin(e.target)) return;
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
|
axisResolved = false;
|
||||||
|
touchBailed = false;
|
||||||
setDrag({
|
setDrag({
|
||||||
source: 'directSelfRow',
|
source: 'directSelfRow',
|
||||||
inputType: 'touch',
|
inputType: 'touch',
|
||||||
|
startX: touch.clientX,
|
||||||
startY: touch.clientY,
|
startY: touch.clientY,
|
||||||
deltaY: 0,
|
deltaY: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -320,12 +362,20 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
if (dragRef.current) return;
|
if (dragRef.current) return;
|
||||||
if (!sheetRef.current) return;
|
if (!sheetRef.current) return;
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
setDrag({ source: 'handle', inputType: 'touch', startY: touch.clientY, deltaY: 0 });
|
axisResolved = false;
|
||||||
|
touchBailed = false;
|
||||||
|
setDrag({
|
||||||
|
source: 'handle',
|
||||||
|
inputType: 'touch',
|
||||||
|
startX: touch.clientX,
|
||||||
|
startY: touch.clientY,
|
||||||
|
deltaY: 0,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const onTouchMove = (e: TouchEvent) => {
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d || d.inputType !== 'touch') return;
|
if (!d || d.inputType !== 'touch') return;
|
||||||
applyMove(e.touches[0].clientY, e);
|
applyMove(e.touches[0].clientX, e.touches[0].clientY, e);
|
||||||
};
|
};
|
||||||
const onTouchEnd = () => {
|
const onTouchEnd = () => {
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
|
|
@ -340,9 +390,12 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
if (sheetRef.current) return;
|
if (sheetRef.current) return;
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
if (!targetIsDragOrigin(e.target)) return;
|
if (!targetIsDragOrigin(e.target)) return;
|
||||||
|
axisResolved = false;
|
||||||
|
touchBailed = false;
|
||||||
setDrag({
|
setDrag({
|
||||||
source: 'directSelfRow',
|
source: 'directSelfRow',
|
||||||
inputType: 'pointer',
|
inputType: 'pointer',
|
||||||
|
startX: e.clientX,
|
||||||
startY: e.clientY,
|
startY: e.clientY,
|
||||||
deltaY: 0,
|
deltaY: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -352,13 +405,21 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
if (dragRef.current) return;
|
if (dragRef.current) return;
|
||||||
if (!sheetRef.current) return;
|
if (!sheetRef.current) return;
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
setDrag({ source: 'handle', inputType: 'pointer', startY: e.clientY, deltaY: 0 });
|
axisResolved = false;
|
||||||
|
touchBailed = false;
|
||||||
|
setDrag({
|
||||||
|
source: 'handle',
|
||||||
|
inputType: 'pointer',
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
deltaY: 0,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const onDocPointerMove = (e: PointerEvent) => {
|
const onDocPointerMove = (e: PointerEvent) => {
|
||||||
if (e.pointerType === 'touch') return;
|
if (e.pointerType === 'touch') return;
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d || d.inputType !== 'pointer') return;
|
if (!d || d.inputType !== 'pointer') return;
|
||||||
applyMove(e.clientY, e);
|
applyMove(e.clientX, e.clientY, e);
|
||||||
};
|
};
|
||||||
const onDocPointerEnd = (e: PointerEvent) => {
|
const onDocPointerEnd = (e: PointerEvent) => {
|
||||||
if (e.pointerType === 'touch') return;
|
if (e.pointerType === 'touch') return;
|
||||||
|
|
@ -461,7 +522,11 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
<div className={css.container} style={containerStyle}>
|
<div className={css.container} style={containerStyle}>
|
||||||
{open && portalTarget
|
{open && portalTarget
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div data-vojo-settings-sheet-active="true" aria-hidden="true" style={{ display: 'none' }} />,
|
<div
|
||||||
|
data-vojo-settings-sheet-active="true"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>,
|
||||||
portalTarget
|
portalTarget
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
@ -517,11 +582,7 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
aria-label={t('Settings.title')}
|
aria-label={t('Settings.title')}
|
||||||
>
|
>
|
||||||
<div className={css.panelContent} style={{ height: `${railHeightPx}px` }}>
|
<div className={css.panelContent} style={{ height: `${railHeightPx}px` }}>
|
||||||
<div
|
<div ref={handleRef} className={css.panelHandle} aria-label={t('Settings.drag_to_close')}>
|
||||||
ref={handleRef}
|
|
||||||
className={css.panelHandle}
|
|
||||||
aria-label={t('Settings.drag_to_close')}
|
|
||||||
>
|
|
||||||
<div className={css.panelHandleBar} />
|
<div className={css.panelHandleBar} />
|
||||||
</div>
|
</div>
|
||||||
<div className={css.panelBody}>
|
<div className={css.panelBody}>
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,22 @@ import { useSetAtom } from 'jotai';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useNavToActivePathAtom } from '../state/hooks/navToActivePath';
|
import { useNavToActivePathAtom } from '../state/hooks/navToActivePath';
|
||||||
|
|
||||||
export const useNavToActivePathMapper = (navId: string) => {
|
// `pathPrefix` gates the atom write to URLs that actually belong to this nav.
|
||||||
|
// Consumers (Direct in particular) can be mounted continuously by the mobile
|
||||||
|
// tab pager even while the URL points at a sibling tab — without the prefix
|
||||||
|
// guard, the «direct» entry in the persisted atom would be overwritten with
|
||||||
|
// «/channels/» or «/bots/» on every tab switch.
|
||||||
|
export const useNavToActivePathMapper = (navId: string, pathPrefix?: string) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const setNavToActivePath = useSetAtom(useNavToActivePathAtom());
|
const setNavToActivePath = useSetAtom(useNavToActivePathAtom());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { pathname, search, hash } = location;
|
const { pathname, search, hash } = location;
|
||||||
|
if (pathPrefix && !pathname.startsWith(pathPrefix)) return;
|
||||||
setNavToActivePath({
|
setNavToActivePath({
|
||||||
type: 'PUT',
|
type: 'PUT',
|
||||||
navId,
|
navId,
|
||||||
path: { pathname, search, hash },
|
path: { pathname, search, hash },
|
||||||
});
|
});
|
||||||
}, [location, setNavToActivePath, navId]);
|
}, [location, setNavToActivePath, navId, pathPrefix]);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsu
|
||||||
import { HorseshoeContainer } from './HorseshoeContainer';
|
import { HorseshoeContainer } from './HorseshoeContainer';
|
||||||
import { useAppUrlOpen } from '../hooks/useAppUrlOpen';
|
import { useAppUrlOpen } from '../hooks/useAppUrlOpen';
|
||||||
import { ChannelsModeProvider } from '../hooks/useChannelsMode';
|
import { ChannelsModeProvider } from '../hooks/useChannelsMode';
|
||||||
|
import { MobileTabsLayout } from '../components/mobile-tabs-pager';
|
||||||
|
|
||||||
function IncomingCallsFeature() {
|
function IncomingCallsFeature() {
|
||||||
useIncomingRtcNotifications();
|
useIncomingRtcNotifications();
|
||||||
|
|
@ -231,113 +232,125 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route
|
{/* Mobile + Capacitor horizontal swipe pager. The layout-route
|
||||||
path={DIRECT_PATH}
|
wrapper has no path: at listing-root URLs it overrides
|
||||||
element={
|
rendering with `MobileTabsPager` (three panes mounted at
|
||||||
<PageRoot
|
once, slide via CSS transform); at detail URLs and on
|
||||||
nav={
|
web/desktop/tablet it falls through to `<Outlet/>` and the
|
||||||
<MobileFriendlyPageNav path={DIRECT_PATH}>
|
wrapped routes render exactly as before. See
|
||||||
<Direct />
|
`src/app/components/mobile-tabs-pager/`. */}
|
||||||
</MobileFriendlyPageNav>
|
<Route element={<MobileTabsLayout />}>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</PageRoot>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
|
||||||
<Route path={_CREATE_PATH} element={<DirectCreate />} />
|
|
||||||
<Route
|
<Route
|
||||||
path={_ROOM_PATH}
|
path={DIRECT_PATH}
|
||||||
element={
|
|
||||||
<DirectRouteRoomProvider>
|
|
||||||
<Room />
|
|
||||||
</DirectRouteRoomProvider>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
{/* Bots reuses StreamHeader segments. /bots/* is reserved before SPACE_PATH so deep URLs don't fall to /:spaceIdOrAlias/. */}
|
|
||||||
<Route
|
|
||||||
path={BOTS_PATH}
|
|
||||||
element={
|
|
||||||
<PageRoot
|
|
||||||
nav={
|
|
||||||
<MobileFriendlyPageNav path={BOTS_PATH}>
|
|
||||||
<Bots />
|
|
||||||
</MobileFriendlyPageNav>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</PageRoot>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
|
||||||
<Route path=":botId" element={<BotExperienceHost />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} />
|
|
||||||
{/* Channels segment. /channels/* is reserved before SPACE_PATH so the
|
|
||||||
generic /:spaceIdOrAlias/ catch-all doesn't swallow the prefix.
|
|
||||||
Phase 1 routes resolve to a stubbed center pane; Phase 3 + Phase 4
|
|
||||||
replace the left list and center timeline respectively. */}
|
|
||||||
<Route
|
|
||||||
path={CHANNELS_PATH}
|
|
||||||
element={
|
|
||||||
<ChannelsModeProvider value>
|
|
||||||
<Outlet />
|
|
||||||
</ChannelsModeProvider>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route
|
|
||||||
index
|
|
||||||
element={
|
element={
|
||||||
<PageRoot
|
<PageRoot
|
||||||
nav={
|
nav={
|
||||||
<MobileFriendlyPageNav path={CHANNELS_PATH}>
|
<MobileFriendlyPageNav path={DIRECT_PATH}>
|
||||||
<ChannelsRootNav />
|
<Direct />
|
||||||
</MobileFriendlyPageNav>
|
</MobileFriendlyPageNav>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{mobile ? null : <WelcomePage />}
|
<Outlet />
|
||||||
</PageRoot>
|
</PageRoot>
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||||
|
<Route path={_CREATE_PATH} element={<DirectCreate />} />
|
||||||
|
<Route
|
||||||
|
path={_ROOM_PATH}
|
||||||
|
element={
|
||||||
|
<DirectRouteRoomProvider>
|
||||||
|
<Room />
|
||||||
|
</DirectRouteRoomProvider>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
{/* Bots reuses StreamHeader segments. /bots/* is reserved before SPACE_PATH so deep URLs don't fall to /:spaceIdOrAlias/. */}
|
||||||
<Route
|
<Route
|
||||||
path={CHANNELS_SPACE_PATH.slice(CHANNELS_PATH.length)}
|
path={BOTS_PATH}
|
||||||
element={
|
element={
|
||||||
<RouteSpaceProvider>
|
<PageRoot
|
||||||
|
nav={
|
||||||
|
<MobileFriendlyPageNav path={BOTS_PATH}>
|
||||||
|
<Bots />
|
||||||
|
</MobileFriendlyPageNav>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</PageRoot>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||||
|
<Route path=":botId" element={<BotExperienceHost />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} />
|
||||||
|
{/* Channels segment. /channels/* is reserved before SPACE_PATH so the
|
||||||
|
generic /:spaceIdOrAlias/ catch-all doesn't swallow the prefix.
|
||||||
|
Phase 1 routes resolve to a stubbed center pane; Phase 3 + Phase 4
|
||||||
|
replace the left list and center timeline respectively. */}
|
||||||
|
<Route
|
||||||
|
path={CHANNELS_PATH}
|
||||||
|
element={
|
||||||
|
<ChannelsModeProvider value>
|
||||||
|
<Outlet />
|
||||||
|
</ChannelsModeProvider>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route
|
||||||
|
index
|
||||||
|
element={
|
||||||
<PageRoot
|
<PageRoot
|
||||||
nav={
|
nav={
|
||||||
<MobileFriendlyPageNav path={CHANNELS_SPACE_PATH}>
|
<MobileFriendlyPageNav path={CHANNELS_PATH}>
|
||||||
<Channels />
|
<ChannelsRootNav />
|
||||||
</MobileFriendlyPageNav>
|
</MobileFriendlyPageNav>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Outlet />
|
{mobile ? null : <WelcomePage />}
|
||||||
</PageRoot>
|
</PageRoot>
|
||||||
</RouteSpaceProvider>
|
}
|
||||||
}
|
/>
|
||||||
>
|
|
||||||
{mobile ? null : <Route index element={<ChannelPickPlaceholder />} />}
|
|
||||||
<Route
|
<Route
|
||||||
path={CHANNELS_ROOM_PATH.slice(CHANNELS_SPACE_PATH.length)}
|
path={CHANNELS_SPACE_PATH.slice(CHANNELS_PATH.length)}
|
||||||
element={
|
element={
|
||||||
<SpaceRouteRoomProvider>
|
<RouteSpaceProvider>
|
||||||
<Room />
|
<PageRoot
|
||||||
</SpaceRouteRoomProvider>
|
nav={
|
||||||
|
<MobileFriendlyPageNav path={CHANNELS_SPACE_PATH}>
|
||||||
|
<Channels />
|
||||||
|
</MobileFriendlyPageNav>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</PageRoot>
|
||||||
|
</RouteSpaceProvider>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Thread drawer URL — same Room element renders, drawer
|
{mobile ? null : <Route index element={<ChannelPickPlaceholder />} />}
|
||||||
opens by reading `:rootId` via useParams. The
|
|
||||||
SpaceRouteRoomProvider lives on the parent route and
|
|
||||||
stays mounted across the room↔thread URL flip. */}
|
|
||||||
<Route path={CHANNELS_THREAD_PATH.slice(CHANNELS_ROOM_PATH.length)} element={null} />
|
|
||||||
{/* Event-anchored URL — search/inbox/mention/push permalinks.
|
|
||||||
RR6 merges child params into the parent's useParams so
|
|
||||||
Room.tsx reads `eventId` without re-routing. */}
|
|
||||||
<Route
|
<Route
|
||||||
path={CHANNELS_ROOM_EVENT_PATH.slice(CHANNELS_ROOM_PATH.length)}
|
path={CHANNELS_ROOM_PATH.slice(CHANNELS_SPACE_PATH.length)}
|
||||||
element={null}
|
element={
|
||||||
/>
|
<SpaceRouteRoomProvider>
|
||||||
|
<Room />
|
||||||
|
</SpaceRouteRoomProvider>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Thread drawer URL — same Room element renders, drawer
|
||||||
|
opens by reading `:rootId` via useParams. The
|
||||||
|
SpaceRouteRoomProvider lives on the parent route and
|
||||||
|
stays mounted across the room↔thread URL flip. */}
|
||||||
|
<Route
|
||||||
|
path={CHANNELS_THREAD_PATH.slice(CHANNELS_ROOM_PATH.length)}
|
||||||
|
element={null}
|
||||||
|
/>
|
||||||
|
{/* Event-anchored URL — search/inbox/mention/push permalinks.
|
||||||
|
RR6 merges child params into the parent's useParams so
|
||||||
|
Room.tsx reads `eventId` without re-routing. */}
|
||||||
|
<Route
|
||||||
|
path={CHANNELS_ROOM_EVENT_PATH.slice(CHANNELS_ROOM_PATH.length)}
|
||||||
|
element={null}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Navigate, useNavigate } from 'react-router-dom';
|
import { Navigate, useMatch, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { Box, Button, Icon, Icons, Text } from 'folds';
|
import { Box, Button, Icon, Icons, Text } from 'folds';
|
||||||
|
|
@ -9,6 +9,7 @@ import { useOrphanSpaces } from '../../../state/hooks/roomList';
|
||||||
import { useOpenCreateSpaceModal } from '../../../state/hooks/createSpaceModal';
|
import { useOpenCreateSpaceModal } from '../../../state/hooks/createSpaceModal';
|
||||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||||
|
import { CHANNELS_PATH } from '../../paths';
|
||||||
import { getChannelsSpacePath, getExplorePath } from '../../pathUtils';
|
import { getChannelsSpacePath, getExplorePath } from '../../pathUtils';
|
||||||
import { NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
|
import { NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
|
||||||
import { useActiveSpace } from './useActiveSpace';
|
import { useActiveSpace } from './useActiveSpace';
|
||||||
|
|
@ -16,6 +17,13 @@ import { useActiveSpace } from './useActiveSpace';
|
||||||
// Index route for /channels/. Resolves the active Space (URL > localStorage >
|
// Index route for /channels/. Resolves the active Space (URL > localStorage >
|
||||||
// first joined orphan Space) and redirects there. If the user is in 0 orphan
|
// first joined orphan Space) and redirects there. If the user is in 0 orphan
|
||||||
// spaces, shows an empty state with a CTA to /explore/.
|
// spaces, shows an empty state with a CTA to /explore/.
|
||||||
|
//
|
||||||
|
// The redirect is gated on `useMatch(CHANNELS_PATH, end:true)` because the
|
||||||
|
// mobile tab pager mounts this component as a side-by-side pane even while
|
||||||
|
// the URL still points at /direct/ or /bots/. Without the gate the
|
||||||
|
// `<Navigate replace>` here would fire on mount regardless of location (RR6
|
||||||
|
// runs Navigate's effect on mount of the element) and bounce the user out
|
||||||
|
// of their actual tab into the channels space on every cold-start.
|
||||||
export function ChannelsLanding() {
|
export function ChannelsLanding() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -24,11 +32,20 @@ export function ChannelsLanding() {
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
|
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
|
||||||
const activeSpaceId = useActiveSpace(orphanSpaces);
|
const activeSpaceId = useActiveSpace(orphanSpaces);
|
||||||
|
const atChannelsRoot = !!useMatch({ path: CHANNELS_PATH, end: true });
|
||||||
|
|
||||||
if (activeSpaceId) {
|
if (activeSpaceId && atChannelsRoot) {
|
||||||
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, activeSpaceId);
|
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, activeSpaceId);
|
||||||
return <Navigate to={getChannelsSpacePath(spaceIdOrAlias)} replace />;
|
return <Navigate to={getChannelsSpacePath(spaceIdOrAlias)} replace />;
|
||||||
}
|
}
|
||||||
|
// When the user has spaces but the pager mounted us in an off-screen
|
||||||
|
// pane (URL is still /direct/ or /bots/), render nothing rather than
|
||||||
|
// the «no spaces» empty state. The empty-state UI would mislead a
|
||||||
|
// user who actually has spaces — they'd see «join a workspace» while
|
||||||
|
// simply standing on the next tab over.
|
||||||
|
if (activeSpaceId && !atChannelsRoot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavEmptyCenter>
|
<NavEmptyCenter>
|
||||||
|
|
|
||||||
|
|
@ -62,14 +62,22 @@ const HORSESHOE_EMERGE_PX = 80;
|
||||||
// Symmetric cubic in-out — linear ramp was too snappy in the canonical
|
// Symmetric cubic in-out — linear ramp was too snappy in the canonical
|
||||||
// horseshoe (corners jumped in within ~10px of drag); cubic keeps them
|
// horseshoe (corners jumped in within ~10px of drag); cubic keeps them
|
||||||
// subtle until ~40% of the gesture, then blossoms around the midpoint.
|
// subtle until ~40% of the gesture, then blossoms around the midpoint.
|
||||||
const easeInOutCubic = (t: number): number =>
|
const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
|
||||||
t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2;
|
|
||||||
|
// Axis dead-zone for horizontal-bail. The finger must travel this far
|
||||||
|
// on either axis before we resolve as vertical (open/close the sheet)
|
||||||
|
// or horizontal (yield to MobileTabsPager on listing surfaces). Note:
|
||||||
|
// the workspace sheet only mounts on /channels/!space/, where the
|
||||||
|
// pager is inactive — the bail here is defensive symmetry with the
|
||||||
|
// settings horseshoe so future layout changes can't surprise us.
|
||||||
|
const AXIS_DEAD_ZONE_PX = 12;
|
||||||
|
|
||||||
type DragSource = 'footer' | 'handle';
|
type DragSource = 'footer' | 'handle';
|
||||||
|
|
||||||
type DragState = {
|
type DragState = {
|
||||||
source: DragSource;
|
source: DragSource;
|
||||||
inputType: 'touch' | 'pointer';
|
inputType: 'touch' | 'pointer';
|
||||||
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
deltaY: number;
|
deltaY: number;
|
||||||
};
|
};
|
||||||
|
|
@ -79,10 +87,7 @@ type ChannelsWorkspaceHorseshoeProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ChannelsWorkspaceHorseshoe({
|
export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspaceHorseshoeProps) {
|
||||||
space,
|
|
||||||
children,
|
|
||||||
}: ChannelsWorkspaceHorseshoeProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const open = useAtomValue(channelsWorkspaceSheetAtom);
|
const open = useAtomValue(channelsWorkspaceSheetAtom);
|
||||||
const openSheet = useOpenChannelsWorkspaceSheet();
|
const openSheet = useOpenChannelsWorkspaceSheet();
|
||||||
|
|
@ -194,9 +199,7 @@ export function ChannelsWorkspaceHorseshoe({
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
if (
|
if (
|
||||||
target &&
|
target &&
|
||||||
(target.tagName === 'INPUT' ||
|
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||||
target.tagName === 'TEXTAREA' ||
|
|
||||||
target.isContentEditable)
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -220,16 +223,44 @@ export function ChannelsWorkspaceHorseshoe({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEl = handleRef.current;
|
const handleEl = handleRef.current;
|
||||||
|
|
||||||
|
// Per-drag axis-resolution flag (same idiom as MobileSettings-
|
||||||
|
// Horseshoe). Reset to false in every touchstart / pointerdown
|
||||||
|
// handler, flipped to true in applyMove once the finger leaves the
|
||||||
|
// dead-zone with a vertical-dominant delta.
|
||||||
|
let axisResolved = false;
|
||||||
|
// Sticky bail for the remainder of the current touch series once
|
||||||
|
// we've yielded to the pager (axis horizontal). Prevents a late
|
||||||
|
// vertical reversal in the same touch from re-engaging the sheet.
|
||||||
|
let touchBailed = false;
|
||||||
|
|
||||||
// CLAMP, not early-return — reversal of gesture direction must
|
// CLAMP, not early-return — reversal of gesture direction must
|
||||||
// drag `deltaY` back toward 0. footer source clamps the upward
|
// drag `deltaY` back toward 0. footer source clamps the upward
|
||||||
// drag-open path to negative deltas; handle source clamps the
|
// drag-open path to negative deltas; handle source clamps the
|
||||||
// downward drag-close path to positive deltas.
|
// downward drag-close path to positive deltas.
|
||||||
const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => {
|
const applyMove = (clientX: number, clientY: number, e: TouchEvent | PointerEvent) => {
|
||||||
|
if (touchBailed) return;
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
const rawDelta = clientY - d.startY;
|
const rawDeltaX = clientX - d.startX;
|
||||||
const nextDelta =
|
const rawDeltaY = clientY - d.startY;
|
||||||
d.source === 'footer' ? Math.min(0, rawDelta) : Math.max(0, rawDelta);
|
|
||||||
|
if (!axisResolved) {
|
||||||
|
const dxAbs = Math.abs(rawDeltaX);
|
||||||
|
const dyAbs = Math.abs(rawDeltaY);
|
||||||
|
if (dxAbs < AXIS_DEAD_ZONE_PX && dyAbs < AXIS_DEAD_ZONE_PX) return;
|
||||||
|
if (dxAbs > dyAbs) {
|
||||||
|
// Horizontal-dominant — yield to MobileTabsPager. Drop drag
|
||||||
|
// state without preventDefault so the pager can take over.
|
||||||
|
// Sticky bail prevents a late vertical reversal from
|
||||||
|
// re-engaging the sheet within the same touch series.
|
||||||
|
touchBailed = true;
|
||||||
|
setDrag(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
axisResolved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDelta = d.source === 'footer' ? Math.min(0, rawDeltaY) : Math.max(0, rawDeltaY);
|
||||||
if (e.cancelable) e.preventDefault();
|
if (e.cancelable) e.preventDefault();
|
||||||
setDrag({ ...d, deltaY: nextDelta });
|
setDrag({ ...d, deltaY: nextDelta });
|
||||||
};
|
};
|
||||||
|
|
@ -256,9 +287,12 @@ export function ChannelsWorkspaceHorseshoe({
|
||||||
if (openRef.current) return; // sheet open → handle owns drag
|
if (openRef.current) return; // sheet open → handle owns drag
|
||||||
if (!targetIsDragOrigin(e.target)) return;
|
if (!targetIsDragOrigin(e.target)) return;
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
|
axisResolved = false;
|
||||||
|
touchBailed = false;
|
||||||
setDrag({
|
setDrag({
|
||||||
source: 'footer',
|
source: 'footer',
|
||||||
inputType: 'touch',
|
inputType: 'touch',
|
||||||
|
startX: touch.clientX,
|
||||||
startY: touch.clientY,
|
startY: touch.clientY,
|
||||||
deltaY: 0,
|
deltaY: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -267,9 +301,12 @@ export function ChannelsWorkspaceHorseshoe({
|
||||||
if (dragRef.current) return;
|
if (dragRef.current) return;
|
||||||
if (!openRef.current) return;
|
if (!openRef.current) return;
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
|
axisResolved = false;
|
||||||
|
touchBailed = false;
|
||||||
setDrag({
|
setDrag({
|
||||||
source: 'handle',
|
source: 'handle',
|
||||||
inputType: 'touch',
|
inputType: 'touch',
|
||||||
|
startX: touch.clientX,
|
||||||
startY: touch.clientY,
|
startY: touch.clientY,
|
||||||
deltaY: 0,
|
deltaY: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -277,7 +314,7 @@ export function ChannelsWorkspaceHorseshoe({
|
||||||
const onTouchMove = (e: TouchEvent) => {
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d || d.inputType !== 'touch') return;
|
if (!d || d.inputType !== 'touch') return;
|
||||||
applyMove(e.touches[0].clientY, e);
|
applyMove(e.touches[0].clientX, e.touches[0].clientY, e);
|
||||||
};
|
};
|
||||||
const onTouchEnd = () => {
|
const onTouchEnd = () => {
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
|
|
@ -292,9 +329,12 @@ export function ChannelsWorkspaceHorseshoe({
|
||||||
if (openRef.current) return;
|
if (openRef.current) return;
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
if (!targetIsDragOrigin(e.target)) return;
|
if (!targetIsDragOrigin(e.target)) return;
|
||||||
|
axisResolved = false;
|
||||||
|
touchBailed = false;
|
||||||
setDrag({
|
setDrag({
|
||||||
source: 'footer',
|
source: 'footer',
|
||||||
inputType: 'pointer',
|
inputType: 'pointer',
|
||||||
|
startX: e.clientX,
|
||||||
startY: e.clientY,
|
startY: e.clientY,
|
||||||
deltaY: 0,
|
deltaY: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -304,9 +344,12 @@ export function ChannelsWorkspaceHorseshoe({
|
||||||
if (dragRef.current) return;
|
if (dragRef.current) return;
|
||||||
if (!openRef.current) return;
|
if (!openRef.current) return;
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
|
axisResolved = false;
|
||||||
|
touchBailed = false;
|
||||||
setDrag({
|
setDrag({
|
||||||
source: 'handle',
|
source: 'handle',
|
||||||
inputType: 'pointer',
|
inputType: 'pointer',
|
||||||
|
startX: e.clientX,
|
||||||
startY: e.clientY,
|
startY: e.clientY,
|
||||||
deltaY: 0,
|
deltaY: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -315,7 +358,7 @@ export function ChannelsWorkspaceHorseshoe({
|
||||||
if (e.pointerType === 'touch') return;
|
if (e.pointerType === 'touch') return;
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d || d.inputType !== 'pointer') return;
|
if (!d || d.inputType !== 'pointer') return;
|
||||||
applyMove(e.clientY, e);
|
applyMove(e.clientX, e.clientY, e);
|
||||||
};
|
};
|
||||||
const onDocPointerEnd = (e: PointerEvent) => {
|
const onDocPointerEnd = (e: PointerEvent) => {
|
||||||
if (e.pointerType === 'touch') return;
|
if (e.pointerType === 'touch') return;
|
||||||
|
|
@ -376,9 +419,7 @@ export function ChannelsWorkspaceHorseshoe({
|
||||||
const silhouetteTransition = isDragging
|
const silhouetteTransition = isDragging
|
||||||
? 'none'
|
? 'none'
|
||||||
: `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
: `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||||
const appBodyTransition = isDragging
|
const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||||
? 'none'
|
|
||||||
: `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
|
||||||
|
|
||||||
const containerStyle: React.CSSProperties = {
|
const containerStyle: React.CSSProperties = {
|
||||||
backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
|
backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
|
||||||
|
|
@ -451,10 +492,7 @@ export function ChannelsWorkspaceHorseshoe({
|
||||||
</div>
|
</div>
|
||||||
<div className={css.panelBody}>
|
<div className={css.panelBody}>
|
||||||
{renderSheet && (
|
{renderSheet && (
|
||||||
<WorkspaceSwitcherSheet
|
<WorkspaceSwitcherSheet space={space} requestClose={() => closeSheetRef.current()} />
|
||||||
space={space}
|
|
||||||
requestClose={() => closeSheetRef.current()}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMappe
|
||||||
import { useDirectRooms } from './useDirectRooms';
|
import { useDirectRooms } from './useDirectRooms';
|
||||||
import { useDirectInvites, DirectInviteEntry } from './useDirectInvites';
|
import { useDirectInvites, DirectInviteEntry } from './useDirectInvites';
|
||||||
import { PageNav, PageNavContent } from '../../../components/page';
|
import { PageNav, PageNavContent } from '../../../components/page';
|
||||||
|
import { DIRECT_PATH } from '../../paths';
|
||||||
import {
|
import {
|
||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
|
|
@ -102,7 +103,10 @@ function SpamToggleRow({ spamCount, expanded, onToggle }: SpamToggleRowProps) {
|
||||||
|
|
||||||
export function Direct() {
|
export function Direct() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
useNavToActivePathMapper('direct');
|
// The mobile tab pager keeps Direct mounted while the URL points at a
|
||||||
|
// sibling tab (/channels/, /bots/). Gate the persisted-path write on the
|
||||||
|
// /direct/ prefix so the atom isn't poisoned with a foreign pathname.
|
||||||
|
useNavToActivePathMapper('direct', DIRECT_PATH);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const directs = useDirectRooms();
|
const directs = useDirectRooms();
|
||||||
const invites = useDirectInvites();
|
const invites = useDirectInvites();
|
||||||
|
|
|
||||||
22
src/app/state/mobilePagerHeader.ts
Normal file
22
src/app/state/mobilePagerHeader.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
// Controls exposed by the active pane's curtain to the shared static
|
||||||
|
// tabs row at the top of MobileTabsPager. The pager hoists the tabs +
|
||||||
|
// action icons OUT of each pane's StreamHeader so the header stays
|
||||||
|
// visually static while the listing content slides horizontally — but
|
||||||
|
// the action icons (Plus / Search / X) still need to drive the active
|
||||||
|
// pane's local curtain, so we proxy them through this atom.
|
||||||
|
//
|
||||||
|
// Lifecycle: each pane's StreamHeader subscribes itself to a
|
||||||
|
// MobilePagerPaneContext that flags whether it's the active pane. When
|
||||||
|
// active, the StreamHeader's effect writes its curtain controls here;
|
||||||
|
// on deactivation (or unmount) the effect clears them. Only one pane
|
||||||
|
// is active at any moment, so writes don't race.
|
||||||
|
export type MobilePagerCurtainControls = {
|
||||||
|
openSearch: () => void;
|
||||||
|
openChat: () => void;
|
||||||
|
closeForm: () => void;
|
||||||
|
isFormActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mobilePagerCurtainAtom = atom<MobilePagerCurtainControls | null>(null);
|
||||||
Loading…
Add table
Reference in a new issue