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(); + }); + }); +});