From c7b6e8f5f03388d2f5b139b81eff939996b911db Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Sat, 11 Jul 2026 20:36:10 +1000 Subject: [PATCH] refactor: enhance mobile swipe gesture handling and improve outlet behavior on compact routes --- overlay/src/app/components/AnimatedOutlet.tsx | 14 +++- .../mobile/MobileSwipeGestureHost.tsx | 4 +- .../components/mobile/mobile-gestures.css.ts | 19 ++++- overlay/src/app/components/page/Page.tsx | 3 + overlay/src/app/features/room/RoomView.tsx | 9 ++- overlay/src/app/hooks/useCompactNav.ts | 30 +++++++ .../src/app/hooks/useMobileKeyboardLayout.ts | 67 +++++++++++++++ overlay/src/app/hooks/useViewTransitions.ts | 57 +++++++++++++ overlay/src/app/pages/client/ClientLayout.tsx | 21 +++++ overlay/src/app/utils/viewTransitions.ts | 81 +++++++++++++++++++ 10 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 overlay/src/app/hooks/useCompactNav.ts create mode 100644 overlay/src/app/hooks/useMobileKeyboardLayout.ts create mode 100644 overlay/src/app/hooks/useViewTransitions.ts create mode 100644 overlay/src/app/pages/client/ClientLayout.tsx create mode 100644 overlay/src/app/utils/viewTransitions.ts diff --git a/overlay/src/app/components/AnimatedOutlet.tsx b/overlay/src/app/components/AnimatedOutlet.tsx index 0f04017..bc0b2da 100644 --- a/overlay/src/app/components/AnimatedOutlet.tsx +++ b/overlay/src/app/components/AnimatedOutlet.tsx @@ -1,15 +1,23 @@ import React from 'react'; import { Outlet, useLocation } from 'react-router-dom'; -import { useCompactNav } from '../hooks/useCompactNav'; +import { useCompactNav, useIsCompactListRoute } from '../hooks/useCompactNav'; import { MobileSwipeGestureHost } from './mobile/MobileSwipeGestureHost'; /** - * Wrapper for Outlet that adds route-based animation - * Forces remount on route change by using location as key + * Outlet wrapper for route remounts + compact swipe-back chrome. + * + * On compact list routes the parent PageRoot already omits children. If this + * outlet is still mounted (stale stack / empty SpaceIndexRedirect), render + * nothing so an empty swipe shell cannot sit above the channel list and eat taps. */ export function AnimatedOutlet() { const location = useLocation(); const compact = useCompactNav(); + const isListRoute = useIsCompactListRoute(); + + if (compact && isListRoute) { + return null; + } return ( diff --git a/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx b/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx index 905d99d..23a7f0c 100644 --- a/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx +++ b/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx @@ -1,4 +1,5 @@ import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; import { useCompactNav } from '../../hooks/useCompactNav'; import { useBackRoute } from '../../hooks/useBackRoute'; import { startReplyToEvent } from '../../features/room/replyToMessage'; @@ -93,6 +94,7 @@ const resetElementTransform = (el: HTMLElement | null) => { export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps) { const compact = useCompactNav(); + const { pathname } = useLocation(); const { canGoBack, goBack } = useBackRoute(); const backEnabled = compact && canGoBack; const replyEnabled = compact; @@ -350,7 +352,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps useEffect(() => { resetGesture(); - }, [canGoBack, resetGesture]); + }, [canGoBack, pathname, resetGesture]); if (!compact) { return <>{children}; diff --git a/overlay/src/app/components/mobile/mobile-gestures.css.ts b/overlay/src/app/components/mobile/mobile-gestures.css.ts index 10926ff..e0deb9b 100644 --- a/overlay/src/app/components/mobile/mobile-gestures.css.ts +++ b/overlay/src/app/components/mobile/mobile-gestures.css.ts @@ -36,7 +36,10 @@ export const CompactNavUnderlay = style({ display: 'flex', flexDirection: 'column', zIndex: 0, - pointerEvents: 'none', + // Keep underlay tappable if the detail layer is empty/transparent + // (e.g. space index after leaving DMs). Opaque detail content still + // sits above and receives taps first. + pointerEvents: 'auto', }); export const CompactDetailLayer = style({ @@ -45,6 +48,20 @@ export const CompactDetailLayer = style({ minWidth: 0, display: 'flex', flexDirection: 'column', + // Pass through taps when outlet content is null/empty; real pages + // re-enable pointer events on their root via the child selector. + pointerEvents: 'none', + selectors: { + '& > *': { + pointerEvents: 'auto', + flex: 1, + minHeight: 0, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + backgroundColor: color.Background.Container, + }, + }, }); export const SwipeToReplyLayer = style({ diff --git a/overlay/src/app/components/page/Page.tsx b/overlay/src/app/components/page/Page.tsx index 020225e..8d8fdc1 100644 --- a/overlay/src/app/components/page/Page.tsx +++ b/overlay/src/app/components/page/Page.tsx @@ -18,6 +18,9 @@ export function PageRoot({ nav, children }: PageRootProps) { const showCompactMaster = useShowCompactMasterView(); const compact = screenSize === ScreenSize.Mobile; + // Master list routes: only the channel/space list is interactive. + // Never mount the detail/underlay stack here — an empty detail layer would + // sit above the list with pointer-events and freeze taps (DM → space bug). if (compact && showCompactMaster) { return ( diff --git a/overlay/src/app/features/room/RoomView.tsx b/overlay/src/app/features/room/RoomView.tsx index 47e478d..652805f 100644 --- a/overlay/src/app/features/room/RoomView.tsx +++ b/overlay/src/app/features/room/RoomView.tsx @@ -26,6 +26,7 @@ import { useRoomCreators } from '../../hooks/useRoomCreators'; import { activeThreadIdAtomFamily } from '../../state/activeThread'; import { ThreadView } from './ThreadView'; import { MobileSwipeToReplyLayer } from '../../components/mobile/MobileSwipeToReplyLayer'; +import { useMobileKeyboardLayout } from '../../hooks/useMobileKeyboardLayout'; const FN_KEYS_REGEX = /^F\d+$/; const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { @@ -63,6 +64,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { const roomViewRef = useRef(null); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const { isLandscape, keyboardOpen } = useMobileKeyboardLayout(); const { roomId } = room; const editor = useEditor(); @@ -101,9 +103,12 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { ) ); + const composerPadding = isLandscape ? '0' : `0 ${config.space.S400}`; + const showHeader = !keyboardOpen; + return ( - + {showHeader && } {activeThreadId ? ( ) : ( @@ -121,7 +126,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { -
+
{tombstoneEvent ? ( { + const screenSize = useScreenSizeContext(); + return screenSize === ScreenSize.Mobile; +}; + +const LIST_ROUTE_PATHS = [HOME_PATH, DIRECT_PATH, SPACE_PATH, EXPLORE_PATH, INBOX_PATH] as const; + +/** + * Routes where sidebar + channel list are shown together (no room/content pane). + * Uses matchPath on the current pathname so trailing-slash / encoding quirks + * after DM → space navigations still count as master list routes. + */ +export const useIsCompactListRoute = (): boolean => { + const { pathname } = useLocation(); + + return LIST_ROUTE_PATHS.some( + (path) => matchPath({ path, caseSensitive: true, end: true }, pathname) != null + ); +}; + +export const useShowCompactMasterView = (): boolean => { + const compact = useCompactNav(); + const isListRoute = useIsCompactListRoute(); + return compact && isListRoute; +}; diff --git a/overlay/src/app/hooks/useMobileKeyboardLayout.ts b/overlay/src/app/hooks/useMobileKeyboardLayout.ts new file mode 100644 index 0000000..a64e194 --- /dev/null +++ b/overlay/src/app/hooks/useMobileKeyboardLayout.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react'; + +const KEYBOARD_HEIGHT_THRESHOLD_PX = 120; + +const getIsLandscape = (): boolean => { + if (typeof window === 'undefined') return false; + if (window.matchMedia?.('(orientation: landscape)').matches) return true; + return window.innerWidth > window.innerHeight; +}; + +const getKeyboardOpen = (): boolean => { + if (typeof window === 'undefined') return false; + + const viewport = window.visualViewport; + if (viewport) { + const obscured = window.innerHeight - viewport.height - viewport.offsetTop; + if (obscured > KEYBOARD_HEIGHT_THRESHOLD_PX) return true; + } + + const active = document.activeElement; + if (!(active instanceof HTMLElement)) return false; + if (active.isContentEditable) return true; + const tag = active.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA'; +}; + +export type MobileKeyboardLayout = { + isLandscape: boolean; + keyboardOpen: boolean; +}; + +/** + * Landscape + soft-keyboard layout signals for compact room chrome. + * Uses visualViewport when available (Capacitor/Android WebView). + */ +export const useMobileKeyboardLayout = (): MobileKeyboardLayout => { + const [isLandscape, setIsLandscape] = useState(getIsLandscape); + const [keyboardOpen, setKeyboardOpen] = useState(getKeyboardOpen); + + useEffect(() => { + const sync = () => { + setIsLandscape(getIsLandscape()); + setKeyboardOpen(getKeyboardOpen()); + }; + + sync(); + + const orientationQuery = window.matchMedia?.('(orientation: landscape)'); + orientationQuery?.addEventListener?.('change', sync); + window.addEventListener('resize', sync); + window.visualViewport?.addEventListener('resize', sync); + window.visualViewport?.addEventListener('scroll', sync); + window.addEventListener('focusin', sync); + window.addEventListener('focusout', sync); + + return () => { + orientationQuery?.removeEventListener?.('change', sync); + window.removeEventListener('resize', sync); + window.visualViewport?.removeEventListener('resize', sync); + window.visualViewport?.removeEventListener('scroll', sync); + window.removeEventListener('focusin', sync); + window.removeEventListener('focusout', sync); + }; + }, []); + + return { isLandscape, keyboardOpen }; +}; diff --git a/overlay/src/app/hooks/useViewTransitions.ts b/overlay/src/app/hooks/useViewTransitions.ts new file mode 100644 index 0000000..a8f8a36 --- /dev/null +++ b/overlay/src/app/hooks/useViewTransitions.ts @@ -0,0 +1,57 @@ +import { useCallback, useEffect } from 'react'; +import { useLocation, useNavigate, NavigateOptions } from 'react-router-dom'; +import { supportsViewTransitions, withViewTransition } from '../utils/viewTransitions'; + +export const useViewTransitions = () => { + const location = useLocation(); + + useEffect(() => { + if (!supportsViewTransitions()) { + return; + } + + document.documentElement.classList.add('view-transitions-enabled'); + + return () => { + document.documentElement.classList.remove('view-transitions-enabled'); + }; + }, []); + + useEffect(() => { + // Route changed + }, [location]); + + return { + supportsViewTransitions: supportsViewTransitions(), + }; +}; + +/** + * Navigate helper. On Capacitor / coarse-pointer, View Transitions are disabled + * so DM ↔ space switches cannot leave a stuck hit-blocking overlay. + */ +export const useNavigateWithTransition = () => { + const navigate = useNavigate(); + + return useCallback( + (to: string | number, options?: NavigateOptions) => { + if (!supportsViewTransitions()) { + if (typeof to === 'number') { + navigate(to); + } else { + navigate(to, options); + } + return; + } + + withViewTransition(() => { + if (typeof to === 'number') { + navigate(to); + } else { + navigate(to, options); + } + }); + }, + [navigate] + ); +}; diff --git a/overlay/src/app/pages/client/ClientLayout.tsx b/overlay/src/app/pages/client/ClientLayout.tsx new file mode 100644 index 0000000..19577ad --- /dev/null +++ b/overlay/src/app/pages/client/ClientLayout.tsx @@ -0,0 +1,21 @@ +import React, { ReactNode } from 'react'; +import { Box } from 'folds'; +import { DockedCallPanel } from '../../features/call/DockedCallPanel'; +import { useMobileKeyboardLayout } from '../../hooks/useMobileKeyboardLayout'; + +type ClientLayoutProps = { + nav: ReactNode; + children: ReactNode; +}; + +export function ClientLayout({ nav, children }: ClientLayoutProps) { + const { keyboardOpen } = useMobileKeyboardLayout(); + + return ( + + {!keyboardOpen && {nav}} + {children} + + + ); +} diff --git a/overlay/src/app/utils/viewTransitions.ts b/overlay/src/app/utils/viewTransitions.ts new file mode 100644 index 0000000..8bdc827 --- /dev/null +++ b/overlay/src/app/utils/viewTransitions.ts @@ -0,0 +1,81 @@ +import { isCapacitorNative } from './tauri'; + +/** + * View Transitions can leave an invisible hit-blocking overlay on Android + * WebView after DM ↔ space navigations. Keep them off for native/touch shells. + */ +const shouldDisableViewTransitions = (): boolean => { + if (typeof window === 'undefined') return true; + if (isCapacitorNative()) return true; + if (window.matchMedia?.('(pointer: coarse)').matches) return true; + return false; +}; + +export const supportsViewTransitions = (): boolean => { + if (shouldDisableViewTransitions()) return false; + return 'startViewTransition' in document; +}; + +export const withViewTransition = async (callback: () => void | Promise): Promise => { + if (!supportsViewTransitions()) { + await callback(); + return; + } + + const doc = document as Document & { + startViewTransition: (callback: () => void | Promise) => { + finished: Promise; + updateCallbackDone: Promise; + ready: Promise; + }; + }; + + try { + const transition = doc.startViewTransition(async () => { + await callback(); + }); + + await transition.finished; + } catch { + await callback(); + } +}; + +/** + * Global link interception for View Transitions. No-op on Capacitor / touch. + * Also fights hash-router navigations — leave disabled on mobile. + */ +export const enableViewTransitionsForNavigation = (): void => { + if (!supportsViewTransitions()) { + return; + } + + const handleLinkClick = (event: MouseEvent) => { + const target = (event.target as HTMLElement).closest('a'); + + if (!target) return; + if (target.target === '_blank') return; + if (target.origin !== window.location.origin) return; + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + + const url = new URL(target.href); + + if (url.origin === window.location.origin) { + event.preventDefault(); + + withViewTransition(() => { + window.history.pushState({}, '', target.href); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + } + }; + + const handlePopState = () => { + withViewTransition(() => { + // URL already changed + }); + }; + + window.addEventListener('click', handleLinkClick); + window.addEventListener('popstate', handlePopState); +};