diff --git a/App.tsx b/App.tsx
index d00a2650..c27a0683 100644
--- a/App.tsx
+++ b/App.tsx
@@ -1,4 +1,6 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Font from 'expo-font';
+import * as Notifications from 'expo-notifications';
import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import React, { useEffect, useRef, useState } from 'react';
@@ -15,10 +17,10 @@ import {
import StorybookUI from './.rnstorybook';
import './global.css';
import { ErrorBoundary } from './src/components/common/ErrorBoundary';
+import { NotificationPermissionExplanationSheet } from './src/components/mobile/NotificationPermissionExplanationSheet';
import { initializeLogging } from './src/config/logging';
import { AuthProvider, useAdaptiveTheme, useReviewMetrics } from './src/hooks';
import AppNavigator from './src/navigation/AppNavigator';
-import { setupNotificationNavigation } from './src/navigation/linking';
import {
apiClient,
getCacheStatus,
@@ -29,7 +31,6 @@ import { warmCriticalCaches } from './src/services/cacheWarming';
import { crashReportingService } from './src/services/crashReporting';
import { featureCapabilities } from './src/services/featureCapabilities';
import { inAppReviewService } from './src/services/inAppReview';
-import { initializeSecureStorage } from './src/services/secureStorage';
import { mobileAuthService } from './src/services/mobileAuth';
import {
registerForPushNotifications, // Added missing native push helpers
@@ -38,7 +39,7 @@ import {
} from './src/services/pushNotifications';
import { requestQueue } from './src/services/requestQueue';
import { searchIndexService } from './src/services/searchIndex';
-import { initializeSecureStorage } from './src/services/secureStorage'; // Added missing storage helper mock path
+import { initializeSecureStorage } from './src/services/secureStorage';
import socketService from './src/services/socket';
import { syncService } from './src/services/syncService'; // Fixed naming convention from the merge conflict
import { useAppStore, useDeviceStore, useNotificationStore } from './src/store'; // Added missing store imports
@@ -233,15 +234,7 @@ const App = () => {
);
});
- // Initialize push notifications: request permissions and get device token
- registerForPushNotifications().then(async token => {
- if (token) {
- const { setPushToken, setTokenRegistered } = useNotificationStore.getState();
- setPushToken(token);
- const registered = await registerTokenWithBackend(token);
- setTokenRegistered(registered);
- }
- });
+ // Push notifications are now initialized within InteractionManager.runAfterInteractions below
// ===== DEFERRED PATH — runs after user interactions complete =====
// These tasks are non-critical: they enhance the experience but are not
@@ -274,15 +267,47 @@ const App = () => {
);
});
- // Push notification registration (permission dialog + network)
- registerForPushNotifications().then(async token => {
- if (token) {
- const { setPushToken, setTokenRegistered } = useNotificationStore.getState();
- setPushToken(token);
- const registered = await registerTokenWithBackend(token);
- setTokenRegistered(registered);
+ // Push notification registration and explainer logic
+ const checkAndRegisterNotifications = async () => {
+ const { status } = await Notifications.getPermissionsAsync();
+
+ if (status === 'granted') {
+ // Already granted, silently get token
+ const token = await registerForPushNotifications(false);
+ if (token) {
+ const { setPushToken, setTokenRegistered } = useNotificationStore.getState();
+ setPushToken(token);
+ const registered = await registerTokenWithBackend(token);
+ setTokenRegistered(registered);
+ }
+ return;
}
- });
+
+ // Check explainer status
+ const hasSeen = await AsyncStorage.getItem('hasSeenNotificationExplainer');
+
+ if (hasSeen === 'true') {
+ // Explainer already seen and accepted, do not show sheet again
+ return;
+ }
+
+ if (hasSeen === null) {
+ // First launch
+ useNotificationStore.getState().setShowNotificationExplainer(true);
+ } else if (hasSeen === 'deferred') {
+ // Deferred users
+ const deferredCountStr = await AsyncStorage.getItem('appOpenCountSinceDeferral') || '0';
+ let deferredCount = parseInt(deferredCountStr, 10);
+ deferredCount += 1;
+ await AsyncStorage.setItem('appOpenCountSinceDeferral', deferredCount.toString());
+
+ if (deferredCount >= 3) {
+ useNotificationStore.getState().setShowNotificationExplainer(true);
+ }
+ }
+ };
+
+ checkAndRegisterNotifications();
// Request queue monitoring
requestQueue.startMonitoring(apiClient);
@@ -393,6 +418,7 @@ const App = () => {
+
);
diff --git a/src/components/mobile/NotificationPermissionExplanationSheet.tsx b/src/components/mobile/NotificationPermissionExplanationSheet.tsx
new file mode 100644
index 00000000..e5df09e2
--- /dev/null
+++ b/src/components/mobile/NotificationPermissionExplanationSheet.tsx
@@ -0,0 +1,162 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import React from 'react';
+import { Modal, SafeAreaView, Text, TouchableOpacity, View } from 'react-native';
+
+import { useNotificationPermission } from '../../hooks';
+import { useNotificationStore } from '../../store/notificationStore';
+import { ErrorBoundary } from '../common/ErrorBoundary';
+
+interface NotificationTypeItemProps {
+ icon: string;
+ title: string;
+ description: string;
+}
+
+const NotificationTypeItem = ({ icon, title, description }: NotificationTypeItemProps) => {
+ return (
+
+
+ {icon}
+
+
+ {title}
+ {description}
+
+
+ );
+};
+
+export const NotificationPermissionExplanationSheet = () => {
+ const { showNotificationExplainer, setShowNotificationExplainer } = useNotificationStore();
+ const { requestPermission, isLoading, isDevice, openSettings, permissionStatus } =
+ useNotificationPermission();
+
+ const handleEnable = async () => {
+ if (permissionStatus === 'denied') {
+ await openSettings();
+ setShowNotificationExplainer(false);
+ return;
+ }
+
+ const granted = await requestPermission();
+ if (granted) {
+ await AsyncStorage.setItem('hasSeenNotificationExplainer', 'true');
+ } else {
+ await AsyncStorage.setItem('hasSeenNotificationExplainer', 'deferred');
+ }
+ setShowNotificationExplainer(false);
+ };
+
+ const handleNotNow = async () => {
+ await AsyncStorage.setItem('hasSeenNotificationExplainer', 'deferred');
+ await AsyncStorage.setItem('appOpenCountSinceDeferral', '0');
+ setShowNotificationExplainer(false);
+ };
+
+ if (!isDevice) {
+ return (
+
+
+
+
+
+ Push notifications are only available on physical devices.
+
+ setShowNotificationExplainer(false)}
+ className="items-center rounded-xl bg-indigo-600 py-4"
+ >
+ Got it
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {/* Header */}
+
+
+ 🔔
+
+
+ Stay Updated
+
+
+ Enable notifications to never miss important updates
+
+
+
+ {/* Notification Types */}
+
+
+
+
+
+
+
+ {/* Buttons */}
+
+
+
+ {isLoading
+ ? 'Enabling...'
+ : permissionStatus === 'denied'
+ ? 'Open Settings'
+ : 'Enable Notifications'}
+
+
+
+
+
+ Not Now
+
+
+
+
+ {/* Privacy Note */}
+
+ You can change your notification preferences anytime in Settings
+
+
+
+
+
+
+ );
+};
+
+export default NotificationPermissionExplanationSheet;
diff --git a/src/components/mobile/index.ts b/src/components/mobile/index.ts
index 0195aa5e..bde85957 100644
--- a/src/components/mobile/index.ts
+++ b/src/components/mobile/index.ts
@@ -34,4 +34,5 @@ export * from './TeamDashboard';
export * from './VirtualList';
export * from './VoiceSearch';
export * from './ProfiledScreen';
+export * from './NotificationPermissionExplanationSheet';
diff --git a/src/hooks/useInAppReview.ts b/src/hooks/useInAppReview.ts
index 75070171..95dab152 100644
--- a/src/hooks/useInAppReview.ts
+++ b/src/hooks/useInAppReview.ts
@@ -1,6 +1,8 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
import { useCallback, useEffect, useState } from 'react';
import { inAppReviewService, ReviewTrigger, ReviewRequestResult } from '../services/inAppReview';
+import { useNotificationStore } from '../store/notificationStore';
import { useReviewStore } from '../store/reviewStore';
import { appLogger } from '../utils/logger';
@@ -161,9 +163,18 @@ export function useReviewMetrics() {
const setLearningStreak = useReviewStore((state) => state.setLearningStreak);
const incrementPerfectQuizScores = useReviewStore((state) => state.incrementPerfectQuizScores);
- const trackCourseComplete = useCallback(() => {
+ const trackCourseComplete = useCallback(async () => {
incrementCoursesCompleted();
appLogger.debug('useReviewMetrics: Course completed');
+
+ try {
+ const hasSeen = await AsyncStorage.getItem('hasSeenNotificationExplainer');
+ if (hasSeen === 'deferred') {
+ useNotificationStore.getState().setShowNotificationExplainer(true);
+ }
+ } catch (e) {
+ appLogger.error('useReviewMetrics: Error checking notification explainer state', e instanceof Error ? e : new Error(String(e)));
+ }
}, [incrementCoursesCompleted]);
const trackSession = useCallback(() => {
diff --git a/src/services/pushNotifications.ts b/src/services/pushNotifications.ts
index eb07c595..4684fc18 100644
--- a/src/services/pushNotifications.ts
+++ b/src/services/pushNotifications.ts
@@ -23,7 +23,7 @@ Notifications.setNotificationHandler({
* Register for push notifications and get the Expo push token
* Includes graceful degradation: if push notifications unavailable, falls back to in-app notifications
*/
-export async function registerForPushNotifications(): Promise {
+export async function registerForPushNotifications(allowPrompt = false): Promise {
// Check device type using the proper 'isDevice' check from expo-device
if (!isDevice) {
logger.warn('Push notifications require a physical device (simulator detected)');
@@ -43,10 +43,14 @@ export async function registerForPushNotifications(): Promise {
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
- // Request permissions if not granted
+ // Request permissions if not granted AND allowPrompt is true
if (existingStatus !== 'granted') {
- const { status } = await Notifications.requestPermissionsAsync();
- finalStatus = status;
+ if (allowPrompt) {
+ const { status } = await Notifications.requestPermissionsAsync();
+ finalStatus = status;
+ } else {
+ return null;
+ }
}
if (finalStatus !== 'granted') {
diff --git a/src/store/notificationStore.ts b/src/store/notificationStore.ts
index c3bc75a5..fbb198cb 100644
--- a/src/store/notificationStore.ts
+++ b/src/store/notificationStore.ts
@@ -20,6 +20,7 @@ interface NotificationState {
// Permission state
hasPromptedForPermission: boolean;
permissionDeniedAt: string | null;
+ showNotificationExplainer: boolean;
// Notification preferences
preferences: NotificationPreferences;
@@ -39,6 +40,7 @@ interface NotificationState {
// Actions - Permission
setHasPromptedForPermission: (prompted: boolean) => void;
setPermissionDeniedAt: (date: string | null) => void;
+ setShowNotificationExplainer: (show: boolean) => void;
// Actions - Preferences
setPreference: (key: keyof NotificationPreferences, value: boolean) => void;
@@ -68,6 +70,7 @@ export const useNotificationStore = create()(
tokenLastUpdated: null,
hasPromptedForPermission: false,
permissionDeniedAt: null,
+ showNotificationExplainer: false,
preferences: DEFAULT_NOTIFICATION_PREFERENCES,
notifications: [],
unreadCount: 0,
@@ -96,6 +99,8 @@ export const useNotificationStore = create()(
setPermissionDeniedAt: date => set({ permissionDeniedAt: date }),
+ setShowNotificationExplainer: show => set({ showNotificationExplainer: show }),
+
// Preference actions
setPreference: (key, value) =>
set(state => ({
diff --git a/tests/components/NotificationPermissionExplanationSheet.test.tsx b/tests/components/NotificationPermissionExplanationSheet.test.tsx
new file mode 100644
index 00000000..0697f409
--- /dev/null
+++ b/tests/components/NotificationPermissionExplanationSheet.test.tsx
@@ -0,0 +1,187 @@
+import React from 'react';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { render, fireEvent, waitFor } from '@testing-library/react-native';
+
+import { NotificationPermissionExplanationSheet } from '../../src/components/mobile/NotificationPermissionExplanationSheet';
+import { registerForPushNotifications } from '../../src/services/pushNotifications';
+import { useNotificationStore } from '../../src/store/notificationStore';
+
+jest.mock('../../src/services/pushNotifications', () => ({
+ registerForPushNotifications: jest.fn(),
+ registerTokenWithBackend: jest.fn(),
+}));
+
+jest.mock('../../src/hooks', () => ({
+ useNotificationPermission: jest.fn(() => ({
+ requestPermission: jest.fn().mockResolvedValue(true),
+ isLoading: false,
+ isDevice: true,
+ openSettings: jest.fn(),
+ permissionStatus: 'undetermined'
+ })),
+}));
+
+jest.mock('@react-native-async-storage/async-storage', () => ({
+ setItem: jest.fn(),
+ getItem: jest.fn(),
+}));
+
+jest.mock('../../src/store/notificationStore', () => ({
+ useNotificationStore: jest.fn(),
+}));
+
+jest.mock('@react-native-community/netinfo', () => ({
+ useNetInfo: jest.fn(() => ({ isConnected: true })),
+ fetch: jest.fn(() => Promise.resolve({ isConnected: true })),
+ addEventListener: jest.fn(),
+}));
+
+jest.mock('expo-clipboard', () => ({
+ setStringAsync: jest.fn(),
+ getStringAsync: jest.fn(),
+}), { virtual: true });
+
+jest.mock('expo-haptics', () => ({
+ impactAsync: jest.fn(),
+ notificationAsync: jest.fn(),
+ ImpactFeedbackStyle: {
+ Light: 'light',
+ Medium: 'medium',
+ Heavy: 'heavy',
+ },
+ NotificationFeedbackType: {
+ Success: 'success',
+ Warning: 'warning',
+ Error: 'error',
+ },
+}), { virtual: true });
+
+// Mock Gorhom BottomSheet
+jest.mock('@gorhom/bottom-sheet', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const React = require('react');
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { View } = require('react-native');
+ const BottomSheetModal = React.forwardRef(({ children }: any, ref: any) => {
+ React.useImperativeHandle(ref, () => ({
+ present: jest.fn(),
+ dismiss: jest.fn(),
+ }));
+ return {children};
+ });
+ BottomSheetModal.displayName = 'BottomSheetModal';
+
+ return {
+ __esModule: true,
+ BottomSheetModal,
+ BottomSheetView: ({ children }: any) => {children},
+ BottomSheetModalProvider: ({ children }: any) => {children},
+ BottomSheetBackdrop: () => ,
+ };
+});
+
+// Mock the problematic hooks directory
+jest.mock('../../hooks/useScrollRestoration', () => ({
+ useScrollRestoration: jest.fn(),
+}), { virtual: true });
+
+jest.mock('../../hooks/useFlatListScrollRestoration', () => ({
+ useFlatListScrollRestoration: jest.fn(),
+}), { virtual: true });
+
+describe('NotificationPermissionExplanationSheet', () => {
+ const setShowNotificationExplainerMock = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ const mockStore = (selector: any) => {
+ const state = {
+ showNotificationExplainer: false,
+ setShowNotificationExplainer: setShowNotificationExplainerMock,
+ setPushToken: jest.fn(),
+ setTokenRegistered: jest.fn(),
+ };
+ return selector ? selector(state) : state;
+ };
+ mockStore.getState = () => mockStore(undefined);
+
+ (useNotificationStore as unknown as jest.Mock).mockImplementation(mockStore);
+ });
+
+ it('renders Modal with visible=false when showNotificationExplainer is false', () => {
+ const { toJSON } = render();
+ const json = toJSON() as any;
+ expect(json.props.visible).toBe(false);
+ });
+
+ it('renders content when showNotificationExplainer is true', () => {
+ const mockStore = (selector: any) => {
+ const state = {
+ showNotificationExplainer: true,
+ setShowNotificationExplainer: setShowNotificationExplainerMock,
+ setPushToken: jest.fn(),
+ setTokenRegistered: jest.fn(),
+ };
+ return selector ? selector(state) : state;
+ };
+ mockStore.getState = () => mockStore(undefined);
+ (useNotificationStore as unknown as jest.Mock).mockImplementation(mockStore);
+
+ const { getByText } = render();
+
+ expect(getByText('Stay Updated')).toBeTruthy();
+ expect(getByText('Enable Notifications')).toBeTruthy();
+ expect(getByText('Not Now')).toBeTruthy();
+ });
+
+ it('handles "Enable Notifications" click correctly', async () => {
+ const mockStore = (selector: any) => {
+ const state = {
+ showNotificationExplainer: true,
+ setShowNotificationExplainer: setShowNotificationExplainerMock,
+ setPushToken: jest.fn(),
+ setTokenRegistered: jest.fn(),
+ };
+ return selector ? selector(state) : state;
+ };
+ mockStore.getState = () => mockStore(undefined);
+ (useNotificationStore as unknown as jest.Mock).mockImplementation(mockStore);
+
+ (registerForPushNotifications as jest.Mock).mockResolvedValue('mock-token');
+
+ const { getByText } = render();
+
+ fireEvent.press(getByText('Enable Notifications'));
+
+ await waitFor(() => {
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith('hasSeenNotificationExplainer', 'true');
+ expect(setShowNotificationExplainerMock).toHaveBeenCalledWith(false);
+ });
+ });
+
+ it('handles "Not Now" click correctly', async () => {
+ const mockStore = (selector: any) => {
+ const state = {
+ showNotificationExplainer: true,
+ setShowNotificationExplainer: setShowNotificationExplainerMock,
+ setPushToken: jest.fn(),
+ setTokenRegistered: jest.fn(),
+ };
+ return selector ? selector(state) : state;
+ };
+ mockStore.getState = () => mockStore(undefined);
+ (useNotificationStore as unknown as jest.Mock).mockImplementation(mockStore);
+
+ const { getByText } = render();
+
+ fireEvent.press(getByText('Not Now'));
+
+ await waitFor(() => {
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith('hasSeenNotificationExplainer', 'deferred');
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith('appOpenCountSinceDeferral', '0');
+ expect(setShowNotificationExplainerMock).toHaveBeenCalledWith(false);
+ expect(registerForPushNotifications).not.toHaveBeenCalled();
+ });
+ });
+});