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,