@goliapkg/sentori-react-native 2.1.0 → 3.0.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.
Files changed (50) hide show
  1. package/android/build.gradle +12 -0
  2. package/android/src/main/AndroidManifest.xml +24 -1
  3. package/android/src/main/java/com/sentori/SentoriFirebaseMessagingService.kt +56 -0
  4. package/android/src/main/java/com/sentori/SentoriModule.kt +43 -0
  5. package/android/src/main/java/com/sentori/SentoriPushNotifications.kt +293 -0
  6. package/ios/SentoriModule.swift +39 -0
  7. package/ios/SentoriPushNotifications.swift +303 -0
  8. package/lib/capture.d.ts +6 -1
  9. package/lib/capture.d.ts.map +1 -1
  10. package/lib/capture.js +49 -2
  11. package/lib/capture.js.map +1 -1
  12. package/lib/compat/sentry.d.ts +14 -0
  13. package/lib/compat/sentry.d.ts.map +1 -1
  14. package/lib/compat/sentry.js +6 -0
  15. package/lib/compat/sentry.js.map +1 -1
  16. package/lib/config.d.ts +12 -18
  17. package/lib/config.d.ts.map +1 -1
  18. package/lib/config.js.map +1 -1
  19. package/lib/expo-compat.d.ts +270 -0
  20. package/lib/expo-compat.d.ts.map +1 -0
  21. package/lib/expo-compat.js +500 -0
  22. package/lib/expo-compat.js.map +1 -0
  23. package/lib/index.d.ts +17 -0
  24. package/lib/index.d.ts.map +1 -1
  25. package/lib/index.js +18 -0
  26. package/lib/index.js.map +1 -1
  27. package/lib/init.d.ts +7 -0
  28. package/lib/init.d.ts.map +1 -1
  29. package/lib/init.js +3 -0
  30. package/lib/init.js.map +1 -1
  31. package/lib/native.d.ts +17 -0
  32. package/lib/native.d.ts.map +1 -1
  33. package/lib/native.js +57 -0
  34. package/lib/native.js.map +1 -1
  35. package/lib/push.d.ts +58 -0
  36. package/lib/push.d.ts.map +1 -0
  37. package/lib/push.js +294 -0
  38. package/lib/push.js.map +1 -0
  39. package/package.json +9 -5
  40. package/src/__tests__/before-send.test.ts +72 -0
  41. package/src/__tests__/compat-sentry.test.ts +121 -0
  42. package/src/__tests__/push.test.ts +178 -0
  43. package/src/capture.ts +48 -2
  44. package/src/compat/sentry.ts +8 -0
  45. package/src/config.ts +12 -15
  46. package/src/expo-compat.ts +698 -0
  47. package/src/index.ts +40 -0
  48. package/src/init.ts +10 -0
  49. package/src/native.ts +102 -0
  50. package/src/push.ts +382 -0
