diff --git a/app/(protected)/_layout.tsx b/app/(protected)/_layout.tsx index fdc05ceba..92fb5d49c 100644 --- a/app/(protected)/_layout.tsx +++ b/app/(protected)/_layout.tsx @@ -5,6 +5,7 @@ import { Stack, useGlobalSearchParams, useLocalSearchParams, + usePathname, useRouter, } from 'expo-router'; import { useQueryClient } from '@tanstack/react-query'; @@ -26,6 +27,7 @@ import { DEPOSIT_MODAL } from '@/constants/modals'; import { path } from '@/constants/path'; import { apysQueryOptions } from '@/hooks/useAnalytics'; import { tokenBalancesQueryOptions } from '@/hooks/useBalances'; +import { useNotificationPermissionReminder } from '@/hooks/useNotificationPermissionReminder'; import { detectPasskeyBlock, passkeyNotSupportedHref } from '@/hooks/usePasskey'; import { usePostSignupInit } from '@/hooks/usePostSignupInit'; import { useRealtime } from '@/hooks/useRealtime'; @@ -36,9 +38,11 @@ import { trackIdentity } from '@/lib/analytics'; import { ADDRESSES } from '@/lib/config'; import { lazyWithRetry } from '@/lib/lazyWithRetry'; import { config } from '@/lib/wagmi'; +import { useCardPaneStore } from '@/store/useCardPaneStore'; import { useDepositStore } from '@/store/useDepositStore'; import { useOnboardingStore } from '@/store/useOnboardingStore'; import { useUserStore } from '@/store/useUserStore'; +import { useWhatsNewStore } from '@/store/useWhatsNewStore'; // Lazy load Loading component - only used during hydration const Loading = lazyWithRetry(() => import('@/components/Loading')); @@ -50,6 +54,7 @@ export default function ProtectedLayout() { ); const searchParams = useLocalSearchParams(); const globalSearchParams = useGlobalSearchParams(); + const pathname = usePathname(); const router = useRouter(); const queryClient = useQueryClient(); const hasSeenNotificationOnboarding = useOnboardingStore( @@ -59,12 +64,29 @@ export default function ProtectedLayout() { state => state.setHasSeenNotificationOnboarding, ); const notificationPermissionRequested = globalSearchParams.notificationPermission === 'open'; + const isHomeReady = useWhatsNewStore(state => state.isHomeReady); + const isWhatsNewVisible = useWhatsNewStore(state => state.isVisible); + const isCardPaneOpen = useCardPaneStore(state => state.isOpen); + const depositModal = useDepositStore(state => state.modal); + const { visible: isNotificationReminderVisible, dismiss: dismissNotificationReminder } = + useNotificationPermissionReminder( + !!user && + _hasHydrated && + pathname === path.HOME && + isHomeReady && + !isWhatsNewVisible && + !isCardPaneOpen && + depositModal.name === DEPOSIT_MODAL.CLOSE.name && + !notificationPermissionRequested, + ); const showNotificationPermissionSheet = - notificationPermissionRequested && !hasSeenNotificationOnboarding; + (notificationPermissionRequested && !hasSeenNotificationOnboarding) || + isNotificationReminderVisible; const handleNotificationPermissionDismiss = useCallback(() => { setHasSeenNotificationOnboarding(true); - }, [setHasSeenNotificationOnboarding]); + dismissNotificationReminder(); + }, [dismissNotificationReminder, setHasSeenNotificationOnboarding]); useEffect(() => { if (notificationPermissionRequested && hasSeenNotificationOnboarding) { diff --git a/components/Notifications/NotificationPermissionSheet.native.tsx b/components/Notifications/NotificationPermissionSheet.native.tsx index efb0c03ff..bd17252b9 100644 --- a/components/Notifications/NotificationPermissionSheet.native.tsx +++ b/components/Notifications/NotificationPermissionSheet.native.tsx @@ -16,7 +16,8 @@ import { BottomSheetBackdrop, BottomSheetModal, BottomSheetView } from '@gorhom/ import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { registerForPushNotificationsAsync } from '@/lib/registerForPushNotifications'; +import useNotificationPermissionStatus from '@/hooks/useNotificationPermissionStatus'; +import { requestNotificationPermissionOrOpenSettings } from '@/lib/requestNotificationPermission'; const FIGMA_SHEET_HEIGHT = 462; const MOTION_DURATION_MS = 2200; @@ -154,6 +155,7 @@ export default function NotificationPermissionSheet({ const { height } = useWindowDimensions(); const insets = useSafeAreaInsets(); const [isLoading, setIsLoading] = useState(false); + const { status: permissionStatus } = useNotificationPermissionStatus(); const sheetHeight = Math.min(FIGMA_SHEET_HEIGHT, height - insets.top - 8); const snapPoints = useMemo(() => [sheetHeight], [sheetHeight]); @@ -169,7 +171,7 @@ export default function NotificationPermissionSheet({ setIsLoading(true); try { - await registerForPushNotificationsAsync(); + await requestNotificationPermissionOrOpenSettings(); } catch (error) { console.error('Notification registration failed:', error); } finally { @@ -221,7 +223,13 @@ export default function NotificationPermissionSheet({ onPress={handleContinue} disabled={isLoading} > - {isLoading ? 'Loading...' : 'Continue'} + + {isLoading + ? 'Loading...' + : permissionStatus === 'Denied' + ? 'Open Settings' + : 'Continue'} + diff --git a/hooks/__tests__/useNotificationPermissionReminder.test.tsx b/hooks/__tests__/useNotificationPermissionReminder.test.tsx new file mode 100644 index 000000000..56dc66a88 --- /dev/null +++ b/hooks/__tests__/useNotificationPermissionReminder.test.tsx @@ -0,0 +1,239 @@ +import React from 'react'; +import { AppState, type AppStateStatus, Platform } from 'react-native'; +import * as Notifications from 'expo-notifications'; + +import { + NOTIFICATION_REMINDER_INTERVAL_MS, + useNotificationPermissionReminder, +} from '@/hooks/useNotificationPermissionReminder'; +import { useOnboardingStore } from '@/store/useOnboardingStore'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +jest.mock('@/lib/mmvkStorage', () => { + const storage = new Map(); + return { + __esModule: true, + storage, + default: () => ({ + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + }), + }; +}); +const mockStorage = jest.requireMock('@/lib/mmvkStorage').storage as Map; +jest.mock('expo-notifications', () => ({ getPermissionsAsync: jest.fn() })); + +const NOW = 1_800_000_000_000; +const permissions = Notifications.getPermissionsAsync as jest.Mock; +const originalOS = Platform.OS; +const listeners = new Set<(state: AppStateStatus) => void>(); +type Result = ReturnType; + +describe('notification reminder cooldown', () => { + let renderer: ReturnType | undefined; + const results: Result[] = []; + const Probe = ({ enabled }: { enabled: boolean }) => { + const result = useNotificationPermissionReminder(enabled); + React.useEffect(() => { + results.push(result); + }); + return null; + }; + const latest = () => results[results.length - 1]; + const mount = async (enabled = true) => { + await act(async () => { + renderer = create(); + }); + }; + const foreground = async () => { + await act(async () => { + Object.assign(AppState, { currentState: 'active' }); + listeners.forEach(listener => listener('active')); + }); + }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(NOW); + results.length = 0; + mockStorage.clear(); + Object.defineProperty(Platform, 'OS', { value: 'ios', configurable: true }); + Object.assign(AppState, { currentState: 'active' }); + jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, listener) => { + listeners.add(listener); + return { remove: () => listeners.delete(listener) }; + }); + useOnboardingStore.setState({ + hasSeenOnboarding: true, + hasSeenNotificationOnboarding: true, + lastNotificationPromptAt: NOW - NOTIFICATION_REMINDER_INTERVAL_MS, + }); + permissions.mockReset().mockResolvedValue({ status: 'undetermined', granted: false }); + }); + + afterEach(() => { + if (renderer) act(() => renderer.unmount()); + renderer = undefined; + listeners.clear(); + jest.restoreAllMocks(); + Object.defineProperty(Platform, 'OS', { value: originalOS, configurable: true }); + jest.useRealTimers(); + }); + + it('shows on day 30 after Home settles, without requesting OS permission', async () => { + await mount(); + act(() => jest.advanceTimersByTime(4999)); + expect(latest().visible).toBe(false); + act(() => jest.advanceTimersByTime(1)); + expect(latest().visible).toBe(true); + expect(useOnboardingStore.getState().lastNotificationPromptAt).toBe(NOW + 5000); + }); + + it('stays hidden until the full 30 days have elapsed and rechecks on foreground', async () => { + useOnboardingStore.setState({ + lastNotificationPromptAt: NOW - NOTIFICATION_REMINDER_INTERVAL_MS + 1, + }); + await mount(); + expect(permissions).not.toHaveBeenCalled(); + jest.setSystemTime(NOW + 1); + await foreground(); + act(() => jest.advanceTimersByTime(5000)); + expect(latest().visible).toBe(true); + }); + + it('starts another 30-day window on dismissal and preserves it across remounts', async () => { + await mount(); + act(() => jest.advanceTimersByTime(5000)); + jest.setSystemTime(NOW + 60_000); + act(() => latest().dismiss()); + expect(latest().visible).toBe(false); + const dismissedAt = NOW + 60_000; + expect(useOnboardingStore.getState().lastNotificationPromptAt).toBe(dismissedAt); + act(() => renderer.unmount()); + await mount(); + act(() => jest.advanceTimersByTime(5000)); + expect(latest().visible).toBe(false); + jest.setSystemTime(dismissedAt + NOTIFICATION_REMINDER_INTERVAL_MS); + await foreground(); + act(() => jest.advanceTimersByTime(5000)); + expect(latest().visible).toBe(true); + }); + + it('prompts legacy OTA installs on their first Home visit and preserves onboarding flags', async () => { + useOnboardingStore.setState({ lastNotificationPromptAt: null }); + mockStorage.set( + 'onboarding-storage', + JSON.stringify({ + state: { hasSeenOnboarding: true, hasSeenNotificationOnboarding: true }, + version: 0, + }), + ); + await useOnboardingStore.persist.rehydrate(); + await mount(); + expect(useOnboardingStore.getState()).toMatchObject({ + hasSeenOnboarding: true, + hasSeenNotificationOnboarding: true, + lastNotificationPromptAt: null, + }); + expect(permissions).toHaveBeenCalledTimes(1); + act(() => jest.advanceTimersByTime(5000)); + expect(latest().visible).toBe(true); + const persisted = JSON.parse(mockStorage.get('onboarding-storage')!); + expect(persisted.state.lastNotificationPromptAt).toBe(NOW + 5000); + act(() => latest().dismiss()); + act(() => renderer.unmount()); + await mount(); + act(() => jest.advanceTimersByTime(5000)); + expect(latest().visible).toBe(false); + }); + + it('does not interrupt users who already granted permission', async () => { + permissions.mockResolvedValue({ status: 'granted', granted: true }); + await mount(); + act(() => jest.advanceTimersByTime(5000)); + expect(latest().visible).toBe(false); + }); + + it('skips the first OTA reminder when notifications are already enabled', async () => { + useOnboardingStore.setState({ lastNotificationPromptAt: null }); + permissions.mockResolvedValue({ status: 'granted', granted: true }); + await mount(); + act(() => jest.advanceTimersByTime(5000)); + expect(latest().visible).toBe(false); + expect(useOnboardingStore.getState().lastNotificationPromptAt).toBeNull(); + }); + + it('can remind a user with denied permission without issuing a system request', async () => { + permissions.mockResolvedValue({ status: 'denied', granted: false }); + await mount(); + act(() => jest.advanceTimersByTime(5000)); + expect(latest().visible).toBe(true); + }); + + it('waits when Home is not ready or another screen or modal is open', async () => { + await mount(false); + act(() => jest.advanceTimersByTime(10000)); + expect(permissions).not.toHaveBeenCalled(); + await act(async () => renderer.update()); + act(() => jest.advanceTimersByTime(5000)); + expect(latest().visible).toBe(true); + }); + + it('cancels a queued reminder when the user leaves Home', async () => { + await mount(); + await act(async () => renderer.update()); + act(() => jest.advanceTimersByTime(5000)); + expect(latest().visible).toBe(false); + expect(useOnboardingStore.getState().lastNotificationPromptAt).toBe( + NOW - NOTIFICATION_REMINDER_INTERVAL_MS, + ); + }); + + it('keeps a presented reminder snoozed after leaving and returning to Home', async () => { + await mount(); + act(() => jest.advanceTimersByTime(5000)); + expect(latest().visible).toBe(true); + await act(async () => renderer.update()); + await act(async () => renderer.update()); + act(() => jest.advanceTimersByTime(5000)); + expect(latest().visible).toBe(false); + }); + + it('ignores stale permission reads after a foreground transition', async () => { + let resolve!: (permission: unknown) => void; + permissions.mockReturnValueOnce( + new Promise(res => { + resolve = res; + }), + ); + await mount(); + await act(async () => { + Object.assign(AppState, { currentState: 'background' }); + listeners.forEach(listener => listener('background')); + }); + permissions.mockResolvedValue({ status: 'granted', granted: true }); + await foreground(); + await act(async () => resolve({ status: 'denied', granted: false })); + act(() => jest.advanceTimersByTime(5000)); + expect(latest().visible).toBe(false); + }); + + it('skips reminders when permission cannot be read', async () => { + permissions.mockRejectedValue(new Error('unavailable')); + await mount(); + act(() => jest.advanceTimersByTime(5000)); + expect(latest().visible).toBe(false); + }); + + it('does not check permissions or start the cooldown on web', async () => { + Object.defineProperty(Platform, 'OS', { value: 'web', configurable: true }); + useOnboardingStore.setState({ lastNotificationPromptAt: null }); + await mount(); + act(() => jest.advanceTimersByTime(5000)); + expect(permissions).not.toHaveBeenCalled(); + expect(latest().visible).toBe(false); + expect(useOnboardingStore.getState().lastNotificationPromptAt).toBeNull(); + }); +}); diff --git a/hooks/useNotificationPermissionReminder.ts b/hooks/useNotificationPermissionReminder.ts new file mode 100644 index 000000000..a6de0d6a4 --- /dev/null +++ b/hooks/useNotificationPermissionReminder.ts @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useState } from 'react'; +import { AppState, Platform } from 'react-native'; +import * as Notifications from 'expo-notifications'; + +import { useOnboardingStore } from '@/store/useOnboardingStore'; + +export const NOTIFICATION_REMINDER_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000; +const HOME_SETTLE_DELAY_MS = 5000; + +/** Reuses the notification sheet on a settled Home screen, at most every 30 days. */ +export function useNotificationPermissionReminder(enabled: boolean) { + const lastPromptAt = useOnboardingStore(state => state.lastNotificationPromptAt); + const recordPrompt = useOnboardingStore(state => state.recordNotificationPrompt); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!enabled || Platform.OS === 'web') { + if (visible) setVisible(false); + return; + } + + let cancelled = false; + let generation = 0; + let timer: ReturnType | undefined; + + const check = async () => { + const checkGeneration = ++generation; + clearTimeout(timer); + const now = Date.now(); + + // Older installs have no timestamp and are eligible on their first Home + // visit. Only an invalid timestamp or a backwards clock resets the window. + if (lastPromptAt !== null && (!Number.isFinite(lastPromptAt) || lastPromptAt > now)) { + recordPrompt(); + return; + } + if ( + visible || + (lastPromptAt !== null && now - lastPromptAt < NOTIFICATION_REMINDER_INTERVAL_MS) + ) { + return; + } + + try { + const permission = await Notifications.getPermissionsAsync(); + if ( + cancelled || + checkGeneration !== generation || + permission.granted || + permission.status === 'granted' + ) { + return; + } + + timer = setTimeout(() => { + if (cancelled || checkGeneration !== generation || AppState.currentState !== 'active') { + return; + } + // Record presentation as well as dismissal, so a restart cannot show + // the same reminder again before the cooldown expires. + recordPrompt(); + setVisible(true); + }, HOME_SETTLE_DELAY_MS); + } catch { + // A failed permission read isn't permission to interrupt the user. + // Returning to Home or foregrounding the app retries the check. + } + }; + + void check(); + const subscription = AppState.addEventListener('change', state => { + if (state === 'active') void check(); + else { + generation += 1; + clearTimeout(timer); + } + }); + + return () => { + cancelled = true; + clearTimeout(timer); + subscription.remove(); + }; + }, [enabled, lastPromptAt, recordPrompt, visible]); + + const dismiss = useCallback(() => { + recordPrompt(); + setVisible(false); + }, [recordPrompt]); + + return { visible: enabled && Platform.OS !== 'web' && visible, dismiss }; +} diff --git a/lib/__tests__/requestNotificationPermission.test.ts b/lib/__tests__/requestNotificationPermission.test.ts new file mode 100644 index 000000000..ed4ce0f32 --- /dev/null +++ b/lib/__tests__/requestNotificationPermission.test.ts @@ -0,0 +1,53 @@ +import { Linking, Platform } from 'react-native'; +import * as IntentLauncher from 'expo-intent-launcher'; +import * as Notifications from 'expo-notifications'; + +import { registerForPushNotificationsAsync } from '@/lib/registerForPushNotifications'; +import { requestNotificationPermissionOrOpenSettings } from '@/lib/requestNotificationPermission'; + +jest.mock('expo-notifications', () => ({ getPermissionsAsync: jest.fn() })); +jest.mock('expo-application', () => ({ applicationId: 'xyz.solid.android' })); +jest.mock('expo-intent-launcher', () => ({ + ActivityAction: { APP_NOTIFICATION_SETTINGS: 'notification-settings' }, + startActivityAsync: jest.fn(), +})); +jest.mock('@/lib/registerForPushNotifications', () => ({ + registerForPushNotificationsAsync: jest.fn(), +})); + +const originalOS = Platform.OS; + +describe('notification reminder action', () => { + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(Platform, 'OS', { value: 'ios', configurable: true }); + }); + afterEach(() => { + jest.restoreAllMocks(); + Object.defineProperty(Platform, 'OS', { value: originalOS, configurable: true }); + }); + + it('requests permission when the user has never answered', async () => { + (Notifications.getPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'undetermined' }); + await requestNotificationPermissionOrOpenSettings(); + expect(registerForPushNotificationsAsync).toHaveBeenCalledTimes(1); + }); + + it('opens iOS Settings after denial instead of requesting permission again', async () => { + (Notifications.getPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'denied' }); + const openSettings = jest.spyOn(Linking, 'openSettings').mockResolvedValue(undefined); + await requestNotificationPermissionOrOpenSettings(); + expect(openSettings).toHaveBeenCalledTimes(1); + expect(registerForPushNotificationsAsync).not.toHaveBeenCalled(); + }); + + it('opens the app notification settings on Android after denial', async () => { + Object.defineProperty(Platform, 'OS', { value: 'android', configurable: true }); + (Notifications.getPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'denied' }); + await requestNotificationPermissionOrOpenSettings(); + expect(IntentLauncher.startActivityAsync).toHaveBeenCalledWith('notification-settings', { + extra: { 'android.provider.extra.APP_PACKAGE': 'xyz.solid.android' }, + }); + expect(registerForPushNotificationsAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/requestNotificationPermission.ts b/lib/requestNotificationPermission.ts new file mode 100644 index 000000000..cb34e64c8 --- /dev/null +++ b/lib/requestNotificationPermission.ts @@ -0,0 +1,23 @@ +import { Linking, Platform } from 'react-native'; +import * as Application from 'expo-application'; +import * as IntentLauncher from 'expo-intent-launcher'; +import * as Notifications from 'expo-notifications'; + +import { registerForPushNotificationsAsync } from '@/lib/registerForPushNotifications'; + +/** A previous denial needs Settings; an unanswered request can show the OS dialog. */ +export async function requestNotificationPermissionOrOpenSettings() { + const permission = await Notifications.getPermissionsAsync(); + if (permission.status !== 'denied') { + return registerForPushNotificationsAsync(); + } + + if (Platform.OS === 'android') { + return IntentLauncher.startActivityAsync( + IntentLauncher.ActivityAction.APP_NOTIFICATION_SETTINGS, + { extra: { 'android.provider.extra.APP_PACKAGE': Application.applicationId } }, + ); + } + + return Linking.openSettings(); +} diff --git a/store/useOnboardingStore.ts b/store/useOnboardingStore.ts index 7a7b5a45b..90a2ca54e 100644 --- a/store/useOnboardingStore.ts +++ b/store/useOnboardingStore.ts @@ -6,8 +6,10 @@ import mmkvStorage from '@/lib/mmvkStorage'; interface OnboardingState { hasSeenOnboarding: boolean; hasSeenNotificationOnboarding: boolean; + lastNotificationPromptAt: number | null; setHasSeenOnboarding: (seen: boolean) => void; setHasSeenNotificationOnboarding: (seen: boolean) => void; + recordNotificationPrompt: () => void; } const ONBOARDING_STORAGE_KEY = 'onboarding-storage'; @@ -17,9 +19,13 @@ export const useOnboardingStore = create()( set => ({ hasSeenOnboarding: false, hasSeenNotificationOnboarding: false, + // Older OTA installs have no timestamp, so they can be prompted on their + // first Home visit without resetting their existing onboarding flags. + lastNotificationPromptAt: null, setHasSeenOnboarding: (seen: boolean) => set({ hasSeenOnboarding: seen }), setHasSeenNotificationOnboarding: (seen: boolean) => set({ hasSeenNotificationOnboarding: seen }), + recordNotificationPrompt: () => set({ lastNotificationPromptAt: Date.now() }), }), { name: ONBOARDING_STORAGE_KEY,