(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 ;
}
+
+ 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 (
-
-
-
+
+
+
+
+
+
+ {hasOverlay && (
+
+
+
+ )}
+
);
}
diff --git a/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx b/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx
index c5d7bde4..b1f95efd 100644
--- a/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx
+++ b/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx
@@ -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 ``, 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(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 `` 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 ;
}
diff --git a/src/app/components/mobile-tabs-pager/style.css.ts b/src/app/components/mobile-tabs-pager/style.css.ts
index 09f5acd2..533cb282 100644
--- a/src/app/components/mobile-tabs-pager/style.css.ts
+++ b/src/app/components/mobile-tabs-pager/style.css.ts
@@ -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
+// `` (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',
+});
diff --git a/src/app/components/swipe-back/SwipeBackOverlay.tsx b/src/app/components/swipe-back/SwipeBackOverlay.tsx
new file mode 100644
index 00000000..8b984ab8
--- /dev/null
+++ b/src/app/components/swipe-back/SwipeBackOverlay.tsx
@@ -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;
+ // 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(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 (
+
+ );
+}
diff --git a/src/app/components/swipe-back/geometry.ts b/src/app/components/swipe-back/geometry.ts
new file mode 100644
index 00000000..8c1261f0
--- /dev/null
+++ b/src/app/components/swipe-back/geometry.ts
@@ -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';
diff --git a/src/app/components/swipe-back/style.css.ts b/src/app/components/swipe-back/style.css.ts
new file mode 100644
index 00000000..fbc37c0c
--- /dev/null
+++ b/src/app/components/swipe-back/style.css.ts
@@ -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 `` (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}`,
+});
diff --git a/src/app/components/swipe-back/useSwipeBackGesture.ts b/src/app/components/swipe-back/useSwipeBackGesture.ts
new file mode 100644
index 00000000..c79812d0
--- /dev/null
+++ b/src/app/components/swipe-back/useSwipeBackGesture.ts
@@ -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;
+ // 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]);
+}
diff --git a/src/app/utils/routeParent.ts b/src/app/utils/routeParent.ts
index 02606380..32f06c41 100644
--- a/src/app/utils/routeParent.ts
+++ b/src/app/utils/routeParent.ts
@@ -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)
+ );
+};