feat(mobile): close the drawers with the reverse edge swipe

The chat opens the sessions drawer with a left-edge swipe and the
workspace drawer with a right-edge one, but neither could be closed the
same way: the only way back was the header X or the Android back button.

Reuse the edge-swipe hook on both open drawers so the mirrored gesture
closes them. To keep that free of the row swipe actions in the sessions
list, those are mirrored too: session, project and worktree rows now
reveal their actions on the LEFT with a swipe to the right, in the
mirrored button order so a partial drag still exposes delete first. When
a row has its actions out, the closing swipe puts them away first
instead of closing the drawer.

The hook grew an `enabled` flag so the listeners re-attach when a drawer
opens or a tablet rotates the workspace between panel and drawer.

Testing: package type-check and lint; gestures need a device check.
This commit is contained in:
Bohdan Triapitsyn
2026-09-09 17:41:14 +03:00
parent af84735388
commit 0841ccdd57
3 changed files with 116 additions and 60 deletions
+90 -54
View File
@@ -73,6 +73,7 @@ import type { WorktreeMetadata } from '@/types/worktree';
import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog'; import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog';
import { MobileProjectEditSurface } from './MobileProjectEditSurface'; import { MobileProjectEditSurface } from './MobileProjectEditSurface';
import { useEdgeSwipe } from './useEdgeSwipe';
type MobileSessionsSheetProps = { type MobileSessionsSheetProps = {
open: boolean; open: boolean;
@@ -272,9 +273,11 @@ const NewWorktreeIconButton: React.FC<{
const ROW_ACTIONS_WIDTH = 144; const ROW_ACTIONS_WIDTH = 144;
const ROW_SWIPE_SNAP_MS = 180; const ROW_SWIPE_SNAP_MS = 180;
/** Generic swipe-left-to-reveal wrapper for drawer rows (projects, worktrees). /** Generic swipe-right-to-reveal wrapper for drawer rows (projects, worktrees).
Same gesture mechanics as SessionRow's swipe actions: horizontal intent Same gesture mechanics as SessionRow's swipe actions: horizontal intent
detection, imperative transform during the drag, snap on release. */ detection, imperative transform during the drag, snap on release. The
actions sit on the LEFT so the opposite direction stays free for the
drawer's own close swipe. */
const MobileSwipeActionsRow: React.FC<{ const MobileSwipeActionsRow: React.FC<{
actionsWidth: number; actionsWidth: number;
actions: React.ReactNode; actions: React.ReactNode;
@@ -298,7 +301,7 @@ const MobileSwipeActionsRow: React.FC<{
React.useEffect(() => { React.useEffect(() => {
revealedRef.current = revealed; revealedRef.current = revealed;
applyOffset(revealed ? -actionsWidth : 0, true); applyOffset(revealed ? actionsWidth : 0, true);
}, [actionsWidth, applyOffset, revealed]); }, [actionsWidth, applyOffset, revealed]);
const handleTouchStart = (event: React.TouchEvent) => { const handleTouchStart = (event: React.TouchEvent) => {
@@ -317,16 +320,16 @@ const MobileSwipeActionsRow: React.FC<{
if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return; if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return;
draggingRef.current = true; draggingRef.current = true;
} }
const base = revealedRef.current ? -actionsWidth : 0; const base = revealedRef.current ? actionsWidth : 0;
applyOffset(Math.min(0, Math.max(-actionsWidth, base + dx)), false); applyOffset(Math.max(0, Math.min(actionsWidth, base + dx)), false);
}; };
const handleTouchEnd = () => { const handleTouchEnd = () => {
startRef.current = null; startRef.current = null;
if (!draggingRef.current) return; if (!draggingRef.current) return;
draggingRef.current = false; draggingRef.current = false;
const shouldReveal = offsetRef.current < -actionsWidth / 2; const shouldReveal = offsetRef.current > actionsWidth / 2;
applyOffset(shouldReveal ? -actionsWidth : 0, true); applyOffset(shouldReveal ? actionsWidth : 0, true);
if (shouldReveal !== revealedRef.current) onRevealedChange(shouldReveal); if (shouldReveal !== revealedRef.current) onRevealedChange(shouldReveal);
}; };
@@ -339,7 +342,7 @@ const MobileSwipeActionsRow: React.FC<{
onTouchCancel={handleTouchEnd} onTouchCancel={handleTouchEnd}
style={{ touchAction: 'pan-y' }} style={{ touchAction: 'pan-y' }}
> >
<div className="absolute inset-y-0 right-0 flex items-stretch" style={{ width: actionsWidth }} aria-hidden={!revealed}> <div className="absolute inset-y-0 left-0 flex items-stretch" style={{ width: actionsWidth }} aria-hidden={!revealed}>
{actions} {actions}
</div> </div>
<div ref={contentRef} className="relative flex w-full items-center bg-background"> <div ref={contentRef} className="relative flex w-full items-center bg-background">
@@ -445,7 +448,7 @@ const SessionRow: React.FC<{
expanded?: boolean; expanded?: boolean;
onToggleChildren?: () => void; onToggleChildren?: () => void;
onSelect: () => void; onSelect: () => void;
/** Swipe-left actions. When omitted, the row is a plain non-swipeable row. */ /** Swipe-right actions. When omitted, the row is a plain non-swipeable row. */
revealed?: boolean; revealed?: boolean;
onRevealedChange?: (revealed: boolean) => void; onRevealedChange?: (revealed: boolean) => void;
confirmingDelete?: boolean; confirmingDelete?: boolean;
@@ -508,7 +511,7 @@ const SessionRow: React.FC<{
React.useEffect(() => { React.useEffect(() => {
revealedRef.current = revealed; revealedRef.current = revealed;
applyOffset(revealed ? -ROW_ACTIONS_WIDTH : 0, true); applyOffset(revealed ? ROW_ACTIONS_WIDTH : 0, true);
}, [applyOffset, revealed]); }, [applyOffset, revealed]);
const handleTouchStart = (event: React.TouchEvent) => { const handleTouchStart = (event: React.TouchEvent) => {
@@ -527,8 +530,8 @@ const SessionRow: React.FC<{
if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return; if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return;
draggingRef.current = true; draggingRef.current = true;
} }
const base = revealedRef.current ? -ROW_ACTIONS_WIDTH : 0; const base = revealedRef.current ? ROW_ACTIONS_WIDTH : 0;
const next = Math.min(0, Math.max(-ROW_ACTIONS_WIDTH, base + dx)); const next = Math.max(0, Math.min(ROW_ACTIONS_WIDTH, base + dx));
applyOffset(next, false); applyOffset(next, false);
}; };
@@ -536,8 +539,8 @@ const SessionRow: React.FC<{
startRef.current = null; startRef.current = null;
if (!draggingRef.current) return; if (!draggingRef.current) return;
draggingRef.current = false; draggingRef.current = false;
const shouldReveal = offsetRef.current < -ROW_ACTIONS_WIDTH / 2; const shouldReveal = offsetRef.current > ROW_ACTIONS_WIDTH / 2;
applyOffset(shouldReveal ? -ROW_ACTIONS_WIDTH : 0, true); applyOffset(shouldReveal ? ROW_ACTIONS_WIDTH : 0, true);
if (shouldReveal !== revealedRef.current) onRevealedChange?.(shouldReveal); if (shouldReveal !== revealedRef.current) onRevealedChange?.(shouldReveal);
}; };
@@ -554,32 +557,14 @@ const SessionRow: React.FC<{
> >
{swipeEnabled ? ( {swipeEnabled ? (
<div <div
className="absolute inset-y-0 right-0 flex items-stretch" className="absolute inset-y-0 left-0 flex items-stretch"
style={{ width: ROW_ACTIONS_WIDTH }} style={{ width: ROW_ACTIONS_WIDTH }}
aria-hidden={!revealed} aria-hidden={!revealed}
> >
{/* Icon-only actions on the row's own background — they read as the {/* Icon-only actions on the row's own background — they read as the
row extending to reveal extra controls, not a separate panel. */} row extending to reveal extra controls, not a separate panel.
<button Ordered outward from the content, so a partial drag exposes
type="button" delete first, exactly as the right-side version did. */}
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.renameSessionAria', { title })}
onClick={onRequestRename}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.archiveSessionAria', { title })}
onClick={onArchive}
style={{ touchAction: 'manipulation' }}
>
<RiArchiveLine className="size-[18px]" />
</button>
<button <button
type="button" type="button"
tabIndex={revealed ? 0 : -1} tabIndex={revealed ? 0 : -1}
@@ -597,6 +582,26 @@ const SessionRow: React.FC<{
> >
<RiDeleteBinLine className="size-[18px]" /> <RiDeleteBinLine className="size-[18px]" />
</button> </button>
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.archiveSessionAria', { title })}
onClick={onArchive}
style={{ touchAction: 'manipulation' }}
>
<RiArchiveLine className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.renameSessionAria', { title })}
onClick={onRequestRename}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
</div> </div>
) : null} ) : null}
<div <div
@@ -895,12 +900,12 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
const toggleParent = useMobileSessionExpansionStore((state) => state.toggleParent); const toggleParent = useMobileSessionExpansionStore((state) => state.toggleParent);
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null); const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
// Swipe-left actions: which row has its actions revealed, and whether its // Swipe-right actions: which row has its actions revealed, and whether its
// delete button is armed (two-step). One row at a time. // delete button is armed (two-step). One row at a time.
const [revealedSessionId, setRevealedSessionId] = React.useState<string | null>(null); const [revealedSessionId, setRevealedSessionId] = React.useState<string | null>(null);
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = React.useState<string | null>(null); const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = React.useState<string | null>(null);
const [renamingSessionId, setRenamingSessionId] = React.useState<string | null>(null); const [renamingSessionId, setRenamingSessionId] = React.useState<string | null>(null);
// Swipe-left actions on group headers (`project:{id}` / `wt:{bucketKey}`) — // Swipe-right actions on group headers (`project:{id}` / `wt:{bucketKey}`) —
// separate from session rows, but mutually exclusive with them. // separate from session rows, but mutually exclusive with them.
const [revealedRowId, setRevealedRowId] = React.useState<string | null>(null); const [revealedRowId, setRevealedRowId] = React.useState<string | null>(null);
const [confirmingRemoveProjectId, setConfirmingRemoveProjectId] = React.useState<string | null>(null); const [confirmingRemoveProjectId, setConfirmingRemoveProjectId] = React.useState<string | null>(null);
@@ -1703,19 +1708,6 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
onRevealedChange={(nextRevealed) => handleRowKeyRevealedChange(`project:${node.project.id}`, nextRevealed)} onRevealedChange={(nextRevealed) => handleRowKeyRevealedChange(`project:${node.project.id}`, nextRevealed)}
actions={( actions={(
<> <>
<button
type="button"
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.editProjectAria', { label: node.project.label })}
onClick={() => {
setRevealedRowId(null);
setEditingProjectId(node.project.id);
}}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
<button <button
type="button" type="button"
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1} tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
@@ -1742,6 +1734,19 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
> >
<RiDeleteBinLine className="size-[18px]" /> <RiDeleteBinLine className="size-[18px]" />
</button> </button>
<button
type="button"
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.editProjectAria', { label: node.project.label })}
onClick={() => {
setRevealedRowId(null);
setEditingProjectId(node.project.id);
}}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
</> </>
)} )}
> >
@@ -1998,6 +2003,19 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
<MobileSessionsDrawerContainer <MobileSessionsDrawerContainer
open={open} open={open}
onClose={() => onOpenChange(false)} onClose={() => onOpenChange(false)}
// The mirror of the swipe that opened the drawer closes it again —
// except while a row has its actions out: then the same swipe is the
// user putting those away, so it only clears them.
onSwipeClose={() => {
if (revealedSessionId || revealedRowId) {
setRevealedSessionId(null);
setRevealedRowId(null);
setConfirmingDeleteSessionId(null);
setConfirmingRemoveProjectId(null);
return;
}
onOpenChange(false);
}}
ariaLabel={t('mobile.sessions.sheet.title')} ariaLabel={t('mobile.sessions.sheet.title')}
> >
<div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3"> <div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3">
@@ -2030,8 +2048,9 @@ const DRAWER_ENTER_DURATION_MS = 320;
const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
/** Full-width left drawer for the phone sessions list: covers the whole app /** Full-width left drawer for the phone sessions list: covers the whole app
and slides in from the left edge. Closes via the header X, Escape, or the and slides in from the left edge. Closes via the header X, a right-edge
Android back button (handled by MobileShell). swipe back toward the left (the mirror of the gesture that opened it),
Escape, or the Android back button (handled by MobileShell).
Stays MOUNTED while closed (parked off-screen, hidden): the sessions Stays MOUNTED while closed (parked off-screen, hidden): the sessions
sheet's project/worktree state stays warm, so reopening shows the tree sheet's project/worktree state stays warm, so reopening shows the tree
@@ -2040,10 +2059,14 @@ const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
const MobileSessionsDrawerContainer: React.FC<{ const MobileSessionsDrawerContainer: React.FC<{
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
/** What the closing edge swipe does; the drawer's owner may want it to undo
a lighter state first. Falls back to `onClose`. */
onSwipeClose?: () => void;
ariaLabel: string; ariaLabel: string;
children: React.ReactNode; children: React.ReactNode;
}> = ({ open, onClose, ariaLabel, children }) => { }> = ({ open, onClose, onSwipeClose, ariaLabel, children }) => {
const rootRef = React.useRef<HTMLElement | null>(null); const rootRef = React.useRef<HTMLElement | null>(null);
const drawerRef = React.useRef<HTMLElement>(null);
const [entered, setEntered] = React.useState(false); const [entered, setEntered] = React.useState(false);
// Kept visible through the exit slide; flipped to hidden once it finishes. // Kept visible through the exit slide; flipped to hidden once it finishes.
const [visible, setVisible] = React.useState(open); const [visible, setVisible] = React.useState(open);
@@ -2051,6 +2074,18 @@ const MobileSessionsDrawerContainer: React.FC<{
React.useEffect(() => { React.useEffect(() => {
onCloseRef.current = onClose; onCloseRef.current = onClose;
}, [onClose]); }, [onClose]);
const onSwipeCloseRef = React.useRef(onSwipeClose);
React.useEffect(() => {
onSwipeCloseRef.current = onSwipeClose;
}, [onSwipeClose]);
// Swipe from the drawer's right edge back toward the left = close, the
// reverse of the left-edge swipe that opened it from the chat. Rows inside
// reveal their actions in the opposite direction, so the two never fight.
useEdgeSwipe(drawerRef, {
enabled: open,
onRightEdgeSwipe: () => (onSwipeCloseRef.current ?? onCloseRef.current)(),
});
if (typeof document !== 'undefined' && !rootRef.current) { if (typeof document !== 'undefined' && !rootRef.current) {
let root = document.getElementById(DRAWER_ROOT_ID); let root = document.getElementById(DRAWER_ROOT_ID);
@@ -2091,6 +2126,7 @@ const MobileSessionsDrawerContainer: React.FC<{
return createPortal( return createPortal(
<section <section
ref={drawerRef}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={ariaLabel} aria-label={ariaLabel}
+15 -2
View File
@@ -17,6 +17,7 @@ import { useMcpStore } from '@/stores/useMcpStore';
import { MobileChangesSurface } from './MobileChangesSurface'; import { MobileChangesSurface } from './MobileChangesSurface';
import { MobileFilesSurface } from './MobileFilesSurface'; import { MobileFilesSurface } from './MobileFilesSurface';
import { useEdgeSwipe } from './useEdgeSwipe';
const DRAWER_ROOT_ID = 'mobile-surface-root'; const DRAWER_ROOT_ID = 'mobile-surface-root';
const ENTER_DELAY_MS = 16; const ENTER_DELAY_MS = 16;
@@ -96,8 +97,9 @@ const McpWorkspacePane: React.FC<{ onOpenMcpSettings: () => void }> = ({ onOpenM
beside the chat (tablet, landscape). The caller owns the width and the beside the chat (tablet, landscape). The caller owns the width and the
open/close animation there; this component only fills it. open/close animation there; this component only fills it.
Closes via the header X, Escape (unless the terminal tab owns the keys), or Closes via the header X, a left-edge swipe back toward the right (the
the Android back button (handled by MobileShell). */ mirror of the gesture that opened it), Escape (unless the terminal tab owns
the keys), or the Android back button (handled by MobileShell). */
export const MobileWorkspaceDrawer: React.FC<{ export const MobileWorkspaceDrawer: React.FC<{
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -113,6 +115,7 @@ export const MobileWorkspaceDrawer: React.FC<{
}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings, variant = 'drawer' }) => { }> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings, variant = 'drawer' }) => {
const { t } = useI18n(); const { t } = useI18n();
const rootRef = React.useRef<HTMLElement | null>(null); const rootRef = React.useRef<HTMLElement | null>(null);
const drawerRef = React.useRef<HTMLElement>(null);
const [entered, setEntered] = React.useState(false); const [entered, setEntered] = React.useState(false);
// Kept visible through the exit slide; flipped to hidden once it finishes. // Kept visible through the exit slide; flipped to hidden once it finishes.
const [visible, setVisible] = React.useState(open); const [visible, setVisible] = React.useState(open);
@@ -125,6 +128,15 @@ export const MobileWorkspaceDrawer: React.FC<{
tabRef.current = tab; tabRef.current = tab;
}, [tab]); }, [tab]);
// Swipe from the drawer's left edge back toward the right = close, the
// reverse of the right-edge swipe that opened it from the chat. Only the
// full-cover drawer has an edge to grab; the tablet panel is closed from the
// header instead.
useEdgeSwipe(drawerRef, {
enabled: variant === 'drawer' && open,
onLeftEdgeSwipe: () => onCloseRef.current(),
});
// Tabs the user has actually opened — their panes stay mounted afterwards. // Tabs the user has actually opened — their panes stay mounted afterwards.
const [visitedTabs, setVisitedTabs] = React.useState<ReadonlySet<MobileWorkspaceTab>>(() => new Set()); const [visitedTabs, setVisitedTabs] = React.useState<ReadonlySet<MobileWorkspaceTab>>(() => new Set());
React.useEffect(() => { React.useEffect(() => {
@@ -278,6 +290,7 @@ export const MobileWorkspaceDrawer: React.FC<{
return createPortal( return createPortal(
<section <section
ref={drawerRef}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t('mobile.header.openWorkspaceAria')} aria-label={t('mobile.header.openWorkspaceAria')}
+11 -4
View File
@@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
/** /**
* Native-feeling edge swipes on the mobile chat: start a horizontal swipe from * Native-feeling edge swipes on the mobile shell: start a horizontal swipe from
* the very left/right screen edge and drag toward the centre. * the very left/right screen edge and drag toward the centre.
* *
* - Left edge centre = open the sessions drawer * On the chat that opens a drawer (left edge sessions, right edge
* - Right edge centre = open the most recent overflow surface * workspace); on an open drawer the mirrored swipe closes it (sessions drawer
* closes from the right edge, workspace drawer from the left edge).
* *
* Only `touchstart`/`touchend` are observed (both passive), so this never * Only `touchstart`/`touchend` are observed (both passive), so this never
* interferes with vertical chat scrolling or the horizontal scroll inside code * interferes with vertical chat scrolling or the horizontal scroll inside code
@@ -27,6 +28,9 @@ export interface EdgeSwipeOptions {
onLeftEdgeSwipe?: () => void; onLeftEdgeSwipe?: () => void;
/** Swipe that started at the right edge and travelled left. */ /** Swipe that started at the right edge and travelled left. */
onRightEdgeSwipe?: () => void; onRightEdgeSwipe?: () => void;
/** Defaults to on. Flipping it re-attaches the listeners, which is what a
drawer needs: its element only exists (or only matters) while open. */
enabled?: boolean;
} }
export const useEdgeSwipe = ( export const useEdgeSwipe = (
@@ -37,7 +41,10 @@ export const useEdgeSwipe = (
const optionsRef = React.useRef(options); const optionsRef = React.useRef(options);
optionsRef.current = options; optionsRef.current = options;
const enabled = options.enabled ?? true;
React.useEffect(() => { React.useEffect(() => {
if (!enabled) return;
const element = ref.current; const element = ref.current;
if (!element) return; if (!element) return;
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
@@ -87,5 +94,5 @@ export const useEdgeSwipe = (
element.removeEventListener('touchstart', onTouchStart); element.removeEventListener('touchstart', onTouchStart);
element.removeEventListener('touchend', onTouchEnd); element.removeEventListener('touchend', onTouchEnd);
}; };
}, [ref]); }, [enabled, ref]);
}; };