diff --git a/ui/app/layouts/default.vue b/ui/app/layouts/default.vue index 464832fc..0a09ce93 100644 --- a/ui/app/layouts/default.vue +++ b/ui/app/layouts/default.vue @@ -534,6 +534,7 @@ import { useMediaQuery } from '~/composables/useMediaQuery'; import type { toastPosition } from '~/composables/useNotification'; import LimitsPage from '~/components/LimitsPage.vue'; import { formatPageTitle, parse_api_response, request, syncOpacity, uri } from '~/utils'; +import { getSidebarSwipeMode } from '~/utils/sidebarSwipe'; import type { YTDLPOption } from '~/types/ytdlp'; import { useDialog } from '~/composables/useDialog'; import Dialog from '~/components/Dialog.vue'; @@ -557,7 +558,6 @@ type SidebarSection = { type ColorModePreference = 'system' | 'light' | 'dark'; type SwipeMode = 'open' | 'close'; -const MOBILE_SIDEBAR_EDGE_WIDTH = 32; const MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE = 64; const socket = useAppSocket(); @@ -655,11 +655,11 @@ const handleSwipeStart = (event: TouchEvent): void => { return; } - const swipeMode: SwipeMode | null = showSidebar.value - ? 'close' - : touch.clientX <= MOBILE_SIDEBAR_EDGE_WIDTH - ? 'open' - : null; + const swipeMode: SwipeMode | null = getSidebarSwipeMode( + showSidebar.value, + touch.clientX, + navigator, + ); if (!swipeMode) { resetSwipe(); diff --git a/ui/app/utils/sidebarSwipe.ts b/ui/app/utils/sidebarSwipe.ts new file mode 100644 index 00000000..9a7dc4f4 --- /dev/null +++ b/ui/app/utils/sidebarSwipe.ts @@ -0,0 +1,54 @@ +type SidebarSwipeMode = 'open' | 'close'; + +type NavigatorLike = { + userAgent?: string; + platform?: string; + maxTouchPoints?: number; +}; + +const MOBILE_SIDEBAR_EDGE_WIDTH = 32; +const IOS_NAVIGATION_EDGE_WIDTH = 24; + +const isAppleMobileTouchNavigator = (nav?: NavigatorLike): boolean => { + if (!nav) { + return false; + } + + const userAgent = nav.userAgent ?? ''; + const platform = nav.platform ?? ''; + const maxTouchPoints = Number(nav.maxTouchPoints ?? 0); + const isiPhoneLike = /(iPhone|iPod|iPad)/i.test(userAgent); + const isiPadDesktopMode = platform === 'MacIntel' && maxTouchPoints > 1; + + return /AppleWebKit/i.test(userAgent) && (isiPhoneLike || isiPadDesktopMode); +}; + +const canStartSidebarOpenSwipe = ( + touchX: number, + nav?: NavigatorLike, + edgeWidth: number = MOBILE_SIDEBAR_EDGE_WIDTH, +): boolean => { + const reservedEdge = isAppleMobileTouchNavigator(nav) ? IOS_NAVIGATION_EDGE_WIDTH : 0; + return touchX > reservedEdge && touchX <= reservedEdge + edgeWidth; +}; + +const getSidebarSwipeMode = ( + sidebarOpen: boolean, + touchX: number, + nav?: NavigatorLike, +): SidebarSwipeMode | null => { + if (sidebarOpen) { + return 'close'; + } + + return canStartSidebarOpenSwipe(touchX, nav) ? 'open' : null; +}; + +export { + MOBILE_SIDEBAR_EDGE_WIDTH, + IOS_NAVIGATION_EDGE_WIDTH, + canStartSidebarOpenSwipe, + getSidebarSwipeMode, + isAppleMobileTouchNavigator, +}; +export type { NavigatorLike, SidebarSwipeMode }; diff --git a/ui/tests/utils/sidebarSwipe.test.ts b/ui/tests/utils/sidebarSwipe.test.ts new file mode 100644 index 00000000..8e1c299f --- /dev/null +++ b/ui/tests/utils/sidebarSwipe.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'bun:test'; + +import { + IOS_NAVIGATION_EDGE_WIDTH, + MOBILE_SIDEBAR_EDGE_WIDTH, + canStartSidebarOpenSwipe, + getSidebarSwipeMode, + isAppleMobileTouchNavigator, +} from '~/utils/sidebarSwipe'; + +describe('sidebarSwipe', () => { + it('detects apple mobile webkit navigators', () => { + expect( + isAppleMobileTouchNavigator({ + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 Version/18.4 Mobile/15E148 Safari/604.1', + platform: 'iPhone', + maxTouchPoints: 5, + }), + ).toBe(true); + + expect( + isAppleMobileTouchNavigator({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Version/18.4 Mobile/15E148 Safari/604.1', + platform: 'MacIntel', + maxTouchPoints: 5, + }), + ).toBe(true); + + expect( + isAppleMobileTouchNavigator({ + userAgent: + 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/147.0.0.0 Mobile Safari/537.36', + platform: 'Linux armv8l', + maxTouchPoints: 5, + }), + ).toBe(false); + }); + + it('reserves the ios navigation edge and starts the sidebar swipe just inside it', () => { + const nav = { + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 Version/18.4 Mobile/15E148 Safari/604.1', + platform: 'iPhone', + maxTouchPoints: 5, + }; + + expect(canStartSidebarOpenSwipe(IOS_NAVIGATION_EDGE_WIDTH, nav)).toBe(false); + expect(canStartSidebarOpenSwipe(IOS_NAVIGATION_EDGE_WIDTH + 1, nav)).toBe(true); + expect(canStartSidebarOpenSwipe(IOS_NAVIGATION_EDGE_WIDTH + MOBILE_SIDEBAR_EDGE_WIDTH, nav)).toBe(true); + expect(canStartSidebarOpenSwipe(IOS_NAVIGATION_EDGE_WIDTH + MOBILE_SIDEBAR_EDGE_WIDTH + 1, nav)).toBe( + false, + ); + }); + + it('keeps the original left-edge open band on non-apple mobile browsers', () => { + const nav = { + userAgent: + 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/147.0.0.0 Mobile Safari/537.36', + platform: 'Linux armv8l', + maxTouchPoints: 5, + }; + + expect(canStartSidebarOpenSwipe(1, nav)).toBe(true); + expect(canStartSidebarOpenSwipe(MOBILE_SIDEBAR_EDGE_WIDTH, nav)).toBe(true); + expect(canStartSidebarOpenSwipe(MOBILE_SIDEBAR_EDGE_WIDTH + 1, nav)).toBe(false); + }); + + it('returns close while the sidebar is already open', () => { + expect(getSidebarSwipeMode(true, 4)).toBe('close'); + }); +});