From 29f7f8257eeaa86bf62d070e33d937aae150dee6 Mon Sep 17 00:00:00 2001 From: Jade Date: Wed, 28 Jan 2026 14:06:39 +0000 Subject: [PATCH] feat: release --- CHANGELOG.md | 30 ++++ MainSDK.js | 228 +++++++++++------------------ babel.config.cjs | 3 + jest.config.cjs | 14 ++ lib/push/PushOrchestrator.js | 275 +++++++++++++++++++++++++++++++++++ package.json | 15 +- 6 files changed, 419 insertions(+), 146 deletions(-) create mode 100644 babel.config.cjs create mode 100644 jest.config.cjs create mode 100644 lib/push/PushOrchestrator.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 74f8bf7..4fbb59a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## 4.0.4 (2026-01-28) + + +* [DEV-359] feat!(main): support expo apps, rm native module. Kudos https://github.com/ahmetkuslular (dc9762c) + + +### Bug Fixes + +* add .js extensions to ESM imports in tests and mock native modules (41317ad) +* **gcm:** message variability (544eb08) + + +### Features + +* **common:** bump version (ce7e70d) +* **sdk:** bump device-info (1d1f10e) +* **sdk:** getToken to use correct token (d07df14) +* **sdk:** in-app push notifications (#51) (6fa0296) +* **sdk:** include types for exclude_brands (#50) (cd1d301) +* **sdk:** rm jest (d0cce5d) +* **sdk:** sid token generation (4a34846) + + +### BREAKING CHANGES + +* rm react-native-device-info, dynamically import if +needed + + + ## 4.0.3 (2026-01-23) diff --git a/MainSDK.js b/MainSDK.js index c255390..c548791 100644 --- a/MainSDK.js +++ b/MainSDK.js @@ -19,7 +19,6 @@ import { PermissionsAndroid } from 'react-native' import { Platform } from 'react-native' import { getMessaging } from '@react-native-firebase/messaging' import { onMessage } from '@react-native-firebase/messaging' -import firebase from '@react-native-firebase/app' import { setBackgroundMessageHandler } from '@react-native-firebase/messaging' import { getToken } from '@react-native-firebase/messaging' import { getAPNSToken } from '@react-native-firebase/messaging' @@ -31,6 +30,7 @@ import { AndroidStyle } from '@notifee/react-native' import { EventType } from '@notifee/react-native' import { AuthorizationStatus } from '@notifee/react-native' import { SDK_PUSH_CHANNEL } from './index' +import PushOrchestrator from './lib/push/PushOrchestrator' import Performer from './lib/performer' import { blankSearchRequest } from './utils' import { isOverOneWeekAgo } from './utils' @@ -114,7 +114,35 @@ class MainSDK extends Performer { // Firebase is initialized automatically by native modules // Initialize messaging lazily when needed this.messaging = null - this._initMessaging() + + /** + * Internal push orchestration (device registration, token fetch, tracking subscriptions). + * @type {PushOrchestrator} + */ + this._pushOrchestrator = new PushOrchestrator({ + getMessaging: () => this._ensureMessaging(), + getToken, + getAPNSToken, + onMessage, + setBackgroundMessageHandler, + onNotificationOpenedApp, + notifee, + EventType, + getPushData, + updPushData, + notificationDelivered: (options) => this.notificationDelivered(options), + pushReceivedListener: (remoteMessage) => + this.pushReceivedListener.call(this, remoteMessage), + pushBgReceivedListener: (remoteMessage) => + this.pushBgReceivedListener.call(this, remoteMessage), + pushClickListener: (event) => this.pushClickListener.call(this, event), + getShopId: () => this.shop_id, + hasSeenMessageId: (messageId) => this.lastMessageIds.includes(messageId), + markMessageIdSeen: (messageId) => { + this.lastMessageIds.push(messageId) + }, + isDebug: () => DEBUG, + }) } /** @@ -149,17 +177,9 @@ class MainSDK extends Performer { _initMessaging() { // Initialize Firebase messaging lazily - // Firebase is initialized automatically by native modules in React Native - // getMessaging() can be called directly without checking firebase.apps try { - // Check if Firebase is available before initializing messaging - if (firebase.apps && firebase.apps.length > 0) { - this.messaging = getMessaging() - } else { - // Firebase not initialized - this is OK for SDK features that don't require push - this.messaging = null - if (DEBUG) console.log('Firebase not initialized - push features will be disabled') - } + // Firebase initialization is the host app responsibility. + this.messaging = getMessaging() } catch (error) { console.warn('Firebase messaging initialization failed:', error) this.messaging = null @@ -288,9 +308,10 @@ class MainSDK extends Performer { // Initialize messaging after SDK is initialized this._initMessaging() this.performQueue() - this.initPushChannelAndToken() if (this.isInit() && this.autoSendPushToken) { - await this.sendPushToken() + // Explicitly request push permission and register token on init. + // This will show the system prompt on first launch if needed. + await this.initPush() } } catch (error) { this.initialized = false @@ -328,17 +349,17 @@ class MainSDK extends Performer { } /** - * @returns {Promise} + * @returns {Promise} */ - getToken = () => { - return this.initPushToken() - .then((token) => { - if (DEBUG) console.log(token) - return token - }) - .catch((error) => { - console.error(error) - }) + getToken = async () => { + try { + const token = await this.initPushToken() + if (DEBUG) console.log(token) + return token ?? null + } catch (error) { + console.error(error) + return null + } } /** @@ -911,9 +932,11 @@ class MainSDK extends Performer { if (await this.checkPushToken()) { this.push(async () => { if (await this.getPushPermission()) { - this.initPushChannel() - await this.initPushToken(false) - await saveLastPushTokenSentDate(new Date(), this.shop_id) + await this.initPushChannel() + const token = await this.initPushToken(false) + if (token) { + await saveLastPushTokenSentDate(new Date(), this.shop_id) + } } }) } @@ -927,6 +950,11 @@ class MainSDK extends Performer { * @returns {void} */ setPushTokenNotification(token) { + if (typeof token !== 'string' || token.length === 0) { + if (DEBUG) console.log('Push token is empty, skipping send') + return + } + this.push(async () => { try { let platform @@ -1009,7 +1037,7 @@ class MainSDK extends Performer { /** * @param {boolean} removeOld - * @returns {Promise} + * @returns {Promise} */ async initPushToken(removeOld = false) { let savedToken = await getSavedPushToken(this.shop_id) @@ -1020,37 +1048,33 @@ class MainSDK extends Performer { if (savedToken) { if (DEBUG) console.log('Old valid FCM token: ', savedToken) + // Even when token is already cached, ensure tracking subscriptions are installed. + await this._pushOrchestrator.ensureTrackingSubscriptions() return savedToken } - let pushToken - const messaging = this._ensureMessaging() if (!messaging) { console.warn('Firebase messaging not available') return null } - if (this._push_type === null && Platform.OS === 'ios') { - getAPNSToken(messaging).then((token) => { - if (DEBUG) console.log('New APN token: ', token) - this.setPushTokenNotification(token) - pushToken = token - }) - } else { - getToken(messaging).then((token) => { - if (DEBUG) console.log('New FCM token: ', token) - this.setPushTokenNotification(token) - pushToken = token - }) - } - return pushToken + const token = await this._pushOrchestrator.fetchToken({ + messaging, + pushType: this._push_type, + platformOS: Platform.OS, + }) + + if (!token) return null + this.setPushTokenNotification(token) + return token } /** * @returns {Promise} */ async initPushChannel() { + if (Platform.OS !== 'android') return await notifee.createChannel({ id: SDK_PUSH_CHANNEL, name: 'RNSDK channel', @@ -1082,6 +1106,14 @@ class MainSDK extends Performer { notifyReceive = false, notifyBgReceive = false ) { + // Always allow updating listeners, even if initPush() is called repeatedly. + if (notifyClick) { + this.pushClickListener = notifyClick + this._pushOrchestrator.setHasCustomClickListener(true) + } + if (notifyReceive) this.pushReceivedListener = notifyReceive + if (notifyBgReceive) this.pushBgReceivedListener = notifyBgReceive + const lock = await initLocker(this.shop_id) if ( lock && @@ -1089,112 +1121,20 @@ class MainSDK extends Performer { lock.state === true && new Date().getTime() < lock.expires ) { + // Ensure subscriptions exist even if init is locked. + await this._pushOrchestrator.ensureTrackingSubscriptions() return false } await setInitLocker(true, this.shop_id) + const granted = await this.getPushPermission() + if (!granted) return false - this.initPushChannelAndToken() - if (notifyClick) this.pushClickListener = notifyClick - if (notifyReceive) this.pushReceivedListener = notifyReceive - if (notifyBgReceive) this.pushBgReceivedListener = notifyBgReceive - - // Register handler - const messaging = this._ensureMessaging() - if (messaging) { - onMessage(messaging, async (remoteMessage) => { - if (this.lastMessageIds.includes(remoteMessage.messageId)) { - return false - } else { - this.lastMessageIds.push(remoteMessage.messageId) - } - - await this.notificationDelivered({ - code: remoteMessage.data.id, - type: remoteMessage.data.type, - }) - if (DEBUG) console.log('Message delivered: ', remoteMessage) - - await updPushData(remoteMessage, this.shop_id) - await this.pushReceivedListener(remoteMessage) - }) - } - - // Register background handler - const messagingForBg = this._ensureMessaging() - if (messagingForBg) { - setBackgroundMessageHandler(messagingForBg, async (remoteMessage) => { - if (this.lastMessageIds.includes(remoteMessage.messageId)) { - return false - } else { - this.lastMessageIds.push(remoteMessage.messageId) - } - - await this.notificationDelivered({ - code: remoteMessage.data.id, - type: remoteMessage.data.type, - }) - if (DEBUG) console.log('Background message delivered: ', remoteMessage) - - await updPushData(remoteMessage, this.shop_id) - await this.pushBgReceivedListener(remoteMessage) - }) - } - - // Register notification opened handler - const messagingForOpened = this._ensureMessaging() - if (messagingForOpened) { - onNotificationOpenedApp(messagingForOpened, async (remoteMessage) => { - if (this.lastMessageIds.includes(remoteMessage.messageId)) { - return false - } else { - this.lastMessageIds.push(remoteMessage.messageId) - } - - await this.notificationDelivered({ - code: remoteMessage.data.id, - type: remoteMessage.data.type, - }) - if (DEBUG) console.log('App opened via notification', remoteMessage) - - await updPushData(remoteMessage, this.shop_id) - await this.pushBgReceivedListener(remoteMessage) - }) - } - - /** Subscribe to click notification */ - notifee.onForegroundEvent(async ({ type, detail }) => { - if (type === EventType.PRESS && detail.notification) { - const n = detail.notification - const data = n.data || {} - await updPushData({ data, messageId: data.message_id }, this.shop_id) - if (!notifyClick) { - await this.pushClickListener({ data }) - } else { - const messageId = - data.message_id || - data['google.message_id'] || - data['gcm.message_id'] - const stored = await getPushData(messageId, this.shop_id) - await this.pushClickListener( - stored && stored.length > 0 ? stored[0] : { data } - ) - } - } - }) - - notifee.onBackgroundEvent(async ({ type, detail }) => { - if (type === EventType.PRESS && detail.notification) { - const data = detail.notification.data || {} - await this.pushClickListener({ data }) - } - }) + await this.initPushChannel() + await this.initPushToken(false) - const initial = await notifee.getInitialNotification() - if (initial?.notification) { - const data = initial.notification.data || {} - await this.pushClickListener({ data }) - } + await this._pushOrchestrator.ensureTrackingSubscriptions() + return true } /** diff --git a/babel.config.cjs b/babel.config.cjs new file mode 100644 index 0000000..f842b77 --- /dev/null +++ b/babel.config.cjs @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:metro-react-native-babel-preset'], +}; diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 0000000..ef4bf1e --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + preset: 'react-native', + rootDir: __dirname, + testMatch: ['/tests/**/*.test.js'], + transformIgnorePatterns: [ + 'node_modules/(?!(react-native|@react-native|@react-native-firebase|@notifee|react-native-device-info|axios)/)', + ], + moduleNameMapper: { + '^@/(.*)$': '/$1', + }, + setupFilesAfterEnv: [], + testEnvironment: 'node', + resolver: undefined, +}; diff --git a/lib/push/PushOrchestrator.js b/lib/push/PushOrchestrator.js new file mode 100644 index 0000000..5d301ad --- /dev/null +++ b/lib/push/PushOrchestrator.js @@ -0,0 +1,275 @@ +export default class PushOrchestrator { + /** + * @param {{ + * getMessaging: () => any | null, + * getToken: (messaging: any) => Promise, + * getAPNSToken: (messaging: any) => Promise, + * onMessage: (messaging: any, cb: Function) => (Function | void), + * setBackgroundMessageHandler: (messaging: any, cb: Function) => void, + * onNotificationOpenedApp: (messaging: any, cb: Function) => (Function | void), + * notifee: any, + * EventType: any, + * getPushData: (messageId: string, shopId: string) => Promise, + * updPushData: (remoteMessage: any, shopId: string) => Promise, + * notificationDelivered: (options: { code: string, type: string }) => Promise, + * pushReceivedListener: (remoteMessage: any) => Promise, + * pushBgReceivedListener: (remoteMessage: any) => Promise, + * pushClickListener: (event: any) => Promise, + * getShopId: () => string, + * hasSeenMessageId: (messageId: string) => boolean, + * markMessageIdSeen: (messageId: string) => void, + * isDebug: () => boolean, + * }} deps + */ + constructor(deps) { + this._deps = deps + + /** @type {boolean} */ + this._hasCustomPushClickListener = false + + /** @type {boolean} */ + this._trackingSubscribed = false + /** @type {Promise | null} */ + this._trackingPromise = null + + /** @type {Promise | null} */ + this._tokenPromise = null + + /** @type {Function | null} */ + this._unsubOnMessage = null + /** @type {Function | null} */ + this._unsubOnNotificationOpened = null + /** @type {Function | null} */ + this._unsubNotifeeForeground = null + + /** @type {boolean} */ + this._backgroundMessageHandlerSet = false + /** @type {boolean} */ + this._notifeeBackgroundHandlerSet = false + /** @type {boolean} */ + this._notifeeInitialChecked = false + } + + /** + * @param {boolean} val + */ + setHasCustomClickListener(val) { + if (val) this._hasCustomPushClickListener = true + } + + /** + * iOS-only: registers device for remote messages if needed. + * @param {any} messaging + * @param {string} platformOS + * @returns {Promise} + */ + async ensureDeviceRegistered(messaging, platformOS) { + if (platformOS !== 'ios' || !messaging) return + + /** @type {boolean | null} */ + let isRegistered = null + + // Prefer the callable API to avoid deprecated property access. + try { + if (typeof messaging?.isDeviceRegisteredForRemoteMessages === 'function') { + isRegistered = await messaging.isDeviceRegisteredForRemoteMessages() + if (isRegistered === true) return + } + } catch (e) { + isRegistered = null + } + + try { + await messaging.registerDeviceForRemoteMessages() + } catch (e) { + // If SDK knows we are not registered, treat it as a hard failure. + // Otherwise (unknown/older versions) ignore and let token fetch decide. + if (isRegistered === false) throw e + if (this._deps.isDebug()) console.log('registerDeviceForRemoteMessages failed', e) + } + } + + /** + * Fetches token and ensures tracking subscriptions exist once token is known. + * Dedupes concurrent calls. + * + * @param {{ + * messaging: any, + * pushType: string | null, + * platformOS: string + * }} args + * @returns {Promise} + */ + async fetchToken(args) { + if (this._tokenPromise) return this._tokenPromise + + const { messaging, pushType, platformOS } = args + + this._tokenPromise = (async () => { + try { + await this.ensureDeviceRegistered(messaging, platformOS) + + let token = null + if (pushType === null && platformOS === 'ios') { + token = await this._deps.getAPNSToken(messaging) + if (this._deps.isDebug()) console.log('New APN token: ', token) + } else { + token = await this._deps.getToken(messaging) + if (this._deps.isDebug()) console.log('New FCM token: ', token) + } + + if (typeof token !== 'string' || token.length === 0) { + return null + } + + await this.ensureTrackingSubscriptions() + return token + } catch (error) { + console.log('initPushToken error', error) + return null + } finally { + this._tokenPromise = null + } + })() + + return this._tokenPromise + } + + /** + * Installs push tracking subscriptions only once. + * + * @returns {Promise} + */ + async ensureTrackingSubscriptions() { + if (this._trackingSubscribed) return true + if (this._trackingPromise) return this._trackingPromise + + this._trackingPromise = (async () => { + try { + const messaging = this._deps.getMessaging() + const shopId = this._deps.getShopId() + + if (messaging && !this._unsubOnMessage) { + const unsub = this._deps.onMessage(messaging, async (remoteMessage) => { + const messageId = remoteMessage?.messageId + if (messageId) { + if (this._deps.hasSeenMessageId(messageId)) return false + this._deps.markMessageIdSeen(messageId) + } + + await this._deps.notificationDelivered({ + code: remoteMessage.data.id, + type: remoteMessage.data.type, + }) + if (this._deps.isDebug()) console.log('Message delivered: ', remoteMessage) + + await this._deps.updPushData(remoteMessage, shopId) + await this._deps.pushReceivedListener(remoteMessage) + }) + + this._unsubOnMessage = typeof unsub === 'function' ? unsub : () => {} + } + + if (messaging && !this._backgroundMessageHandlerSet) { + this._deps.setBackgroundMessageHandler(messaging, async (remoteMessage) => { + const messageId = remoteMessage?.messageId + if (messageId) { + if (this._deps.hasSeenMessageId(messageId)) return false + this._deps.markMessageIdSeen(messageId) + } + + await this._deps.notificationDelivered({ + code: remoteMessage.data.id, + type: remoteMessage.data.type, + }) + if (this._deps.isDebug()) console.log('Background message delivered: ', remoteMessage) + + await this._deps.updPushData(remoteMessage, shopId) + await this._deps.pushBgReceivedListener(remoteMessage) + }) + this._backgroundMessageHandlerSet = true + } + + if (messaging && !this._unsubOnNotificationOpened) { + const unsub = this._deps.onNotificationOpenedApp( + messaging, + async (remoteMessage) => { + const messageId = remoteMessage?.messageId + if (messageId) { + if (this._deps.hasSeenMessageId(messageId)) return false + this._deps.markMessageIdSeen(messageId) + } + + await this._deps.notificationDelivered({ + code: remoteMessage.data.id, + type: remoteMessage.data.type, + }) + if (this._deps.isDebug()) console.log('App opened via notification', remoteMessage) + + await this._deps.updPushData(remoteMessage, shopId) + await this._deps.pushBgReceivedListener(remoteMessage) + } + ) + + this._unsubOnNotificationOpened = + typeof unsub === 'function' ? unsub : () => {} + } + + if (!this._unsubNotifeeForeground) { + const unsub = this._deps.notifee.onForegroundEvent(async ({ type, detail }) => { + if (type !== this._deps.EventType.PRESS || !detail.notification) return + + const n = detail.notification + const data = n.data || {} + + await this._deps.updPushData({ data, messageId: data.message_id }, shopId) + + if (!this._hasCustomPushClickListener) { + await this._deps.pushClickListener({ data }) + return + } + + const messageId = + data.message_id || data['google.message_id'] || data['gcm.message_id'] + const stored = messageId ? await this._deps.getPushData(messageId, shopId) : [] + await this._deps.pushClickListener( + stored && stored.length > 0 ? stored[0] : { data } + ) + }) + + this._unsubNotifeeForeground = typeof unsub === 'function' ? unsub : () => {} + } + + if (!this._notifeeBackgroundHandlerSet) { + this._deps.notifee.onBackgroundEvent(async ({ type, detail }) => { + if (type === this._deps.EventType.PRESS && detail.notification) { + const data = detail.notification.data || {} + await this._deps.pushClickListener({ data }) + } + }) + this._notifeeBackgroundHandlerSet = true + } + + if (!this._notifeeInitialChecked) { + const initial = await this._deps.notifee.getInitialNotification() + if (initial?.notification) { + const data = initial.notification.data || {} + await this._deps.pushClickListener({ data }) + } + this._notifeeInitialChecked = true + } + + this._trackingSubscribed = true + return true + } catch (e) { + if (this._deps.isDebug()) console.log('Failed to setup push tracking subscriptions', e) + return false + } finally { + this._trackingPromise = null + } + })() + + return this._trackingPromise + } +} + diff --git a/package.json b/package.json index b02d39f..35792ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@personaclick/rn-sdk", - "version": "4.0.3", + "version": "4.0.4", "description": "PersonaClick React Native SDK", "type": "module", "exports": { @@ -20,12 +20,23 @@ "axios": "^0.21.1" }, "devDependencies": { + "@babel/core": "^7.25.2", + "@jest/core": "29.7.0", "@jest/create-cache-key-function": "^26.6.2", + "@jest/types": "29.6.3", "@notifee/react-native": "9.1.8", "@react-native-async-storage/async-storage": "1.22.0", "@react-native-firebase/app": "^23", "@react-native-firebase/messaging": "^23", + "@react-native/babel-preset": "0.81.0", + "babel-jest": "^29.7.0", "conventional-changelog-cli": "5.0.0", + "jest": "29.7.0", + "jest-cli": "29.7.0", + "jest-config": "29.7.0", + "jest-runtime": "29.7.0", + "jest-util": "29.7.0", + "metro-react-native-babel-preset": "0.77.0", "react": "^19.1.0", "react-native": "^0.81.0", "react-test-renderer": "^17.0.2" @@ -40,7 +51,7 @@ "lib": "lib" }, "scripts": { - "test": "node --test", + "test": "yarn dlx jest@29.7.0 --config jest.config.cjs", "changelog": "conventional-changelog -i CHANGELOG.md -s --commit-path . -p angular -t @personaclick/rn-sdk-" }, "publishConfig": {