From d92f6dc1ca78ee4a7cbf8529934476a0d74a6f19 Mon Sep 17 00:00:00 2001 From: heaven Date: Tue, 2 Jun 2026 13:31:57 +0300 Subject: [PATCH] feat(mobile): add swipe-right-to-go-back on chat screens, sliding the chat over the static listing pager --- .../mobile-tabs-pager/MobileTabsLayout.tsx | 110 ++++++++---- .../mobile-tabs-pager/MobileTabsPager.tsx | 82 ++++++--- .../components/mobile-tabs-pager/style.css.ts | 35 ++++ .../swipe-back/SwipeBackOverlay.tsx | 154 ++++++++++++++++ src/app/components/swipe-back/geometry.ts | 21 +++ src/app/components/swipe-back/style.css.ts | 71 ++++++++ .../swipe-back/useSwipeBackGesture.ts | 170 ++++++++++++++++++ src/app/utils/routeParent.ts | 17 ++ 8 files changed, 601 insertions(+), 59 deletions(-) create mode 100644 src/app/components/swipe-back/SwipeBackOverlay.tsx create mode 100644 src/app/components/swipe-back/geometry.ts create mode 100644 src/app/components/swipe-back/style.css.ts create mode 100644 src/app/components/swipe-back/useSwipeBackGesture.ts diff --git a/src/app/components/mobile-tabs-pager/MobileTabsLayout.tsx b/src/app/components/mobile-tabs-pager/MobileTabsLayout.tsx index 093a3e8a..c6b3acaa 100644 --- a/src/app/components/mobile-tabs-pager/MobileTabsLayout.tsx +++ b/src/app/components/mobile-tabs-pager/MobileTabsLayout.tsx @@ -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 `` 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(null); + useEffect(() => { + if (ref.current) ref.current.inert = inert; + }, [inert]); + return ( +
+ {children} +
+ ); +} + +// 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` 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 — 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 `` 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(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 ( +
+
+ {children} +
+
+ ); +} 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) + ); +};