import * as Notifications from "expo-notifications"; import Constants from "expo-constants"; import { Platform } from "react-native"; import { router } from "expo-router"; import { apiClient } from "./apiClient"; import { profileSettingsApi } from "./profileSettingsApi"; import type { Profile } from "../types"; import { captureError } from "./crashlytics"; import { Events } from "./analytics"; import { useMatchStore } from "../store/matchStore"; // Configure notification behavior for foreground // We don't show system alerts since the app handles notifications with in-app toasts Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: false, shouldShowBanner: false, shouldShowList: false, shouldPlaySound: false, shouldSetBadge: false, }), }); // Types export interface NotificationData { type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary"; match_id?: string; message_id?: string; count?: number; } const notificationTypes = new Set([ "match", "message", "match_digest", "partner_activity", "nudge", "weekly_summary", ]); function isNotificationData(value: unknown): value is NotificationData { if (!value || typeof value !== "object") return false; const type = (value as { type?: unknown }).type; return typeof type === "string" && notificationTypes.has(type as NotificationData["type"]); } /** * Request notification permissions and get Expo push token. * Returns null if permissions denied, on web, or if registration fails. */ export async function registerForPushNotificationsAsync(): Promise { // Push notifications don't work on web if (Platform.OS === "web") { return null; } try { // Check current permission status const { status: existingStatus } = await Notifications.getPermissionsAsync(); let finalStatus = existingStatus; // Request permission if not already granted if (existingStatus !== "granted") { const { status } = await Notifications.requestPermissionsAsync(); finalStatus = status; // Track permission result (only when we actually requested) if (status === "granted") { Events.notificationPermissionGranted(); } else { Events.notificationPermissionDenied(); } } if (finalStatus !== "granted") { console.log("Push notification permission denied"); return null; } // Get Expo push token const projectId = Constants.expoConfig?.extra?.eas?.projectId; if (!projectId) { console.warn("EAS project ID not configured - push notifications disabled"); return null; } const tokenData = await Notifications.getExpoPushTokenAsync({ projectId }); return tokenData.data; } catch (error) { // This typically fails in simulator or development console.error("Failed to get push token:", error); captureError(error as Error, { context: "push_token_registration" }); return null; } } /** * Save push token to the standalone profile API. */ export async function savePushToken(userId: string, token: string): Promise { try { await profileSettingsApi.updateProfile({ push_token: token }); } catch (error) { console.error("Failed to save push token:", error); captureError(error as Error, { userId }); throw error; } } /** * Clear push token from user profile on sign out. */ export async function clearPushToken(userId: string): Promise { try { await profileSettingsApi.updateProfile({ push_token: null }); } catch (error) { console.error("Failed to clear push token:", error); throw error; } } /** * Get Expo push token with retry logic for Android. * On Android, Firebase may not be initialized immediately on app start, * so we retry a few times with increasing delays. */ async function getExpoPushTokenWithRetry( projectId: string, maxRetries = 5 ): Promise { console.log(`[Push] Getting token for project: ${projectId}, platform: ${Platform.OS}`); for (let attempt = 0; attempt < maxRetries; attempt++) { try { console.log(`[Push] Attempt ${attempt + 1}/${maxRetries} to get push token...`); const tokenData = await Notifications.getExpoPushTokenAsync({ projectId }); console.log(`[Push] Successfully got token: ${tokenData.data}`); return tokenData.data; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.log(`[Push] Attempt ${attempt + 1} failed: ${errorMessage}`); // Retry for any Firebase-related errors on Android const isFirebaseError = error instanceof Error && (error.message.includes("Firebase") || error.message.includes("FCM") || error.message.includes("Google Play")); if (Platform.OS === "android" && attempt < maxRetries - 1) { const delay = (attempt + 1) * 1000; console.log(`[Push] Retrying in ${delay}ms...`); await new Promise((resolve) => setTimeout(resolve, delay)); continue; } throw error; } } return null; } /** * Check if current device needs to register for push notifications. * This handles the case where a user onboarded on one device (e.g., iOS) * and then installs on another device (e.g., Android) - the new device * needs its own push token. * * Returns true if a new token was registered. */ export async function checkAndRegisterPushToken(userId: string): Promise { console.log(`[Push] checkAndRegisterPushToken called for user: ${userId}`); if (Platform.OS === "web") { console.log("[Push] Skipping - web platform"); return false; } try { // First check if permissions are already granted const { status } = await Notifications.getPermissionsAsync(); console.log(`[Push] Current permission status: ${status}`); // If permissions not granted, we need to prompt the user // But only do this if we don't already have a token for this device if (status !== "granted") { console.log("[Push] Requesting permissions..."); const { status: newStatus } = await Notifications.requestPermissionsAsync(); console.log(`[Push] New permission status: ${newStatus}`); if (newStatus !== "granted") { console.log("[Push] Permission denied"); return false; } } // Get the current device's push token const projectId = Constants.expoConfig?.extra?.eas?.projectId; console.log(`[Push] Project ID: ${projectId}`); if (!projectId) { console.log("[Push] No project ID configured"); return false; } const currentToken = await getExpoPushTokenWithRetry(projectId); console.log(`[Push] Current token: ${currentToken}`); if (!currentToken) { console.log("[Push] Failed to get token"); return false; } // Fetch the stored token from the database const { profile } = await apiClient.get<{ profile: Profile }>("/v1/me"); console.log(`[Push] Stored token: ${profile?.push_token}`); // If the stored token is different (or null), save the new one if (profile?.push_token !== currentToken) { console.log("[Push] Token changed, saving new token..."); await savePushToken(userId, currentToken); console.log("[Push] Push token updated for new device"); return true; } console.log("[Push] Token unchanged"); return false; } catch (error) { console.error("[Push] Failed to check/register push token:", error); captureError(error as Error, { context: "push_token_check" }); return false; } } /** * Handle notification tap - navigate to appropriate screen. */ export function handleNotificationResponse( response: Notifications.NotificationResponse ): void { const data = response.notification.request.content.data; if (!isNotificationData(data)) { return; } if (data.type === "match_digest" || data.type === "weekly_summary") { router.push("/(app)/matches"); return; } if (data.type === "nudge") { // Navigate to Matches > Your Turn tab useMatchStore.getState().setCurrentView('pending'); router.push("/(app)/matches"); return; } if (data.type === "partner_activity") { // Navigate to Matches > Your Turn tab (questions to answer) useMatchStore.getState().setCurrentView('pending'); router.push("/(app)/matches"); return; } if (!data.match_id) { return; } // Both match and message notifications navigate to chat router.push(`/(app)/chat/${data.match_id}`); } /** * Set up listener for notification taps (when app is running). * Returns subscription that should be cleaned up on unmount. */ export function setupNotificationResponseListener(): Notifications.Subscription { return Notifications.addNotificationResponseReceivedListener(handleNotificationResponse); } /** * Check if app was opened from a notification tap (cold start). */ export async function getInitialNotification(): Promise { return Notifications.getLastNotificationResponseAsync(); }