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:
heaven 2026-05-18 22:00:53 +03:00
parent 727a53a776
commit 870e13d895
17 changed files with 1342 additions and 139 deletions

View file

@ -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);
}

View 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 />;
}

View 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>
);
}

View 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>
);
}

View 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;

View 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';

View 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,
});

View file

@ -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]);
}

View file

@ -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}

View file

@ -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;

View file

@ -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}>

View file

@ -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]);
}; };

View file

@ -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 roomthread 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 roomthread 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>

View file

@ -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>

View file

@ -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>

View file

@ -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();

View 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);