@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.
- package/android/build.gradle +12 -0
- package/android/src/main/AndroidManifest.xml +24 -1
- package/android/src/main/java/com/sentori/SentoriFirebaseMessagingService.kt +56 -0
- package/android/src/main/java/com/sentori/SentoriModule.kt +43 -0
- package/android/src/main/java/com/sentori/SentoriPushNotifications.kt +293 -0
- package/ios/SentoriModule.swift +39 -0
- package/ios/SentoriPushNotifications.swift +303 -0
- package/lib/capture.d.ts +6 -1
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +49 -2
- package/lib/capture.js.map +1 -1
- package/lib/compat/sentry.d.ts +14 -0
- package/lib/compat/sentry.d.ts.map +1 -1
- package/lib/compat/sentry.js +6 -0
- package/lib/compat/sentry.js.map +1 -1
- package/lib/config.d.ts +12 -18
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js.map +1 -1
- package/lib/expo-compat.d.ts +270 -0
- package/lib/expo-compat.d.ts.map +1 -0
- package/lib/expo-compat.js +500 -0
- package/lib/expo-compat.js.map +1 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +18 -0
- package/lib/index.js.map +1 -1
- package/lib/init.d.ts +7 -0
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +3 -0
- package/lib/init.js.map +1 -1
- package/lib/native.d.ts +17 -0
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +57 -0
- package/lib/native.js.map +1 -1
- package/lib/push.d.ts +58 -0
- package/lib/push.d.ts.map +1 -0
- package/lib/push.js +294 -0
- package/lib/push.js.map +1 -0
- package/package.json +9 -5
- package/src/__tests__/before-send.test.ts +72 -0
- package/src/__tests__/compat-sentry.test.ts +121 -0
- package/src/__tests__/push.test.ts +178 -0
- package/src/capture.ts +48 -2
- package/src/compat/sentry.ts +8 -0
- package/src/config.ts +12 -15
- package/src/expo-compat.ts +698 -0
- package/src/index.ts +40 -0
- package/src/init.ts +10 -0
- package/src/native.ts +102 -0
- 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
|
+
}
|