diff --git a/packages/web/src/components/layout/MobileShell.tsx b/packages/web/src/components/layout/MobileShell.tsx index cd4a1e77..919d4e05 100644 --- a/packages/web/src/components/layout/MobileShell.tsx +++ b/packages/web/src/components/layout/MobileShell.tsx @@ -4,6 +4,7 @@ import { useVoiceStore } from '../../stores/voiceStore'; import { useLocation } from 'react-router-dom'; import { MobileScreenStack } from './MobileScreenStack'; import { MobileBottomNav } from './MobileBottomNav'; +import { useSwipeGesture } from '../../hooks/useSwipeGesture'; // Screen components — imported as they are created in subsequent tasks. // Inline placeholders are used until the real components exist. @@ -45,6 +46,16 @@ export function MobileShell() { const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const location = useLocation(); + // Edge swipe back gesture + useSwipeGesture({ + onSwipeRight: () => { + if (mobileStack.length > 0) { + popMobileScreen(); + } + }, + enabled: mobileStack.length > 0, + }); + // Reconstruct mobile stack from URL on mount (deep link / refresh support) useEffect(() => { const path = location.pathname; diff --git a/packages/web/src/hooks/useSwipeGesture.ts b/packages/web/src/hooks/useSwipeGesture.ts new file mode 100644 index 00000000..a68e3450 --- /dev/null +++ b/packages/web/src/hooks/useSwipeGesture.ts @@ -0,0 +1,68 @@ +import { useRef, useCallback, useEffect } from 'react'; + +interface SwipeGestureOptions { + onSwipeRight?: () => void; + edgeThreshold?: number; // px from left edge to start detection (default: 20) + swipeThreshold?: number; // px of horizontal movement to trigger (default: 50) + enabled?: boolean; +} + +export function useSwipeGesture({ + onSwipeRight, + edgeThreshold = 20, + swipeThreshold = 50, + enabled = true, +}: SwipeGestureOptions) { + const touchStartRef = useRef<{ x: number; y: number } | null>(null); + const swipingRef = useRef(false); + + const handleTouchStart = useCallback((e: TouchEvent) => { + if (!enabled) return; + const touch = e.touches[0]; + if (touch.clientX <= edgeThreshold) { + touchStartRef.current = { x: touch.clientX, y: touch.clientY }; + swipingRef.current = false; + } + }, [enabled, edgeThreshold]); + + const handleTouchMove = useCallback((e: TouchEvent) => { + if (!touchStartRef.current) return; + const touch = e.touches[0]; + const dx = touch.clientX - touchStartRef.current.x; + const dy = touch.clientY - touchStartRef.current.y; + + // If vertical movement exceeds horizontal, cancel swipe detection + if (Math.abs(dy) > Math.abs(dx) && !swipingRef.current) { + touchStartRef.current = null; + return; + } + + if (dx > swipeThreshold) { + swipingRef.current = true; + e.preventDefault(); + } + }, [swipeThreshold]); + + const handleTouchEnd = useCallback(() => { + if (swipingRef.current && onSwipeRight) { + onSwipeRight(); + } + touchStartRef.current = null; + swipingRef.current = false; + }, [onSwipeRight]); + + useEffect(() => { + if (!enabled) return; + document.addEventListener('touchstart', handleTouchStart, { passive: true }); + document.addEventListener('touchmove', handleTouchMove, { passive: false }); + document.addEventListener('touchend', handleTouchEnd); + document.addEventListener('touchcancel', handleTouchEnd); + + return () => { + document.removeEventListener('touchstart', handleTouchStart); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + document.removeEventListener('touchcancel', handleTouchEnd); + }; + }, [enabled, handleTouchStart, handleTouchMove, handleTouchEnd]); +}