diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 0c1bcb1c..7f3c2703 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -329,6 +329,10 @@ OneSignal.Notifications.removeEventListener('permissionChange', permissionObserv ### Notification Lifecycle Listener +Foreground notifications display automatically unless `preventDefault()` is called synchronously +inside the listener. To delay display for asynchronous work, call `preventDefault()` first, then call +`display()` within about 25 seconds. + ```typescript OneSignal.Notifications.addEventListener( 'foregroundWillDisplay', diff --git a/examples/build.md b/examples/build.md index 7e3beda4..2691c081 100644 --- a/examples/build.md +++ b/examples/build.md @@ -216,7 +216,7 @@ OneSignal.User.addEventListener('change', handler); ### Foreground notification handler -- `foregroundWillDisplay` calls `e.getNotification().display()` inside `useOneSignal.ts` so foreground pushes are still shown by the OS UI. +- `foregroundWillDisplay` logs the notification; foreground pushes display automatically unless the handler calls `preventDefault()`. --- diff --git a/examples/demo/src/hooks/useOneSignal.ts b/examples/demo/src/hooks/useOneSignal.ts index 5587c2af..b7ee9932 100644 --- a/examples/demo/src/hooks/useOneSignal.ts +++ b/examples/demo/src/hooks/useOneSignal.ts @@ -195,7 +195,11 @@ function useOneSignalState(): UseOneSignalReturn { const handleForegroundWillDisplay = (e: NotificationWillDisplayEvent) => { console.log(`Notification foregroundWillDisplay: ${e.getNotification().title ?? ''}`); - e.getNotification().display(); + // uncomment to test preventing the default display behavior + // e.preventDefault(); + + // can call this after preventDefault (within ~25 seconds) to force display of notification + // e.getNotification().display(); }; const pushSubHandler = (event: PushSubscriptionChangedState) => { diff --git a/src/events/EventManager.test.ts b/src/events/EventManager.test.ts index 6edb6ba2..f8e7af2d 100644 --- a/src/events/EventManager.test.ts +++ b/src/events/EventManager.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, test, vi } from 'vite-plus/test'; +import { mockRNOneSignal } from '../../__mocks__/react-native'; import { IN_APP_MESSAGE_CLICKED, IN_APP_MESSAGE_DID_DISMISS, @@ -50,6 +51,7 @@ describe('EventManager', () => { let callbacks: Map void>; beforeEach(() => { + vi.clearAllMocks(); const mock = createMockNativeModule(); mockModule = mock.module; callbacks = mock.callbacks; @@ -195,6 +197,97 @@ describe('EventManager', () => { expect(receivedEvent).toBeInstanceOf(NotificationWillDisplayEvent); }); + test('should display a foreground notification when no handlers are registered', () => { + callbacks.get('onNotificationWillDisplay')!(rawWillDisplayPayload); + + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith('test-id'); + }); + + test('should display a foreground notification when handlers do not prevent it', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, handler1); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, handler2); + + callbacks.get('onNotificationWillDisplay')!(rawWillDisplayPayload); + + expect(handler1).toHaveBeenCalledOnce(); + expect(handler2).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledOnce(); + }); + + test('should not display automatically when any handler prevents default', () => { + const observingHandler = vi.fn(); + const preventingHandler = vi.fn((event: NotificationWillDisplayEvent) => { + event.preventDefault(); + }); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, observingHandler); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, preventingHandler); + + callbacks.get('onNotificationWillDisplay')!(rawWillDisplayPayload); + + expect(observingHandler).toHaveBeenCalledOnce(); + expect(preventingHandler).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.preventDefault).toHaveBeenCalledWith('test-id'); + expect(mockRNOneSignal.displayNotification).not.toHaveBeenCalled(); + }); + + test('should allow deferred display after preventing default', () => { + let receivedEvent: NotificationWillDisplayEvent | undefined; + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, (event) => { + receivedEvent = event; + event.preventDefault(); + }); + + callbacks.get('onNotificationWillDisplay')!(rawWillDisplayPayload); + expect(mockRNOneSignal.displayNotification).not.toHaveBeenCalled(); + + receivedEvent?.getNotification().display(); + + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith('test-id'); + }); + + test('should not display twice when a handler displays explicitly', () => { + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, (event) => { + event.getNotification().display(); + }); + + callbacks.get('onNotificationWillDisplay')!(rawWillDisplayPayload); + + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith('test-id'); + }); + + test('should display by default when a handler throws', () => { + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, () => { + throw new Error('listener failed'); + }); + + expect(() => { + callbacks.get('onNotificationWillDisplay')!(rawWillDisplayPayload); + }).toThrow('listener failed'); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith('test-id'); + }); + + test('should continue dispatching after a handler throws', () => { + const preventingHandler = vi.fn((event: NotificationWillDisplayEvent) => { + event.preventDefault(); + }); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, () => { + throw new Error('listener failed'); + }); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, preventingHandler); + + expect(() => { + callbacks.get('onNotificationWillDisplay')!(rawWillDisplayPayload); + }).toThrow('listener failed'); + expect(preventingHandler).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.displayNotification).not.toHaveBeenCalled(); + }); + test('should handle PERMISSION_CHANGED events with boolean payload', () => { const handler = vi.fn(); eventManager.addEventListener(PERMISSION_CHANGED, handler); @@ -366,8 +459,12 @@ describe('EventManager', () => { expect(permissionHandler).toHaveBeenCalledWith(true); expect(subscriptionHandler).toHaveBeenCalledWith(pushChangedPayload); - expect(notificationWillDisplayHandler).toHaveBeenCalledWith( - new NotificationWillDisplayEvent(rawWillDisplayPayload), + expect(notificationWillDisplayHandler).toHaveBeenCalledOnce(); + expect(notificationWillDisplayHandler.mock.calls[0][0]).toBeInstanceOf( + NotificationWillDisplayEvent, + ); + expect(notificationWillDisplayHandler.mock.calls[0][0].getNotification().notificationId).toBe( + 'test-id', ); }); diff --git a/src/events/EventManager.ts b/src/events/EventManager.ts index 86f5799e..e2cfc9f9 100644 --- a/src/events/EventManager.ts +++ b/src/events/EventManager.ts @@ -24,7 +24,10 @@ import type { import type { NotificationClickEvent } from '../types/notificationEvents'; import type { PushSubscriptionChangedState } from '../types/subscription'; import type { UserChangedState } from '../types/user'; -import NotificationWillDisplayEvent from './NotificationWillDisplayEvent'; +import NotificationWillDisplayEvent, { + isDefaultPrevented, + isDisplayRequested, +} from './NotificationWillDisplayEvent'; export interface EventListenerMap { [PERMISSION_CHANGED]: (event: boolean) => void; @@ -72,10 +75,14 @@ export default class EventManager { this.dispatchHandlers(USER_STATE_CHANGED, payload); }), this.RNOneSignal.onNotificationWillDisplay((payload) => { - this.dispatchHandlers( - NOTIFICATION_WILL_DISPLAY, - new NotificationWillDisplayEvent(payload as OSNotification), - ); + const event = new NotificationWillDisplayEvent(payload as OSNotification); + try { + this.dispatchNotificationWillDisplayHandlers(event); + } finally { + if (!isDefaultPrevented(event) && !isDisplayRequested(event)) { + event.getNotification().display(); + } + } }), this.RNOneSignal.onNotificationClicked((payload) => { this.dispatchHandlers(NOTIFICATION_CLICKED, payload); @@ -124,6 +131,28 @@ export default class EventManager { } } + private dispatchNotificationWillDisplayHandlers(event: NotificationWillDisplayEvent) { + // Every handler must run because any one of them can prevent automatic display. + const handlers = [...(this.eventListenerArrayMap.get(NOTIFICATION_WILL_DISPLAY) ?? [])]; + let firstError: unknown; + let handlerThrew = false; + + handlers.forEach((handler) => { + try { + handler(event); + } catch (error) { + if (!handlerThrew) { + firstError = error; + handlerThrew = true; + } + } + }); + + if (handlerThrew) { + throw firstError; + } + } + private dispatchHandlers(eventName: string, payload: unknown) { const handlerArray = this.eventListenerArrayMap.get(eventName); if (handlerArray) { diff --git a/src/events/NotificationWillDisplayEvent.test.ts b/src/events/NotificationWillDisplayEvent.test.ts index 704c8cfd..0283a7fc 100644 --- a/src/events/NotificationWillDisplayEvent.test.ts +++ b/src/events/NotificationWillDisplayEvent.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from 'vite-plus/test'; import { mockRNOneSignal } from '../../__mocks__/react-native'; import OSNotification, { type BaseNotificationData } from '../OSNotification'; -import NotificationWillDisplayEvent from './NotificationWillDisplayEvent'; +import NotificationWillDisplayEvent, { + isDefaultPrevented, + isDisplayRequested, +} from './NotificationWillDisplayEvent'; describe('NotificationWillDisplayEvent', () => { const notificationId = 'test-notification-id'; @@ -68,6 +71,7 @@ describe('NotificationWillDisplayEvent', () => { const result = event.preventDefault(); expect(mockRNOneSignal.preventDefault).toHaveBeenCalledWith(notificationId); + expect(isDefaultPrevented(event)).toBe(true); expect(result).toBeUndefined(); }); @@ -81,6 +85,32 @@ describe('NotificationWillDisplayEvent', () => { expect(mockRNOneSignal.preventDefault).toHaveBeenCalledTimes(3); expect(mockRNOneSignal.preventDefault).toHaveBeenCalledWith('test-notification-id'); + expect(isDefaultPrevented(event)).toBe(true); + }); + }); + + describe('isDefaultPrevented', () => { + test('should be false before preventDefault is called', () => { + const notification = new OSNotification(baseNotificationData); + const event = new NotificationWillDisplayEvent(notification); + + expect(isDefaultPrevented(event)).toBe(false); + }); + }); + + describe('isDisplayRequested', () => { + test('should track notification display calls', () => { + const notification = new OSNotification(baseNotificationData); + const event = new NotificationWillDisplayEvent(notification); + + expect(isDisplayRequested(event)).toBe(false); + + event.getNotification().display(); + event.getNotification().display(); + + expect(isDisplayRequested(event)).toBe(true); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith(notificationId); }); }); diff --git a/src/events/NotificationWillDisplayEvent.ts b/src/events/NotificationWillDisplayEvent.ts index e8ab9caf..22c988cc 100644 --- a/src/events/NotificationWillDisplayEvent.ts +++ b/src/events/NotificationWillDisplayEvent.ts @@ -2,14 +2,32 @@ import NativeOneSignal from '../NativeOneSignal'; import OSNotification from '../OSNotification'; const RNOneSignal = NativeOneSignal; +const displayedNotifications = new WeakSet(); +const preventedEvents = new WeakSet(); + +class ForegroundNotification extends OSNotification { + display(): void { + if (displayedNotifications.has(this)) { + return; + } + displayedNotifications.add(this); + super.display(); + } +} + export default class NotificationWillDisplayEvent { public notification: OSNotification; constructor(displayEvent: OSNotification) { - this.notification = new OSNotification(displayEvent); + this.notification = new ForegroundNotification(displayEvent); } + /** + * This must be called synchronously while the foreground listener is running. + * Calling it later cannot stop the automatic display. + */ preventDefault(): void { + preventedEvents.add(this); RNOneSignal.preventDefault(this.notification.notificationId); } @@ -17,3 +35,11 @@ export default class NotificationWillDisplayEvent { return this.notification; } } + +export function isDefaultPrevented(event: NotificationWillDisplayEvent): boolean { + return preventedEvents.has(event); +} + +export function isDisplayRequested(event: NotificationWillDisplayEvent): boolean { + return displayedNotifications.has(event.notification); +}