@idealyst/notifications 1.2.114

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.
@@ -0,0 +1,571 @@
1
+ // ============================================================================
2
+ // Native Local Notification Implementation
3
+ // Wraps @notifee/react-native for rich local notifications on iOS and Android.
4
+ // ============================================================================
5
+
6
+ import { Platform } from 'react-native';
7
+ import type {
8
+ DisplayNotificationOptions,
9
+ ScheduleNotificationOptions,
10
+ DisplayedNotification,
11
+ PendingNotification,
12
+ AndroidChannel,
13
+ AndroidNotificationStyle,
14
+ NotificationCategory,
15
+ NotificationEvent,
16
+ NotificationEventHandler,
17
+ NotificationImportance,
18
+ } from '../types';
19
+ import { createNotificationError, normalizeNotifeeError } from '../errors';
20
+ import { DEFAULT_CHANNEL_ID } from '../constants';
21
+
22
+ // Graceful optional import
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ let Notifee: any = null;
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ let NotifeeEventType: any = null;
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ let NotifeeTriggerType: any = null;
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ let NotifeeRepeatFrequency: any = null;
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ let NotifeeAndroidImportance: any = null;
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ let NotifeeAndroidVisibility: any = null;
35
+
36
+ try {
37
+ const notifeeModule = require('@notifee/react-native');
38
+ Notifee = notifeeModule.default;
39
+ NotifeeEventType = notifeeModule.EventType;
40
+ NotifeeTriggerType = notifeeModule.TriggerType;
41
+ NotifeeRepeatFrequency = notifeeModule.RepeatFrequency;
42
+ NotifeeAndroidImportance = notifeeModule.AndroidImportance;
43
+ NotifeeAndroidVisibility = notifeeModule.AndroidVisibility;
44
+ } catch {
45
+ // Will degrade gracefully
46
+ }
47
+
48
+ function assertNotifee(): void {
49
+ if (!Notifee) {
50
+ throw createNotificationError(
51
+ 'not_available',
52
+ '@notifee/react-native is not installed. Run: yarn add @notifee/react-native',
53
+ );
54
+ }
55
+ }
56
+
57
+ // ============================================================================
58
+ // Display Functions
59
+ // ============================================================================
60
+
61
+ /**
62
+ * Display a notification immediately.
63
+ * Returns the notification ID.
64
+ */
65
+ export async function displayNotification(
66
+ options: DisplayNotificationOptions,
67
+ ): Promise<string> {
68
+ assertNotifee();
69
+
70
+ try {
71
+ const id = await Notifee.displayNotification(mapToNotifee(options));
72
+ return id;
73
+ } catch (error) {
74
+ throw normalizeNotifeeError(error);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Schedule a notification with a trigger.
80
+ * Returns the notification ID.
81
+ */
82
+ export async function scheduleNotification(
83
+ options: ScheduleNotificationOptions,
84
+ ): Promise<string> {
85
+ assertNotifee();
86
+
87
+ try {
88
+ const trigger = mapTrigger(options.trigger);
89
+ const id = await Notifee.createTriggerNotification(
90
+ mapToNotifee(options),
91
+ trigger,
92
+ );
93
+ return id;
94
+ } catch (error) {
95
+ throw normalizeNotifeeError(error);
96
+ }
97
+ }
98
+
99
+ // ============================================================================
100
+ // Cancel Functions
101
+ // ============================================================================
102
+
103
+ export async function cancelNotification(id: string): Promise<void> {
104
+ assertNotifee();
105
+ try {
106
+ await Notifee.cancelNotification(id);
107
+ } catch (error) {
108
+ throw normalizeNotifeeError(error);
109
+ }
110
+ }
111
+
112
+ export async function cancelAllNotifications(): Promise<void> {
113
+ assertNotifee();
114
+ try {
115
+ await Notifee.cancelAllNotifications();
116
+ } catch (error) {
117
+ throw normalizeNotifeeError(error);
118
+ }
119
+ }
120
+
121
+ export async function cancelDisplayedNotifications(): Promise<void> {
122
+ assertNotifee();
123
+ try {
124
+ await Notifee.cancelDisplayedNotifications();
125
+ } catch (error) {
126
+ throw normalizeNotifeeError(error);
127
+ }
128
+ }
129
+
130
+ export async function cancelPendingNotifications(): Promise<void> {
131
+ assertNotifee();
132
+ try {
133
+ await Notifee.cancelTriggerNotifications();
134
+ } catch (error) {
135
+ throw normalizeNotifeeError(error);
136
+ }
137
+ }
138
+
139
+ // ============================================================================
140
+ // Channels (Android)
141
+ // ============================================================================
142
+
143
+ export async function createChannel(channel: AndroidChannel): Promise<void> {
144
+ assertNotifee();
145
+ if (Platform.OS !== 'android') return;
146
+
147
+ try {
148
+ await Notifee.createChannel({
149
+ id: channel.id,
150
+ name: channel.name,
151
+ description: channel.description,
152
+ importance: mapImportance(channel.importance),
153
+ vibration: channel.vibration,
154
+ vibrationPattern: channel.vibrationPattern,
155
+ lights: channel.lights,
156
+ lightColor: channel.lightColor,
157
+ sound: channel.sound ?? 'default',
158
+ badge: channel.badge,
159
+ visibility: mapVisibility(channel.visibility),
160
+ });
161
+ } catch (error) {
162
+ throw normalizeNotifeeError(error);
163
+ }
164
+ }
165
+
166
+ export async function deleteChannel(channelId: string): Promise<void> {
167
+ assertNotifee();
168
+ if (Platform.OS !== 'android') return;
169
+
170
+ try {
171
+ await Notifee.deleteChannel(channelId);
172
+ } catch (error) {
173
+ throw normalizeNotifeeError(error);
174
+ }
175
+ }
176
+
177
+ export async function getChannels(): Promise<AndroidChannel[]> {
178
+ assertNotifee();
179
+ if (Platform.OS !== 'android') return [];
180
+
181
+ try {
182
+ const channels = await Notifee.getChannels();
183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
184
+ return channels.map((ch: any) => ({
185
+ id: ch.id,
186
+ name: ch.name,
187
+ description: ch.description,
188
+ importance: reverseMapImportance(ch.importance),
189
+ vibration: ch.vibration,
190
+ vibrationPattern: ch.vibrationPattern,
191
+ lights: ch.lights,
192
+ lightColor: ch.lightColor,
193
+ sound: ch.sound,
194
+ badge: ch.badge,
195
+ visibility: reverseMapVisibility(ch.visibility),
196
+ }));
197
+ } catch (error) {
198
+ throw normalizeNotifeeError(error);
199
+ }
200
+ }
201
+
202
+ // ============================================================================
203
+ // Categories (iOS)
204
+ // ============================================================================
205
+
206
+ export async function setCategories(
207
+ categories: NotificationCategory[],
208
+ ): Promise<void> {
209
+ assertNotifee();
210
+ if (Platform.OS !== 'ios') return;
211
+
212
+ try {
213
+ await Notifee.setNotificationCategories(
214
+ categories.map((cat) => ({
215
+ id: cat.id,
216
+ actions: cat.actions.map((action) => ({
217
+ id: action.id,
218
+ title: action.title,
219
+ foreground: action.foreground,
220
+ destructive: action.destructive,
221
+ input: action.input
222
+ ? { placeholderText: action.inputPlaceholder }
223
+ : undefined,
224
+ })),
225
+ })),
226
+ );
227
+ } catch (error) {
228
+ throw normalizeNotifeeError(error);
229
+ }
230
+ }
231
+
232
+ // ============================================================================
233
+ // Queries
234
+ // ============================================================================
235
+
236
+ export async function getDisplayedNotifications(): Promise<DisplayedNotification[]> {
237
+ assertNotifee();
238
+
239
+ try {
240
+ const notifications = await Notifee.getDisplayedNotifications();
241
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
242
+ return notifications.map((n: any) => ({
243
+ id: n.id,
244
+ title: n.notification?.title,
245
+ body: n.notification?.body,
246
+ data: n.notification?.data,
247
+ date: n.date,
248
+ }));
249
+ } catch (error) {
250
+ throw normalizeNotifeeError(error);
251
+ }
252
+ }
253
+
254
+ export async function getPendingNotifications(): Promise<PendingNotification[]> {
255
+ assertNotifee();
256
+
257
+ try {
258
+ const triggers = await Notifee.getTriggerNotifications();
259
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
260
+ return triggers.map((t: any) => ({
261
+ id: t.notification?.id ?? '',
262
+ title: t.notification?.title,
263
+ body: t.notification?.body,
264
+ trigger: {
265
+ type: t.trigger?.type === 0 ? 'timestamp' : 'interval',
266
+ timestamp: t.trigger?.timestamp,
267
+ interval: t.trigger?.interval,
268
+ repeatFrequency: reverseMapRepeatFrequency(t.trigger?.repeatFrequency),
269
+ },
270
+ data: t.notification?.data,
271
+ }));
272
+ } catch (error) {
273
+ throw normalizeNotifeeError(error);
274
+ }
275
+ }
276
+
277
+ // ============================================================================
278
+ // Badge
279
+ // ============================================================================
280
+
281
+ export async function setBadgeCount(count: number): Promise<void> {
282
+ assertNotifee();
283
+ try {
284
+ await Notifee.setBadgeCount(count);
285
+ } catch (error) {
286
+ throw normalizeNotifeeError(error);
287
+ }
288
+ }
289
+
290
+ export async function getBadgeCount(): Promise<number> {
291
+ assertNotifee();
292
+ try {
293
+ return await Notifee.getBadgeCount();
294
+ } catch (error) {
295
+ throw normalizeNotifeeError(error);
296
+ }
297
+ }
298
+
299
+ // ============================================================================
300
+ // Events
301
+ // ============================================================================
302
+
303
+ /**
304
+ * Register a handler for foreground notification events.
305
+ * Returns an unsubscribe function.
306
+ */
307
+ export function onNotificationEvent(handler: NotificationEventHandler): () => void {
308
+ assertNotifee();
309
+
310
+ return Notifee.onForegroundEvent(
311
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
312
+ ({ type, detail }: { type: number; detail: any }) => {
313
+ const event = mapNotifeeEvent(type, detail);
314
+ if (event) handler(event);
315
+ },
316
+ );
317
+ }
318
+
319
+ // ============================================================================
320
+ // Internal Helpers
321
+ // ============================================================================
322
+
323
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
324
+ function mapToNotifee(options: DisplayNotificationOptions): any {
325
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
326
+ const notifeeNotification: any = {
327
+ id: options.id,
328
+ title: options.title,
329
+ body: options.body,
330
+ subtitle: options.subtitle,
331
+ data: options.data,
332
+ };
333
+
334
+ // Android options
335
+ if (Platform.OS === 'android') {
336
+ notifeeNotification.android = {
337
+ channelId: options.android?.channelId ?? DEFAULT_CHANNEL_ID,
338
+ smallIcon: options.android?.smallIcon ?? 'ic_notification',
339
+ largeIcon: options.android?.largeIcon,
340
+ color: options.android?.color,
341
+ importance: options.android?.importance
342
+ ? mapImportance(options.android.importance)
343
+ : undefined,
344
+ ongoing: options.android?.ongoing,
345
+ autoCancel: options.android?.autoCancel ?? true,
346
+ groupId: options.android?.groupId,
347
+ groupSummary: options.android?.groupSummary,
348
+ sound: options.sound ?? 'default',
349
+ pressAction: options.android?.pressAction ?? { id: 'default' },
350
+ };
351
+
352
+ if (options.android?.progress) {
353
+ notifeeNotification.android.progress = options.android.progress;
354
+ }
355
+
356
+ if (options.android?.style) {
357
+ notifeeNotification.android.style = mapAndroidStyle(options.android.style);
358
+ }
359
+
360
+ if (options.android?.actions) {
361
+ notifeeNotification.android.actions = options.android.actions.map(
362
+ (action) => ({
363
+ title: action.title,
364
+ pressAction: { id: action.id },
365
+ icon: action.icon,
366
+ input: action.input
367
+ ? { placeholder: action.inputPlaceholder }
368
+ : undefined,
369
+ }),
370
+ );
371
+ }
372
+
373
+ if (options.imageUrl) {
374
+ notifeeNotification.android.largeIcon =
375
+ notifeeNotification.android.largeIcon ?? options.imageUrl;
376
+ }
377
+ }
378
+
379
+ // iOS options
380
+ if (Platform.OS === 'ios') {
381
+ notifeeNotification.ios = {
382
+ categoryId: options.ios?.categoryId ?? options.categoryId,
383
+ interruptionLevel: options.ios?.interruptionLevel,
384
+ relevanceScore: options.ios?.relevanceScore,
385
+ targetContentId: options.ios?.targetContentId,
386
+ sound: options.sound ?? 'default',
387
+ badgeCount: options.badge,
388
+ threadId: options.threadId,
389
+ };
390
+
391
+ if (options.ios?.attachments) {
392
+ notifeeNotification.ios.attachments = options.ios.attachments.map(
393
+ (att) => ({
394
+ url: att.url,
395
+ id: att.id,
396
+ thumbnailHidden: att.thumbnailHidden,
397
+ }),
398
+ );
399
+ }
400
+ }
401
+
402
+ return notifeeNotification;
403
+ }
404
+
405
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
406
+ function mapTrigger(trigger: ScheduleNotificationOptions['trigger']): any {
407
+ if (trigger.type === 'timestamp') {
408
+ return {
409
+ type: NotifeeTriggerType?.TIMESTAMP ?? 0,
410
+ timestamp: trigger.timestamp,
411
+ repeatFrequency: mapRepeatFrequency(trigger.repeatFrequency),
412
+ alarmManager: trigger.alarmManager
413
+ ? { allowWhileIdle: true }
414
+ : undefined,
415
+ };
416
+ }
417
+
418
+ return {
419
+ type: NotifeeTriggerType?.INTERVAL ?? 1,
420
+ interval: trigger.interval,
421
+ timeUnit: 2, // MINUTES
422
+ };
423
+ }
424
+
425
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
426
+ function mapAndroidStyle(style: AndroidNotificationStyle): any {
427
+ if (!style) return undefined;
428
+
429
+ switch (style.type) {
430
+ case 'bigPicture':
431
+ return { type: 0, picture: style.picture, title: style.title, summary: style.summary };
432
+ case 'bigText':
433
+ return { type: 1, text: style.text, title: style.title, summary: style.summary };
434
+ case 'inbox':
435
+ return { type: 2, lines: style.lines, title: style.title, summary: style.summary };
436
+ case 'messaging':
437
+ return {
438
+ type: 3,
439
+ person: style.person,
440
+ messages: style.messages,
441
+ };
442
+ default:
443
+ return undefined;
444
+ }
445
+ }
446
+
447
+ function mapImportance(importance?: NotificationImportance): number {
448
+ if (!NotifeeAndroidImportance) {
449
+ // Fallback numeric values matching Notifee's AndroidImportance enum
450
+ switch (importance) {
451
+ case 'none': return 0;
452
+ case 'min': return 1;
453
+ case 'low': return 2;
454
+ case 'default': return 3;
455
+ case 'high': return 4;
456
+ case 'max': return 5;
457
+ default: return 3;
458
+ }
459
+ }
460
+
461
+ switch (importance) {
462
+ case 'none': return NotifeeAndroidImportance.NONE;
463
+ case 'min': return NotifeeAndroidImportance.MIN;
464
+ case 'low': return NotifeeAndroidImportance.LOW;
465
+ case 'default': return NotifeeAndroidImportance.DEFAULT;
466
+ case 'high': return NotifeeAndroidImportance.HIGH;
467
+ case 'max': return NotifeeAndroidImportance.MAX;
468
+ default: return NotifeeAndroidImportance.DEFAULT;
469
+ }
470
+ }
471
+
472
+ function reverseMapImportance(importance: number): NotificationImportance {
473
+ switch (importance) {
474
+ case 0: return 'none';
475
+ case 1: return 'min';
476
+ case 2: return 'low';
477
+ case 3: return 'default';
478
+ case 4: return 'high';
479
+ case 5: return 'max';
480
+ default: return 'default';
481
+ }
482
+ }
483
+
484
+ function mapVisibility(visibility?: string): number | undefined {
485
+ if (!visibility) return undefined;
486
+
487
+ if (NotifeeAndroidVisibility) {
488
+ switch (visibility) {
489
+ case 'private': return NotifeeAndroidVisibility.PRIVATE;
490
+ case 'public': return NotifeeAndroidVisibility.PUBLIC;
491
+ case 'secret': return NotifeeAndroidVisibility.SECRET;
492
+ default: return NotifeeAndroidVisibility.PRIVATE;
493
+ }
494
+ }
495
+
496
+ switch (visibility) {
497
+ case 'private': return 0;
498
+ case 'public': return 1;
499
+ case 'secret': return -1;
500
+ default: return 0;
501
+ }
502
+ }
503
+
504
+ function reverseMapVisibility(visibility: number): 'private' | 'public' | 'secret' {
505
+ switch (visibility) {
506
+ case 1: return 'public';
507
+ case -1: return 'secret';
508
+ case 0:
509
+ default: return 'private';
510
+ }
511
+ }
512
+
513
+ function mapRepeatFrequency(frequency?: string): number | undefined {
514
+ if (!frequency || frequency === 'none') return undefined;
515
+
516
+ if (NotifeeRepeatFrequency) {
517
+ switch (frequency) {
518
+ case 'hourly': return NotifeeRepeatFrequency.HOURLY;
519
+ case 'daily': return NotifeeRepeatFrequency.DAILY;
520
+ case 'weekly': return NotifeeRepeatFrequency.WEEKLY;
521
+ default: return undefined;
522
+ }
523
+ }
524
+
525
+ switch (frequency) {
526
+ case 'hourly': return 1;
527
+ case 'daily': return 2;
528
+ case 'weekly': return 3;
529
+ default: return undefined;
530
+ }
531
+ }
532
+
533
+ function reverseMapRepeatFrequency(frequency?: number): 'none' | 'hourly' | 'daily' | 'weekly' {
534
+ switch (frequency) {
535
+ case 1: return 'hourly';
536
+ case 2: return 'daily';
537
+ case 3: return 'weekly';
538
+ default: return 'none';
539
+ }
540
+ }
541
+
542
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
543
+ function mapNotifeeEvent(type: number, detail: any): NotificationEvent | null {
544
+ const notification = detail?.notification;
545
+ if (!notification) return null;
546
+
547
+ const displayed: DisplayedNotification = {
548
+ id: notification.id ?? '',
549
+ title: notification.title,
550
+ body: notification.body,
551
+ data: notification.data,
552
+ };
553
+
554
+ // NotifeeEventType: 1=DISMISSED, 2=PRESS, 3=ACTION_PRESS
555
+ switch (type) {
556
+ case 2: // PRESS
557
+ return { type: 'press', id: displayed.id, notification: displayed };
558
+ case 3: // ACTION_PRESS
559
+ return {
560
+ type: 'action_press',
561
+ id: displayed.id,
562
+ notification: displayed,
563
+ actionId: detail.pressAction?.id,
564
+ input: detail.input,
565
+ };
566
+ case 1: // DISMISSED
567
+ return { type: 'dismissed', id: displayed.id, notification: displayed };
568
+ default:
569
+ return null;
570
+ }
571
+ }