mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add a conditional Home cue for partner-answered questions and route both the card and catch-up reminders into the pending answer flow. Track prompt engagement and cover hidden, visible, CTA, and notification behavior.
294 lines
9.4 KiB
TypeScript
294 lines
9.4 KiB
TypeScript
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" | "catchup_reminder";
|
|
match_id?: string;
|
|
message_id?: string;
|
|
count?: number;
|
|
}
|
|
|
|
const notificationTypes = new Set<NotificationData["type"]>([
|
|
"match",
|
|
"message",
|
|
"match_digest",
|
|
"partner_activity",
|
|
"nudge",
|
|
"weekly_summary",
|
|
"catchup_reminder",
|
|
]);
|
|
|
|
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<string | null> {
|
|
// 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<void> {
|
|
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<void> {
|
|
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<string | null> {
|
|
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<boolean> {
|
|
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.type === "catchup_reminder") {
|
|
useMatchStore.getState().setCurrentView('pending');
|
|
router.push("/(app)/swipe?mode=pending");
|
|
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<Notifications.NotificationResponse | null> {
|
|
return Notifications.getLastNotificationResponseAsync();
|
|
}
|