feat(mobile): add swipe-right-to-go-back on chat screens, sliding the chat over the static listing pager

This commit is contained in:
heaven 2026-06-02 13:31:57 +03:00
parent 77959167fa
commit d92f6dc1ca
8 changed files with 601 additions and 59 deletions

View file

@ -1,8 +1,11 @@
import React, { Suspense } from 'react';
import { Outlet, useMatch } from 'react-router-dom';
import React, { ReactNode, Suspense, useEffect, useRef } from 'react';
import { Outlet, useLocation, useMatch, useNavigate } 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 { getRouteSectionParent, isListingRootPath } from '../../utils/routeParent';
import { SwipeBackOverlay } from '../swipe-back/SwipeBackOverlay';
import * as css from './style.css';
// MobileTabsPager statically imports the Channels and Bots feature modules and
// renders only on `mobile && native` (below), so lazy-loading it here keeps the
@ -16,48 +19,91 @@ const MobileTabsPager = React.lazy(() =>
import('./MobileTabsPager').then((m) => ({ default: m.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.
// Wraps the persistent listing pager when it's serving as the BASE layer
// behind a chat overlay. Toggles `inert` (+ aria-hidden) so the whole pager
// subtree is removed from hit-testing, focus order and the a11y tree while a
// chat sits on top — same technique as `PaneSlot` in MobileTabsPager.
// `inert` is assigned via ref (not a JSX attr) because React 18.2's
// HTMLAttributes typing doesn't include it; the DOM property is supported on
// Capacitor's WebView baseline (Chromium 102+).
function BaseLayer({ inert, children }: { inert: boolean; children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) ref.current.inert = inert;
}, [inert]);
return (
<div ref={ref} className={css.tabsBaseLayer} aria-hidden={inert || undefined}>
{children}
</div>
);
}
// Router-level wrapper around the three listing tabs (/direct/, /channels/,
// /bots/) and their detail screens. On `mobile && native` it implements a
// native navigation-stack: the listing pager is ALWAYS mounted as the base
// layer, and a detail URL (a chat) is mounted as a sliding overlay on top of
// it. A rightward swipe pops the chat to reveal the real list behind it (see
// `SwipeBackOverlay`). At a listing root there's no overlay — just the pager,
// exactly as before, only wrapped in `tabsHost`/`BaseLayer`.
//
// 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.
// Anywhere else — non-mobile breakpoints (tablet, desktop), non-Capacitor
// runtimes (mobile web, Electron) — we pass through to `<Outlet/>` and the
// existing route tree (its own PageRoot nav + Room) renders unchanged.
export function MobileTabsLayout() {
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
const native = isNativePlatform();
const location = useLocation();
const navigate = useNavigate();
// The shared CSS-var host: SwipeBackOverlay writes `--swipe-x` +
// `data-swipe-dragging` here; the overlay card reads the inherited
// `--swipe-x` to slide. The base layer is static (no parallax).
const hostRef = useRef<HTMLDivElement>(null);
// Listing roots (exact). Determine base-only vs base+overlay.
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) {
// Section membership (prefix). A detail URL under one of these is a chat we
// overlay; the pager backdrop resolves the matching tab from the section.
const directSection = !!useMatch({ path: DIRECT_PATH, end: false });
const channelsSection = !!useMatch({ path: CHANNELS_PATH, end: false });
const botsSection = !!useMatch({ path: BOTS_PATH, end: false });
const onTabsSection = directSection || channelsSection || botsSection;
if (!(mobile && native) || !onTabsSection) {
return <Outlet />;
}
const hasOverlay = !onListingRoot; // a detail URL under a tab section → a chat
// Pop target. When it's a real listing root we run the animated card-slide;
// when it's itself a detail screen (a thread / event-permalink whose parent
// is the room) we skip the visual swipe and leave header/hardware back.
const parent = getRouteSectionParent(location.pathname);
const swipeEnabled = !!parent && isListingRootPath(parent);
const onBack = () => {
if (parent) navigate(parent, { replace: true });
};
return (
<Suspense fallback={null}>
<MobileTabsPager />
</Suspense>
<div ref={hostRef} className={css.tabsHost}>
<BaseLayer inert={hasOverlay}>
<Suspense fallback={null}>
<MobileTabsPager asBackdrop={hasOverlay} />
</Suspense>
</BaseLayer>
{hasOverlay && (
<SwipeBackOverlay
hostRef={hostRef}
swipeEnabled={swipeEnabled}
resetKey={location.key}
onBack={onBack}
>
<Outlet />
</SwipeBackOverlay>
)}
</div>
);
}

View file

@ -109,29 +109,41 @@ function PaneSlot({ isActive, children }: PaneSlotProps) {
// 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() {
type MobileTabsPagerProps = {
// When true the pager is mounted as the inert BASE layer behind a chat
// overlay (see MobileTabsLayout). It must (a) suppress its own swipe
// gesture and (b) never fall through to `<Outlet/>`, which would render
// the chat a second time behind itself.
asBackdrop?: boolean;
};
export function MobileTabsPager({ asBackdrop = false }: MobileTabsPagerProps) {
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.
// Resolve the active tab from the URL's SECTION, not only the listing
// root. At a listing root (/direct/, /channels/, /channels/!space/,
// /bots/) the pager is the live foreground; at a DETAIL url under a
// section (/direct/!room, /channels/!space/!room/, /bots/:botId) the
// pager is mounted as the inert BACKDROP behind a chat overlay
// (MobileTabsLayout `asBackdrop`) and must show that section's list
// behind the chat. So Channels/Bots use `end:false` section matches;
// Channels keeps the exact landing match too. `channelsSpaceMatch`
// (end:false) yields `params.spaceIdOrAlias` for both /channels/!space/
// and any /channels/!space/!room/..., which `urlSpaceId` below resolves so
// the correct workspace renders behind a channel room.
const channelsRoot = !!useMatch({ path: CHANNELS_PATH, end: true });
const channelsSpaceMatch = useMatch({ path: CHANNELS_SPACE_PATH, end: true });
const channelsSpaceMatch = useMatch({ path: CHANNELS_SPACE_PATH, end: false });
const channelsSpaceRoot = !!channelsSpaceMatch;
const channelsActive = channelsRoot || channelsSpaceRoot;
const botsRoot = !!useMatch({ path: BOTS_PATH, end: true });
const botsRoot = !!useMatch({ path: BOTS_PATH, end: false });
// 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
// an orphan (no parent Space). Both resolvers below constrain their
// 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
@ -143,22 +155,33 @@ export function MobileTabsPager() {
// Invalid URL spaces hit the early-return below.
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanSpaceIds = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
const activeSpaceId = useActiveSpace(orphanSpaceIds);
// Resolve the URL `:spaceIdOrAlias` to a joined-orphan space id. Read from
// the `useMatch` param (channelsSpaceMatch), which is populated even when
// the pager is the inert BACKDROP behind a chat — unlike `useActiveSpace`'s
// `useParams()`, which is empty there because the pager sits OUTSIDE the
// detail route's element tree. So a deep-link / push straight into a room of
// a not-yet-visited workspace still renders THAT workspace behind the chat,
// matching where the back-swipe lands (`getRouteSectionParent`).
const urlSpaceParam = channelsSpaceMatch?.params.spaceIdOrAlias;
const urlSpaceId = useMemo(() => {
if (!urlSpaceParam) return undefined;
const decoded = safeDecode(urlSpaceParam);
if (!decoded) return undefined;
const resolved = isRoomAlias(decoded) ? getCanonicalAliasRoomId(mx, decoded) : decoded;
return resolved !== undefined && orphanSpaceIds.includes(resolved) ? resolved : undefined;
}, [mx, urlSpaceParam, orphanSpaceIds]);
// URL space wins (correct in the backdrop); fall back to the persisted /
// first-orphan resolver for listing roots where there's no room param.
const fallbackSpaceId = useActiveSpace(orphanSpaceIds);
const activeSpaceId = urlSpaceId ?? fallbackSpaceId;
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]);
// A URL space present in the param but not resolving to a joined orphan is
// an invalid deep-link (unjoined / unknown) → defer to the route tree's
// JoinBeforeNavigate via the early-return below (unless we're the backdrop).
const urlSpaceIsValid = !urlSpaceParam || urlSpaceId !== undefined;
const showBots = bots.length > 0 || botsRoot;
@ -318,7 +341,8 @@ export function MobileTabsPager() {
// transitions.
const settingsSheetOpen = !!useAtomValue(settingsSheetAtom);
const workspaceSheetOpen = !!useAtomValue(channelsWorkspaceSheetAtom);
const gestureDisabled = settingsSheetOpen || workspaceSheetOpen || pendingTargetTab !== null;
const gestureDisabled =
settingsSheetOpen || workspaceSheetOpen || pendingTargetTab !== null || asBackdrop;
const rootRef = useRef<HTMLDivElement>(null);
useMobileTabsPagerGesture({
@ -390,7 +414,11 @@ export function MobileTabsPager() {
// 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) {
// Suppressed when serving as a backdrop: there `<Outlet/>` is the chat
// we're rendering BEHIND, so falling through would mount it twice. The
// activeSpace resolver below degrades to a persisted/first-orphan space
// (or ChannelsRootNav) which is a fine, non-interactive backdrop.
if (channelsSpaceRoot && !urlSpaceIsValid && !asBackdrop) {
return <Outlet />;
}

View file

@ -159,3 +159,38 @@ export const pane = style({
height: '100%',
minWidth: 0,
});
// ── Navigation-stack host (swipe-to-go-back) ────────────────────────────
//
// On mobile + native, MobileTabsLayout renders the listing pager as a
// persistent, STATIC base layer and mounts a chat as a sliding OVERLAY on
// top (see `features/.../swipe-back`). `tabsHost` is the shared positioning +
// CSS-custom-property host: the overlay writes `--swipe-x` (px the chat card
// is dragged right) onto this element and toggles `data-swipe-dragging`. The
// base layer does NOT move or dim — dragging the chat card right just reveals
// the static screen underneath, like a shutter.
//
// `tabsHost` takes over the flex-slot sizing that `pagerRoot` carries when the
// pager is the only child — when the pager is the base layer it's absolute
// inset:0 inside this host instead.
export const tabsHost = style({
position: 'relative',
flex: '1 1 0',
minWidth: 0,
minHeight: 0,
height: '100%',
overflow: 'hidden',
});
// The base layer wraps the persistent (inert, while a chat is up) pager.
// `display:flex; column` so a child fills the layer's height the same way the
// authed shell's flex slot used to: the pager's `pagerRoot` carries its own
// height:100%, but the pager's invalid-space early-return renders a bare
// `<Outlet/>` (JoinBeforeNavigate) with no height source of its own — a flex
// column parent stretches it, matching the pre-`tabsHost` layout.
export const tabsBaseLayer = style({
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
});

View file

@ -0,0 +1,154 @@
import React, {
MutableRefObject,
ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
} from 'react';
import { useAtomValue } from 'jotai';
import { mediaViewerAtom } from '../../state/mediaViewer';
import { useSwipeBackGesture } from './useSwipeBackGesture';
import { PAGER_TRANSITION_MS } from './geometry';
import * as css from './style.css';
type SwipeBackOverlayProps = {
// The shared `tabsHost` element. We write `--swipe-x` and toggle
// `data-swipe-dragging` on it; the card reads the inherited `--swipe-x` to
// slide — one source of truth, zero React re-renders of the heavy chat
// subtree during the drag.
hostRef: MutableRefObject<HTMLDivElement | null>;
// Run the animated card-pop. False when the back target is itself a detail
// screen (e.g. a thread) — the chat still renders, but the gesture is not
// bound and the user falls back to header / hardware back.
swipeEnabled: boolean;
// Changes whenever the routed chat changes (location.key). Re-runs the
// CSS-var reset below so a fresh chat never inherits a prior drag's state —
// the overlay subtree stays mounted across chat→chat navigation, so a
// mount-only reset wouldn't fire.
resetKey: string;
// Pop to the parent (animate-out then navigate is handled here; this just
// performs the actual `navigate(parent, { replace })`).
onBack: () => void;
children: ReactNode;
};
// Sliding chat layer painted above the persistent, STATIC listing pager. A
// rightward drag moves the card off to reveal the list behind it; past the
// threshold we animate the card fully off and THEN navigate — so the chat is
// never torn out mid-animation (a pop unmounts this overlay, unlike the tab
// pager which never unmounts).
export function SwipeBackOverlay({
hostRef,
swipeEnabled,
resetKey,
onBack,
children,
}: SwipeBackOverlayProps) {
const cardRef = useRef<HTMLDivElement>(null);
const closingRef = useRef(false);
// Cancels an in-flight commit animation (removes the transitionend listener
// + clears the fallback timer) WITHOUT navigating. Stored so an external
// navigation that interrupts the slide can abort it — otherwise the
// leftover listener/timer would later fire `onBack` with a now-stale parent
// and override the new route. Null when no commit is in flight.
const cancelCommitRef = useRef<(() => void) | null>(null);
// ONLY the media viewer is suppressed: it renders inside the chat and has
// its OWN horizontal image paging, which a rightward swipe would fight. The
// profile / members horseshoes are deliberately NOT suppressed — they're
// vertical-only (no horizontal gesture to conflict with), so a rightward
// swipe simply pops the whole chat (panel included), consistent with
// "swipe right anywhere = back".
const mediaOpen = !!useAtomValue(mediaViewerAtom);
// The host's `--swipe-x` survives a previous commit (left at +vw) and the
// host is NOT unmounted between chats, so reset to rest before paint on
// every fresh chat (`resetKey` = location.key) — otherwise the card could
// appear off-screen or frozen mid-drag until the first new gesture.
useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
// An external navigation may have interrupted an in-flight commit from the
// PRIOR chat (the overlay subtree persists across chat→chat nav). Abort it
// so its stale `onBack` can't fire and override this route.
cancelCommitRef.current?.();
closingRef.current = false;
host.style.setProperty('--swipe-x', '0px');
host.dataset.swipeDragging = 'false';
}, [hostRef, resetKey]);
// Belt-and-suspenders: if the overlay unmounts mid-commit (e.g. the screen
// crosses the mobile breakpoint), abort the pending commit so it can't fire
// after unmount.
useEffect(
() => () => {
cancelCommitRef.current?.();
},
[]
);
const setDrag = useCallback(
(px: number, dragging: boolean) => {
const host = hostRef.current;
if (!host || closingRef.current) return;
host.style.setProperty('--swipe-x', `${px}px`);
host.dataset.swipeDragging = dragging ? 'true' : 'false';
},
[hostRef]
);
const onCommit = useCallback(() => {
const host = hostRef.current;
const card = cardRef.current;
if (closingRef.current) return;
if (!host || !card) {
onBack();
return;
}
closingRef.current = true;
// Enable the transition (data-swipe-dragging=false) and drive the card
// fully off-screen; the static base already shows the destination list, so
// when the overlay unmounts off-screen there is zero flash.
host.dataset.swipeDragging = 'false';
const vw = window.innerWidth || 1;
host.style.setProperty('--swipe-x', `${vw}px`);
let settled = false;
let timer = 0;
let onEnd: ((e: TransitionEvent) => void) | null = null;
// `navigate` true on natural completion, false when an external nav aborts
// us mid-slide (then we must NOT navigate — that would clobber the route).
const settle = (navigate: boolean) => {
if (settled) return;
settled = true;
if (onEnd) card.removeEventListener('transitionend', onEnd);
if (timer) window.clearTimeout(timer);
cancelCommitRef.current = null;
if (navigate) onBack();
};
onEnd = (e: TransitionEvent) => {
if (e.target === card && e.propertyName === 'transform') settle(true);
};
card.addEventListener('transitionend', onEnd);
// Fallback if transitionend doesn't fire (transform interrupted/replaced,
// or the card was already off-screen so there's no transition to end).
timer = window.setTimeout(() => settle(true), PAGER_TRANSITION_MS + 80);
cancelCommitRef.current = () => settle(false);
}, [hostRef, onBack]);
useSwipeBackGesture({
rootRef: cardRef,
enabled: swipeEnabled,
disabled: mediaOpen,
setDrag,
onBack: onCommit,
});
return (
<div className={css.overlayRoot}>
<div ref={cardRef} className={css.card}>
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
// Tuning for the mobile swipe-to-go-back (interactive pop) gesture.
//
// Active only on Capacitor native + mobile, on detail screens nested under a
// tab section. The gesture is FUNCTIONALLY IDENTICAL to the tab pager:
// distance-only commit with snap-back, and the SAME tuning — axis-resolve
// dead-zone, edge-guard, commit threshold and animation curve are all
// re-exported from the pager's geometry (not re-literalled), so the two
// gestures stay in exact lockstep and can never drift in feel.
//
// Commit threshold = max(MIN_COMMIT_PX, viewport_width × COMMIT_FRACTION) =
// max(150, 0.4·vw) ≈ 160px on a 400px phone. Below it the card springs back
// and we STAY on the chat — exactly the pager's "didn't drag far enough →
// nothing opens".
export {
DEAD_ZONE_PX,
EDGE_GUARD_PX,
COMMIT_FRACTION,
MIN_COMMIT_PX,
PAGER_TRANSITION_MS,
PAGER_EASING,
} from '../mobile-tabs-pager/geometry';

View file

@ -0,0 +1,71 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
import { PAGER_EASING, PAGER_TRANSITION_MS } from './geometry';
import { tabsHost } from '../mobile-tabs-pager/style.css';
// The sliding chat lives in this overlay, painted ABOVE the persistent
// listing pager (the base layer) inside `tabsHost`. Full-bleed, no padding:
// the chat owns its own safe-top via its wrapping horseshoe and the base
// pager owns its insets — adding env(safe-area-inset-bottom) here would
// double-lift (see global.css.ts).
export const overlayRoot = style({
position: 'absolute',
inset: 0,
zIndex: 1,
overflow: 'hidden',
});
// The sliding chat card — a "shutter" over the STATIC listing behind it.
// Driven by `--swipe-x` (written to `tabsHost` by SwipeBackOverlay on every
// touchmove, inherited down here — no React re-render of the heavy chat
// subtree). The base list does NOT move or dim: dragging this card right
// simply reveals the static screen underneath.
//
// `display: flex` is load-bearing: the chat route mounts `PageRoot` → an
// outer `<Box grow="Yes">` (a flex child that needs a flex parent to fill).
// Without it the whole chat (Room timeline + composer) collapses to zero
// height and renders as an empty black screen on DM / channel chats.
export const card = style({
position: 'absolute',
inset: 0,
display: 'flex',
overflow: 'hidden',
transform: 'translate3d(var(--swipe-x, 0px), 0, 0)',
willChange: 'transform',
// `pan-y` (same as the tab pager's pagerRoot) reserves HORIZONTAL panning
// for our JS gesture: the browser keeps vertical scroll (timeline,
// composer) but never composite-scrolls horizontally, so our touchmoves
// stay cancelable EVERYWHERE on the card — the swipe engages across the
// whole screen, not just over non-scrollable spots. (With `auto`, Chrome
// optimistically composites the timeline's scroll and marks those moves
// non-cancelable, which is why the gesture previously only worked off a
// non-scrolling sliver.) Trade-off: a horizontally-overflowing code block
// (`Scroll direction="Both"`) can't be touch-panned sideways while inside a
// `pan-y` ancestor — an accepted, rare cost for a reliable back-swipe.
touchAction: 'pan-y',
backgroundColor: color.SurfaceVariant.Container,
// Square at rest (covering the viewport), so no base slivers show in the
// corners. Leading-edge rounding is added only WHILE dragging (below) —
// toggled by an attribute, not ramped per-frame, to avoid a clip repaint
// on every touchmove of the heavy chat layer.
borderRadius: 0,
// Soft shadow on the leading edge so the shutter reads as lifted off the
// static screen behind it.
boxShadow: '-8px 0 24px rgba(0, 0, 0, 0.35)',
});
// While actively dragging: follow the finger 1:1 (no transition) and round
// the leading (left) corners so the chat reads as a rounded shutter pulled
// off the static screen behind it («если экран скруглён, то и уезжающий
// скруглён»). The trailing edge stays square against the viewport edge.
globalStyle(`.${tabsHost}[data-swipe-dragging="true"] .${card}`, {
transition: 'none',
borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
borderBottomLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
});
// On release / commit: animate the shutter home or off-screen. Same
// curve/duration as the tab pager + curtain so all motion feels consistent.
globalStyle(`.${tabsHost}:not([data-swipe-dragging="true"]) .${card}`, {
transition: `transform ${PAGER_TRANSITION_MS}ms ${PAGER_EASING}`,
});

View file

@ -0,0 +1,170 @@
import { MutableRefObject, useEffect, useRef } from 'react';
import { COMMIT_FRACTION, DEAD_ZONE_PX, EDGE_GUARD_PX, MIN_COMMIT_PX } from './geometry';
type Args = {
// The sliding chat CARD element the listeners bind to. Touches outside it
// never reach the gesture. Because the card itself translates, the finger
// stays over it for the whole drag.
rootRef: MutableRefObject<HTMLDivElement | null>;
// Master gate. When false the effect binds NOTHING — a true no-op (used
// when the back target is itself a detail screen, e.g. a thread, where the
// animated card-pop would flash the list; header/hardware back stays).
enabled: boolean;
// Extra suppression while bound (e.g. an in-flight commit, an open media
// viewer with its own horizontal paging). touchstart bails immediately
// when true; cheaper than re-binding on every toggle.
disabled: boolean;
// Live drag reporter — px the card is dragged right (>=0) and whether a
// drag is in flight. The parent drives the transform from this via
// ref-written CSS vars, so the heavy chat subtree never re-renders.
setDrag: (px: number, dragging: boolean) => void;
// Commit = go back. The parent animates the card out then navigates.
onBack: () => void;
};
// Rightward "swipe-to-go-back" (interactive pop) driver. Mirrors
// `useMobileTabsPagerGesture`: single listener on the card root, refs for
// live state, axis-resolve in the dead-zone, distance threshold-commit on
// release with snap-back below it. Differences: rightward-only (back, never
// forward — leftward clamps at 0). The card is `touch-action: pan-y`, so the
// browser reserves horizontal panning for us and our moves stay cancelable
// over the whole screen.
export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBack }: Args): void {
const disabledRef = useRef(disabled);
const setDragRef = useRef(setDrag);
const onBackRef = useRef(onBack);
disabledRef.current = disabled;
setDragRef.current = setDrag;
onBackRef.current = onBack;
useEffect(() => {
const root = rootRef.current;
if (!root || !enabled) 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;
};
// Snap any in-flight visual offset back to rest, THEN clear state. Used on
// every abort/terminal path (multi-touch, disabled, browser-stole-scroll,
// cancel) so a partial drag can never be left applied — otherwise a second
// finger landing mid-drag would bail with `reset()` only and freeze the
// card half-dismissed (it's `data-swipe-dragging`, so no transition heals
// it).
const springHome = () => {
if (engaged || lastDragPx !== 0) setDragRef.current(0, false);
reset();
};
const onTouchStart = (e: TouchEvent) => {
if (disabledRef.current || e.touches.length !== 1) {
springHome();
return;
}
const t = e.touches[0];
const vw = window.innerWidth;
// The L/R edge strip belongs to the Android system back-gesture in
// edge-to-edge mode. Ours shares its direction, so we MUST cede it —
// a drag must start at least EDGE_GUARD_PX inside the left edge.
if (t.clientX < EDGE_GUARD_PX || t.clientX > vw - EDGE_GUARD_PX) {
springHome();
return;
}
startX = t.clientX;
startY = t.clientY;
engaged = false;
bailed = false;
lastDragPx = 0;
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1 || disabledRef.current) {
springHome();
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) {
if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) return;
// Ties go vertical — lets the timeline scroll and the profile /
// members / media horseshoes (all vertical) win ambiguous gestures.
if (Math.abs(dy) >= Math.abs(dx)) {
bailed = true;
return;
}
// Rightward-only: leftward-dominant horizontal isn't "back".
if (dx <= 0) {
bailed = true;
return;
}
engaged = true;
}
// The card is `touch-action: pan-y`, so the browser reserves HORIZONTAL
// panning for us — these touchmoves stay cancelable across the whole
// screen (including over the vertical-scrolling timeline), so the
// gesture engages everywhere, not just over non-scrollable spots. The
// guard is defensive only.
if (e.cancelable) e.preventDefault();
const vw = window.innerWidth;
// Follow the finger 1:1 to the right; clamp to [0, vw]. Never negative:
// there is no forward navigation, and a sub-zero translate would expose
// a sliver of the static base list on the card's RIGHT edge.
const drag = Math.max(0, Math.min(vw, dx));
lastDragPx = drag;
setDragRef.current(drag, true);
};
const onTouchEnd = () => {
if (!engaged || disabledRef.current) {
springHome();
return;
}
// Distance-only commit, exactly like the tab pager: drag past the
// threshold to go back, otherwise snap home and STAY on the chat. No
// velocity/fling — a short flick must not navigate (the user asked for
// the pager's "didn't drag far enough → nothing opens" feel).
const vw = window.innerWidth;
const threshold = Math.max(MIN_COMMIT_PX, vw * COMMIT_FRACTION);
if (lastDragPx >= threshold) {
onBackRef.current();
reset();
} else {
springHome();
}
};
const onTouchCancel = () => {
// System cancel (incoming call, scroll take-over, …) never commits.
springHome();
};
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);
};
// setDrag / onBack are mirrored via refs so the listener never re-binds
// mid-gesture; `enabled` is a real dep so toggling it binds/unbinds.
}, [rootRef, enabled]);
}

View file

@ -96,3 +96,20 @@ export const getRouteSectionParent = (pathname: string): string | null => {
return null;
};
/**
* True when `pathname` is exactly one of the mobile tab listing roots
* (Direct, Bots, Channels landing, or a Channels workspace). The mobile
* swipe-back overlay uses this on the result of `getRouteSectionParent` to
* decide whether a chat pops to a real LIST (run the animated card-slide
* with the list revealed behind) or to another DETAIL screen e.g. a
* thread / event-permalink whose parent is the room itself in which case
* it skips the visual swipe and leaves the instant header/hardware back.
*/
export const isListingRootPath = (pathname: string): boolean => {
const exact = (path: string) =>
matchPath({ path, caseSensitive: true, end: true }, pathname) !== null;
return (
exact(DIRECT_PATH) || exact(BOTS_PATH) || exact(CHANNELS_PATH) || exact(CHANNELS_SPACE_PATH)
);
};