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,