mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Upgrade the mobile runtime and tracked Android project to compile and target API 36. Align Expo modules, React Native, Node, Gradle, and Android build tooling while preserving the custom widget sources.\n\nAdapt file-system, notification, animation, test, and Metro compatibility for the SDK 54 runtime.
287 lines
9.2 KiB
TypeScript
287 lines
9.2 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";
|
|
match_id?: string;
|
|
message_id?: string;
|
|
count?: number;
|
|
}
|
|
|
|
const notificationTypes = new Set<NotificationData["type"]>([
|
|
"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<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.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();
|
|
}
|