import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { Platform } from 'react-native'; import { useDubs } from '../provider'; export interface PushNotificationStatus { /** * Whether push is currently delivering to this user — the right signal for * UI bindings like a "Notifications: Enabled/Disabled" toggle. * * Equivalent to `enabled && !!pushToken`. Goes false after `unregister()` * even though OS permission is still granted. Goes true after `register()` * succeeds. * * Use this — NOT `hasPermission` — when binding switches/toggles. */ isActive: boolean; /** Whether push notifications are enabled in the SDK configuration (the `pushEnabled` prop on DubsProvider). */ enabled: boolean; /** * Whether OS-level notification permission has been granted on this device. * * Note: this stays true after `unregister()` because the OS doesn't revoke * permission when a server token is dropped. Useful for permission-flow * prompts (e.g. "Re-enable in Settings" hint), but NOT for "is push on" UI — * use `isActive` for that. */ hasPermission: boolean; /** The native device push token (FCM/APNs), if currently registered with the server. */ pushToken: string | null; /** * @deprecated Use pushToken instead. Kept for backwards compatibility. */ expoPushToken: string | null; /** Whether a registration operation is in progress */ loading: boolean; /** The last error encountered */ error: Error | null; /** * Request notification permission and register the push token. * Does NOT auto-prompt — must be called explicitly (e.g. from an onboarding screen). */ register: () => Promise; /** Unregister the current push token */ unregister: () => Promise; /** * Silently re-register if permission was already granted (no prompt). * Safe to call on app startup for returning users. */ restoreIfGranted: () => Promise; } export function usePushNotifications(): PushNotificationStatus { const { client, appName, pushEnabled } = useDubs(); const channelId = useMemo(() => appName.toLowerCase().replace(/[^a-z0-9-]/g, ''), [appName]); const [hasPermission, setHasPermission] = useState(false); const [pushToken, setPushToken] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const registering = useRef(false); const getNotificationsModule = useCallback(() => { try { return require('expo-notifications'); } catch { return null; } }, []); const getDeviceName = useCallback((): string | null => { try { const Device = require('expo-device'); return Device.deviceName || null; } catch { return null; } }, []); const setupAndroidChannels = useCallback((Notifications: any) => { if (Platform.OS === 'android') { Notifications.setNotificationChannelAsync(channelId || 'default', { name: appName || 'Default', importance: Notifications.AndroidImportance?.MAX ?? 4, vibrationPattern: [0, 250, 250, 250], }).catch(() => {}); } }, [channelId, appName]); const registerTokenWithServer = useCallback(async (token: string) => { const deviceName = getDeviceName(); await client.registerPushToken({ token, platform: Platform.OS, deviceName: deviceName || undefined, }); }, [client, getDeviceName]); const register = useCallback(async (): Promise => { if (!pushEnabled) return false; if (registering.current) return false; registering.current = true; setLoading(true); setError(null); try { const Notifications = getNotificationsModule(); if (!Notifications) { throw new Error('expo-notifications is not installed'); } // Request permission const { status: existingStatus } = await Notifications.getPermissionsAsync(); let finalStatus = existingStatus; if (existingStatus !== 'granted') { const { status } = await Notifications.requestPermissionsAsync(); finalStatus = status; } if (finalStatus !== 'granted') { setHasPermission(false); setLoading(false); registering.current = false; return false; } setHasPermission(true); // Get native device push token (FCM on Android, APNs on iOS) const tokenResult = await Notifications.getDevicePushTokenAsync(); const token = tokenResult.data; setPushToken(token); // Register with server await registerTokenWithServer(token); // Setup Android channels setupAndroidChannels(Notifications); setLoading(false); registering.current = false; return true; } catch (err) { const e = err instanceof Error ? err : new Error(String(err)); setError(e); setLoading(false); registering.current = false; console.error('[usePushNotifications] Registration error:', e.message); return false; } }, [getNotificationsModule, registerTokenWithServer, setupAndroidChannels]); const unregister = useCallback(async () => { if (!pushToken) return; try { await client.unregisterPushToken(pushToken); setPushToken(null); } catch (err) { console.error('[usePushNotifications] Unregister error:', err); } }, [client, pushToken]); const restoreIfGranted = useCallback(async () => { if (!pushEnabled) return; try { const Notifications = getNotificationsModule(); if (!Notifications) return; const { status } = await Notifications.getPermissionsAsync(); if (status !== 'granted') return; // Get native device push token (FCM on Android, APNs on iOS) const tokenResult = await Notifications.getDevicePushTokenAsync(); const token = tokenResult.data; // Register with server — only mark as enabled if this succeeds await registerTokenWithServer(token); setPushToken(token); setHasPermission(true); setupAndroidChannels(Notifications); } catch (err) { // Server registration failed — don't show as enabled setHasPermission(false); console.log('[usePushNotifications] Restore skipped:', err instanceof Error ? err.message : err); } }, [getNotificationsModule, registerTokenWithServer, setupAndroidChannels]); // On mount, set up foreground notification handler and restore push state const didMount = useRef(false); useEffect(() => { if (didMount.current) return; didMount.current = true; const Notifications = getNotificationsModule(); if (Notifications) { // Show notifications even when app is in the foreground Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true, shouldPlaySound: true, shouldSetBadge: false, }), }); } restoreIfGranted(); }, []); // eslint-disable-line react-hooks/exhaustive-deps return { isActive: pushEnabled && !!pushToken, enabled: pushEnabled, hasPermission, pushToken, expoPushToken: pushToken, // backwards compat loading, error, register, unregister, restoreIfGranted, }; }