@@ -0,0 +1,698 @@
1
+ // v2.18 — `expo-notifications` drop-in shim.
2
+ //
3
+ // Customers coming from `expo-notifications` change ONE line:
4
+ //
5
+ // - import * as Notifications from 'expo-notifications'
6
+ // + import * as Notifications from '@goliapkg/sentori-react-native/expo-compat'
7
+ //
8
+ // …and most of their existing code keeps compiling. The API
9
+ // surface here mirrors expo-notifications' public exports; under the
10
+ // hood every call routes through the same `sentori.push.*` native
11
+ // module the rest of the SDK uses.
12
+ //
13
+ // What works — P0 surface, covered by the existing native module:
14
+ //
15
+ // getPermissionsAsync — non-prompting status read
16
+ // requestPermissionsAsync — OS prompt + permission flow
17
+ // getDevicePushTokenAsync — raw APNs (iOS) / FCM (Android) token
18
+ // getExpoPushTokenAsync — same as device token, wrapped in
19
+ // { type: 'expo', data } shape;
20
+ // NOT a real exp.host token — see
21
+ // recipe for the server-side change
22
+ // addNotificationReceivedListener — foreground notification callback
23
+ // addNotificationResponseReceivedListener — tap callback
24
+ // setNotificationHandler — presentation behaviour hook
25
+ // unregisterForNotificationsAsync — revoke + drop cached handle
26
+ // addPushTokenListener — rebind on token rotation
27
+ // AndroidImportance / IosAuthorizationStatus / DEFAULT_ACTION_IDENTIFIER
28
+ //
29
+ // What's not implemented yet (each throws a tagged Error with a
30
+ // pointer at the migration recipe — silent no-ops would be worse):
31
+ //
32
+ // scheduleNotificationAsync + the 7 SchedulableTriggerInputTypes
33
+ // getBadgeCountAsync / setBadgeCountAsync
34
+ // setNotificationChannelAsync + the channel-group CRUD
35
+ // setNotificationCategoryAsync + interactive actions
36
+ // useLastNotificationResponse + getLastNotificationResponseAsync
37
+ // subscribeToTopicAsync / unsubscribeFromTopicAsync
38
+ // registerTaskAsync (background task)
39
+ // getPresentedNotificationsAsync / dismissNotificationAsync
40
+ //
41
+ // The "not implemented" list will close in follow-up minor releases
42
+ // as the native module grows. Anything in expo-notifications that
43
+ // requires native code we don't have today goes here; nothing here
44
+ // is a permanent gap.
45
+
46
+ import { logger } from '@goliapkg/sentori-core'
47
+
48
+ import {
49
+ pushDrainState,
50
+ pushGetStatus,
51
+ pushRegister,
52
+ pushRequestPermission,
53
+ pushUnregister,
54
+ } from './native'
55
+
56
+ // ── Types — match expo-notifications shapes exactly ────────────────
57
+
58
+ export type DevicePushToken = {
59
+ type: 'ios' | 'android'
60
+ data: string
61
+ }
62
+
63
+ export type ExpoPushToken = {
64
+ type: 'expo'
65
+ data: string
66
+ }
67
+
68
+ export type NotificationPermissionsStatus = {
69
+ status: PermissionStatus
70
+ granted: boolean
71
+ expires: PermissionExpiration
72
+ canAskAgain: boolean
73
+ ios?: IosNotificationPermissions
74
+ android?: AndroidNotificationPermissions
75
+ }
76
+
77
+ export type PermissionStatus = 'granted' | 'denied' | 'undetermined'
78
+
79
+ export type PermissionExpiration = 'never' | number
80
+
81
+ export type IosNotificationPermissions = {
82
+ status: IosAuthorizationStatusValue
83
+ allowsAlert: boolean
84
+ allowsBadge: boolean
85
+ allowsSound: boolean
86
+ allowsAnnouncements?: boolean
87
+ allowsCriticalAlerts?: boolean
88
+ allowsDisplayInCarPlay?: boolean
89
+ allowsDisplayInNotificationCenter?: boolean
90
+ allowsDisplayOnLockScreen?: boolean
91
+ allowsPersistentTypes?: boolean
92
+ allowsPreviews?: boolean
93
+ providesAppNotificationSettings?: boolean
94
+ }
95
+
96
+ export type AndroidNotificationPermissions = {
97
+ status: PermissionStatus
98
+ }
99
+
100
+ export type Notification = {
101
+ date: number
102
+ request: NotificationRequest
103
+ }
104
+
105
+ export type NotificationRequest = {
106
+ identifier: string
107
+ content: NotificationContent
108
+ trigger: NotificationTrigger | null
109
+ }
110
+
111
+ export type NotificationContent = {
112
+ title: string | null
113
+ subtitle: string | null
114
+ body: string | null
115
+ data: Record<string, unknown>
116
+ badge?: number | null
117
+ sound?: NotificationSound
118
+ categoryIdentifier?: string | null
119
+ attachments?: NotificationAttachment[]
120
+ /** iOS 15+ */
121
+ interruptionLevel?: 'passive' | 'active' | 'timeSensitive' | 'critical'
122
+ }
123
+
124
+ export type NotificationAttachment = {
125
+ identifier?: string
126
+ url?: string
127
+ type?: string
128
+ hideThumbnail?: boolean
129
+ thumbnailClipArea?: { x: number; y: number; width: number; height: number }
130
+ thumbnailTime?: number
131
+ }
132
+
133
+ export type NotificationSound = boolean | 'default' | 'defaultCritical' | string
134
+
135
+ export type NotificationTrigger = unknown // expo-notifications uses a union; we treat as opaque on the receive side
136
+
137
+ export type NotificationResponse = {
138
+ actionIdentifier: string
139
+ notification: Notification
140
+ userText?: string
141
+ }
142
+
143
+ export type NotificationBehavior = {
144
+ shouldShowBanner: boolean
145
+ shouldShowList: boolean
146
+ shouldPlaySound: boolean
147
+ shouldSetBadge: boolean
148
+ priority?: AndroidNotificationPriorityValue
149
+ }
150
+
151
+ export type NotificationHandler = {
152
+ handleNotification: (notification: Notification) => Promise<NotificationBehavior>
153
+ handleSuccess?: (id: string) => void
154
+ handleError?: (id: string, err: { code: string; message: string }) => void
155
+ }
156
+
157
+ export type EventSubscription = {
158
+ remove: () => void
159
+ }
160
+
161
+ export type PushTokenListener = (token: DevicePushToken) => void
162
+
163
+ // ── Constants — same values as expo-notifications ─────────────────
164
+
165
+ export const DEFAULT_ACTION_IDENTIFIER = 'expo.modules.notifications.actions.DEFAULT'
166
+
167
+ export const AndroidImportance = {
168
+ NONE: 0,
169
+ MIN: 1,
170
+ LOW: 2,
171
+ DEFAULT: 3,
172
+ HIGH: 4,
173
+ MAX: 5,
174
+ } as const
175
+ export type AndroidImportanceValue = (typeof AndroidImportance)[keyof typeof AndroidImportance]
176
+
177
+ export const AndroidNotificationPriority = {
178
+ MIN: 'min',
179
+ LOW: 'low',
180
+ DEFAULT: 'default',
181
+ HIGH: 'high',
182
+ MAX: 'max',
183
+ } as const
184
+ export type AndroidNotificationPriorityValue =
185
+ (typeof AndroidNotificationPriority)[keyof typeof AndroidNotificationPriority]
186
+
187
+ export const AndroidNotificationVisibility = {
188
+ PUBLIC: 1,
189
+ PRIVATE: 0,
190
+ SECRET: -1,
191
+ } as const
192
+
193
+ export const IosAuthorizationStatus = {
194
+ NOT_DETERMINED: 0,
195
+ DENIED: 1,
196
+ AUTHORIZED: 2,
197
+ PROVISIONAL: 3,
198
+ EPHEMERAL: 4,
199
+ } as const
200
+ export type IosAuthorizationStatusValue =
201
+ (typeof IosAuthorizationStatus)[keyof typeof IosAuthorizationStatus]
202
+
203
+ export const IosAlertStyle = {
204
+ NONE: 0,
205
+ BANNER: 1,
206
+ ALERT: 2,
207
+ } as const
208
+
209
+ // 7 SchedulableTriggerInputTypes — re-exported for compile parity
210
+ // even though scheduleNotificationAsync throws. Customers that
211
+ // destructure these from the module won't break their build.
212
+ export const SchedulableTriggerInputTypes = {
213
+ TIME_INTERVAL: 'timeInterval',
214
+ DATE: 'date',
215
+ CALENDAR: 'calendar',
216
+ DAILY: 'daily',
217
+ WEEKLY: 'weekly',
218
+ MONTHLY: 'monthly',
219
+ YEARLY: 'yearly',
220
+ } as const
221
+
222
+ // ── Module state (handler + drain loop + listener registry) ───────
223
+
224
+ const RECEIVED_LISTENERS = new Set<(n: Notification) => void>()
225
+ const RESPONSE_LISTENERS = new Set<(r: NotificationResponse) => void>()
226
+ const TOKEN_LISTENERS = new Set<PushTokenListener>()
227
+
228
+ let _handler: NotificationHandler | null = null
229
+ let _drainInterval: ReturnType<typeof setInterval> | null = null
230
+ let _lastDeviceToken: null | DevicePushToken = null
231
+
232
+ // ── Permissions ───────────────────────────────────────────────────
233
+
234
+ /**
235
+ * Returns the OS-reported notification permission status without
236
+ * prompting. Mirrors `Notifications.getPermissionsAsync()`.
237
+ */
238
+ export async function getPermissionsAsync(): Promise<NotificationPermissionsStatus> {
239
+ const native = await pushGetStatus()
240
+ return coercePermissionStatus(native)
241
+ }
242
+
243
+ export type RequestPermissionsOptions = {
244
+ ios?: {
245
+ allowAlert?: boolean
246
+ allowBadge?: boolean
247
+ allowSound?: boolean
248
+ allowDisplayInCarPlay?: boolean
249
+ allowCriticalAlerts?: boolean
250
+ provideAppNotificationSettings?: boolean
251
+ allowProvisional?: boolean
252
+ allowAnnouncements?: boolean
253
+ }
254
+ android?: Record<string, never>
255
+ }
256
+
257
+ /**
258
+ * Triggers the OS permission prompt the first time, otherwise
259
+ * returns the cached decision. Mirrors
260
+ * `Notifications.requestPermissionsAsync()`.
261
+ *
262
+ * `options.ios.allowProvisional` is accepted for API parity but
263
+ * silently falls back to a regular authorization request — the
264
+ * underlying native module doesn't surface the provisional flag
265
+ * separately yet. Follow up: thread the `provisional` option
266
+ * through `SentoriPushNotifications.requestPermission(...)` on iOS.
267
+ */
268
+ export async function requestPermissionsAsync(
269
+ options?: RequestPermissionsOptions,
270
+ ): Promise<NotificationPermissionsStatus> {
271
+ if (options?.ios?.allowProvisional) {
272
+ logger.debug(
273
+ 'push.expo-compat',
274
+ 'allowProvisional requested — falls back to regular authorization in this release',
275
+ )
276
+ }
277
+ const native = await pushRequestPermission()
278
+ return coercePermissionStatus(native)
279
+ }
280
+
281
+ function coercePermissionStatus(
282
+ native: null | string,
283
+ ): NotificationPermissionsStatus {
284
+ const grantedStatuses = new Set(['granted', 'provisional', 'ephemeral'])
285
+ let status: PermissionStatus
286
+ if (native === 'granted' || native === 'provisional' || native === 'ephemeral') {
287
+ status = 'granted'
288
+ } else if (native === 'denied') {
289
+ status = 'denied'
290
+ } else {
291
+ status = 'undetermined'
292
+ }
293
+ const granted = native != null && grantedStatuses.has(native)
294
+ const iosStatus =
295
+ native === 'granted'
296
+ ? IosAuthorizationStatus.AUTHORIZED
297
+ : native === 'denied'
298
+ ? IosAuthorizationStatus.DENIED
299
+ : native === 'provisional'
300
+ ? IosAuthorizationStatus.PROVISIONAL
301
+ : native === 'ephemeral'
302
+ ? IosAuthorizationStatus.EPHEMERAL
303
+ : IosAuthorizationStatus.NOT_DETERMINED
304
+ return {
305
+ status,
306
+ granted,
307
+ expires: 'never',
308
+ canAskAgain: status === 'undetermined',
309
+ ios: {
310
+ status: iosStatus,
311
+ allowsAlert: granted,
312
+ allowsBadge: granted,
313
+ allowsSound: granted,
314
+ },
315
+ android: { status },
316
+ }
317
+ }
318
+
319
+ // ── Token retrieval ───────────────────────────────────────────────
320
+
321
+ const TOKEN_TIMEOUT_MS = 8000
322
+ const TOKEN_POLL_INTERVAL_MS = 200
323
+
324
+ /**
325
+ * Returns the raw APNs (iOS) or FCM (Android) token. Mirrors
326
+ * `Notifications.getDevicePushTokenAsync()` — for customers who
327
+ * already wire the device token to a custom backend, the migration
328
+ * is literally a one-line import change.
329
+ */
330
+ export async function getDevicePushTokenAsync(): Promise<DevicePushToken> {
331
+ await pushRegister()
332
+ return waitForDeviceToken()
333
+ }
334
+
335
+ /**
336
+ * `expo-notifications`'s `getExpoPushTokenAsync` returns a
337
+ * `ExponentPushToken[...]` string that Expo's exp.host service uses
338
+ * to route to APNs / FCM. We don't run that service — `data` here
339
+ * is the raw native token wrapped in the same envelope shape so
340
+ * destructuring code keeps compiling.
341
+ *
342
+ * **Server-side change required:** instead of POSTing to
343
+ * `https://exp.host/--/api/v2/push/send`, your backend should POST
344
+ * to Sentori's ingest. See the migration recipe.
345
+ */
346
+ export async function getExpoPushTokenAsync(
347
+ _options?: { projectId?: string; experienceId?: string },
348
+ ): Promise<ExpoPushToken> {
349
+ const tok = await getDevicePushTokenAsync()
350
+ return { type: 'expo', data: tok.data }
351
+ }
352
+
353
+ async function waitForDeviceToken(): Promise<DevicePushToken> {
354
+ const start = Date.now()
355
+ while (Date.now() - start < TOKEN_TIMEOUT_MS) {
356
+ const state = await pushDrainState()
357
+ if (state.error) {
358
+ throw new Error(`Push registration failed: ${state.error}`)
359
+ }
360
+ if (state.token) {
361
+ // Forward the buffered events to anyone who's already
362
+ // subscribed — they would otherwise be lost since the drain
363
+ // call here consumed them.
364
+ forwardBufferedEvents(state.notifications, state.taps)
365
+ const tok: DevicePushToken = {
366
+ type: platformOs(),
367
+ data: state.token,
368
+ }
369
+ _lastDeviceToken = tok
370
+ for (const cb of TOKEN_LISTENERS) {
371
+ try {
372
+ cb(tok)
373
+ } catch (e) {
374
+ logger.warn('push.expo-compat', 'pushTokenListener threw', e)
375
+ }
376
+ }
377
+ return tok
378
+ }
379
+ forwardBufferedEvents(state.notifications, state.taps)
380
+ await sleep(TOKEN_POLL_INTERVAL_MS)
381
+ }
382
+ throw new Error(`Push token not received within ${TOKEN_TIMEOUT_MS} ms`)
383
+ }
384
+
385
+ /**
386
+ * Mirrors `Notifications.unregisterForNotificationsAsync()`. Calls
387
+ * the native unregister + stops the drain loop.
388
+ */
389
+ export async function unregisterForNotificationsAsync(): Promise<void> {
390
+ pushUnregister()
391
+ _lastDeviceToken = null
392
+ stopDrainLoop()
393
+ }
394
+
395
+ // ── Listeners ─────────────────────────────────────────────────────
396
+
397
+ /**
398
+ * Foreground notification callback. Mirrors
399
+ * `Notifications.addNotificationReceivedListener()`. The first
400
+ * subscription starts a 1 Hz native-buffer drain loop; the last
401
+ * unsubscribe stops it.
402
+ */
403
+ export function addNotificationReceivedListener(
404
+ listener: (notification: Notification) => void,
405
+ ): EventSubscription {
406
+ RECEIVED_LISTENERS.add(listener)
407
+ ensureDrainLoop()
408
+ return {
409
+ remove: () => {
410
+ RECEIVED_LISTENERS.delete(listener)
411
+ maybeStopDrainLoop()
412
+ },
413
+ }
414
+ }
415
+
416
+ /**
417
+ * User-tapped-a-notification callback. Mirrors
418
+ * `Notifications.addNotificationResponseReceivedListener()`.
419
+ */
420
+ export function addNotificationResponseReceivedListener(
421
+ listener: (response: NotificationResponse) => void,
422
+ ): EventSubscription {
423
+ RESPONSE_LISTENERS.add(listener)
424
+ ensureDrainLoop()
425
+ return {
426
+ remove: () => {
427
+ RESPONSE_LISTENERS.delete(listener)
428
+ maybeStopDrainLoop()
429
+ },
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Token-rotation callback. Mirrors
435
+ * `Notifications.addPushTokenListener()`. Fires once per new token —
436
+ * the native side detects rotation and pushes into the buffer; the
437
+ * shared drain loop forwards it here.
438
+ */
439
+ export function addPushTokenListener(listener: PushTokenListener): EventSubscription {
440
+ TOKEN_LISTENERS.add(listener)
441
+ return {
442
+ remove: () => {
443
+ TOKEN_LISTENERS.delete(listener)
444
+ },
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Presentation behaviour hook. Mirrors
450
+ * `Notifications.setNotificationHandler()`. The handler runs once
451
+ * per foreground notification before the listener fan-out; it can
452
+ * suppress the banner/sound/badge.
453
+ *
454
+ * Today the SDK always shows the system banner because that's the
455
+ * native delegate's hard-coded behaviour. The handler's
456
+ * `handleNotification` is still invoked so customer logic that
457
+ * inspects the notification can run, but the returned
458
+ * `NotificationBehavior` flags don't override presentation yet.
459
+ * Follow up: pipe these flags through
460
+ * `SentoriPushNotifications` so willPresent can return what the
461
+ * handler asked for.
462
+ */
463
+ export function setNotificationHandler(handler: NotificationHandler | null): void {
464
+ _handler = handler
465
+ }
466
+
467
+ // ── Stubs that throw with a pointer at the recipe ─────────────────
468
+
469
+ type ScheduleArgs = { content: unknown; trigger?: unknown; identifier?: string }
470
+ export function scheduleNotificationAsync(_args: ScheduleArgs): never {
471
+ throw mkUnsupported('scheduleNotificationAsync', 'local-scheduling')
472
+ }
473
+ export function cancelScheduledNotificationAsync(_id: string): never {
474
+ throw mkUnsupported('cancelScheduledNotificationAsync', 'local-scheduling')
475
+ }
476
+ export function cancelAllScheduledNotificationsAsync(): never {
477
+ throw mkUnsupported('cancelAllScheduledNotificationsAsync', 'local-scheduling')
478
+ }
479
+ export function getAllScheduledNotificationsAsync(): never {
480
+ throw mkUnsupported('getAllScheduledNotificationsAsync', 'local-scheduling')
481
+ }
482
+ export function getNextTriggerDateAsync(_trigger: unknown): never {
483
+ throw mkUnsupported('getNextTriggerDateAsync', 'local-scheduling')
484
+ }
485
+
486
+ export function setBadgeCountAsync(_count: number, _options?: unknown): never {
487
+ throw mkUnsupported('setBadgeCountAsync', 'badge')
488
+ }
489
+ export function getBadgeCountAsync(): never {
490
+ throw mkUnsupported('getBadgeCountAsync', 'badge')
491
+ }
492
+
493
+ export function setNotificationChannelAsync(_id: string, _channel: unknown): never {
494
+ throw mkUnsupported('setNotificationChannelAsync', 'android-channels')
495
+ }
496
+ export function getNotificationChannelAsync(_id: string): never {
497
+ throw mkUnsupported('getNotificationChannelAsync', 'android-channels')
498
+ }
499
+ export function getNotificationChannelsAsync(): never {
500
+ throw mkUnsupported('getNotificationChannelsAsync', 'android-channels')
501
+ }
502
+ export function deleteNotificationChannelAsync(_id: string): never {
503
+ throw mkUnsupported('deleteNotificationChannelAsync', 'android-channels')
504
+ }
505
+ export function setNotificationChannelGroupAsync(_id: string, _group: unknown): never {
506
+ throw mkUnsupported('setNotificationChannelGroupAsync', 'android-channels')
507
+ }
508
+
509
+ export function setNotificationCategoryAsync(
510
+ _id: string,
511
+ _actions: unknown,
512
+ _options?: unknown,
513
+ ): never {
514
+ throw mkUnsupported('setNotificationCategoryAsync', 'categories')
515
+ }
516
+ export function getNotificationCategoriesAsync(): never {
517
+ throw mkUnsupported('getNotificationCategoriesAsync', 'categories')
518
+ }
519
+ export function deleteNotificationCategoryAsync(_id: string): never {
520
+ throw mkUnsupported('deleteNotificationCategoryAsync', 'categories')
521
+ }
522
+
523
+ export function useLastNotificationResponse(): NotificationResponse | null | undefined {
524
+ // Hook contract: keep it a no-throw hook so the host's React tree
525
+ // still mounts. Returns null. Customers who depend on this for
526
+ // deep-link-from-cold-start should fall back to subscribing on
527
+ // mount and relying on the early-drain.
528
+ return null
529
+ }
530
+ export function getLastNotificationResponseAsync(): Promise<NotificationResponse | null> {
531
+ return Promise.resolve(null)
532
+ }
533
+ export function clearLastNotificationResponseAsync(): Promise<void> {
534
+ return Promise.resolve()
535
+ }
536
+
537
+ export function subscribeToTopicAsync(_topic: string): never {
538
+ throw mkUnsupported('subscribeToTopicAsync', 'topics')
539
+ }
540
+ export function unsubscribeFromTopicAsync(_topic: string): never {
541
+ throw mkUnsupported('unsubscribeFromTopicAsync', 'topics')
542
+ }
543
+
544
+ export function registerTaskAsync(_taskName: string): never {
545
+ throw mkUnsupported('registerTaskAsync', 'background-task')
546
+ }
547
+ export function unregisterTaskAsync(_taskName: string): never {
548
+ throw mkUnsupported('unregisterTaskAsync', 'background-task')
549
+ }
550
+
551
+ export function dismissNotificationAsync(_id: string): never {
552
+ throw mkUnsupported('dismissNotificationAsync', 'dismissal')
553
+ }
554
+ export function dismissAllNotificationsAsync(): never {
555
+ throw mkUnsupported('dismissAllNotificationsAsync', 'dismissal')
556
+ }
557
+ export function getPresentedNotificationsAsync(): never {
558
+ throw mkUnsupported('getPresentedNotificationsAsync', 'dismissal')
559
+ }
560
+
561
+ function mkUnsupported(name: string, slug: string): Error {
562
+ return new Error(
563
+ `${name} is not implemented in @goliapkg/sentori-react-native/expo-compat yet. ` +
564
+ `See the migration recipe at /docs/recipes/migrate-from-expo-notifications/#${slug} ` +
565
+ `for the workaround, or wait for a follow-up minor release where the native module ` +
566
+ `will surface the underlying capability.`,
567
+ )
568
+ }
569
+
570
+ // ── Drain-loop plumbing — pumps native buffer into listeners ──────
571
+
572
+ const DRAIN_INTERVAL_MS = 1000
573
+
574
+ function ensureDrainLoop(): void {
575
+ if (_drainInterval) return
576
+ _drainInterval = setInterval(() => {
577
+ void pumpOnce()
578
+ }, DRAIN_INTERVAL_MS)
579
+ }
580
+
581
+ function maybeStopDrainLoop(): void {
582
+ if (RECEIVED_LISTENERS.size > 0) return
583
+ if (RESPONSE_LISTENERS.size > 0) return
584
+ stopDrainLoop()
585
+ }
586
+
587
+ function stopDrainLoop(): void {
588
+ if (_drainInterval) {
589
+ clearInterval(_drainInterval)
590
+ _drainInterval = null
591
+ }
592
+ }
593
+
594
+ async function pumpOnce(): Promise<void> {
595
+ const state = await pushDrainState()
596
+ forwardBufferedEvents(state.notifications, state.taps)
597
+ }
598
+
599
+ function forwardBufferedEvents(
600
+ rawNotifications: Array<Record<string, unknown>>,
601
+ rawTaps: Array<Record<string, unknown>>,
602
+ ): void {
603
+ for (const raw of rawNotifications) {
604
+ const notification = coerceNotification(raw)
605
+ // Run the optional handler before fan-out (best-effort — we
606
+ // don't currently use its returned NotificationBehavior to
607
+ // gate native presentation).
608
+ if (_handler) {
609
+ try {
610
+ void _handler.handleNotification(notification).catch((err: unknown) => {
611
+ logger.warn('push.expo-compat', 'handleNotification threw', err)
612
+ })
613
+ } catch (e) {
614
+ logger.warn('push.expo-compat', 'setNotificationHandler.handleNotification threw', e)
615
+ }
616
+ }
617
+ for (const cb of RECEIVED_LISTENERS) {
618
+ try {
619
+ cb(notification)
620
+ } catch (e) {
621
+ logger.warn('push.expo-compat', 'notification listener threw', e)
622
+ }
623
+ }
624
+ }
625
+ for (const raw of rawTaps) {
626
+ const notification = coerceNotification(raw)
627
+ const response: NotificationResponse = {
628
+ actionIdentifier: DEFAULT_ACTION_IDENTIFIER,
629
+ notification,
630
+ }
631
+ for (const cb of RESPONSE_LISTENERS) {
632
+ try {
633
+ cb(response)
634
+ } catch (e) {
635
+ logger.warn('push.expo-compat', 'response listener threw', e)
636
+ }
637
+ }
638
+ }
639
+ }
640
+
641
+ function coerceNotification(raw: Record<string, unknown>): Notification {
642
+ const userInfo = (raw.userInfo as Record<string, unknown>) ?? {}
643
+ const id = (raw.id as string | undefined) ?? ''
644
+ const date =
645
+ typeof raw.receivedAt === 'number'
646
+ ? Math.round(raw.receivedAt * 1000)
647
+ : Date.now()
648
+ return {
649
+ date,
650
+ request: {
651
+ identifier: id,
652
+ content: {
653
+ title: (raw.title as string | undefined) ?? null,
654
+ subtitle: (raw.subtitle as string | undefined) ?? null,
655
+ body: (raw.body as string | undefined) ?? null,
656
+ data: userInfo,
657
+ categoryIdentifier: (raw.category as string | undefined) ?? null,
658
+ },
659
+ trigger: null,
660
+ },
661
+ }
662
+ }
663
+
664
+ // ── Tiny platform helpers ─────────────────────────────────────────
665
+
666
+ function platformOs(): 'ios' | 'android' {
667
+ try {
668
+ const rn = require('react-native') as { Platform?: { OS?: string } }
669
+ const os = rn.Platform?.OS
670
+ if (os === 'ios' || os === 'android') return os
671
+ } catch {
672
+ /* unavailable */
673
+ }
674
+ return 'ios'
675
+ }
676
+
677
+ function sleep(ms: number): Promise<void> {
678
+ return new Promise((resolve) => setTimeout(resolve, ms))
679
+ }
680
+
681
+ // ── Test-only helpers ─────────────────────────────────────────────
682
+
683
+ /** Returns the last device token returned by getDevicePushTokenAsync.
684
+ * Used in tests + the migration recipe. */
685
+ export function __getLastDeviceTokenForTests(): null | DevicePushToken {
686
+ return _lastDeviceToken
687
+ }
688
+
689
+ /** Resets module-scoped state. Test-only — do not call from
690
+ * production code. */
691
+ export function __resetForTests(): void {
692
+ RECEIVED_LISTENERS.clear()
693
+ RESPONSE_LISTENERS.clear()
694
+ TOKEN_LISTENERS.clear()
695
+ _handler = null
696
+ _lastDeviceToken = null
697
+ stopDrainLoop()
698
+ }