feat(mobile): add swipe-right-to-go-back on chat screens, sliding the chat over the static listing pager
This commit is contained in:
parent
77959167fa
commit
d92f6dc1ca
8 changed files with 601 additions and 59 deletions
|
|
@ -1,8 +1,11 @@
|
||||||
import React, { Suspense } from 'react';
|
import React, { ReactNode, Suspense, useEffect, useRef } from 'react';
|
||||||
import { Outlet, useMatch } from 'react-router-dom';
|
import { Outlet, useLocation, useMatch, useNavigate } from 'react-router-dom';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { isNativePlatform } from '../../utils/capacitor';
|
import { isNativePlatform } from '../../utils/capacitor';
|
||||||
import { BOTS_PATH, CHANNELS_PATH, CHANNELS_SPACE_PATH, DIRECT_PATH } from '../../pages/paths';
|
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
|
// MobileTabsPager statically imports the Channels and Bots feature modules and
|
||||||
// renders only on `mobile && native` (below), so lazy-loading it here keeps the
|
// 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 }))
|
import('./MobileTabsPager').then((m) => ({ default: m.MobileTabsPager }))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Router-level wrapper around the three listing tabs (/direct/,
|
// Wraps the persistent listing pager when it's serving as the BASE layer
|
||||||
// /channels/, /bots/). When all of (mobile breakpoint, Capacitor
|
// behind a chat overlay. Toggles `inert` (+ aria-hidden) so the whole pager
|
||||||
// native runtime, listing-root URL) hold, we hijack rendering and
|
// subtree is removed from hit-testing, focus order and the a11y tree while a
|
||||||
// mount `MobileTabsPager` directly — the wrapped routes' Outlet is
|
// chat sits on top — same technique as `PaneSlot` in MobileTabsPager.
|
||||||
// never read, so their `element` chains stay unmounted. Anywhere else
|
// `inert` is assigned via ref (not a JSX attr) because React 18.2's
|
||||||
// — non-mobile breakpoints (tablet, desktop), non-Capacitor runtimes
|
// HTMLAttributes typing doesn't include it; the DOM property is supported on
|
||||||
// (mobile web, Electron desktop), AND detail URLs nested under any
|
// Capacitor's WebView baseline (Chromium 102+).
|
||||||
// listing root (/direct/!roomId, /channels/!space/!roomId,
|
function BaseLayer({ inert, children }: { inert: boolean; children: ReactNode }) {
|
||||||
// /bots/:botId) — we pass through to `<Outlet/>` and the existing
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
// route tree renders unchanged.
|
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
|
// Anywhere else — non-mobile breakpoints (tablet, desktop), non-Capacitor
|
||||||
// the Channels tab:
|
// runtimes (mobile web, Electron) — we pass through to `<Outlet/>` and the
|
||||||
//
|
// existing route tree (its own PageRoot nav + Room) renders unchanged.
|
||||||
// * `/channels/` — landing (empty-state CTA when the user has no
|
|
||||||
// orphan spaces; otherwise pager-internal render of the active
|
|
||||||
// workspace via the persisted active-space resolver).
|
|
||||||
// * `/channels/!space/` — workspace listing for that specific space.
|
|
||||||
// This is what `commitTo('channels')` actually navigates to when
|
|
||||||
// an active space is known, so the user lands directly on the
|
|
||||||
// workspace view without bouncing through `/channels/` and the
|
|
||||||
// `ChannelsLanding` <Navigate> redirect (which previously cut the
|
|
||||||
// pager animation short and made the swipe feel jerky).
|
|
||||||
//
|
|
||||||
// Detail URLs nested under either (`/channels/!space/!roomId`, etc.)
|
|
||||||
// flip both matches to false and fall through to <Outlet/> — Room
|
|
||||||
// renders full-screen on mobile as before.
|
|
||||||
export function MobileTabsLayout() {
|
export function MobileTabsLayout() {
|
||||||
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
|
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||||
const native = isNativePlatform();
|
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 directRoot = !!useMatch({ path: DIRECT_PATH, end: true });
|
||||||
const channelsRoot = !!useMatch({ path: CHANNELS_PATH, end: true });
|
const channelsRoot = !!useMatch({ path: CHANNELS_PATH, end: true });
|
||||||
const channelsSpaceRoot = !!useMatch({ path: CHANNELS_SPACE_PATH, end: true });
|
const channelsSpaceRoot = !!useMatch({ path: CHANNELS_SPACE_PATH, end: true });
|
||||||
const botsRoot = !!useMatch({ path: BOTS_PATH, end: true });
|
const botsRoot = !!useMatch({ path: BOTS_PATH, end: true });
|
||||||
const onListingRoot = directRoot || channelsRoot || channelsSpaceRoot || botsRoot;
|
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 />;
|
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 (
|
return (
|
||||||
|
<div ref={hostRef} className={css.tabsHost}>
|
||||||
|
<BaseLayer inert={hasOverlay}>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<MobileTabsPager />
|
<MobileTabsPager asBackdrop={hasOverlay} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</BaseLayer>
|
||||||
|
{hasOverlay && (
|
||||||
|
<SwipeBackOverlay
|
||||||
|
hostRef={hostRef}
|
||||||
|
swipeEnabled={swipeEnabled}
|
||||||
|
resetKey={location.key}
|
||||||
|
onBack={onBack}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</SwipeBackOverlay>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,29 +109,41 @@ function PaneSlot({ isActive, children }: PaneSlotProps) {
|
||||||
// silently fall back to the persisted-or-first-orphan space and the
|
// silently fall back to the persisted-or-first-orphan space and the
|
||||||
// pager would show a DIFFERENT workspace than the URL claims —
|
// pager would show a DIFFERENT workspace than the URL claims —
|
||||||
// confusing the user and breaking deep-link semantics.
|
// 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 mx = useMatrixClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const bots = useBotPresets();
|
const bots = useBotPresets();
|
||||||
|
|
||||||
// `end: true` matches the listing-root URL exactly. Detail URLs
|
// Resolve the active tab from the URL's SECTION, not only the listing
|
||||||
// (/channels/!space/!room/, /direct/!room, /bots/:botId) flip these
|
// root. At a listing root (/direct/, /channels/, /channels/!space/,
|
||||||
// to false — and MobileTabsLayout above us would have rendered
|
// /bots/) the pager is the live foreground; at a DETAIL url under a
|
||||||
// Outlet instead of the pager in that case, so we never see those
|
// section (/direct/!room, /channels/!space/!room/, /bots/:botId) the
|
||||||
// states. Channels is matched via EITHER /channels/ (landing) OR
|
// pager is mounted as the inert BACKDROP behind a chat overlay
|
||||||
// /channels/!space/ (workspace listing) — both keep us on the
|
// (MobileTabsLayout `asBackdrop`) and must show that section's list
|
||||||
// channels tab.
|
// 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 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 channelsSpaceRoot = !!channelsSpaceMatch;
|
||||||
const channelsActive = channelsRoot || channelsSpaceRoot;
|
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
|
// Active space resolution mirrors ChannelsLanding: URL > localStorage
|
||||||
// > first joined orphan. `useOrphanSpaces` filters `allRoomsAtom`
|
// > first joined orphan. `useOrphanSpaces` filters `allRoomsAtom`
|
||||||
// through `isSpace(mx.getRoom) && !roomToParents.has(...)`, so every
|
// through `isSpace(mx.getRoom) && !roomToParents.has(...)`, so every
|
||||||
// entry it returns is BOTH (a) a Space the user has joined and (b)
|
// 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
|
// result to that orphan set. Net invariant: if `activeSpaceId` is
|
||||||
// defined, `mx.getRoom(activeSpaceId)` is a Space the user is
|
// defined, `mx.getRoom(activeSpaceId)` is a Space the user is
|
||||||
// currently a member of — which is exactly the precondition
|
// currently a member of — which is exactly the precondition
|
||||||
|
|
@ -143,22 +155,33 @@ export function MobileTabsPager() {
|
||||||
// Invalid URL spaces hit the early-return below.
|
// Invalid URL spaces hit the early-return below.
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const orphanSpaceIds = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
|
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;
|
const activeSpace = activeSpaceId ? mx.getRoom(activeSpaceId) : null;
|
||||||
|
|
||||||
// Validate the URL `:spaceIdOrAlias` param when on the workspace
|
// A URL space present in the param but not resolving to a joined orphan is
|
||||||
// route. If the param exists but doesn't resolve to a joined orphan
|
// an invalid deep-link (unjoined / unknown) → defer to the route tree's
|
||||||
// (deep-link to an unjoined / unknown space), we'll defer to the
|
// JoinBeforeNavigate via the early-return below (unless we're the backdrop).
|
||||||
// original route tree below instead of silently substituting another
|
const urlSpaceIsValid = !urlSpaceParam || urlSpaceId !== undefined;
|
||||||
// workspace.
|
|
||||||
const urlSpaceParam = channelsSpaceMatch?.params.spaceIdOrAlias;
|
|
||||||
const urlSpaceIsValid = useMemo(() => {
|
|
||||||
if (!urlSpaceParam) return true;
|
|
||||||
const decoded = safeDecode(urlSpaceParam);
|
|
||||||
if (!decoded) return false;
|
|
||||||
const resolved = isRoomAlias(decoded) ? getCanonicalAliasRoomId(mx, decoded) : decoded;
|
|
||||||
return resolved !== undefined && orphanSpaceIds.includes(resolved);
|
|
||||||
}, [mx, urlSpaceParam, orphanSpaceIds]);
|
|
||||||
|
|
||||||
const showBots = bots.length > 0 || botsRoot;
|
const showBots = bots.length > 0 || botsRoot;
|
||||||
|
|
||||||
|
|
@ -318,7 +341,8 @@ export function MobileTabsPager() {
|
||||||
// transitions.
|
// transitions.
|
||||||
const settingsSheetOpen = !!useAtomValue(settingsSheetAtom);
|
const settingsSheetOpen = !!useAtomValue(settingsSheetAtom);
|
||||||
const workspaceSheetOpen = !!useAtomValue(channelsWorkspaceSheetAtom);
|
const workspaceSheetOpen = !!useAtomValue(channelsWorkspaceSheetAtom);
|
||||||
const gestureDisabled = settingsSheetOpen || workspaceSheetOpen || pendingTargetTab !== null;
|
const gestureDisabled =
|
||||||
|
settingsSheetOpen || workspaceSheetOpen || pendingTargetTab !== null || asBackdrop;
|
||||||
|
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
useMobileTabsPagerGesture({
|
useMobileTabsPagerGesture({
|
||||||
|
|
@ -390,7 +414,11 @@ export function MobileTabsPager() {
|
||||||
// handles unjoined / unknown spaces via JoinBeforeNavigate. All
|
// handles unjoined / unknown spaces via JoinBeforeNavigate. All
|
||||||
// hooks above must run unconditionally for rules-of-hooks
|
// hooks above must run unconditionally for rules-of-hooks
|
||||||
// compliance; this early-return is the first conditional render.
|
// 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 />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -159,3 +159,38 @@ export const pane = style({
|
||||||
height: '100%',
|
height: '100%',
|
||||||
minWidth: 0,
|
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',
|
||||||
|
});
|
||||||
|
|
|
||||||
154
src/app/components/swipe-back/SwipeBackOverlay.tsx
Normal file
154
src/app/components/swipe-back/SwipeBackOverlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/app/components/swipe-back/geometry.ts
Normal file
21
src/app/components/swipe-back/geometry.ts
Normal 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';
|
||||||
71
src/app/components/swipe-back/style.css.ts
Normal file
71
src/app/components/swipe-back/style.css.ts
Normal 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}`,
|
||||||
|
});
|
||||||
170
src/app/components/swipe-back/useSwipeBackGesture.ts
Normal file
170
src/app/components/swipe-back/useSwipeBackGesture.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
|
@ -96,3 +96,20 @@ export const getRouteSectionParent = (pathname: string): string | null => {
|
||||||
|
|
||||||
return 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)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue