diff --git a/__tests__/components/notifications/NotificationsContext.test.tsx b/__tests__/components/notifications/NotificationsContext.test.tsx index 3c9812b93c..b06ffa1ffa 100644 --- a/__tests__/components/notifications/NotificationsContext.test.tsx +++ b/__tests__/components/notifications/NotificationsContext.test.tsx @@ -8,9 +8,13 @@ import React from "react"; const push = jest.fn(); const mockUseRouter = jest.fn(() => ({ push })); +let mockIsActive = true; jest.mock("@/hooks/useCapacitor", () => () => ({ isCapacitor: true, isIos: true, + get isActive() { + return mockIsActive; + }, })); jest.mock("next/navigation", () => ({ __esModule: true, @@ -43,6 +47,9 @@ jest.mock("@capacitor/push-notifications", () => { jest.mock("@capacitor/device", () => ({ Device: { getInfo: jest.fn().mockResolvedValue({ platform: "ios" }) }, })); +jest.mock("@/components/notifications/stable-device-id", () => ({ + getStableDeviceId: jest.fn().mockResolvedValue("test-device-id"), +})); const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} @@ -68,6 +75,52 @@ describe("NotificationsContext", () => { }); }); +describe("NotificationsContext initialization", () => { + beforeEach(() => { + mockIsActive = true; + const { PushNotifications } = require("@capacitor/push-notifications"); + jest.clearAllMocks(); + PushNotifications.removeAllListeners.mockClear(); + PushNotifications.addListener.mockClear(); + PushNotifications.register.mockClear(); + }); + + it("does not initialize when isActive is false", async () => { + mockIsActive = false; + const { PushNotifications } = require("@capacitor/push-notifications"); + + renderHook(() => useNotificationsContext(), { wrapper }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + expect(PushNotifications.removeAllListeners).not.toHaveBeenCalled(); + expect(PushNotifications.addListener).not.toHaveBeenCalled(); + expect(PushNotifications.register).not.toHaveBeenCalled(); + }); + + it("initializes when isActive is true", async () => { + mockIsActive = true; + const { PushNotifications } = require("@capacitor/push-notifications"); + renderHook(() => useNotificationsContext(), { wrapper }); + + await waitFor(() => { + expect(PushNotifications.removeAllListeners).toHaveBeenCalled(); + }); + + expect(PushNotifications.addListener).toHaveBeenCalled(); + expect(PushNotifications.requestPermissions).toHaveBeenCalled(); + + await waitFor( + () => { + expect(PushNotifications.register).toHaveBeenCalled(); + }, + { timeout: 2000 } + ); + }); +}); + it("removes notifications when functions called", async () => { const { PushNotifications } = require("@capacitor/push-notifications"); const { result } = renderHook(() => useNotificationsContext(), { wrapper }); @@ -95,7 +148,7 @@ describe("push notification action handling", () => { it("redirects based on notification data", async () => { const { PushNotifications } = require("@capacitor/push-notifications"); - const { result } = renderHook(() => useNotificationsContext(), { wrapper }); + renderHook(() => useNotificationsContext(), { wrapper }); await waitFor(() => { expect(PushNotifications.addListener).toHaveBeenCalled(); diff --git a/components/notifications/NotificationsContext.tsx b/components/notifications/NotificationsContext.tsx index 606db88b56..c1a0124f26 100644 --- a/components/notifications/NotificationsContext.tsx +++ b/components/notifications/NotificationsContext.tsx @@ -1,23 +1,73 @@ "use client"; +import { ApiIdentity } from "@/generated/models/ApiIdentity"; +import useCapacitor from "@/hooks/useCapacitor"; +import { commonApiPost } from "@/services/api/common-api"; +import { Device, DeviceInfo } from "@capacitor/device"; +import { + PushNotifications, + PushNotificationSchema, +} from "@capacitor/push-notifications"; +import * as Sentry from "@sentry/nextjs"; +import { useRouter } from "next/navigation"; import React, { createContext, + useCallback, useContext, useEffect, useMemo, useRef, } from "react"; -import { - PushNotifications, - PushNotificationSchema, -} from "@capacitor/push-notifications"; -import { Device, DeviceInfo } from "@capacitor/device"; -import { useRouter } from "next/navigation"; -import useCapacitor from "@/hooks/useCapacitor"; import { useAuth } from "../auth/Auth"; -import { commonApiPost } from "@/services/api/common-api"; import { getStableDeviceId } from "./stable-device-id"; -import { ApiIdentity } from "@/generated/models/ApiIdentity"; + +const MAX_REGISTRATION_RETRIES = 3; +const INITIAL_RETRY_DELAY_MS = 1000; +const MAX_RETRY_DELAY_MS = 5000; +const IOS_INITIALIZATION_DELAY_MS = 500; + +const DELEGATE_ERROR_PATTERNS = [ + "capacitorDidRegisterForRemoteNotifications", + "didRegisterForRemoteNotifications", +]; + +const isDelegateError = (error: unknown): boolean => { + const errorMessage = error instanceof Error ? error.message : String(error); + return DELEGATE_ERROR_PATTERNS.some((pattern) => + errorMessage.includes(pattern) + ); +}; + +const registerWithRetry = async ( + maxRetries = MAX_REGISTRATION_RETRIES +): Promise => { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + await PushNotifications.register(); + return; + } catch (registerError: unknown) { + const isDelegate = isDelegateError(registerError); + const hasRetriesLeft = attempt < maxRetries - 1; + + if (isDelegate && hasRetriesLeft) { + const delay = Math.min( + INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt), + MAX_RETRY_DELAY_MS + ); + console.warn( + `iOS push notification registration attempt ${ + attempt + 1 + } failed. Retrying in ${delay}ms...`, + registerError + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + + throw registerError; + } + } +}; type NotificationsContextType = { removeWaveDeliveredNotifications: (waveId: string) => Promise; @@ -40,179 +90,215 @@ const redirectConfig = { }, }; +interface NotificationData { + redirect?: keyof typeof redirectConfig; + profile_id?: string; + path?: string; + handle?: string; + id?: string; + wave_id?: string; + drop_id?: string; + [key: string]: unknown; +} + export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { - const { isCapacitor, isIos } = useCapacitor(); + const { isCapacitor, isIos, isActive } = useCapacitor(); const { connectedProfile } = useAuth(); const router = useRouter(); const initializationRef = useRef(null); - useEffect(() => { - const profileId = connectedProfile?.id ?? null; - if ( - isCapacitor && - initializationRef.current !== profileId - ) { - initializationRef.current = profileId; - initializeNotifications(connectedProfile ?? undefined); - } - }, [connectedProfile, isCapacitor]); + const removeDeliveredNotifications = useCallback( + async (notifications: PushNotificationSchema[]) => { + if (isIos) { + try { + await PushNotifications.removeDeliveredNotifications({ + notifications, + }); + } catch (error) { + console.error("Error removing delivered notifications", error); + } + } + }, + [isIos] + ); - const initializeNotifications = async (profile?: ApiIdentity) => { - try { - if (isCapacitor) { - console.log("Initializing push notifications"); - await initializePushNotifications(profile); + const handlePushNotificationAction = useCallback( + async ( + routerInstance: ReturnType, + notification: PushNotificationSchema, + profileInstance?: ApiIdentity + ) => { + console.log("Push notification action performed", notification); + const notificationData = notification.data ?? {}; + const notificationProfileId = notificationData.profile_id; + + if ( + profileInstance && + notificationProfileId && + notificationProfileId !== profileInstance.id + ) { + console.log("Notification profile id does not match connected profile"); + return; } - } catch (error) { - console.error("Error initializing notifications", error); - } - }; - const initializePushNotifications = async (profile?: ApiIdentity) => { - try { - await PushNotifications.removeAllListeners(); + await removeDeliveredNotifications([notification]); - const stableDeviceId = await getStableDeviceId(); + const redirectUrl = resolveRedirectUrl(notificationData); + if (redirectUrl) { + console.log("Redirecting to", redirectUrl); + routerInstance.push(redirectUrl); + } else { + console.log( + "No redirect url found in notification data", + notificationData + ); + } + }, + [removeDeliveredNotifications] + ); - const deviceInfo = await Device.getInfo(); + const initializePushNotifications = useCallback( + async (profile?: ApiIdentity) => { + try { + await PushNotifications.removeAllListeners(); - await PushNotifications.addListener("registration", async (token) => { - registerPushNotification( - stableDeviceId, - deviceInfo, - token.value, - profile - ); - }); + const stableDeviceId = await getStableDeviceId(); - await PushNotifications.addListener("registrationError", (error) => { - console.error("Push registration error: ", error); - }); + const deviceInfo = await Device.getInfo(); - await PushNotifications.addListener( - "pushNotificationReceived", - (notification) => { - console.log("Push notification received: ", notification); - } - ); - - await PushNotifications.addListener( - "pushNotificationActionPerformed", - async (action) => { - await handlePushNotificationAction( - router, - action.notification, + await PushNotifications.addListener("registration", async (token) => { + await registerPushNotification( + stableDeviceId, + deviceInfo, + token.value, profile ); - } - ); + }); + + await PushNotifications.addListener("registrationError", (error) => { + console.error("Push registration error: ", error); + Sentry.captureException(error, { + tags: { + component: "NotificationsProvider", + operation: "pushRegistrationError", + }, + }); + }); + + await PushNotifications.addListener( + "pushNotificationReceived", + (notification) => { + console.log("Push notification received: ", notification); + } + ); - const permStatus = await PushNotifications.requestPermissions(); - console.log("Push permission status", permStatus); + await PushNotifications.addListener( + "pushNotificationActionPerformed", + async (action) => { + await handlePushNotificationAction( + router, + action.notification, + profile + ); + } + ); - if (permStatus.receive === "granted") { - if (isIos) { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - try { - await PushNotifications.register(); - } catch (registerError: any) { - const errorMessage = registerError?.message || String(registerError); - if ( - errorMessage.includes( - "capacitorDidRegisterForRemoteNotifications" - ) || - errorMessage.includes("didRegisterForRemoteNotifications") - ) { - console.warn( - "iOS push notification registration callback issue. This may occur if the native delegate is not properly configured.", - registerError + const permStatus = await PushNotifications.requestPermissions(); + console.log("Push permission status", permStatus); + + if (permStatus.receive === "granted") { + if (isIos) { + await new Promise((resolve) => + setTimeout(resolve, IOS_INITIALIZATION_DELAY_MS) ); - } else { - throw registerError; } + await registerWithRetry(); + } else { + console.warn("Push notifications permission not granted"); } - } else { - console.warn("Push notifications permission not granted"); + } catch (error) { + console.error("Error in initializePushNotifications", error); + throw error; } - } catch (error) { - console.error("Error in initializePushNotifications", error); - } - }; - - const handlePushNotificationAction = async ( - router: ReturnType, - notification: PushNotificationSchema, - profile?: ApiIdentity - ) => { - console.log("Push notification action performed", notification); - const notificationData = notification.data; - const notificationProfileId = notificationData.profile_id; - - if ( - profile && - notificationProfileId && - notificationProfileId !== profile.id - ) { - console.log("Notification profile id does not match connected profile"); - return; - } - - void removeDeliveredNotifications([notification]); - - const redirectUrl = resolveRedirectUrl(notificationData); - if (redirectUrl) { - console.log("Redirecting to", redirectUrl); - router.push(redirectUrl); - } else { - console.log( - "No redirect url found in notification data", - notificationData - ); - } - }; + }, + [isIos, router, handlePushNotificationAction] + ); - const removeDeliveredNotifications = async ( - notifications: PushNotificationSchema[] - ) => { - if (isIos) { - try { - await PushNotifications.removeDeliveredNotifications({ notifications }); - } catch (error) { - console.error("Error removing delivered notifications", error); + const initializeNotifications = useCallback( + async (profile?: ApiIdentity) => { + if (isCapacitor) { + console.log("Initializing push notifications"); + await initializePushNotifications(profile); } - } - }; + }, + [isCapacitor, initializePushNotifications] + ); - const removeWaveDeliveredNotifications = async (waveId: string) => { - if (isIos) { - const deliveredNotifications = - await PushNotifications.getDeliveredNotifications(); - const waveNotifications = deliveredNotifications.notifications.filter( - (notification) => notification.data.wave_id === waveId - ); - await removeDeliveredNotifications(waveNotifications); + useEffect(() => { + const profileId = connectedProfile?.id ?? null; + if (isCapacitor && isActive && initializationRef.current !== profileId) { + initializationRef.current = profileId; + initializeNotifications(connectedProfile ?? undefined).catch((error) => { + console.error("Failed to initialize push notifications", error); + Sentry.captureException(error, { + tags: { + component: "NotificationsProvider", + operation: "initializeNotifications", + }, + }); + initializationRef.current = null; + }); } - }; + }, [connectedProfile, isCapacitor, isActive, initializeNotifications]); - const removeAllDeliveredNotifications = async () => { + const removeWaveDeliveredNotifications = useCallback( + async (waveId: string) => { + if (isIos) { + try { + const deliveredNotifications = + await PushNotifications.getDeliveredNotifications(); + const waveNotifications = deliveredNotifications.notifications.filter( + (notification) => notification.data?.wave_id === waveId + ); + await removeDeliveredNotifications(waveNotifications); + } catch (error) { + console.error("Error removing wave delivered notifications", error); + Sentry.captureException(error, { + tags: { + component: "NotificationsProvider", + operation: "removeWaveDeliveredNotifications", + }, + }); + } + } + }, + [isIos, removeDeliveredNotifications] + ); + + const removeAllDeliveredNotifications = useCallback(async () => { if (isIos) { try { await PushNotifications.removeAllDeliveredNotifications(); } catch (error) { console.error("Error removing all delivered notifications", error); + Sentry.captureException(error, { + tags: { + component: "NotificationsProvider", + operation: "removeAllDeliveredNotifications", + }, + }); } } - }; + }, [isIos]); const value = useMemo( () => ({ removeWaveDeliveredNotifications, removeAllDeliveredNotifications, }), - [] + [removeWaveDeliveredNotifications, removeAllDeliveredNotifications] ); return ( @@ -241,10 +327,16 @@ const registerPushNotification = async ( console.log("Push registration success", response); } catch (error) { console.error("Push registration error", error); + Sentry.captureException(error, { + tags: { + component: "NotificationsProvider", + operation: "registerPushNotification", + }, + }); } }; -const resolveRedirectUrl = (notificationData: any) => { +const resolveRedirectUrl = (notificationData: NotificationData) => { const { redirect, ...params } = notificationData; if (!redirect) { @@ -255,7 +347,7 @@ const resolveRedirectUrl = (notificationData: any) => { return null; } - const resolveFn = redirectConfig[redirect as keyof typeof redirectConfig]; + const resolveFn = redirectConfig[redirect]; if (!resolveFn) { console.error("Unknown redirect type", redirect); @@ -263,7 +355,7 @@ const resolveRedirectUrl = (notificationData: any) => { } try { - return resolveFn(params); + return (resolveFn as (params: Record) => string)(params); } catch (error) { console.error("Error resolving redirect URL", error); return null; diff --git a/instrumentation-client.ts b/instrumentation-client.ts index cd053329d4..c54f01dcb3 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -21,7 +21,7 @@ const noisyPatterns = [ const referenceErrors = ["__firefox__"]; -const filenameExceptions = ["inpage.js"]; +const filenameExceptions = ["inpage.js", "injectLeap.js", "inject.chrome"]; const URL_REGEX = /\(([^)]+?)\)/; diff --git a/utils/error-sanitizer.ts b/utils/error-sanitizer.ts index 712ab67355..d03d17b453 100644 --- a/utils/error-sanitizer.ts +++ b/utils/error-sanitizer.ts @@ -99,6 +99,7 @@ export const isIndexedDBError = (error: unknown): boolean => { /indexeddb/i, /\bIDB\b/, /IndexedDB.*connection.*lost/i, + /database\s+connection\s+is\s+closing/i, /Internal error opening backing store/i, /DOMException.*QuotaExceeded/i, /DOMException.*UnknownError/i,