@msgera/push 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,72 @@
1
- # Temporary Holding Version
1
+ # Msgera Push SDK for React Native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Push notifications for React Native apps on Android and iOS using `@react-native-firebase/messaging` and Msgera.
4
+
5
+ ## 1. Install
6
+
7
+ Install `@msgera/push` alongside `@react-native-firebase/app` and `@react-native-firebase/messaging`:
8
+
9
+ ```bash
10
+ npm install @msgera/push @react-native-firebase/app @react-native-firebase/messaging
11
+ ```
12
+
13
+ Optional for foreground banners on Android:
14
+
15
+ ```bash
16
+ npm install @notifee/react-native
17
+ ```
18
+
19
+ ## 2. Initialize
20
+
21
+ Register the background handler in `index.js` before `AppRegistry.registerComponent`, and call `init` when your app mounts:
22
+
23
+ ```javascript
24
+ // index.js
25
+ import { registerBackgroundHandler } from '@msgera/push';
26
+ registerBackgroundHandler();
27
+ ```
28
+
29
+ ```javascript
30
+ // App.tsx
31
+ import { useEffect } from 'react';
32
+ import { init, setLogLevel } from '@msgera/push';
33
+
34
+ export default function App() {
35
+ useEffect(() => {
36
+ setLogLevel('verbose'); // optional for debugging
37
+ init('YOUR_APP_PUBLIC_ID');
38
+ }, []);
39
+ }
40
+ ```
41
+
42
+ ## 3. Request Permission
43
+
44
+ Call `requestPermission` from a user action:
45
+
46
+ ```javascript
47
+ import { requestPermission } from '@msgera/push';
48
+
49
+ const granted = await requestPermission({ fallbackToSettings: true });
50
+ ```
51
+
52
+ ## 4. Login & Logout
53
+
54
+ Link the device to your user ID (pass `userHash` when identity verification is enabled):
55
+
56
+ ```javascript
57
+ import { login, logout } from '@msgera/push';
58
+
59
+ await login('user_123', userHash);
60
+
61
+ // On sign-out:
62
+ await logout();
63
+ ```
64
+
65
+ ## 5. Tags, consent, and notification events
66
+
67
+ `addTag`, `addTags`, `removeTag`, `removeTags`, and `getTags` debounce for 2 seconds. Also: `setLanguage`, `trackEvent`, `optOut`, `optIn`, `pushSubscription`, `addClickListener`, `addForegroundListener`, `addPermissionObserver`, `addSubscriptionObserver`, `canRequestPermission`, `setConsentRequired`, `setConsentGiven`, `deleteDevice`, `clearAllNotifications`, and `removeNotification`. Clicks open with `Linking.openURL` unless `preventDefault()` is called. Call `setConsentRequired(true)` before `init`. `groupKey` on the payload sets the Android group and iOS thread when Notifee is installed.
68
+
69
+ ## 6. Test
70
+
71
+ 1. Launch your app on a device and grant notification permission.
72
+ 2. In the Msgera Dashboard, open **Subscribers**, mark your device as a **Test device**, and use **Send test** in the composer.
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "@msgera/push",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "Msgera push notifications SDK for React Native",
5
+ "main": "src/index.js",
6
+ "types": "src/index.d.ts",
7
+ "files": [
8
+ "src",
9
+ "README.md"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/msgera/push-sdks.git",
14
+ "directory": "react-native"
15
+ },
16
+ "license": "MIT",
17
+ "peerDependencies": {
18
+ "@react-native-firebase/app": ">=21.0.0",
19
+ "@react-native-firebase/messaging": ">=21.0.0",
20
+ "react-native": ">=0.76.0"
21
+ },
22
+ "peerDependenciesMeta": {
23
+ "@notifee/react-native": {
24
+ "optional": true
25
+ }
26
+ }
27
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,100 @@
1
+ export type Done = (error?: Error) => void;
2
+ export type LogLevel = 'none' | 'error' | 'verbose';
3
+
4
+ export interface RequestPermissionOptions {
5
+ fallbackToSettings?: boolean;
6
+ }
7
+
8
+ export interface InitOptions {
9
+ appId: string;
10
+ apiBase?: string;
11
+ }
12
+
13
+ export const SDK_VERSION: string;
14
+ export const DEFAULT_API_BASE: string;
15
+
16
+ export function init(appId: string, apiBase?: string): Promise<void>;
17
+ export function init(options: InitOptions): Promise<void>;
18
+ export function requestPermission(fallbackToSettings?: boolean | RequestPermissionOptions): Promise<boolean>;
19
+ export function login(externalUserId: string, userHash?: string): Promise<void>;
20
+ export function logout(): Promise<void>;
21
+ export function setLogLevel(level: LogLevel): void;
22
+
23
+ export interface PushSubscriptionState {
24
+ id: string | null;
25
+ token: string | null;
26
+ optedIn: boolean;
27
+ }
28
+
29
+ export interface ClickEvent {
30
+ messageId: string | null;
31
+ title: string | null;
32
+ body: string | null;
33
+ data: Record<string, string>;
34
+ launchUrl: string | null;
35
+ actionId: string | null;
36
+ preventDefault(): void;
37
+ }
38
+
39
+ export interface ForegroundEvent {
40
+ messageId: string | null;
41
+ title: string | null;
42
+ body: string | null;
43
+ data: Record<string, string>;
44
+ preventDefault(): void;
45
+ }
46
+
47
+ export function pushSubscription(): PushSubscriptionState;
48
+ export function addClickListener(listener: (event: ClickEvent) => void): void;
49
+ export function addForegroundListener(listener: (event: ForegroundEvent) => void): void;
50
+ export function addPermissionObserver(listener: (granted: boolean) => void): void;
51
+ export function addSubscriptionObserver(listener: (subscription: PushSubscriptionState) => void): void;
52
+ export function canRequestPermission(): Promise<boolean>;
53
+ export function optOut(): Promise<void>;
54
+ export function optIn(): Promise<void>;
55
+ export function addTag(key: string, value: string): void;
56
+ export function addTags(tags: Record<string, string>): void;
57
+ export function removeTag(key: string): void;
58
+ export function removeTags(keys: string[]): void;
59
+ export function getTags(): Record<string, string>;
60
+ export function setLanguage(language: string): Promise<void>;
61
+ export function trackEvent(name: string, properties?: Record<string, unknown>): Promise<void>;
62
+ export function setConsentRequired(required: boolean): void;
63
+ export function setConsentGiven(given: boolean): void;
64
+ export function deleteDevice(): Promise<void>;
65
+ export function clearAllNotifications(): Promise<void>;
66
+ export function removeNotification(id: number): Promise<void>;
67
+
68
+ /** Call from index.js, before AppRegistry. A handler registered later misses a tap that launched a killed app. */
69
+ export function registerBackgroundHandler(): void;
70
+
71
+ export const MsgeraPush: {
72
+ SDK_VERSION: string;
73
+ DEFAULT_API_BASE: string;
74
+ init: typeof init;
75
+ requestPermission: typeof requestPermission;
76
+ login: typeof login;
77
+ logout: typeof logout;
78
+ setLogLevel: typeof setLogLevel;
79
+ registerBackgroundHandler: typeof registerBackgroundHandler;
80
+ pushSubscription: typeof pushSubscription;
81
+ addClickListener: typeof addClickListener;
82
+ addForegroundListener: typeof addForegroundListener;
83
+ addPermissionObserver: typeof addPermissionObserver;
84
+ addSubscriptionObserver: typeof addSubscriptionObserver;
85
+ canRequestPermission: typeof canRequestPermission;
86
+ optOut: typeof optOut;
87
+ optIn: typeof optIn;
88
+ addTag: typeof addTag;
89
+ addTags: typeof addTags;
90
+ removeTag: typeof removeTag;
91
+ removeTags: typeof removeTags;
92
+ getTags: typeof getTags;
93
+ setLanguage: typeof setLanguage;
94
+ trackEvent: typeof trackEvent;
95
+ setConsentRequired: typeof setConsentRequired;
96
+ setConsentGiven: typeof setConsentGiven;
97
+ deleteDevice: typeof deleteDevice;
98
+ clearAllNotifications: typeof clearAllNotifications;
99
+ removeNotification: typeof removeNotification;
100
+ };
package/src/index.js ADDED
@@ -0,0 +1,596 @@
1
+ const { Platform, PermissionsAndroid, Linking } = require('react-native');
2
+ const {
3
+ getMessaging,
4
+ getToken,
5
+ onTokenRefresh,
6
+ onMessage,
7
+ onNotificationOpenedApp,
8
+ getInitialNotification,
9
+ setBackgroundMessageHandler,
10
+ requestPermission: requestMessagingPermission,
11
+ AuthorizationStatus,
12
+ getAPNSToken,
13
+ setAutoInitEnabled,
14
+ } = require('@react-native-firebase/messaging');
15
+
16
+ const SDK_VERSION = '1.1.0';
17
+ const DEFAULT_API_BASE = 'https://api.msgera.io';
18
+ const seenClicks = new Set();
19
+ let config = null;
20
+ let logLevel = 'error';
21
+ const clickListeners = [];
22
+ const foregroundListeners = [];
23
+ const permissionObservers = [];
24
+ const subscriptionObservers = [];
25
+ const queuedClicks = [];
26
+ let pendingTags = {};
27
+ let tagTimer = null;
28
+ let consentRequired = false;
29
+ let consentGiven = false;
30
+ let optedIn = true;
31
+
32
+ function networkAllowed() {
33
+ return !consentRequired || consentGiven;
34
+ }
35
+
36
+ function notifySubscription() {
37
+ const state = pushSubscription();
38
+ subscriptionObservers.forEach((listener) => listener(state));
39
+ }
40
+
41
+ function notifyPermission(granted) {
42
+ permissionObservers.forEach((listener) => listener(granted));
43
+ }
44
+
45
+ function javaHash(value) {
46
+ let hash = 0;
47
+ const text = value || '';
48
+ for (let index = 0; index < text.length; index += 1) {
49
+ hash = (Math.imul(hash, 31) + text.charCodeAt(index)) | 0;
50
+ }
51
+ return hash;
52
+ }
53
+
54
+ function logVerbose(message) {
55
+ if (logLevel === 'verbose') {
56
+ console.log(`[MsgeraPush] ${message}`);
57
+ }
58
+ }
59
+
60
+ function logError(message, error) {
61
+ if (logLevel === 'error' || logLevel === 'verbose') {
62
+ console.error(`[MsgeraPush] ${message}`, error || '');
63
+ }
64
+ }
65
+
66
+ function setLogLevel(level) {
67
+ const normalized = String(level || '').toLowerCase();
68
+ if (normalized === 'none' || normalized === 'error' || normalized === 'verbose') {
69
+ logLevel = normalized;
70
+ }
71
+ }
72
+
73
+ function base() {
74
+ if (!config) throw new Error('Call init before using Msgera push');
75
+ const raw = (config.apiBase || DEFAULT_API_BASE).replace(/\/+$/, '');
76
+ return raw || DEFAULT_API_BASE;
77
+ }
78
+
79
+ async function post(path, body) {
80
+ return request('POST', path, body);
81
+ }
82
+
83
+ async function request(method, path, body) {
84
+ if (!networkAllowed()) throw new Error('Consent is required before Msgera push can send data');
85
+ const response = await fetch(`${base()}/v1/public/push/${config.appId}${path}`, {
86
+ method,
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: method === 'GET' || method === 'DELETE' ? undefined : JSON.stringify(body || {}),
89
+ });
90
+ const text = await response.text();
91
+ if (!response.ok) {
92
+ const errText = text || `Push request failed (${response.status})`;
93
+ logError(`Request ${path} failed: ${errText}`);
94
+ throw new Error(errText);
95
+ }
96
+ return text ? JSON.parse(text) : {};
97
+ }
98
+
99
+ async function register(token) {
100
+ if (!networkAllowed() || !optedIn) return;
101
+ if (config.token === token && config.subscriberId) return;
102
+ const json = await post('/subscribers', {
103
+ token,
104
+ platform: Platform.OS === 'ios' ? 'ios' : 'android',
105
+ transport: 'fcm',
106
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
107
+ sdkVersion: SDK_VERSION,
108
+ });
109
+ config.subscriberId = json.subscriberId;
110
+ config.token = token;
111
+ logVerbose(`Registered subscriber ${json.subscriberId}`);
112
+ notifySubscription();
113
+ if (Object.keys(pendingTags).length) scheduleTagFlush();
114
+ }
115
+
116
+ async function currentToken() {
117
+ if (Platform.OS === 'ios') {
118
+ let apns = null;
119
+ for (let attempt = 0; attempt < 10 && !apns; attempt += 1) {
120
+ apns = await getAPNSToken(getMessaging());
121
+ if (!apns) await new Promise((resolve) => setTimeout(resolve, 300));
122
+ }
123
+ }
124
+ const token = await getToken(getMessaging());
125
+ if (!token) throw new Error('Firebase did not return a token. Add the Firebase config files.');
126
+ await register(token);
127
+ }
128
+
129
+ async function report(messageId, type) {
130
+ if (!messageId || !config?.subscriberId) return;
131
+ await post(`/subscribers/${config.subscriberId}/events`, { messageId, type }).catch((err) => {
132
+ logError(`Event report (${type}) failed`, err);
133
+ });
134
+ }
135
+
136
+ function pushSubscription() {
137
+ return {
138
+ id: config?.subscriberId || null,
139
+ token: config?.token || null,
140
+ optedIn,
141
+ };
142
+ }
143
+
144
+ function dispatchClick(event) {
145
+ clickListeners.forEach((listener) => listener(event));
146
+ if (!event.prevented && event.launchUrl) {
147
+ Linking.openURL(event.launchUrl).catch(() => undefined);
148
+ }
149
+ }
150
+
151
+ async function openClick(message, actionId) {
152
+ const data = message?.data || {};
153
+ const messageId = data.msgeraMessageId;
154
+ const resolvedAction = actionId && actionId !== 'default' ? actionId : data.actionId || null;
155
+ const seenKey = `${messageId || ''}:${resolvedAction || ''}`;
156
+ if (!messageId || seenClicks.has(seenKey)) return;
157
+ seenClicks.add(seenKey);
158
+ await report(messageId, 'clicked');
159
+ let launchUrl = data.clickUrl || null;
160
+ if (resolvedAction) {
161
+ const match = parseButtons(data.msgeraButtons, 4).find((button) => button.id === resolvedAction);
162
+ if (match && match.url) launchUrl = match.url;
163
+ }
164
+ const event = {
165
+ messageId,
166
+ title: message.notification?.title || data.title || null,
167
+ body: message.notification?.body || data.body || null,
168
+ data,
169
+ launchUrl,
170
+ actionId: resolvedAction,
171
+ prevented: false,
172
+ preventDefault() {
173
+ this.prevented = true;
174
+ },
175
+ };
176
+ if (!clickListeners.length) {
177
+ queuedClicks.push(event);
178
+ return;
179
+ }
180
+ dispatchClick(event);
181
+ }
182
+
183
+ function parseButtons(raw, limit = 2) {
184
+ if (!raw) return [];
185
+ try {
186
+ const rows = JSON.parse(raw);
187
+ if (!Array.isArray(rows)) return [];
188
+ return rows.slice(0, limit).flatMap((row) => {
189
+ if (!row || typeof row.id !== 'string') return [];
190
+ return [{ id: row.id, text: row.text || row.title || row.id, url: row.url || '' }];
191
+ });
192
+ } catch {
193
+ return [];
194
+ }
195
+ }
196
+
197
+ async function syncChannels() {
198
+ let notifee;
199
+ try {
200
+ notifee = require('@notifee/react-native').default;
201
+ } catch {
202
+ return;
203
+ }
204
+ const config = await request('GET', '/config');
205
+ const rows = Array.isArray(config.channels) ? config.channels : [];
206
+ for (const row of rows) {
207
+ if (!row || !row.channelId || !row.name) continue;
208
+ if (row.key && row.version > 1) {
209
+ for (let older = 1; older < row.version; older += 1) {
210
+ await notifee.deleteChannel(`${row.key}_v${older}`).catch(() => undefined);
211
+ }
212
+ }
213
+ const importance = row.importance === 'low' ? 2 : row.importance === 'default' ? 3 : 4;
214
+ await notifee.createChannel({
215
+ id: row.channelId,
216
+ name: row.name,
217
+ description: row.description || undefined,
218
+ importance,
219
+ sound: row.sound && row.sound !== 'default' && row.sound !== 'none' ? row.sound : undefined,
220
+ });
221
+ }
222
+ }
223
+
224
+ async function showForeground(message) {
225
+ const data = message.data || {};
226
+ const event = {
227
+ messageId: data.msgeraMessageId || null,
228
+ title: message.notification?.title || data.title || null,
229
+ body: message.notification?.body || data.body || null,
230
+ data,
231
+ prevented: false,
232
+ preventDefault() {
233
+ this.prevented = true;
234
+ },
235
+ };
236
+ foregroundListeners.forEach((listener) => listener(event));
237
+ if (event.prevented) return;
238
+ let notifee;
239
+ try {
240
+ notifee = require('@notifee/react-native').default;
241
+ } catch {
242
+ return;
243
+ }
244
+ const channelId = data.channelId || (await notifee.createChannel({
245
+ id: 'msgera_default',
246
+ name: 'Notifications',
247
+ importance: 4,
248
+ }));
249
+ const buttons = parseButtons(data.msgeraButtons, Platform.OS === 'ios' ? 4 : 2);
250
+ let iosCategoryId;
251
+ if (Platform.OS === 'ios' && buttons.length && typeof notifee.setNotificationCategories === 'function') {
252
+ iosCategoryId = `msgera.${buttons.map((button) => button.id).join('.')}`;
253
+ const existing = typeof notifee.getNotificationCategories === 'function'
254
+ ? await notifee.getNotificationCategories().catch(() => [])
255
+ : [];
256
+ const next = (existing || []).filter((row) => row && row.id !== iosCategoryId);
257
+ next.push({
258
+ id: iosCategoryId,
259
+ actions: buttons.map((button) => ({ id: button.id, title: button.text })),
260
+ });
261
+ await notifee.setNotificationCategories(next);
262
+ }
263
+ await notifee.displayNotification({
264
+ id: String(javaHash(data.msgeraMessageId || event.title || 'msgera')),
265
+ title: event.title,
266
+ body: event.body,
267
+ data: message.data,
268
+ android: {
269
+ channelId,
270
+ smallIcon: data.smallIcon || 'ic_stat_msgera',
271
+ pressAction: { id: 'default' },
272
+ ...(data.groupKey ? { groupId: data.groupKey } : {}),
273
+ ...(data.largeIconUrl ? { largeIcon: data.largeIconUrl } : {}),
274
+ ...(data.accentColor ? { color: data.accentColor } : {}),
275
+ ...(data.imageUrl ? { style: { type: 0, picture: data.imageUrl } } : {}),
276
+ ...(buttons.length
277
+ ? {
278
+ actions: buttons.slice(0, 2).map((button) => ({
279
+ title: button.text,
280
+ pressAction: { id: button.id, launchActivity: 'default' },
281
+ })),
282
+ }
283
+ : {}),
284
+ },
285
+ ios: {
286
+ ...(data.groupKey ? { threadId: data.groupKey } : {}),
287
+ ...(iosCategoryId ? { categoryId: iosCategoryId } : {}),
288
+ },
289
+ });
290
+ }
291
+
292
+ function notifeeClick(type, detail) {
293
+ if (type !== 1 && type !== 2) return;
294
+ const notification = detail?.notification || {};
295
+ const actionId = type === 2 ? detail?.pressAction?.id : null;
296
+ openClick({ data: notification.data || {}, notification }, actionId).catch(() => undefined);
297
+ }
298
+
299
+ function attachNotifeeClicks() {
300
+ let notifee;
301
+ try {
302
+ notifee = require('@notifee/react-native').default;
303
+ } catch {
304
+ return;
305
+ }
306
+ if (attachNotifeeClicks.done) return;
307
+ attachNotifeeClicks.done = true;
308
+ notifee.onForegroundEvent(({ type, detail }) => notifeeClick(type, detail));
309
+ notifee.getInitialNotification().then((initial) => {
310
+ if (!initial?.notification) return;
311
+ const actionId = initial.pressAction?.id;
312
+ openClick(
313
+ { data: initial.notification.data || {}, notification: initial.notification },
314
+ actionId,
315
+ ).catch(() => undefined);
316
+ }).catch(() => undefined);
317
+ }
318
+
319
+ function registerBackgroundHandler() {
320
+ setBackgroundMessageHandler(getMessaging(), async (message) => {
321
+ await report(message?.data?.msgeraMessageId, 'received');
322
+ });
323
+ try {
324
+ const notifee = require('@notifee/react-native').default;
325
+ notifee.onBackgroundEvent(async ({ type, detail }) => {
326
+ notifeeClick(type, detail);
327
+ });
328
+ } catch {
329
+ /* notifee is optional */
330
+ }
331
+ }
332
+
333
+ async function init(appIdOrOptions, apiBase = DEFAULT_API_BASE) {
334
+ let resolvedAppId = appIdOrOptions;
335
+ let resolvedApiBase = apiBase || DEFAULT_API_BASE;
336
+ if (appIdOrOptions && typeof appIdOrOptions === 'object') {
337
+ resolvedAppId = appIdOrOptions.appId;
338
+ resolvedApiBase = appIdOrOptions.apiBase || DEFAULT_API_BASE;
339
+ }
340
+ config = {
341
+ appId: resolvedAppId,
342
+ apiBase: resolvedApiBase,
343
+ subscriberId: config?.subscriberId || null,
344
+ token: config?.token || null,
345
+ tags: config?.tags || {},
346
+ userHash: config?.userHash,
347
+ };
348
+ logVerbose(`Initialized MsgeraPush v${SDK_VERSION} for app ${resolvedAppId}`);
349
+ if (networkAllowed()) syncChannels().catch((err) => logError('Channel sync failed', err));
350
+ // Fetching a token asks for permission on its own. Init must not do that.
351
+ await setAutoInitEnabled(getMessaging(), true);
352
+ onTokenRefresh(getMessaging(), (token) => {
353
+ register(token).catch((err) => logError('Token refresh failed', err));
354
+ });
355
+ onMessage(getMessaging(), async (message) => {
356
+ await showForeground(message);
357
+ await report(message?.data?.msgeraMessageId, 'received');
358
+ });
359
+ onNotificationOpenedApp(getMessaging(), (message) => {
360
+ openClick(message).catch(() => undefined);
361
+ });
362
+ attachNotifeeClicks();
363
+ const initial = await getInitialNotification(getMessaging());
364
+ if (initial) await openClick(initial);
365
+ }
366
+
367
+ async function requestPermission(options) {
368
+ const fallbackToSettings =
369
+ typeof options === 'boolean' ? options : Boolean(options && options.fallbackToSettings);
370
+
371
+ if (Platform.OS === 'android' && Platform.Version >= 33) {
372
+ const result = await PermissionsAndroid.request(
373
+ PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
374
+ );
375
+ if (result !== PermissionsAndroid.RESULTS.GRANTED) {
376
+ notifyPermission(false);
377
+ if (fallbackToSettings && result === PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN && Linking.openSettings) {
378
+ await Linking.openSettings().catch(() => undefined);
379
+ }
380
+ return false;
381
+ }
382
+ }
383
+ const status = await requestMessagingPermission(getMessaging());
384
+ const allowed =
385
+ status === AuthorizationStatus.AUTHORIZED ||
386
+ status === AuthorizationStatus.PROVISIONAL;
387
+ notifyPermission(Platform.OS === 'android' ? true : allowed);
388
+ if (!allowed && Platform.OS === 'ios') {
389
+ if (fallbackToSettings && Linking.openSettings) {
390
+ await Linking.openSettings().catch(() => undefined);
391
+ }
392
+ return false;
393
+ }
394
+ await currentToken();
395
+ return true;
396
+ }
397
+
398
+ async function login(externalUserId, userHash) {
399
+ if (!config?.subscriberId) throw new Error('Call requestPermission before login');
400
+ await post(`/subscribers/${config.subscriberId}/login`, {
401
+ externalUserId,
402
+ ...(userHash ? { userHash } : {}),
403
+ });
404
+ if (userHash) config.userHash = userHash;
405
+ }
406
+
407
+ function queueTag(key, value) {
408
+ config.tags = config.tags || {};
409
+ if (value == null) delete config.tags[key];
410
+ else config.tags[key] = value;
411
+ pendingTags[key] = value;
412
+ scheduleTagFlush();
413
+ }
414
+
415
+ function scheduleTagFlush() {
416
+ if (tagTimer) clearTimeout(tagTimer);
417
+ tagTimer = setTimeout(() => {
418
+ tagTimer = null;
419
+ flushTags().catch((error) => logError('Tag update failed', error));
420
+ }, 2000);
421
+ }
422
+
423
+ async function flushTags() {
424
+ if (!networkAllowed() || !config?.subscriberId || !Object.keys(pendingTags).length) return;
425
+ const tags = pendingTags;
426
+ pendingTags = {};
427
+ await request('PATCH', `/subscribers/${config.subscriberId}/tags`, {
428
+ tags,
429
+ ...(config.userHash ? { userHash: config.userHash } : {}),
430
+ });
431
+ }
432
+
433
+ function addClickListener(listener) {
434
+ clickListeners.push(listener);
435
+ const pending = queuedClicks.splice(0, queuedClicks.length);
436
+ pending.forEach((event) => dispatchClick(event));
437
+ }
438
+
439
+ function addForegroundListener(listener) {
440
+ foregroundListeners.push(listener);
441
+ }
442
+
443
+ function addPermissionObserver(listener) {
444
+ permissionObservers.push(listener);
445
+ }
446
+
447
+ function addSubscriptionObserver(listener) {
448
+ subscriptionObservers.push(listener);
449
+ }
450
+
451
+ async function canRequestPermission() {
452
+ try {
453
+ const messaging = require('@react-native-firebase/messaging');
454
+ if (typeof messaging.hasPermission === 'function') {
455
+ const status = await messaging.hasPermission(getMessaging());
456
+ return status !== AuthorizationStatus.DENIED;
457
+ }
458
+ } catch {
459
+ /* older messaging builds only expose the request call */
460
+ }
461
+ if (Platform.OS === 'android' && Platform.Version >= 33) {
462
+ return PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);
463
+ }
464
+ return true;
465
+ }
466
+
467
+ async function optOut() {
468
+ optedIn = false;
469
+ notifySubscription();
470
+ if (config?.subscriberId) {
471
+ await request('PATCH', `/subscribers/${config.subscriberId}/subscription`, { optedIn: false });
472
+ }
473
+ }
474
+
475
+ async function optIn() {
476
+ optedIn = true;
477
+ notifySubscription();
478
+ if (config?.subscriberId) {
479
+ await request('PATCH', `/subscribers/${config.subscriberId}/subscription`, { optedIn: true });
480
+ }
481
+ await currentToken();
482
+ }
483
+
484
+ function addTag(key, value) {
485
+ queueTag(key, value);
486
+ }
487
+ function addTags(tags) {
488
+ Object.keys(tags || {}).forEach((key) => queueTag(key, tags[key]));
489
+ }
490
+ function removeTag(key) {
491
+ queueTag(key, null);
492
+ }
493
+ function removeTags(keys) {
494
+ (keys || []).forEach((key) => queueTag(key, null));
495
+ }
496
+ function getTags() {
497
+ return { ...(config?.tags || {}) };
498
+ }
499
+
500
+ async function setLanguage(language) {
501
+ config.language = language;
502
+ if (!config?.subscriberId) return;
503
+ await request('PATCH', `/subscribers/${config.subscriberId}`, { language });
504
+ }
505
+
506
+ async function trackEvent(name, properties) {
507
+ if (!config?.subscriberId) throw new Error('Call requestPermission before trackEvent');
508
+ await post(`/subscribers/${config.subscriberId}/events`, {
509
+ name,
510
+ ...(properties ? { properties } : {}),
511
+ });
512
+ }
513
+
514
+ function setConsentRequired(required) {
515
+ consentRequired = !!required;
516
+ }
517
+
518
+ function setConsentGiven(given) {
519
+ consentGiven = !!given;
520
+ if (consentGiven) currentToken().catch((error) => logError('Consent flush failed', error));
521
+ }
522
+
523
+ async function deleteDevice() {
524
+ if (config?.subscriberId && networkAllowed()) {
525
+ await request('DELETE', `/subscribers/${config.subscriberId}`, {});
526
+ }
527
+ if (config) {
528
+ config.subscriberId = null;
529
+ config.token = null;
530
+ config.tags = {};
531
+ config.userHash = null;
532
+ }
533
+ pendingTags = {};
534
+ optedIn = true;
535
+ notifySubscription();
536
+ }
537
+
538
+ async function clearAllNotifications() {
539
+ try {
540
+ const notifee = require('@notifee/react-native').default;
541
+ await notifee.cancelAllNotifications();
542
+ } catch {
543
+ /* notifee is optional */
544
+ }
545
+ }
546
+
547
+ async function removeNotification(id) {
548
+ if (Platform.OS !== 'android') return;
549
+ try {
550
+ const notifee = require('@notifee/react-native').default;
551
+ await notifee.cancelNotification(String(id));
552
+ } catch {
553
+ /* notifee is optional */
554
+ }
555
+ }
556
+
557
+ async function logout() {
558
+ if (!config?.subscriberId) return;
559
+ await post(`/subscribers/${config.subscriberId}/logout`, {});
560
+ }
561
+
562
+ const MsgeraPush = {
563
+ SDK_VERSION,
564
+ DEFAULT_API_BASE,
565
+ init,
566
+ requestPermission,
567
+ login,
568
+ logout,
569
+ setLogLevel,
570
+ registerBackgroundHandler,
571
+ pushSubscription,
572
+ addClickListener,
573
+ addForegroundListener,
574
+ addPermissionObserver,
575
+ addSubscriptionObserver,
576
+ canRequestPermission,
577
+ optOut,
578
+ optIn,
579
+ addTag,
580
+ addTags,
581
+ removeTag,
582
+ removeTags,
583
+ getTags,
584
+ setLanguage,
585
+ trackEvent,
586
+ setConsentRequired,
587
+ setConsentGiven,
588
+ deleteDevice,
589
+ clearAllNotifications,
590
+ removeNotification,
591
+ };
592
+
593
+ module.exports = {
594
+ ...MsgeraPush,
595
+ MsgeraPush,
596
+ };