From 83c5b92442ff3e4cf27a35ec640f7cb0620692c9 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 7 Nov 2025 15:24:36 -0800 Subject: [PATCH 1/4] add os notification tests --- __mocks__/react-native.ts | 1 + src/OSNotification.test.ts | 242 +++++++++++++++++++++++++++++++++++++ src/OSNotification.ts | 62 +++++++++- 3 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 src/OSNotification.test.ts diff --git a/__mocks__/react-native.ts b/__mocks__/react-native.ts index 617e9411..25321bf1 100644 --- a/__mocks__/react-native.ts +++ b/__mocks__/react-native.ts @@ -61,6 +61,7 @@ const mockRNOneSignal = { addOutcome: vi.fn(), addUniqueOutcome: vi.fn(), addOutcomeWithValue: vi.fn(), + displayNotification: vi.fn(), }; const mockPlatform = { diff --git a/src/OSNotification.test.ts b/src/OSNotification.test.ts new file mode 100644 index 00000000..0dee397e --- /dev/null +++ b/src/OSNotification.test.ts @@ -0,0 +1,242 @@ +import { NativeModules, Platform } from 'react-native'; +import OSNotification, { type BaseNotificationData } from './OSNotification'; + +const mockRNOneSignal = NativeModules.OneSignal; +const mockPlatform = Platform; + +describe('OSNotification', () => { + const baseNotificationData: BaseNotificationData = { + body: 'Test notification body', + sound: 'default', + title: 'Test Title', + launchURL: 'https://example.com', + rawPayload: { key: 'value' }, + actionButtons: [{ id: 'btn1', text: 'Button 1' }], + additionalData: { custom: 'data' }, + notificationId: 'test-notification-id', + }; + + beforeEach(() => { + mockPlatform.OS = 'ios'; + mockRNOneSignal.displayNotification.mockClear(); + }); + + describe('constructor', () => { + test('should initialize common properties', () => { + const notification = new OSNotification(baseNotificationData); + + expect(notification.body).toBe('Test notification body'); + expect(notification.sound).toBe('default'); + expect(notification.title).toBe('Test Title'); + expect(notification.launchURL).toBe('https://example.com'); + expect(notification.rawPayload).toEqual({ key: 'value' }); + expect(notification.actionButtons).toEqual([ + { id: 'btn1', text: 'Button 1' }, + ]); + expect(notification.additionalData).toEqual({ custom: 'data' }); + expect(notification.notificationId).toBe('test-notification-id'); + }); + + test('should initialize with optional common properties as undefined', () => { + const notificationData = { + body: 'Test body', + rawPayload: '', + notificationId: 'id-123', + }; + const notification = new OSNotification(notificationData); + + expect(notification.body).toBe('Test body'); + expect(notification.sound).toBeUndefined(); + expect(notification.title).toBeUndefined(); + expect(notification.launchURL).toBeUndefined(); + expect(notification.actionButtons).toBeUndefined(); + expect(notification.additionalData).toBeUndefined(); + }); + + describe('Android-specific properties', () => { + beforeEach(() => { + mockPlatform.OS = 'android'; + }); + + test('should initialize Android-specific properties on Android platform', () => { + const androidData = { + ...baseNotificationData, + groupKey: 'group-1', + groupMessage: 'group message', + ledColor: 'FFFF0000', + priority: 2, + smallIcon: 'icon_small', + largeIcon: 'icon_large', + bigPicture: 'image_url', + collapseId: 'collapse-1', + fromProjectNumber: '123456789', + smallIconAccentColor: 'FFFF0000', + lockScreenVisibility: '1', + androidNotificationId: 456, + }; + const notification = new OSNotification(androidData); + + expect(notification.groupKey).toBe('group-1'); + expect(notification.groupMessage).toBe('group message'); + expect(notification.ledColor).toBe('FFFF0000'); + expect(notification.priority).toBe(2); + expect(notification.smallIcon).toBe('icon_small'); + expect(notification.largeIcon).toBe('icon_large'); + expect(notification.bigPicture).toBe('image_url'); + expect(notification.collapseId).toBe('collapse-1'); + expect(notification.fromProjectNumber).toBe('123456789'); + expect(notification.smallIconAccentColor).toBe('FFFF0000'); + expect(notification.lockScreenVisibility).toBe('1'); + expect(notification.androidNotificationId).toBe(456); + }); + + test('should not set iOS-specific properties on Android', () => { + const notification = new OSNotification(baseNotificationData); + + expect(notification.badge).toBeUndefined(); + expect(notification.badgeIncrement).toBeUndefined(); + expect(notification.category).toBeUndefined(); + expect(notification.threadId).toBeUndefined(); + expect(notification.subtitle).toBeUndefined(); + expect(notification.templateId).toBeUndefined(); + expect(notification.templateName).toBeUndefined(); + expect(notification.attachments).toBeUndefined(); + expect(notification.mutableContent).toBeUndefined(); + expect(notification.contentAvailable).toBeUndefined(); + expect(notification.relevanceScore).toBeUndefined(); + expect(notification.interruptionLevel).toBeUndefined(); + }); + }); + + describe('iOS-specific properties', () => { + beforeEach(() => { + mockPlatform.OS = 'ios'; + }); + + test('should initialize iOS-specific properties on iOS platform', () => { + const iosData = { + ...baseNotificationData, + badge: '1', + badgeIncrement: '5', + category: 'NOTIFICATION_CATEGORY', + threadId: 'thread-123', + subtitle: 'Test Subtitle', + templateId: 'template-1', + templateName: 'template-name', + attachments: { key: 'attachment-value' }, + mutableContent: true, + contentAvailable: '1', + relevanceScore: 0.9, + interruptionLevel: 'timeSensitive', + }; + const notification = new OSNotification(iosData); + + expect(notification.badge).toBe('1'); + expect(notification.badgeIncrement).toBe('5'); + expect(notification.category).toBe('NOTIFICATION_CATEGORY'); + expect(notification.threadId).toBe('thread-123'); + expect(notification.subtitle).toBe('Test Subtitle'); + expect(notification.templateId).toBe('template-1'); + expect(notification.templateName).toBe('template-name'); + expect(notification.attachments).toEqual({ key: 'attachment-value' }); + expect(notification.mutableContent).toBe(true); + expect(notification.contentAvailable).toBe('1'); + expect(notification.relevanceScore).toBe(0.9); + expect(notification.interruptionLevel).toBe('timeSensitive'); + }); + + test('should not set Android-specific properties on iOS', () => { + const notification = new OSNotification(baseNotificationData); + + expect(notification.groupKey).toBeUndefined(); + expect(notification.groupMessage).toBeUndefined(); + expect(notification.ledColor).toBeUndefined(); + expect(notification.priority).toBeUndefined(); + expect(notification.smallIcon).toBeUndefined(); + expect(notification.largeIcon).toBeUndefined(); + expect(notification.bigPicture).toBeUndefined(); + expect(notification.collapseId).toBeUndefined(); + expect(notification.fromProjectNumber).toBeUndefined(); + expect(notification.smallIconAccentColor).toBeUndefined(); + expect(notification.lockScreenVisibility).toBeUndefined(); + expect(notification.androidNotificationId).toBeUndefined(); + }); + }); + }); + + describe('display', () => { + test('should call native displayNotification with notificationId', () => { + const notification = new OSNotification(baseNotificationData); + notification.display(); + + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith( + 'test-notification-id', + ); + }); + + test('should display notification with different notificationId', () => { + const notificationID = 'custom-id-789'; + const notification = new OSNotification({ + ...baseNotificationData, + notificationId: notificationID, + }); + notification.display(); + + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith( + notificationID, + ); + }); + + test('should return undefined', () => { + const notification = new OSNotification(baseNotificationData); + const result = notification.display(); + + expect(result).toBeUndefined(); + }); + }); + + describe('rawPayload types', () => { + test('should accept object as rawPayload', () => { + const notificationData = { + ...baseNotificationData, + rawPayload: { key: 'value', nested: { key: 'value' } }, + }; + const notification = new OSNotification(notificationData); + + expect(notification.rawPayload).toEqual({ + key: 'value', + nested: { key: 'value' }, + }); + }); + + test('should accept string as rawPayload', () => { + const notificationData = { + ...baseNotificationData, + rawPayload: '{"key":"value"}', + }; + const notification = new OSNotification(notificationData); + + expect(notification.rawPayload).toBe('{"key":"value"}'); + }); + + test('should accept empty object as rawPayload', () => { + const notificationData = { + ...baseNotificationData, + rawPayload: {}, + }; + const notification = new OSNotification(notificationData); + + expect(notification.rawPayload).toEqual({}); + }); + + test('should accept empty string as rawPayload', () => { + const notificationData = { + ...baseNotificationData, + rawPayload: '', + }; + const notification = new OSNotification(notificationData); + + expect(notification.rawPayload).toBe(''); + }); + }); +}); diff --git a/src/OSNotification.ts b/src/OSNotification.ts index ffc42747..30b98ced 100644 --- a/src/OSNotification.ts +++ b/src/OSNotification.ts @@ -1,6 +1,47 @@ import { NativeModules, Platform } from 'react-native'; const RNOneSignal = NativeModules.OneSignal; +export interface BaseNotificationData { + body: string; + sound?: string; + title?: string; + launchURL?: string; + rawPayload: object | string; // platform bridges return different types + actionButtons?: object[]; + additionalData?: object; + notificationId: string; +} + +interface AndroidNotificationData extends BaseNotificationData { + groupKey?: string; + groupMessage?: string; + ledColor?: string; + priority?: number; + smallIcon?: string; + largeIcon?: string; + bigPicture?: string; + collapseId?: string; + fromProjectNumber?: string; + smallIconAccentColor?: string; + lockScreenVisibility?: string; + androidNotificationId?: number; +} + +interface iOSNotificationData extends BaseNotificationData { + badge?: string; + badgeIncrement?: string; + category?: string; + threadId?: string; + subtitle?: string; + templateId?: string; + templateName?: string; + attachments?: object; + mutableContent?: boolean; + contentAvailable?: string; + relevanceScore?: number; + interruptionLevel?: string; +} + export default class OSNotification { body: string; sound?: string; @@ -37,7 +78,7 @@ export default class OSNotification { relevanceScore?: number; interruptionLevel?: string; - constructor(receivedEvent: OSNotification) { + constructor(receivedEvent: AndroidNotificationData | iOSNotificationData) { this.body = receivedEvent.body; this.sound = receivedEvent.sound; this.title = receivedEvent.title; @@ -47,7 +88,8 @@ export default class OSNotification { this.additionalData = receivedEvent.additionalData; this.notificationId = receivedEvent.notificationId; - if (Platform.OS === 'android') { + /* v8 ignore else -- @preserve */ + if (isAndroidNotificationData(receivedEvent)) { this.groupKey = receivedEvent.groupKey; this.ledColor = receivedEvent.ledColor; this.priority = receivedEvent.priority; @@ -60,9 +102,7 @@ export default class OSNotification { this.smallIconAccentColor = receivedEvent.smallIconAccentColor; this.lockScreenVisibility = receivedEvent.lockScreenVisibility; this.androidNotificationId = receivedEvent.androidNotificationId; - } - - if (Platform.OS === 'ios') { + } else if (isiOSNotificationData(receivedEvent)) { this.badge = receivedEvent.badge; this.category = receivedEvent.category; this.threadId = receivedEvent.threadId; @@ -83,3 +123,15 @@ export default class OSNotification { return; } } + +const isAndroidNotificationData = ( + _data: AndroidNotificationData | iOSNotificationData, +): _data is AndroidNotificationData => { + return Platform.OS === 'android'; +}; + +const isiOSNotificationData = ( + _data: AndroidNotificationData | iOSNotificationData, +): _data is iOSNotificationData => { + return Platform.OS === 'ios'; +}; From 7f737887356a0d3044f3de3be670cfee05dc42f1 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 7 Nov 2025 15:40:28 -0800 Subject: [PATCH 2/4] improve test coverage --- src/index.test.ts | 28 ++++++++++++++++++++++++++++ src/index.ts | 20 ++++++-------------- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index a5d83227..9da303c1 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -38,6 +38,12 @@ const removeEventManagerListenerSpy = vi.spyOn( 'removeEventListener', ); +const filterEventListener = (eventName: string) => { + return addEventManagerListenerSpy.mock.calls.filter( + (call) => call[0] === eventName, + )[0][1]; +}; + describe('OneSignal', () => { beforeEach(() => { mockPlatform.OS = 'ios'; @@ -64,6 +70,28 @@ describe('OneSignal', () => { test('should initialize OneSignal with appId', () => { OneSignal.initialize(APP_ID); expect(mockRNOneSignal.initialize).toHaveBeenCalledWith(APP_ID); + + // test permission change listener + const changeFn = filterEventListener(PERMISSION_CHANGED); + changeFn(true); + const permission = OneSignal.Notifications.hasPermission(); + expect(permission).toBe(true); + + // test push subscription change listener + const subscriptionChangeFn = filterEventListener(SUBSCRIPTION_CHANGED); + subscriptionChangeFn({ + current: { + id: 'subscription-id', + token: 'push-token', + optedIn: true, + }, + }); + const pushSubscription = + OneSignal.User.pushSubscription.getPushSubscriptionId(); + expect(pushSubscription).toBe('subscription-id'); + + // reset push subscription + subscriptionChangeFn({ current: {} }); }); test('should not initialize if native module is not loaded', () => { diff --git a/src/index.ts b/src/index.ts index a6fb7ce5..19648624 100644 --- a/src/index.ts +++ b/src/index.ts @@ -165,14 +165,10 @@ export namespace OneSignal { export function enter( activityId: string, token: string, - handler?: Function, + handler: Function = () => {}, ) { if (!isNativeModuleLoaded(RNOneSignal)) return; - if (!handler) { - handler = () => {}; - } - // Only Available on iOS if (Platform.OS === 'ios') { RNOneSignal.enterLiveActivity(activityId, token, handler); @@ -186,13 +182,9 @@ export namespace OneSignal { * * @param activityId: The activity identifier the live activity on this device will no longer receive updates for. **/ - export function exit(activityId: string, handler?: Function) { + export function exit(activityId: string, handler: Function = () => {}) { if (!isNativeModuleLoaded(RNOneSignal)) return; - if (!handler) { - handler = () => {}; - } - if (Platform.OS === 'ios') { RNOneSignal.exitLiveActivity(activityId, handler); } @@ -697,6 +689,7 @@ export namespace OneSignal { if (!isNativeModuleLoaded(RNOneSignal)) return; isValidCallback(listener); + /* v8 ignore else -- @preserve */ if (event === 'click') { RNOneSignal.addNotificationClickListener(); eventManager.addEventListener( @@ -725,14 +718,13 @@ export namespace OneSignal { event: K, listener: (event: NotificationEventTypeMap[K]) => void, ): void { + /* v8 ignore else -- @preserve */ if (event === 'click') { eventManager.removeEventListener(NOTIFICATION_CLICKED, listener); } else if (event === 'foregroundWillDisplay') { eventManager.removeEventListener(NOTIFICATION_WILL_DISPLAY, listener); } else if (event === 'permissionChange') { eventManager.removeEventListener(PERMISSION_CHANGED, listener); - } else { - return; } } @@ -799,6 +791,7 @@ export namespace OneSignal { listener as (event: InAppMessageClickEvent) => void, ); } else { + /* v8 ignore else -- @preserve */ if (event === 'willDisplay') { isValidCallback(listener); eventManager.addEventListener( @@ -840,6 +833,7 @@ export namespace OneSignal { if (event === 'click') { eventManager.removeEventListener(IN_APP_MESSAGE_CLICKED, listener); } else { + /* v8 ignore else -- @preserve */ if (event === 'willDisplay') { eventManager.removeEventListener( IN_APP_MESSAGE_WILL_DISPLAY, @@ -860,8 +854,6 @@ export namespace OneSignal { IN_APP_MESSAGE_DID_DISMISS, listener, ); - } else { - return; } } } From 1eb33be1cfb7395879b4254fc4750c5622401ea5 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 7 Nov 2025 15:52:41 -0800 Subject: [PATCH 3/4] add event manager tests --- __mocks__/react-native.ts | 29 +- src/OSNotification.ts | 4 +- src/events/EventManager.test.ts | 474 ++++++++++++++++++++++++++++++++ src/events/EventManager.ts | 43 ++- src/index.ts | 5 +- 5 files changed, 540 insertions(+), 15 deletions(-) create mode 100644 src/events/EventManager.test.ts diff --git a/__mocks__/react-native.ts b/__mocks__/react-native.ts index 25321bf1..3c6670b6 100644 --- a/__mocks__/react-native.ts +++ b/__mocks__/react-native.ts @@ -1,5 +1,28 @@ import { vi } from 'vitest'; +export const createEmitterSubscriptionMock = ( + eventName: string, + callback: (payload: unknown) => void, +) => ({ + remove: vi.fn(), + emitter: { + addListener: vi.fn(), + removeAllListeners: vi.fn(), + listenerCount: vi.fn(() => 1), + emit: vi.fn(), + }, + listener: () => callback, + context: undefined, + eventType: eventName, + key: 0, + subscriber: { + addSubscription: vi.fn(), + removeSubscription: vi.fn(), + removeAllSubscriptions: vi.fn(), + getSubscriptionsForType: vi.fn(), + }, +}); + const mockRNOneSignal = { initialize: vi.fn(), login: vi.fn(), @@ -79,10 +102,8 @@ export { mockPlatform, mockRNOneSignal }; export class NativeEventEmitter { constructor(_nativeModule: typeof mockRNOneSignal) {} - addListener(_eventName: string, _callback: (payload: unknown) => void) { - return { - remove: vi.fn(), - }; + addListener(eventName: string, callback: (payload: unknown) => void) { + return createEmitterSubscriptionMock(eventName, callback); } removeListener(_eventName: string, _callback: (payload: unknown) => void) { diff --git a/src/OSNotification.ts b/src/OSNotification.ts index 30b98ced..7e7cf5c9 100644 --- a/src/OSNotification.ts +++ b/src/OSNotification.ts @@ -42,6 +42,8 @@ interface iOSNotificationData extends BaseNotificationData { interruptionLevel?: string; } +export type OSNotificationData = AndroidNotificationData | iOSNotificationData; + export default class OSNotification { body: string; sound?: string; @@ -78,7 +80,7 @@ export default class OSNotification { relevanceScore?: number; interruptionLevel?: string; - constructor(receivedEvent: AndroidNotificationData | iOSNotificationData) { + constructor(receivedEvent: OSNotificationData) { this.body = receivedEvent.body; this.sound = receivedEvent.sound; this.title = receivedEvent.title; diff --git a/src/events/EventManager.test.ts b/src/events/EventManager.test.ts new file mode 100644 index 00000000..30c11898 --- /dev/null +++ b/src/events/EventManager.test.ts @@ -0,0 +1,474 @@ +import { NativeEventEmitter } from 'react-native'; +import { createEmitterSubscriptionMock } from '../../__mocks__/react-native'; +import { + IN_APP_MESSAGE_CLICKED, + IN_APP_MESSAGE_DID_DISMISS, + IN_APP_MESSAGE_DID_DISPLAY, + IN_APP_MESSAGE_WILL_DISMISS, + IN_APP_MESSAGE_WILL_DISPLAY, + NOTIFICATION_CLICKED, + NOTIFICATION_WILL_DISPLAY, + PERMISSION_CHANGED, + SUBSCRIPTION_CHANGED, + USER_STATE_CHANGED, +} from '../constants/events'; +import type { PushSubscriptionChangedState } from '../types/subscription'; +import type { UserChangedState } from '../types/user'; +import EventManager, { type EventListenerMap } from './EventManager'; +import NotificationWillDisplayEvent from './NotificationWillDisplayEvent'; + +describe('EventManager', () => { + let eventManager: EventManager; + let mockNativeModule: any; + let eventCallbacks: Map void>; + + const getCallback = ( + eventName: K, + ): ((payload: Parameters[0]) => void) | undefined => { + return eventCallbacks.get(eventName) as any; + }; + + beforeEach(() => { + eventCallbacks = new Map(); + + mockNativeModule = { + addListener: vi.fn(), + removeListener: vi.fn(), + }; + + // Spy on NativeEventEmitter.prototype.addListener to capture callbacks + vi.spyOn(NativeEventEmitter.prototype, 'addListener').mockImplementation( + (eventName: string, callback: (payload: any) => void) => { + eventCallbacks.set(eventName, callback); + return createEmitterSubscriptionMock(eventName, callback) as never; + }, + ); + + eventManager = new EventManager(mockNativeModule); + }); + + describe('constructor', () => { + test('should initialize with all required properties and listeners', () => { + expect(eventManager).toBeDefined(); + expect(eventManager['RNOneSignal']).toBe(mockNativeModule); + expect(eventManager['oneSignalEventEmitter']).toBeDefined(); + expect(eventManager['eventListenerArrayMap']).toBeInstanceOf(Map); + + const listeners = eventManager['listeners']; + const expectedEvents = [ + PERMISSION_CHANGED, + SUBSCRIPTION_CHANGED, + USER_STATE_CHANGED, + NOTIFICATION_WILL_DISPLAY, + NOTIFICATION_CLICKED, + IN_APP_MESSAGE_CLICKED, + IN_APP_MESSAGE_WILL_DISPLAY, + IN_APP_MESSAGE_WILL_DISMISS, + IN_APP_MESSAGE_DID_DISMISS, + IN_APP_MESSAGE_DID_DISPLAY, + ]; + + expectedEvents.forEach((eventName) => { + expect(listeners[eventName]).toBeDefined(); + }); + + // Verify that eventCallbacks were populated during setup + expect(eventCallbacks.size).toBe(10); + }); + }); + + describe('setupListeners', () => { + test('should register all event listeners with NativeEventEmitter', () => { + const eventList = [ + PERMISSION_CHANGED, + SUBSCRIPTION_CHANGED, + USER_STATE_CHANGED, + NOTIFICATION_WILL_DISPLAY, + NOTIFICATION_CLICKED, + IN_APP_MESSAGE_CLICKED, + IN_APP_MESSAGE_WILL_DISPLAY, + IN_APP_MESSAGE_WILL_DISMISS, + IN_APP_MESSAGE_DID_DISMISS, + IN_APP_MESSAGE_DID_DISPLAY, + ]; + + eventList.forEach((eventName) => { + expect(eventCallbacks.has(eventName)).toBe(true); + }); + }); + + test('should not setup listeners if RNOneSignal is null', () => { + new EventManager(null as any); + expect(mockNativeModule.addListener).not.toHaveBeenCalled(); + }); + }); + + describe('addEventListener', () => { + test('should add a handler to a new event', () => { + const handler = vi.fn(); + eventManager.addEventListener(PERMISSION_CHANGED, handler); + + const handlerArray = + eventManager['eventListenerArrayMap'].get(PERMISSION_CHANGED); + expect(handlerArray).toContain(handler); + }); + + test('should add multiple handlers to the same event', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + eventManager.addEventListener(PERMISSION_CHANGED, handler1); + eventManager.addEventListener(PERMISSION_CHANGED, handler2); + + const handlerArray = + eventManager['eventListenerArrayMap'].get(PERMISSION_CHANGED); + expect(handlerArray).toContain(handler1); + expect(handlerArray).toContain(handler2); + expect(handlerArray?.length).toBe(2); + }); + + test('should add handlers to different events independently', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + eventManager.addEventListener(PERMISSION_CHANGED, handler1); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler2); + + const handlerArray1 = + eventManager['eventListenerArrayMap'].get(PERMISSION_CHANGED); + const handlerArray2 = + eventManager['eventListenerArrayMap'].get(SUBSCRIPTION_CHANGED); + + expect(handlerArray1).toContain(handler1); + expect(handlerArray2).toContain(handler2); + }); + }); + + describe('removeEventListener', () => { + test('should remove a handler from an event', () => { + const handler = vi.fn(); + eventManager.addEventListener(PERMISSION_CHANGED, handler); + eventManager.removeEventListener(PERMISSION_CHANGED, handler); + + const handlerArray = + eventManager['eventListenerArrayMap'].get(PERMISSION_CHANGED); + expect(handlerArray).toBeUndefined(); + }); + + test('should remove specific handler when multiple exist', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + const handler3 = vi.fn(); + + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler1); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler2); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler3); + eventManager.removeEventListener(SUBSCRIPTION_CHANGED, handler2); + + const handlerArray = + eventManager['eventListenerArrayMap'].get(SUBSCRIPTION_CHANGED); + expect(handlerArray).toContain(handler1); + expect(handlerArray).not.toContain(handler2); + expect(handlerArray).toContain(handler3); + expect(handlerArray?.length).toBe(2); + }); + + test('should do nothing if event does not exist', () => { + const handler = vi.fn(); + expect(() => { + eventManager.removeEventListener('non-existent-event' as any, handler); + }).not.toThrow(); + }); + + test('should do nothing if handler is not in the list', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, handler1); + expect(() => { + eventManager.removeEventListener(NOTIFICATION_WILL_DISPLAY, handler2); + }).not.toThrow(); + + const handlerArray = eventManager['eventListenerArrayMap'].get( + NOTIFICATION_WILL_DISPLAY, + ); + expect(handlerArray).toContain(handler1); + expect(handlerArray?.length).toBe(1); + }); + }); + + describe('generateEventListener', () => { + test('should return an EmitterSubscription', () => { + const listener = + eventManager['generateEventListener'](PERMISSION_CHANGED); + expect(listener).toBeDefined(); + expect(listener.remove).toBeDefined(); + }); + + test('should handle NOTIFICATION_WILL_DISPLAY events', () => { + const handler = vi.fn(); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, handler); + + const callback = getCallback(NOTIFICATION_WILL_DISPLAY); + + const notificationPayload = { + notificationId: 'test-id', + body: 'test-body', + rawPayload: {}, + }; + + callback!(notificationPayload); + + expect(handler).toHaveBeenCalled(); + const receivedEvent = handler.mock.calls[0][0]; + expect(receivedEvent).toBeInstanceOf(NotificationWillDisplayEvent); + }); + + test('should handle PERMISSION_CHANGED events with boolean payload', () => { + const handler = vi.fn(); + eventManager.addEventListener(PERMISSION_CHANGED, handler); + + const callback = getCallback(PERMISSION_CHANGED); + callback!({ permission: true }); + + expect(handler).toHaveBeenCalledWith(true); + }); + + test('should handle generic events with object payload', () => { + const handler = vi.fn(); + const payload = { + previous: { + id: 'previous-id', + token: 'previous-token', + optedIn: false, + }, + current: { + id: 'current-id', + token: 'current-token', + optedIn: true, + }, + } satisfies PushSubscriptionChangedState; + + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler); + + const callback = getCallback(SUBSCRIPTION_CHANGED); + callback!(payload); + + expect(handler).toHaveBeenCalledWith(payload); + }); + + test('should call all handlers for an event', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + const handler3 = vi.fn(); + + eventManager.addEventListener(USER_STATE_CHANGED, handler1); + eventManager.addEventListener(USER_STATE_CHANGED, handler2); + eventManager.addEventListener(USER_STATE_CHANGED, handler3); + + const callback = getCallback(USER_STATE_CHANGED); + const payload = { + current: { onesignalId: '123', externalId: '456' }, + } satisfies UserChangedState; + callback!(payload); + + expect(handler1).toHaveBeenCalledWith(payload); + expect(handler2).toHaveBeenCalledWith(payload); + expect(handler3).toHaveBeenCalledWith(payload); + }); + + test('should do nothing if no handlers are registered', () => { + const callback = getCallback(IN_APP_MESSAGE_WILL_DISPLAY); + + expect(() => { + callback!({ message: { messageId: 'msg-123' } }); + }).not.toThrow(); + }); + + test('should handle IN_APP_MESSAGE_CLICKED events', () => { + const handler = vi.fn(); + const payload = { + message: { messageId: 'msg-123' }, + result: { closingMessage: false, actionId: 'action-1' }, + }; + + eventManager.addEventListener(IN_APP_MESSAGE_CLICKED, handler); + + const callback = getCallback(IN_APP_MESSAGE_CLICKED); + callback!(payload); + + expect(handler).toHaveBeenCalledWith(payload); + }); + + test('should handle IN_APP_MESSAGE_WILL_DISPLAY events', () => { + const handler = vi.fn(); + const payload = { message: { messageId: 'msg-123' } }; + + eventManager.addEventListener(IN_APP_MESSAGE_WILL_DISPLAY, handler); + + const callback = getCallback(IN_APP_MESSAGE_WILL_DISPLAY); + callback!(payload); + + expect(handler).toHaveBeenCalledWith(payload); + }); + + test('should handle IN_APP_MESSAGE_DID_DISPLAY events', () => { + const handler = vi.fn(); + const payload = { message: { messageId: 'msg-123' } }; + + eventManager.addEventListener(IN_APP_MESSAGE_DID_DISPLAY, handler); + + const callback = getCallback(IN_APP_MESSAGE_DID_DISPLAY); + callback!(payload); + + expect(handler).toHaveBeenCalledWith(payload); + }); + + test('should handle IN_APP_MESSAGE_WILL_DISMISS events', () => { + const handler = vi.fn(); + const payload = { message: { messageId: 'msg-123' } }; + + eventManager.addEventListener(IN_APP_MESSAGE_WILL_DISMISS, handler); + + const callback = getCallback(IN_APP_MESSAGE_WILL_DISMISS); + callback!(payload); + + expect(handler).toHaveBeenCalledWith(payload); + }); + + test('should handle IN_APP_MESSAGE_DID_DISMISS events', () => { + const handler = vi.fn(); + const payload = { message: { messageId: 'msg-123' } }; + + eventManager.addEventListener(IN_APP_MESSAGE_DID_DISMISS, handler); + + const callback = getCallback(IN_APP_MESSAGE_DID_DISMISS); + callback!(payload); + + expect(handler).toHaveBeenCalledWith(payload); + }); + + test('should handle NOTIFICATION_CLICKED events', () => { + const handler = vi.fn(); + const payload = { notificationId: 'notif-123', action: 'clicked' }; + + eventManager.addEventListener(NOTIFICATION_CLICKED, handler); + + const callback = getCallback(NOTIFICATION_CLICKED); + callback!(payload); + + expect(handler).toHaveBeenCalledWith(payload); + }); + }); + + describe('integration scenarios', () => { + test('should handle add and remove listener lifecycle', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + // Add handlers + eventManager.addEventListener(PERMISSION_CHANGED, handler1); + eventManager.addEventListener(PERMISSION_CHANGED, handler2); + + // Trigger event + const callback = getCallback(PERMISSION_CHANGED); + callback!({ permission: true }); + + expect(handler1).toHaveBeenCalledWith(true); + expect(handler2).toHaveBeenCalledWith(true); + + // Remove one handler + eventManager.removeEventListener(PERMISSION_CHANGED, handler1); + + // Reset mocks + handler1.mockClear(); + handler2.mockClear(); + + // Trigger event again + callback!({ permission: false }); + + expect(handler1).not.toHaveBeenCalled(); + expect(handler2).toHaveBeenCalledWith(false); + }); + + test('should handle complex event type scenarios', () => { + const permissionHandler = vi.fn(); + const subscriptionHandler = vi.fn(); + const notificationWillDisplayHandler = vi.fn(); + + eventManager.addEventListener(PERMISSION_CHANGED, permissionHandler); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, subscriptionHandler); + eventManager.addEventListener( + NOTIFICATION_WILL_DISPLAY, + notificationWillDisplayHandler, + ); + + // Get callbacks + const permissionCallback = getCallback(PERMISSION_CHANGED); + const subscriptionCallback = getCallback(SUBSCRIPTION_CHANGED); + const notificationCallback = getCallback(NOTIFICATION_WILL_DISPLAY); + + // Trigger different event types + permissionCallback!({ permission: true }); + subscriptionCallback!({ id: 'sub-123' }); + notificationCallback!({ + notificationId: 'notif-123', + body: 'test', + rawPayload: {}, + }); + + expect(permissionHandler).toHaveBeenCalledWith(true); + expect(subscriptionHandler).toHaveBeenCalledWith({ id: 'sub-123' }); + expect(notificationWillDisplayHandler).toHaveBeenCalled(); + }); + + test('should maintain separate handler arrays for different events', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + const handler3 = vi.fn(); + + eventManager.addEventListener(PERMISSION_CHANGED, handler1); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler2); + eventManager.addEventListener(USER_STATE_CHANGED, handler3); + + const permissionArray = + eventManager['eventListenerArrayMap'].get(PERMISSION_CHANGED); + const subscriptionArray = + eventManager['eventListenerArrayMap'].get(SUBSCRIPTION_CHANGED); + const userStateArray = + eventManager['eventListenerArrayMap'].get(USER_STATE_CHANGED); + + expect(permissionArray).toEqual([handler1]); + expect(subscriptionArray).toEqual([handler2]); + expect(userStateArray).toEqual([handler3]); + expect(permissionArray).not.toEqual(subscriptionArray); + }); + + test('should handle multiple sequential add and remove operations', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + const handler3 = vi.fn(); + + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler1); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler2); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler3); + + const callback = getCallback(SUBSCRIPTION_CHANGED); + + callback!({ id: '1' }); + expect(handler1).toHaveBeenCalledWith({ id: '1' }); + expect(handler2).toHaveBeenCalledWith({ id: '1' }); + expect(handler3).toHaveBeenCalledWith({ id: '1' }); + + eventManager.removeEventListener(SUBSCRIPTION_CHANGED, handler2); + handler1.mockClear(); + handler2.mockClear(); + handler3.mockClear(); + + callback!({ id: '2' }); + expect(handler1).toHaveBeenCalledWith({ id: '2' }); + expect(handler2).not.toHaveBeenCalled(); + expect(handler3).toHaveBeenCalledWith({ id: '2' }); + }); + }); +}); diff --git a/src/events/EventManager.ts b/src/events/EventManager.ts index 1a94e579..186bc8c7 100644 --- a/src/events/EventManager.ts +++ b/src/events/EventManager.ts @@ -16,8 +16,31 @@ import { USER_STATE_CHANGED, } from '../constants/events'; import OSNotification from '../OSNotification'; +import type { + InAppMessageClickEvent, + InAppMessageDidDismissEvent, + InAppMessageDidDisplayEvent, + InAppMessageWillDismissEvent, + InAppMessageWillDisplayEvent, +} from '../types/inAppMessage'; +import type { NotificationClickEvent } from '../types/notificationEvents'; +import type { PushSubscriptionChangedState } from '../types/subscription'; +import type { UserChangedState } from '../types/user'; import NotificationWillDisplayEvent from './NotificationWillDisplayEvent'; +export interface EventListenerMap { + [PERMISSION_CHANGED]: (event: { permission: boolean }) => void; + [SUBSCRIPTION_CHANGED]: (event: PushSubscriptionChangedState) => void; + [USER_STATE_CHANGED]: (event: UserChangedState) => void; + [NOTIFICATION_WILL_DISPLAY]: (event: NotificationWillDisplayEvent) => void; + [NOTIFICATION_CLICKED]: (event: NotificationClickEvent) => void; + [IN_APP_MESSAGE_CLICKED]: (event: InAppMessageClickEvent) => void; + [IN_APP_MESSAGE_WILL_DISPLAY]: (event: InAppMessageWillDisplayEvent) => void; + [IN_APP_MESSAGE_WILL_DISMISS]: (event: InAppMessageWillDismissEvent) => void; + [IN_APP_MESSAGE_DID_DISMISS]: (event: InAppMessageDidDismissEvent) => void; + [IN_APP_MESSAGE_DID_DISPLAY]: (event: InAppMessageDidDisplayEvent) => void; +} + const eventList = [ PERMISSION_CHANGED, SUBSCRIPTION_CHANGED, @@ -29,7 +52,7 @@ const eventList = [ IN_APP_MESSAGE_WILL_DISMISS, IN_APP_MESSAGE_DID_DISMISS, IN_APP_MESSAGE_DID_DISPLAY, -]; +] as const; export default class EventManager { private RNOneSignal: NativeModule; @@ -61,7 +84,10 @@ export default class EventManager { * @param {function} handler * @returns void */ - addEventListener(eventName: string, handler: (event: T) => void) { + addEventListener( + eventName: K, + handler: EventListenerMap[K], + ) { let handlerArray = this.eventListenerArrayMap.get(eventName); if (handlerArray && handlerArray.length > 0) { handlerArray.push(handler); @@ -76,7 +102,10 @@ export default class EventManager { * @param {function} handler * @returns void */ - removeEventListener(eventName: string, handler: any) { + removeEventListener( + eventName: K, + handler: EventListenerMap[K], + ) { const handlerArray = this.eventListenerArrayMap.get(eventName); if (!handlerArray) { return; @@ -91,8 +120,10 @@ export default class EventManager { } // returns an event listener with the js to native mapping - generateEventListener(eventName: string): EmitterSubscription { - const addListenerCallback = (payload: object) => { + generateEventListener( + eventName: K, + ): EmitterSubscription { + const addListenerCallback = (payload: unknown) => { let handlerArray = this.eventListenerArrayMap.get(eventName); if (handlerArray) { if (eventName === NOTIFICATION_WILL_DISPLAY) { @@ -108,7 +139,7 @@ export default class EventManager { }); } else { handlerArray.forEach((handler) => { - handler(payload); + handler(payload as EventListenerMap[K]); }); } } diff --git a/src/index.ts b/src/index.ts index 19648624..db14cf6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -407,10 +407,7 @@ export namespace OneSignal { isValidCallback(listener); RNOneSignal.addUserStateObserver(); - eventManager.addEventListener( - USER_STATE_CHANGED, - listener, - ); + eventManager.addEventListener(USER_STATE_CHANGED, listener); } /** Clears current user state observers. */ From db39a4dfbd080372f783e4e596fa3cd0a1fa71c5 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 10 Nov 2025 11:54:23 -0800 Subject: [PATCH 4/4] improve typings for event manager --- src/events/EventManager.test.ts | 107 +++++++++++++----------- src/events/EventManager.ts | 18 +++- src/index.test.ts | 25 ++++-- src/index.ts | 141 +++++++++++--------------------- src/types/inAppMessage.ts | 15 ++-- src/types/notificationEvents.ts | 14 ++-- 6 files changed, 156 insertions(+), 164 deletions(-) diff --git a/src/events/EventManager.test.ts b/src/events/EventManager.test.ts index 30c11898..c307bc2b 100644 --- a/src/events/EventManager.test.ts +++ b/src/events/EventManager.test.ts @@ -12,9 +12,11 @@ import { SUBSCRIPTION_CHANGED, USER_STATE_CHANGED, } from '../constants/events'; +import OSNotification from '../OSNotification'; +import type { NotificationClickEvent } from '../types/notificationEvents'; import type { PushSubscriptionChangedState } from '../types/subscription'; import type { UserChangedState } from '../types/user'; -import EventManager, { type EventListenerMap } from './EventManager'; +import EventManager, { type RawEventListenerMap } from './EventManager'; import NotificationWillDisplayEvent from './NotificationWillDisplayEvent'; describe('EventManager', () => { @@ -22,10 +24,10 @@ describe('EventManager', () => { let mockNativeModule: any; let eventCallbacks: Map void>; - const getCallback = ( + const getCallback = ( eventName: K, - ): ((payload: Parameters[0]) => void) | undefined => { - return eventCallbacks.get(eventName) as any; + ): RawEventListenerMap[K] | undefined => { + return eventCallbacks.get(eventName) as RawEventListenerMap[K] | undefined; }; beforeEach(() => { @@ -210,14 +212,7 @@ describe('EventManager', () => { eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, handler); const callback = getCallback(NOTIFICATION_WILL_DISPLAY); - - const notificationPayload = { - notificationId: 'test-id', - body: 'test-body', - rawPayload: {}, - }; - - callback!(notificationPayload); + callback!(rawWillDisplayPayload); expect(handler).toHaveBeenCalled(); const receivedEvent = handler.mock.calls[0][0]; @@ -228,33 +223,22 @@ describe('EventManager', () => { const handler = vi.fn(); eventManager.addEventListener(PERMISSION_CHANGED, handler); + const payload = getRawPermissionChangedPayload(true); const callback = getCallback(PERMISSION_CHANGED); - callback!({ permission: true }); + callback!(payload); expect(handler).toHaveBeenCalledWith(true); }); test('should handle generic events with object payload', () => { const handler = vi.fn(); - const payload = { - previous: { - id: 'previous-id', - token: 'previous-token', - optedIn: false, - }, - current: { - id: 'current-id', - token: 'current-token', - optedIn: true, - }, - } satisfies PushSubscriptionChangedState; eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler); const callback = getCallback(SUBSCRIPTION_CHANGED); - callback!(payload); + callback!(pushChangedPayload); - expect(handler).toHaveBeenCalledWith(payload); + expect(handler).toHaveBeenCalledWith(pushChangedPayload); }); test('should call all handlers for an event', () => { @@ -350,7 +334,14 @@ describe('EventManager', () => { test('should handle NOTIFICATION_CLICKED events', () => { const handler = vi.fn(); - const payload = { notificationId: 'notif-123', action: 'clicked' }; + const payload = { + result: { actionId: 'action-1' }, + notification: new OSNotification({ + notificationId: 'test-id', + body: 'test-body', + rawPayload: {}, + }), + } satisfies NotificationClickEvent; eventManager.addEventListener(NOTIFICATION_CLICKED, handler); @@ -372,7 +363,8 @@ describe('EventManager', () => { // Trigger event const callback = getCallback(PERMISSION_CHANGED); - callback!({ permission: true }); + const payload = getRawPermissionChangedPayload(true); + callback!(payload); expect(handler1).toHaveBeenCalledWith(true); expect(handler2).toHaveBeenCalledWith(true); @@ -385,7 +377,7 @@ describe('EventManager', () => { handler2.mockClear(); // Trigger event again - callback!({ permission: false }); + callback!(getRawPermissionChangedPayload(false)); expect(handler1).not.toHaveBeenCalled(); expect(handler2).toHaveBeenCalledWith(false); @@ -409,17 +401,16 @@ describe('EventManager', () => { const notificationCallback = getCallback(NOTIFICATION_WILL_DISPLAY); // Trigger different event types - permissionCallback!({ permission: true }); - subscriptionCallback!({ id: 'sub-123' }); - notificationCallback!({ - notificationId: 'notif-123', - body: 'test', - rawPayload: {}, - }); + const permissionPayload = getRawPermissionChangedPayload(true); + permissionCallback!(permissionPayload); + subscriptionCallback!(pushChangedPayload); + notificationCallback!(rawWillDisplayPayload); expect(permissionHandler).toHaveBeenCalledWith(true); - expect(subscriptionHandler).toHaveBeenCalledWith({ id: 'sub-123' }); - expect(notificationWillDisplayHandler).toHaveBeenCalled(); + expect(subscriptionHandler).toHaveBeenCalledWith(pushChangedPayload); + expect(notificationWillDisplayHandler).toHaveBeenCalledWith( + new NotificationWillDisplayEvent(rawWillDisplayPayload), + ); }); test('should maintain separate handler arrays for different events', () => { @@ -455,20 +446,44 @@ describe('EventManager', () => { const callback = getCallback(SUBSCRIPTION_CHANGED); - callback!({ id: '1' }); - expect(handler1).toHaveBeenCalledWith({ id: '1' }); - expect(handler2).toHaveBeenCalledWith({ id: '1' }); - expect(handler3).toHaveBeenCalledWith({ id: '1' }); + callback!(pushChangedPayload); + expect(handler1).toHaveBeenCalledWith(pushChangedPayload); + expect(handler2).toHaveBeenCalledWith(pushChangedPayload); + expect(handler3).toHaveBeenCalledWith(pushChangedPayload); eventManager.removeEventListener(SUBSCRIPTION_CHANGED, handler2); handler1.mockClear(); handler2.mockClear(); handler3.mockClear(); - callback!({ id: '2' }); - expect(handler1).toHaveBeenCalledWith({ id: '2' }); + callback!(pushChangedPayload); + expect(handler1).toHaveBeenCalledWith(pushChangedPayload); expect(handler2).not.toHaveBeenCalled(); - expect(handler3).toHaveBeenCalledWith({ id: '2' }); + expect(handler3).toHaveBeenCalledWith(pushChangedPayload); }); }); }); + +// helper payloads +const getRawPermissionChangedPayload = (permission: boolean) => ({ + permission: permission, +}); + +const rawWillDisplayPayload = new OSNotification({ + notificationId: 'test-id', + body: 'test-body', + rawPayload: {}, +}); + +const pushChangedPayload = { + previous: { + id: 'previous-id', + token: 'previous-token', + optedIn: false, + }, + current: { + id: 'current-id', + token: 'current-token', + optedIn: true, + }, +} satisfies PushSubscriptionChangedState; diff --git a/src/events/EventManager.ts b/src/events/EventManager.ts index 186bc8c7..5d5b2edd 100644 --- a/src/events/EventManager.ts +++ b/src/events/EventManager.ts @@ -29,7 +29,7 @@ import type { UserChangedState } from '../types/user'; import NotificationWillDisplayEvent from './NotificationWillDisplayEvent'; export interface EventListenerMap { - [PERMISSION_CHANGED]: (event: { permission: boolean }) => void; + [PERMISSION_CHANGED]: (event: boolean) => void; [SUBSCRIPTION_CHANGED]: (event: PushSubscriptionChangedState) => void; [USER_STATE_CHANGED]: (event: UserChangedState) => void; [NOTIFICATION_WILL_DISPLAY]: (event: NotificationWillDisplayEvent) => void; @@ -41,6 +41,18 @@ export interface EventListenerMap { [IN_APP_MESSAGE_DID_DISPLAY]: (event: InAppMessageDidDisplayEvent) => void; } +// Internal event listeners that connect to the native modules then get +// transformed (via generateEventListener) into the EventListenerMap types +type RawNotificationOverrides = { + [PERMISSION_CHANGED]: (payload: { permission: boolean }) => void; + [NOTIFICATION_WILL_DISPLAY]: (payload: OSNotification) => void; +}; +export type RawEventListenerMap = Omit< + EventListenerMap, + keyof RawNotificationOverrides +> & + RawNotificationOverrides; + const eventList = [ PERMISSION_CHANGED, SUBSCRIPTION_CHANGED, @@ -120,10 +132,10 @@ export default class EventManager { } // returns an event listener with the js to native mapping - generateEventListener( + generateEventListener( eventName: K, ): EmitterSubscription { - const addListenerCallback = (payload: unknown) => { + const addListenerCallback: RawEventListenerMap[K] = (payload: unknown) => { let handlerArray = this.eventListenerArrayMap.get(eventName); if (handlerArray) { if (eventName === NOTIFICATION_WILL_DISPLAY) { diff --git a/src/index.test.ts b/src/index.test.ts index 9da303c1..6b40bd7d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -12,7 +12,7 @@ import { SUBSCRIPTION_CHANGED, USER_STATE_CHANGED, } from './constants/events'; -import EventManager from './events/EventManager'; +import EventManager, { type EventListenerMap } from './events/EventManager'; import * as helpers from './helpers'; import { LogLevel, OneSignal, OSNotificationPermission } from './index'; @@ -38,10 +38,12 @@ const removeEventManagerListenerSpy = vi.spyOn( 'removeEventListener', ); -const filterEventListener = (eventName: string) => { +const filterEventListener = ( + eventName: K, +): EventListenerMap[K] => { return addEventManagerListenerSpy.mock.calls.filter( (call) => call[0] === eventName, - )[0][1]; + )[0][1] as EventListenerMap[K]; }; describe('OneSignal', () => { @@ -78,20 +80,29 @@ describe('OneSignal', () => { expect(permission).toBe(true); // test push subscription change listener - const subscriptionChangeFn = filterEventListener(SUBSCRIPTION_CHANGED); - subscriptionChangeFn({ + const pushData = { + previous: { + id: '', + token: '', + optedIn: false, + }, current: { id: 'subscription-id', token: 'push-token', optedIn: true, }, - }); + }; + const subscriptionChangeFn = filterEventListener(SUBSCRIPTION_CHANGED); + subscriptionChangeFn(pushData); const pushSubscription = OneSignal.User.pushSubscription.getPushSubscriptionId(); expect(pushSubscription).toBe('subscription-id'); // reset push subscription - subscriptionChangeFn({ current: {} }); + subscriptionChangeFn({ + ...pushData, + current: { id: '', token: '', optedIn: false }, + }); }); test('should not initialize if native module is not loaded', () => { diff --git a/src/index.ts b/src/index.ts index db14cf6c..2fc85415 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,16 +20,14 @@ import type { InAppMessageClickEvent, InAppMessageDidDismissEvent, InAppMessageDidDisplayEvent, - InAppMessageEventName, - InAppMessageEventTypeMap, + InAppMessageListeners, InAppMessageWillDismissEvent, InAppMessageWillDisplayEvent, } from './types/inAppMessage'; import type { LiveActivitySetupOptions } from './types/liveActivities'; import type { NotificationClickEvent, - NotificationEventName, - NotificationEventTypeMap, + NotificationListeners, } from './types/notificationEvents'; import type { PushSubscriptionChangedState, @@ -62,9 +60,12 @@ let pushSub: PushSubscriptionState = { }; async function _addPermissionObserver() { - OneSignal.Notifications.addEventListener('permissionChange', (granted) => { - notificationPermission = granted; - }); + OneSignal.Notifications.addEventListener( + 'permissionChange', + (granted: boolean) => { + notificationPermission = granted; + }, + ); notificationPermission = await RNOneSignal.hasNotificationPermission(); } @@ -285,10 +286,7 @@ export namespace OneSignal { isValidCallback(listener); RNOneSignal.addPushSubscriptionObserver(); - eventManager.addEventListener( - SUBSCRIPTION_CHANGED, - listener, - ); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, listener); } /** Clears current subscription observers. */ @@ -679,9 +677,8 @@ export namespace OneSignal { /** * Add listeners for notification click and/or lifecycle events. */ - export function addEventListener( - event: K, - listener: (event: NotificationEventTypeMap[K]) => void, + export function addEventListener( + ...[event, listener]: NotificationListeners ): void { if (!isNativeModuleLoaded(RNOneSignal)) return; isValidCallback(listener); @@ -689,31 +686,21 @@ export namespace OneSignal { /* v8 ignore else -- @preserve */ if (event === 'click') { RNOneSignal.addNotificationClickListener(); - eventManager.addEventListener( - NOTIFICATION_CLICKED, - listener as (event: NotificationClickEvent) => void, - ); + eventManager.addEventListener(NOTIFICATION_CLICKED, listener); } else if (event === 'foregroundWillDisplay') { RNOneSignal.addNotificationForegroundLifecycleListener(); - eventManager.addEventListener( - NOTIFICATION_WILL_DISPLAY, - listener as (event: NotificationWillDisplayEvent) => void, - ); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, listener); } else if (event === 'permissionChange') { isValidCallback(listener); RNOneSignal.addPermissionObserver(); - eventManager.addEventListener( - PERMISSION_CHANGED, - listener as (event: boolean) => void, - ); + eventManager.addEventListener(PERMISSION_CHANGED, listener); } } /** * Remove listeners for notification click and/or lifecycle events. */ - export function removeEventListener( - event: K, - listener: (event: NotificationEventTypeMap[K]) => void, + export function removeEventListener( + ...[event, listener]: NotificationListeners ): void { /* v8 ignore else -- @preserve */ if (event === 'click') { @@ -772,86 +759,50 @@ export namespace OneSignal { /** * Add listeners for In-App Message click and/or lifecycle events. */ - export function addEventListener( - event: K, - listener: (event: InAppMessageEventTypeMap[K]) => void, + export function addEventListener( + ...[event, listener]: InAppMessageListeners ): void { - if (!isNativeModuleLoaded(RNOneSignal)) { - return; - } + if (!isNativeModuleLoaded(RNOneSignal)) return; + isValidCallback(listener); + /* v8 ignore else -- @preserve */ if (event === 'click') { - isValidCallback(listener); RNOneSignal.addInAppMessageClickListener(); - eventManager.addEventListener( - IN_APP_MESSAGE_CLICKED, - listener as (event: InAppMessageClickEvent) => void, - ); - } else { - /* v8 ignore else -- @preserve */ - if (event === 'willDisplay') { - isValidCallback(listener); - eventManager.addEventListener( - IN_APP_MESSAGE_WILL_DISPLAY, - listener as (event: InAppMessageWillDisplayEvent) => void, - ); - } else if (event === 'didDisplay') { - isValidCallback(listener); - eventManager.addEventListener( - IN_APP_MESSAGE_DID_DISPLAY, - listener as (event: InAppMessageDidDisplayEvent) => void, - ); - } else if (event === 'willDismiss') { - isValidCallback(listener); - eventManager.addEventListener( - IN_APP_MESSAGE_WILL_DISMISS, - listener as (event: InAppMessageWillDismissEvent) => void, - ); - } else if (event === 'didDismiss') { - isValidCallback(listener); - eventManager.addEventListener( - IN_APP_MESSAGE_DID_DISMISS, - listener as (event: InAppMessageDidDismissEvent) => void, - ); - } else { - return; - } + eventManager.addEventListener(IN_APP_MESSAGE_CLICKED, listener); + } else if (event === 'willDisplay') { RNOneSignal.addInAppMessagesLifecycleListener(); + eventManager.addEventListener(IN_APP_MESSAGE_WILL_DISPLAY, listener); + } else if (event === 'didDisplay') { + RNOneSignal.addInAppMessagesLifecycleListener(); + eventManager.addEventListener(IN_APP_MESSAGE_DID_DISPLAY, listener); + } else if (event === 'willDismiss') { + RNOneSignal.addInAppMessagesLifecycleListener(); + eventManager.addEventListener(IN_APP_MESSAGE_WILL_DISMISS, listener); + } else if (event === 'didDismiss') { + RNOneSignal.addInAppMessagesLifecycleListener(); + eventManager.addEventListener(IN_APP_MESSAGE_DID_DISMISS, listener); } } /** * Remove listeners for In-App Message click and/or lifecycle events. */ - export function removeEventListener( - event: K, - listener: (obj: InAppMessageEventTypeMap[K]) => void, + export function removeEventListener( + ...[event, listener]: InAppMessageListeners ): void { + if (!isNativeModuleLoaded(RNOneSignal)) return; + isValidCallback(listener); + if (event === 'click') { eventManager.removeEventListener(IN_APP_MESSAGE_CLICKED, listener); - } else { - /* v8 ignore else -- @preserve */ - if (event === 'willDisplay') { - eventManager.removeEventListener( - IN_APP_MESSAGE_WILL_DISPLAY, - listener, - ); - } else if (event === 'didDisplay') { - eventManager.removeEventListener( - IN_APP_MESSAGE_DID_DISPLAY, - listener, - ); - } else if (event === 'willDismiss') { - eventManager.removeEventListener( - IN_APP_MESSAGE_WILL_DISMISS, - listener, - ); - } else if (event === 'didDismiss') { - eventManager.removeEventListener( - IN_APP_MESSAGE_DID_DISMISS, - listener, - ); - } + } else if (event === 'willDisplay') { + eventManager.removeEventListener(IN_APP_MESSAGE_WILL_DISPLAY, listener); + } else if (event === 'didDisplay') { + eventManager.removeEventListener(IN_APP_MESSAGE_DID_DISPLAY, listener); + } else if (event === 'willDismiss') { + eventManager.removeEventListener(IN_APP_MESSAGE_WILL_DISMISS, listener); + } else if (event === 'didDismiss') { + eventManager.removeEventListener(IN_APP_MESSAGE_DID_DISMISS, listener); } } diff --git a/src/types/inAppMessage.ts b/src/types/inAppMessage.ts index 36b5007c..9b34ec85 100644 --- a/src/types/inAppMessage.ts +++ b/src/types/inAppMessage.ts @@ -1,3 +1,5 @@ +import type { EventListenerMap } from '../events/EventManager'; + export type InAppMessageEventName = | 'click' | 'willDisplay' @@ -5,13 +7,12 @@ export type InAppMessageEventName = | 'willDismiss' | 'didDismiss'; -export type InAppMessageEventTypeMap = { - click: InAppMessageClickEvent; - willDisplay: InAppMessageWillDisplayEvent; - didDisplay: InAppMessageDidDisplayEvent; - willDismiss: InAppMessageWillDismissEvent; - didDismiss: InAppMessageDidDismissEvent; -}; +export type InAppMessageListeners = + | ['click', EventListenerMap['OneSignal-inAppMessageClicked']] + | ['willDisplay', EventListenerMap['OneSignal-inAppMessageWillDisplay']] + | ['didDisplay', EventListenerMap['OneSignal-inAppMessageDidDisplay']] + | ['willDismiss', EventListenerMap['OneSignal-inAppMessageWillDismiss']] + | ['didDismiss', EventListenerMap['OneSignal-inAppMessageDidDismiss']]; export interface InAppMessage { messageId: string; diff --git a/src/types/notificationEvents.ts b/src/types/notificationEvents.ts index 281e8b11..c08b8903 100644 --- a/src/types/notificationEvents.ts +++ b/src/types/notificationEvents.ts @@ -1,16 +1,18 @@ import OSNotification from '../OSNotification'; -import NotificationWillDisplayEvent from '../events/NotificationWillDisplayEvent'; +import type { EventListenerMap } from '../events/EventManager'; export type NotificationEventName = | 'click' | 'foregroundWillDisplay' | 'permissionChange'; -export type NotificationEventTypeMap = { - click: NotificationClickEvent; - foregroundWillDisplay: NotificationWillDisplayEvent; - permissionChange: boolean; -}; +export type NotificationListeners = + | ['click', EventListenerMap['OneSignal-notificationClicked']] + | [ + 'foregroundWillDisplay', + EventListenerMap['OneSignal-notificationWillDisplayInForeground'], + ] + | ['permissionChange', EventListenerMap['OneSignal-permissionChanged']]; export interface NotificationClickEvent { result: NotificationClickResult;