diff --git a/mobile/app/mobile-onboarding.tsx b/mobile/app/mobile-onboarding.tsx index 872d2ca0379..3139de1d6ab 100644 --- a/mobile/app/mobile-onboarding.tsx +++ b/mobile/app/mobile-onboarding.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useRef, useState } from 'react' -import { Animated, BackHandler, Text, useWindowDimensions, View } from 'react-native' +import { Animated, BackHandler, Text, View, type LayoutChangeEvent } from 'react-native' import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import { SafeAreaView } from 'react-native-safe-area-context' import { OrcaLogo } from '../src/components/OrcaLogo' @@ -46,7 +46,9 @@ function MobileOnboardingFlow({ }) { const router = useRouter() const steps = useMemo(() => parseMobileOnboardingSteps(rawSteps), [rawSteps]) - const { width } = useWindowDimensions() + // Why: an iPad modal is a centred sheet narrower than the window, so slides must + // follow the viewport; null holds the pager back until the first measurement. + const [width, setWidth] = useState(null) const [activeIndex, setActiveIndex] = useState(0) const [busyChoice, setBusyChoice] = useState(null) const [error, setError] = useState(null) @@ -132,7 +134,17 @@ function MobileOnboardingFlow({ [advanceOrContinue] ) - const translateX = useMemo(() => Animated.multiply(slideProgress, -width), [slideProgress, width]) + const translateX = useMemo( + () => Animated.multiply(slideProgress, -(width ?? 0)), + [slideProgress, width] + ) + const onViewportLayout = useCallback((event: LayoutChangeEvent) => { + const measured = event.nativeEvent.layout.width + // Why: a transient zero-width pass must not collapse the slides to nothing. + if (measured > 0) { + setWidth(measured) + } + }, []) return ( @@ -157,26 +169,28 @@ function MobileOnboardingFlow({ ) : null} - - - {steps.map((step, index) => ( - void chooseSessionView(view)} - onNotificationChoice={(choice) => void chooseNotifications(choice)} - /> - ))} - + + {width === null ? null : ( + + {steps.map((step, index) => ( + void chooseSessionView(view)} + onNotificationChoice={(choice) => void chooseNotifications(choice)} + /> + ))} + + )} ) diff --git a/mobile/src/notifications/notification-consent-ownership.test.ts b/mobile/src/notifications/notification-consent-ownership.test.ts index 6d729d0db02..c72d3d07c68 100644 --- a/mobile/src/notifications/notification-consent-ownership.test.ts +++ b/mobile/src/notifications/notification-consent-ownership.test.ts @@ -117,12 +117,19 @@ async function connectedHost() { client.sendRequest.mockClear() return client } +// The onboarding pager renders once its viewport reports a width. +function layoutOnboarding() { + renderer!.root + .findAll((node) => node.props.onLayout !== undefined)[0] + ?.props.onLayout({ nativeEvent: { layout: { width: 390 } } }) +} async function choose(entry: string) { await act(async () => { renderer = create( createElement(entry === 'settings' ? NotificationsScreen : MobileOnboardingScreen) ) }) + await act(async () => layoutOnboarding()) await act(async () => { if (entry === 'settings') { renderer!.root.findByType('Switch').props.onValueChange(true) @@ -183,6 +190,7 @@ it('remembers Not now without registering and does not ask again', async () => { await act(async () => { renderer = create(createElement(MobileOnboardingScreen)) }) + await act(async () => layoutOnboarding()) await act(async () => { renderer!.root.findByType('Page').props.onNotificationChoice('skip') }) diff --git a/mobile/src/onboarding/mobile-onboarding-screen.test.ts b/mobile/src/onboarding/mobile-onboarding-screen.test.ts index efa2f19d5ac..f9e61ce8d17 100644 --- a/mobile/src/onboarding/mobile-onboarding-screen.test.ts +++ b/mobile/src/onboarding/mobile-onboarding-screen.test.ts @@ -6,6 +6,7 @@ import MobileOnboardingScreen from '../../app/mobile-onboarding' const mocks = vi.hoisted(() => ({ params: { hostId: 'paired-host', steps: 'session-view,notifications' }, replace: vi.fn(), + windowWidth: 390, reducedMotionEnabled: false, animatedTiming: vi.fn(), ensureNotificationPermissions: vi.fn(), @@ -19,9 +20,12 @@ vi.mock('react-native', () => ({ isReduceMotionEnabled: vi.fn(() => Promise.resolve(mocks.reducedMotionEnabled)) }, Animated: { - Value: class {}, + Value: class { + constructor(public current: number) {} + }, View: 'AnimatedView', - multiply: vi.fn(() => 0), + // Lazy like a real Animated node, so translateX tracks later timing updates. + multiply: (value: { current: number }, factor: number) => ({ value, factor }), timing: mocks.animatedTiming }, BackHandler: { @@ -30,7 +34,7 @@ vi.mock('react-native', () => ({ StyleSheet: { create: (styles: unknown) => styles }, Text: 'Text', View: 'View', - useWindowDimensions: () => ({ width: 390, height: 844 }) + useWindowDimensions: () => ({ width: mocks.windowWidth, height: 844 }) })) vi.mock('expo-router', () => ({ @@ -58,10 +62,16 @@ describe('MobileOnboardingScreen', () => { beforeEach(() => { mocks.params = { hostId: 'paired-host', steps: 'session-view,notifications' } mocks.replace.mockReset() + mocks.windowWidth = 390 mocks.reducedMotionEnabled = false - mocks.animatedTiming.mockReset().mockReturnValue({ - start: (callback: (result: { finished: boolean }) => void) => callback({ finished: true }) - }) + mocks.animatedTiming + .mockReset() + .mockImplementation((value: { current: number }, config: { toValue: number }) => ({ + start: (callback: (result: { finished: boolean }) => void) => { + value.current = config.toValue + callback({ finished: true }) + } + })) mocks.ensureNotificationPermissions.mockReset().mockResolvedValue(true) mocks.saveDefaultSessionView.mockReset().mockResolvedValue(undefined) mocks.setRemotePushEnabled.mockReset().mockResolvedValue(undefined) @@ -73,7 +83,7 @@ describe('MobileOnboardingScreen', () => { vi.restoreAllMocks() }) - async function renderScreen() { + async function renderScreen(viewportWidth = 390) { const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) { throw new Error(String(args[0])) @@ -82,9 +92,29 @@ describe('MobileOnboardingScreen', () => { await act(async () => { renderer = create(createElement(MobileOnboardingScreen)) }) + await layoutViewport(viewportWidth) consoleError.mockRestore() } + async function layoutViewport(width: number) { + const viewport = renderer!.root.findAll((node) => node.props.onLayout !== undefined)[0] + await act(async () => viewport?.props.onLayout({ nativeEvent: { layout: { width } } })) + } + + function trackStyle() { + return renderer!.root.findByType('AnimatedView').props.style[1] + } + + function trackWidth(): number { + return trackStyle().width + } + + function translateX(): number { + const { value, factor } = trackStyle().transform[0].translateX + // + 0 normalises -0 on the first slide. + return value.current * factor + 0 + } + function pages() { return renderer!.root.findAllByType('MobileOnboardingPage') } @@ -159,6 +189,7 @@ describe('MobileOnboardingScreen', () => { mocks.params = { hostId: 'paired-host', steps: 'notifications' } await act(async () => renderer!.update(createElement(MobileOnboardingScreen))) + await layoutViewport(390) expect(pages()).toHaveLength(1) expect(pages()[0].props).toMatchObject({ step: 'notifications', active: true }) @@ -186,4 +217,30 @@ describe('MobileOnboardingScreen', () => { expect(pages()[1].props).toMatchObject({ active: true, busyChoice: null }) }) + + it('sizes slides from the modal viewport rather than the wider window', async () => { + // iPad presents the modal as a centred sheet narrower than the window. + mocks.windowWidth = 1024 + await renderScreen(540) + + expect(pages().map((page) => page.props.width)).toEqual([540, 540]) + expect(trackWidth()).toBe(1080) + expect(translateX()).toBe(0) + + await act(async () => pages()[0].props.onSessionChoice('chat')) + expect(translateX()).toBe(-540) + + await layoutViewport(700) + expect(pages().map((page) => page.props.width)).toEqual([700, 700]) + expect(trackWidth()).toBe(1400) + expect(translateX()).toBe(-700) + }) + + it('keeps the pager unrendered until the viewport reports a real width', async () => { + await renderScreen(0) + expect(pages()).toHaveLength(0) + + await layoutViewport(540) + expect(pages().map((page) => page.props.width)).toEqual([540, 540]) + }) })