diff --git a/.changeset/cool-apples-love.md b/.changeset/cool-apples-love.md new file mode 100644 index 000000000..e6fa919cd --- /dev/null +++ b/.changeset/cool-apples-love.md @@ -0,0 +1,5 @@ +--- +"@knocklabs/expo": patch +--- + +feat: rework the expo push provider to better support android diff --git a/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx b/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx index 6aab72054..f5b493244 100644 --- a/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx +++ b/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx @@ -1,196 +1,134 @@ import { Message, MessageEngagementStatus } from "@knocklabs/client"; import { useKnockClient } from "@knocklabs/react-core"; import { - type KnockPushNotificationContextType, KnockPushNotificationProvider, usePushNotifications, } from "@knocklabs/react-native"; -import Constants from "expo-constants"; -import * as Device from "expo-device"; import * as Notifications from "expo-notifications"; import React, { createContext, useCallback, useContext, useEffect, + useRef, useState, } from "react"; -export interface KnockExpoPushNotificationContextType - extends KnockPushNotificationContextType { - expoPushToken: string | null; - registerForPushNotifications: () => Promise; - onNotificationReceived: ( - handler: (notification: Notifications.Notification) => void, - ) => void; - onNotificationTapped: ( - handler: (response: Notifications.NotificationResponse) => void, - ) => void; -} +import type { + KnockExpoPushNotificationContextType, + KnockExpoPushNotificationProviderProps, +} from "./types"; +import { + DEFAULT_NOTIFICATION_BEHAVIOR, + registerForPushNotifications as registerForPushNotificationsUtil, + setupDefaultAndroidChannel, +} from "./utils"; -Notifications.setNotificationHandler({ - handleNotification: async () => { - return { - shouldShowAlert: true, - shouldPlaySound: true, - shouldSetBadge: true, - shouldShowBanner: true, - shouldShowList: true, - }; - }, -}); - -const defaultNotificationHandler = async ( - _notification: Notifications.Notification, -): Promise => { - return { - shouldShowAlert: true, - shouldPlaySound: true, - shouldSetBadge: true, - shouldShowBanner: true, - shouldShowList: true, - }; -}; +// Re-export types for consumers +export type { + KnockExpoPushNotificationContextType, + KnockExpoPushNotificationProviderProps, +} from "./types"; const KnockExpoPushNotificationContext = createContext< KnockExpoPushNotificationContextType | undefined >(undefined); -export interface KnockExpoPushNotificationProviderProps { - knockExpoChannelId: string; - customNotificationHandler?: ( - notification: Notifications.Notification, - ) => Promise; - setupAndroidNotificationChannel?: () => Promise; - children?: React.ReactElement; - autoRegister?: boolean; -} - -async function requestPushPermissionIfNeeded(): Promise { - const { status: existingStatus } = await Notifications.getPermissionsAsync(); - - if (existingStatus !== "granted") { - const { status } = await Notifications.requestPermissionsAsync(); - return status; - } - - return existingStatus; -} - -async function getExpoPushToken(): Promise { - try { - if ( - !Constants.expoConfig || - !Constants.expoConfig.extra || - !Constants.expoConfig.extra.eas - ) { - console.error( - "[Knock] Expo Project ID is not defined in the app configuration.", - ); - return null; - } - const token = await Notifications.getExpoPushTokenAsync({ - projectId: Constants.expoConfig.extra.eas.projectId, - }); - return token; - } catch (error) { - console.error("[Knock] Error getting Expo push token:", error); - return null; - } -} - -async function defaultSetupAndroidNotificationChannel(): Promise { - if (Device.osName === "Android") { - await Notifications.setNotificationChannelAsync("default", { - name: "Default", - importance: Notifications.AndroidImportance.MAX, - vibrationPattern: [0, 250, 250, 250], - lightColor: "#FF231F7C", - }); - } -} - -async function requestPermissionAndGetPushToken( - setupAndroidChannel: () => Promise, -): Promise { - // Check for device support - if (!Device.isDevice) { - console.warn("[Knock] Must use physical device for Push Notifications"); - return null; - } - - // Setup Android notification channel before requesting permissions - await setupAndroidChannel(); - - const permissionStatus = await requestPushPermissionIfNeeded(); +/** + * Hook to access push notification functionality within a KnockExpoPushNotificationProvider. + * @throws Error if used outside of a KnockExpoPushNotificationProvider + */ +export function useExpoPushNotifications(): KnockExpoPushNotificationContextType { + const context = useContext(KnockExpoPushNotificationContext); - if (permissionStatus !== "granted") { - console.warn("[Knock] Push notification permission not granted"); - return null; + if (context === undefined) { + throw new Error( + "[Knock] useExpoPushNotifications must be used within a KnockExpoPushNotificationProvider", + ); } - return getExpoPushToken(); + return context; } -const InternalKnockExpoPushNotificationProvider: React.FC< +/** + * Internal provider component that handles all the Expo push notification logic. + */ +const InternalExpoPushNotificationProvider: React.FC< KnockExpoPushNotificationProviderProps > = ({ knockExpoChannelId, customNotificationHandler, - setupAndroidNotificationChannel = defaultSetupAndroidNotificationChannel, + setupAndroidNotificationChannel = setupDefaultAndroidChannel, children, autoRegister = true, }) => { + const knockClient = useKnockClient(); const { registerPushTokenToChannel, unregisterPushTokenFromChannel } = usePushNotifications(); + const [expoPushToken, setExpoPushToken] = useState(null); - const knockClient = useKnockClient(); - const [notificationReceivedHandler, setNotificationReceivedHandler] = - useState<(notification: Notifications.Notification) => void>( - () => () => {}, - ); + // Use refs for handlers to avoid re-running effects when handlers change + const notificationReceivedHandlerRef = useRef< + (notification: Notifications.Notification) => void + >(() => {}); - const [notificationTappedHandler, setNotificationTappedHandler] = useState< + const notificationTappedHandlerRef = useRef< (response: Notifications.NotificationResponse) => void - >(() => () => {}); + >(() => {}); - const handleNotificationReceived = useCallback( + /** + * Register a handler to be called when a notification is received in the foreground. + */ + const onNotificationReceived = useCallback( (handler: (notification: Notifications.Notification) => void) => { - setNotificationReceivedHandler(() => handler); + notificationReceivedHandlerRef.current = handler; }, [], ); - const handleNotificationTapped = useCallback( + /** + * Register a handler to be called when a notification is tapped. + */ + const onNotificationTapped = useCallback( (handler: (response: Notifications.NotificationResponse) => void) => { - setNotificationTappedHandler(() => handler); + notificationTappedHandlerRef.current = handler; }, [], ); + /** + * Manually trigger push notification registration. + * Returns the push token if successful, or null if registration failed. + */ const registerForPushNotifications = useCallback(async (): Promise< string | null > => { try { - knockClient.log(`[Knock] Registering for push notifications`); - const token = await requestPermissionAndGetPushToken( + knockClient.log("[Knock] Registering for push notifications"); + + const token = await registerForPushNotificationsUtil( setupAndroidNotificationChannel, ); - knockClient.log(`[Knock] Token received: ${token?.data}`); - if (token?.data) { - knockClient.log(`[Knock] Setting push token: ${token.data}`); - setExpoPushToken(token.data); - return token.data; + + if (token) { + knockClient.log(`[Knock] Push token received: ${token}`); + setExpoPushToken(token); + return token; } + return null; } catch (error) { - console.error(`[Knock] Error registering for push notifications:`, error); + console.error("[Knock] Error registering for push notifications:", error); return null; } }, [knockClient, setupAndroidNotificationChannel]); - const updateKnockMessageStatusFromNotification = useCallback( + /** + * Update the Knock message status when a notification is received or interacted with. + * Only updates status for notifications that originated from Knock (have a knock_message_id). + */ + const updateMessageStatus = useCallback( async ( notification: Notifications.Notification, status: MessageEngagementStatus, @@ -213,105 +151,109 @@ const InternalKnockExpoPushNotificationProvider: React.FC< [knockClient], ); + // Set up the notification handler for foreground notifications useEffect(() => { - Notifications.setNotificationHandler({ - handleNotification: - customNotificationHandler ?? defaultNotificationHandler, - }); - - if (autoRegister) { - registerForPushNotifications() - .then((token) => { - if (token) { - registerPushTokenToChannel(token, knockExpoChannelId) - .then((_result) => { - knockClient.log("[Knock] setChannelData success"); - }) - .catch((_error) => { - console.error( - "[Knock] Error in setting push token or channel data", - _error, - ); - }); - } - }) - .catch((_error) => { - console.error( - "[Knock] Error in setting push token or channel data", - _error, - ); - }); + const handleNotification = customNotificationHandler + ? customNotificationHandler + : async () => DEFAULT_NOTIFICATION_BEHAVIOR; + + Notifications.setNotificationHandler({ handleNotification }); + }, [customNotificationHandler]); + + // Auto-register for push notifications on mount if enabled + useEffect(() => { + if (!autoRegister) { + return; } - const notificationReceivedSubscription = - Notifications.addNotificationReceivedListener((notification) => { - knockClient.log( - "[Knock] Expo Push Notification received in foreground:", - ); - updateKnockMessageStatusFromNotification(notification, "interacted"); - notificationReceivedHandler(notification); - }); + let isMounted = true; - const notificationResponseSubscription = - Notifications.addNotificationResponseReceivedListener((response) => { - knockClient.log("[Knock] Expo Push Notification was interacted with"); - updateKnockMessageStatusFromNotification( - response.notification, - "interacted", - ); - notificationTappedHandler(response); - }); + const register = async () => { + try { + const token = await registerForPushNotifications(); - return () => { - notificationReceivedSubscription.remove(); - notificationResponseSubscription.remove(); + if (token && isMounted) { + await registerPushTokenToChannel(token, knockExpoChannelId); + knockClient.log("[Knock] Push token registered with Knock channel"); + } + } catch (error) { + console.error("[Knock] Error during auto-registration:", error); + } }; - // TODO: Remove when possible and ensure dependency array is correct - // eslint-disable-next-line react-hooks/exhaustive-deps + register(); + + return () => { + isMounted = false; + }; }, [ - registerForPushNotifications, - notificationReceivedHandler, - notificationTappedHandler, - customNotificationHandler, autoRegister, knockExpoChannelId, + registerForPushNotifications, + registerPushTokenToChannel, knockClient, ]); + // Set up notification listeners for received and tapped notifications + useEffect(() => { + const receivedSubscription = Notifications.addNotificationReceivedListener( + (notification) => { + knockClient.log("[Knock] Notification received in foreground"); + updateMessageStatus(notification, "interacted"); + notificationReceivedHandlerRef.current(notification); + }, + ); + + const responseSubscription = + Notifications.addNotificationResponseReceivedListener((response) => { + knockClient.log("[Knock] Notification was tapped"); + updateMessageStatus(response.notification, "interacted"); + notificationTappedHandlerRef.current(response); + }); + + return () => { + receivedSubscription.remove(); + responseSubscription.remove(); + }; + }, [knockClient, updateMessageStatus]); + + const contextValue: KnockExpoPushNotificationContextType = { + expoPushToken, + registerForPushNotifications, + registerPushTokenToChannel, + unregisterPushTokenFromChannel, + onNotificationReceived, + onNotificationTapped, + }; + return ( - + {children} ); }; +/** + * Provider component for Expo push notifications with Knock. + * + * Wraps the internal provider with the base KnockPushNotificationProvider + * to provide full push notification functionality. + * + * @example + * ```tsx + * + * + * + * + * + * ``` + */ export const KnockExpoPushNotificationProvider: React.FC< KnockExpoPushNotificationProviderProps > = (props) => { return ( - + ); }; - -export const useExpoPushNotifications = - (): KnockExpoPushNotificationContextType => { - const context = useContext(KnockExpoPushNotificationContext); - if (context === undefined) { - throw new Error( - "[Knock] useExpoPushNotifications must be used within a KnockExpoPushNotificationProvider", - ); - } - return context; - }; diff --git a/packages/expo/src/modules/push/index.ts b/packages/expo/src/modules/push/index.ts index a65cc36cc..02c07f8b0 100644 --- a/packages/expo/src/modules/push/index.ts +++ b/packages/expo/src/modules/push/index.ts @@ -1 +1,2 @@ export * from "./KnockExpoPushNotificationProvider"; +export * from "./types"; diff --git a/packages/expo/src/modules/push/types.ts b/packages/expo/src/modules/push/types.ts new file mode 100644 index 000000000..d9ed6b140 --- /dev/null +++ b/packages/expo/src/modules/push/types.ts @@ -0,0 +1,57 @@ +import type { KnockPushNotificationContextType } from "@knocklabs/react-native"; +import type * as Notifications from "expo-notifications"; +import type React from "react"; + +/** + * Context type for the Expo push notification provider. + * Extends the base push notification context with Expo-specific functionality. + */ +export interface KnockExpoPushNotificationContextType + extends KnockPushNotificationContextType { + /** The Expo push token, or null if not yet registered */ + expoPushToken: string | null; + + /** Manually trigger push notification registration */ + registerForPushNotifications: () => Promise; + + /** Register a handler for when a notification is received in the foreground */ + onNotificationReceived: ( + handler: (notification: Notifications.Notification) => void, + ) => void; + + /** Register a handler for when a notification is tapped */ + onNotificationTapped: ( + handler: (response: Notifications.NotificationResponse) => void, + ) => void; +} + +/** + * Props for the KnockExpoPushNotificationProvider component. + */ +export interface KnockExpoPushNotificationProviderProps { + /** The Knock channel ID for Expo push notifications */ + knockExpoChannelId: string; + + /** + * Custom handler for determining how notifications should be displayed. + * If not provided, notifications will show alerts, play sounds, and set badges. + */ + customNotificationHandler?: ( + notification: Notifications.Notification, + ) => Promise; + + /** + * Custom function to set up the Android notification channel. + * If not provided, a default channel will be created. + */ + setupAndroidNotificationChannel?: () => Promise; + + /** Children to render within the provider */ + children?: React.ReactElement; + + /** + * Whether to automatically register for push notifications on mount. + * @default true + */ + autoRegister?: boolean; +} diff --git a/packages/expo/src/modules/push/utils.ts b/packages/expo/src/modules/push/utils.ts new file mode 100644 index 000000000..953d7382d --- /dev/null +++ b/packages/expo/src/modules/push/utils.ts @@ -0,0 +1,165 @@ +import Constants from "expo-constants"; +import * as Device from "expo-device"; +import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ExpoConstants = typeof Constants & Record; + +/** + * Default notification behavior when a notification is received. + */ +export const DEFAULT_NOTIFICATION_BEHAVIOR: Notifications.NotificationBehavior = + { + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: true, + shouldShowBanner: true, + shouldShowList: true, + }; + +/** + * Get the Expo project ID from various possible sources. + * Different Expo SDK versions and configurations store this differently. + */ +export function getProjectId(): string | null { + const constants = Constants as ExpoConstants; + + // Try Constants.expoConfig.extra.eas.projectId (common in EAS builds) + if (constants.expoConfig?.extra?.eas?.projectId) { + return constants.expoConfig.extra.eas.projectId; + } + + // Try Constants.easConfig?.projectId (available in newer SDK versions) + if (constants.easConfig?.projectId) { + return constants.easConfig.projectId; + } + + // Try Constants.manifest?.extra?.eas?.projectId (older SDK versions) + if (constants.manifest?.extra?.eas?.projectId) { + return constants.manifest.extra.eas.projectId; + } + + // Try Constants.manifest2?.extra?.eas?.projectId (Expo SDK 46+) + if (constants.manifest2?.extra?.eas?.projectId) { + return constants.manifest2.extra.eas.projectId; + } + + return null; +} + +/** + * Request push notification permissions if not already granted. + * @returns The permission status string + */ +export async function requestPushPermission(): Promise { + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + + if (existingStatus === "granted") { + return existingStatus; + } + + const { status } = await Notifications.requestPermissionsAsync(); + return status; +} + +/** + * Get the Expo push token for this device. + * @returns The push token or null if unable to get one + */ +export async function getExpoPushToken(): Promise { + const projectId = getProjectId(); + + if (!projectId) { + console.error( + "[Knock] Expo Project ID is not defined in the app configuration. " + + "Make sure you have configured your project with EAS. " + + "The projectId should be in app.json/app.config.js at extra.eas.projectId.", + ); + return null; + } + + const token = await Notifications.getExpoPushTokenAsync({ projectId }); + return token?.data ?? null; +} + +/** + * Set up the default Android notification channel. + */ +export async function setupDefaultAndroidChannel(): Promise { + await Notifications.setNotificationChannelAsync("default", { + name: "Default", + importance: Notifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: "#FF231F7C", + }); +} + +/** + * Check if the current environment supports push notifications. + * @returns true if push notifications are supported + */ +export function isPushNotificationSupported(): boolean { + return Device.isDevice; +} + +/** + * Check if the current platform is Android. + */ +export function isAndroid(): boolean { + return Platform.OS === "android"; +} + +/** + * Request permissions and get a push token. + * Handles Android-specific channel setup and provides appropriate error messaging. + * + * @param setupAndroidChannel - Function to set up the Android notification channel + * @returns The push token string or null if registration failed + */ +export async function registerForPushNotifications( + setupAndroidChannel: () => Promise = setupDefaultAndroidChannel, +): Promise { + // Check for device support + if (!isPushNotificationSupported()) { + console.warn( + "[Knock] Must use physical device for Push Notifications. " + + "Push notifications are not supported on emulators/simulators.", + ); + return null; + } + + // Setup Android notification channel before requesting permissions + // This is REQUIRED for Android 13+ to show the permission prompt + if (isAndroid()) { + await setupAndroidChannel(); + } + + const permissionStatus = await requestPushPermission(); + + if (permissionStatus !== "granted") { + console.warn( + `[Knock] Push notification permission not granted. Status: ${permissionStatus}. ` + + "User may have denied the permission or the system blocked it.", + ); + return null; + } + + try { + return await getExpoPushToken(); + } catch (error) { + console.error("[Knock] Error getting Expo push token:", error); + + if (isAndroid()) { + console.error( + "[Knock] Android push token registration failed. Common causes:\n" + + "1. FCM is not configured (google-services.json missing)\n" + + "2. Running on an emulator\n" + + "3. Network connectivity issues\n" + + "4. expo-notifications plugin not configured in app.json", + ); + } + + return null; + } +} diff --git a/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx b/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx index a860d2372..291864a32 100644 --- a/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx +++ b/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx @@ -24,6 +24,13 @@ vi.mock("expo-device", () => ({ osName: "iOS", })); +// Mock react-native Platform +vi.mock("react-native", () => ({ + Platform: { + OS: "ios", + }, +})); + vi.mock("expo-notifications", () => ({ setNotificationHandler: vi.fn(), getPermissionsAsync: vi.fn().mockResolvedValue({ status: "granted" }), @@ -39,6 +46,10 @@ vi.mock("expo-notifications", () => ({ }, })); +// Create stable mock functions for usePushNotifications +const mockRegisterPushTokenToChannel = vi.fn().mockResolvedValue(undefined); +const mockUnregisterPushTokenFromChannel = vi.fn().mockResolvedValue(undefined); + // Mock the react-native providers vi.mock("@knocklabs/react-native", () => ({ KnockPushNotificationProvider: ({ @@ -47,19 +58,22 @@ vi.mock("@knocklabs/react-native", () => ({ children: React.ReactNode; }) =>
{children}
, usePushNotifications: () => ({ - registerPushTokenToChannel: vi.fn().mockResolvedValue(undefined), - unregisterPushTokenFromChannel: vi.fn().mockResolvedValue(undefined), + registerPushTokenToChannel: mockRegisterPushTokenToChannel, + unregisterPushTokenFromChannel: mockUnregisterPushTokenFromChannel, }), })); +// Create a stable mock for knockClient +const mockKnockClient = { + log: vi.fn(), + isAuthenticated: vi.fn().mockReturnValue(true), + messages: { + updateStatus: vi.fn(), + }, +}; + vi.mock("@knocklabs/react-core", () => ({ - useKnockClient: () => ({ - log: vi.fn(), - isAuthenticated: vi.fn().mockReturnValue(true), - messages: { - updateStatus: vi.fn(), - }, - }), + useKnockClient: () => mockKnockClient, })); describe("KnockExpoPushNotificationProvider", () => { @@ -94,6 +108,11 @@ describe("KnockExpoPushNotificationProvider", () => { }); test("renders with custom Android notification channel setup", async () => { + // Set platform to Android so the custom setup function gets called + const ReactNativeMock = await import("react-native"); + const originalOS = ReactNativeMock.Platform.OS; + vi.mocked(ReactNativeMock.Platform).OS = "android"; + const customSetup = vi.fn().mockResolvedValue(undefined); const TestChild = () =>
Test Child
; @@ -112,8 +131,10 @@ describe("KnockExpoPushNotificationProvider", () => { await waitFor(() => { expect(customSetup).toHaveBeenCalled(); }); - }); + // Restore original Platform.OS + vi.mocked(ReactNativeMock.Platform).OS = originalOS; + }); test("does not register when autoRegister is false", () => { const TestChild = () =>
Test Child
; @@ -137,7 +158,9 @@ describe("KnockExpoPushNotificationProvider", () => { ); - const { result } = renderHook(() => useExpoPushNotifications(), { wrapper }); + const { result } = renderHook(() => useExpoPushNotifications(), { + wrapper, + }); expect(result.current).toHaveProperty("expoPushToken"); expect(result.current).toHaveProperty("registerForPushNotifications"); @@ -165,12 +188,14 @@ describe("KnockExpoPushNotificationProvider", () => { ); - const { result } = renderHook(() => useExpoPushNotifications(), { wrapper }); + const { result } = renderHook(() => useExpoPushNotifications(), { + wrapper, + }); const token = await result.current.registerForPushNotifications(); expect(token).toBe("test-token"); - + // Wait for state to update await waitFor(() => { expect(result.current.expoPushToken).toBe("test-token"); @@ -184,7 +209,9 @@ describe("KnockExpoPushNotificationProvider", () => { ); - const { result } = renderHook(() => useExpoPushNotifications(), { wrapper }); + const { result } = renderHook(() => useExpoPushNotifications(), { + wrapper, + }); const mockHandler = vi.fn(); result.current.onNotificationReceived(mockHandler); @@ -200,7 +227,9 @@ describe("KnockExpoPushNotificationProvider", () => { ); - const { result } = renderHook(() => useExpoPushNotifications(), { wrapper }); + const { result } = renderHook(() => useExpoPushNotifications(), { + wrapper, + }); const mockHandler = vi.fn(); result.current.onNotificationTapped(mockHandler); @@ -210,10 +239,10 @@ describe("KnockExpoPushNotificationProvider", () => { }); test("calls setNotificationChannelAsync on Android devices", async () => { - // Temporarily mock as Android - const DeviceMock = await import("expo-device"); - const originalOsName = DeviceMock.osName; - vi.mocked(DeviceMock).osName = "Android"; + // Temporarily mock as Android using Platform.OS + const ReactNativeMock = await import("react-native"); + const originalOS = ReactNativeMock.Platform.OS; + vi.mocked(ReactNativeMock.Platform).OS = "android"; const NotificationsMock = await import("expo-notifications"); const setChannelSpy = vi.mocked( @@ -239,14 +268,14 @@ describe("KnockExpoPushNotificationProvider", () => { }); }); - // Restore original osName - vi.mocked(DeviceMock).osName = originalOsName; + // Restore original Platform.OS + vi.mocked(ReactNativeMock.Platform).OS = originalOS; }); test("does not call setNotificationChannelAsync on non-Android devices", async () => { - // Ensure it's set as iOS - const DeviceMock = await import("expo-device"); - vi.mocked(DeviceMock).osName = "iOS"; + // Ensure it's set as iOS using Platform.OS + const ReactNativeMock = await import("react-native"); + vi.mocked(ReactNativeMock.Platform).OS = "ios"; const NotificationsMock = await import("expo-notifications"); const setChannelSpy = vi.mocked( @@ -272,7 +301,7 @@ describe("KnockExpoPushNotificationProvider", () => { test("handles errors during push token registration", async () => { const NotificationsMock = await import("expo-notifications"); const getTokenSpy = vi.mocked(NotificationsMock.getExpoPushTokenAsync); - + // Mock a failure getTokenSpy.mockRejectedValueOnce(new Error("Token fetch failed")); @@ -285,7 +314,9 @@ describe("KnockExpoPushNotificationProvider", () => { ); - const { result } = renderHook(() => useExpoPushNotifications(), { wrapper }); + const { result } = renderHook(() => useExpoPushNotifications(), { + wrapper, + }); const token = await result.current.registerForPushNotifications(); @@ -298,7 +329,7 @@ describe("KnockExpoPushNotificationProvider", () => { test("handles missing Expo config gracefully", async () => { const ConstantsMock = await import("expo-constants"); const originalConfig = ConstantsMock.default.expoConfig; - + // Mock missing config vi.mocked(ConstantsMock.default).expoConfig = undefined; @@ -311,7 +342,9 @@ describe("KnockExpoPushNotificationProvider", () => { ); - const { result } = renderHook(() => useExpoPushNotifications(), { wrapper }); + const { result } = renderHook(() => useExpoPushNotifications(), { + wrapper, + }); const token = await result.current.registerForPushNotifications(); @@ -324,7 +357,7 @@ describe("KnockExpoPushNotificationProvider", () => { test("returns null when running on simulator/non-device", async () => { const DeviceMock = await import("expo-device"); const originalIsDevice = DeviceMock.isDevice; - + // Mock as non-device (simulator) vi.mocked(DeviceMock).isDevice = false; @@ -337,7 +370,9 @@ describe("KnockExpoPushNotificationProvider", () => { ); - const { result } = renderHook(() => useExpoPushNotifications(), { wrapper }); + const { result } = renderHook(() => useExpoPushNotifications(), { + wrapper, + }); const token = await result.current.registerForPushNotifications(); @@ -347,4 +382,217 @@ describe("KnockExpoPushNotificationProvider", () => { vi.mocked(DeviceMock).isDevice = originalIsDevice; }); + test("sets up notification listeners on mount", async () => { + const NotificationsMock = await import("expo-notifications"); + const addReceivedListenerSpy = vi.mocked( + NotificationsMock.addNotificationReceivedListener, + ); + const addResponseListenerSpy = vi.mocked( + NotificationsMock.addNotificationResponseReceivedListener, + ); + + addReceivedListenerSpy.mockClear(); + addResponseListenerSpy.mockClear(); + + const TestChild = () =>
Test Child
; + + render( + + + , + ); + + // Verify listeners were set up + expect(addReceivedListenerSpy).toHaveBeenCalledTimes(1); + expect(addResponseListenerSpy).toHaveBeenCalledTimes(1); + }); + + test("cleans up notification listeners on unmount", async () => { + const removeReceivedListener = vi.fn(); + const removeResponseListener = vi.fn(); + + const NotificationsMock = await import("expo-notifications"); + vi.mocked( + NotificationsMock.addNotificationReceivedListener, + ).mockReturnValue({ + remove: removeReceivedListener, + }); + vi.mocked( + NotificationsMock.addNotificationResponseReceivedListener, + ).mockReturnValue({ + remove: removeResponseListener, + }); + + const TestChild = () =>
Test Child
; + + const { unmount } = render( + + + , + ); + + // Unmount the component + unmount(); + + // Verify listeners were cleaned up + expect(removeReceivedListener).toHaveBeenCalled(); + expect(removeResponseListener).toHaveBeenCalled(); + }); + + test("notification received listener calls user handler via ref", async () => { + const NotificationsMock = await import("expo-notifications"); + let capturedReceivedCallback: ((notification: unknown) => void) | null = + null; + + vi.mocked( + NotificationsMock.addNotificationReceivedListener, + ).mockImplementation((callback) => { + capturedReceivedCallback = callback; + return { remove: vi.fn() }; + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useExpoPushNotifications(), { + wrapper, + }); + + // Set up a handler + const mockHandler = vi.fn(); + result.current.onNotificationReceived(mockHandler); + + // Simulate a notification being received + const mockNotification = { + request: { content: { data: {} } }, + }; + + if (capturedReceivedCallback) { + capturedReceivedCallback(mockNotification); + } + + // Verify the user's handler was called + expect(mockHandler).toHaveBeenCalledWith(mockNotification); + }); + + test("notification tapped listener calls user handler via ref", async () => { + const NotificationsMock = await import("expo-notifications"); + let capturedResponseCallback: ((response: unknown) => void) | null = null; + + vi.mocked( + NotificationsMock.addNotificationResponseReceivedListener, + ).mockImplementation((callback) => { + capturedResponseCallback = callback; + return { remove: vi.fn() }; + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useExpoPushNotifications(), { + wrapper, + }); + + // Set up a handler + const mockHandler = vi.fn(); + result.current.onNotificationTapped(mockHandler); + + // Simulate a notification being tapped + const mockResponse = { + notification: { request: { content: { data: {} } } }, + }; + + if (capturedResponseCallback) { + capturedResponseCallback(mockResponse); + } + + // Verify the user's handler was called + expect(mockHandler).toHaveBeenCalledWith(mockResponse); + }); + + test("setNotificationHandler is called with custom handler", async () => { + const NotificationsMock = await import("expo-notifications"); + const setHandlerSpy = vi.mocked(NotificationsMock.setNotificationHandler); + setHandlerSpy.mockClear(); + + const customHandler = vi.fn().mockResolvedValue({ + shouldShowAlert: false, + shouldPlaySound: false, + shouldSetBadge: false, + }); + + const TestChild = () =>
Test Child
; + + render( + + + , + ); + + // Verify setNotificationHandler was called with the custom handler + expect(setHandlerSpy).toHaveBeenCalledWith({ + handleNotification: customHandler, + }); + }); + + test("auto-registration only runs once on mount", async () => { + const NotificationsMock = await import("expo-notifications"); + const getTokenSpy = vi.mocked(NotificationsMock.getExpoPushTokenAsync); + getTokenSpy.mockClear(); + getTokenSpy.mockResolvedValue({ data: "test-token" }); + + const TestChild = () =>
Test Child
; + + const { rerender } = render( + + + , + ); + + // Wait for initial registration + await waitFor(() => { + expect(getTokenSpy).toHaveBeenCalledTimes(1); + }); + + // Re-render the component (simulating a parent re-render) + rerender( + + + , + ); + + // Wait a bit to ensure no additional calls + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should still only be called once + expect(getTokenSpy).toHaveBeenCalledTimes(1); + }); });