Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 46 additions & 20 deletions App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -393,6 +418,7 @@ const App = () => {
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
<CacheRevalidationBanner />
<AppNavigator />
<NotificationPermissionExplanationSheet />
</AuthProvider>
</ErrorBoundary>
);
Expand Down
162 changes: 162 additions & 0 deletions src/components/mobile/NotificationPermissionExplanationSheet.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View className="mb-4 flex-row items-start">
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-indigo-100">
<Text className="text-xl">{icon}</Text>
</View>
<View className="flex-1">
<Text className="text-base font-semibold text-gray-900 dark:text-white">{title}</Text>
<Text className="text-sm text-gray-600 dark:text-gray-400">{description}</Text>
</View>
</View>
);
};

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 (
<ErrorBoundary boundaryName="NotificationPermissionExplanationSheet.DeviceModal">
<Modal visible={showNotificationExplainer} animationType="slide" transparent>
<View className="flex-1 justify-end bg-black/50">
<View className="rounded-t-3xl bg-white px-6 pb-10 pt-6 dark:bg-gray-900">
<Text className="mb-4 text-center text-lg text-gray-600 dark:text-gray-400">
Push notifications are only available on physical devices.
</Text>
<TouchableOpacity
onPress={() => setShowNotificationExplainer(false)}
className="items-center rounded-xl bg-indigo-600 py-4"
>
<Text className="text-base font-semibold text-white">Got it</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</ErrorBoundary>
);
}

return (
<ErrorBoundary boundaryName="NotificationPermissionExplanationSheet.Modal">
<Modal visible={showNotificationExplainer} animationType="slide" transparent>
<View className="flex-1 justify-end bg-black/50">
<SafeAreaView className="rounded-t-3xl bg-white dark:bg-gray-900">
<View className="px-6 pb-4 pt-6">
{/* Header */}
<View className="mb-6 items-center">
<View className="mb-4 h-16 w-16 items-center justify-center rounded-full bg-indigo-100">
<Text className="text-3xl">🔔</Text>
</View>
<Text className="text-center text-2xl font-bold text-gray-900 dark:text-white">
Stay Updated
</Text>
<Text className="mt-2 text-center text-base text-gray-600 dark:text-gray-400">
Enable notifications to never miss important updates
</Text>
</View>

{/* Notification Types */}
<View className="mb-6">
<NotificationTypeItem
icon="📚"
title="Course Updates"
description="New lessons, content updates, and course announcements"
/>
<NotificationTypeItem
icon="💬"
title="Messages"
description="Direct messages and chat notifications"
/>
<NotificationTypeItem
icon="⏰"
title="Learning Reminders"
description="Daily reminders to keep your learning streak"
/>
<NotificationTypeItem
icon="🏆"
title="Achievements"
description="Celebrate when you unlock new achievements"
/>
</View>

{/* Buttons */}
<View className="space-y-3">
<TouchableOpacity
onPress={handleEnable}
disabled={isLoading}
className={`items-center rounded-xl py-4 ${
isLoading ? 'bg-indigo-400' : 'bg-indigo-600'
}`}
>
<Text className="text-base font-semibold text-white">
{isLoading
? 'Enabling...'
: permissionStatus === 'denied'
? 'Open Settings'
: 'Enable Notifications'}
</Text>
</TouchableOpacity>

<TouchableOpacity
onPress={handleNotNow}
disabled={isLoading}
className="items-center rounded-xl bg-gray-100 py-4 dark:bg-gray-800"
>
<Text className="text-base font-semibold text-gray-700 dark:text-gray-300">
Not Now
</Text>
</TouchableOpacity>
</View>

{/* Privacy Note */}
<Text className="mt-4 text-center text-xs text-gray-500 dark:text-gray-500">
You can change your notification preferences anytime in Settings
</Text>
</View>
</SafeAreaView>
</View>
</Modal>
</ErrorBoundary>
);
};

export default NotificationPermissionExplanationSheet;
1 change: 1 addition & 0 deletions src/components/mobile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ export * from './TeamDashboard';
export * from './VirtualList';
export * from './VoiceSearch';
export * from './ProfiledScreen';
export * from './NotificationPermissionExplanationSheet';

13 changes: 12 additions & 1 deletion src/hooks/useInAppReview.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(() => {
Expand Down
12 changes: 8 additions & 4 deletions src/services/pushNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
export async function registerForPushNotifications(allowPrompt = false): Promise<string | null> {
// Check device type using the proper 'isDevice' check from expo-device
if (!isDevice) {
logger.warn('Push notifications require a physical device (simulator detected)');
Expand All @@ -43,10 +43,14 @@ export async function registerForPushNotifications(): Promise<string | null> {
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') {
Expand Down
5 changes: 5 additions & 0 deletions src/store/notificationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface NotificationState {
// Permission state
hasPromptedForPermission: boolean;
permissionDeniedAt: string | null;
showNotificationExplainer: boolean;

// Notification preferences
preferences: NotificationPreferences;
Expand All @@ -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;
Expand Down Expand Up @@ -68,6 +70,7 @@ export const useNotificationStore = create<NotificationState>()(
tokenLastUpdated: null,
hasPromptedForPermission: false,
permissionDeniedAt: null,
showNotificationExplainer: false,
preferences: DEFAULT_NOTIFICATION_PREFERENCES,
notifications: [],
unreadCount: 0,
Expand Down Expand Up @@ -96,6 +99,8 @@ export const useNotificationStore = create<NotificationState>()(

setPermissionDeniedAt: date => set({ permissionDeniedAt: date }),

setShowNotificationExplainer: show => set({ showNotificationExplainer: show }),

// Preference actions
setPreference: (key, value) =>
set(state => ({
Expand Down
Loading
Loading