@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,360 @@
1
+ import type {
2
+ DisplayNotificationOptions,
3
+ ScheduleNotificationOptions,
4
+ DisplayedNotification,
5
+ PendingNotification,
6
+ AndroidChannel,
7
+ NotificationCategory,
8
+ NotificationEvent,
9
+ NotificationEventHandler,
10
+ } from '../types';
11
+ import { createNotificationError } from '../errors';
12
+
13
+ // ============================================================================
14
+ // Internal State
15
+ // ============================================================================
16
+
17
+ interface TrackedNotification {
18
+ id: string;
19
+ notification: Notification;
20
+ options: DisplayNotificationOptions;
21
+ displayedAt: number;
22
+ }
23
+
24
+ interface ScheduledNotification {
25
+ id: string;
26
+ options: ScheduleNotificationOptions;
27
+ timerId: ReturnType<typeof setTimeout>;
28
+ intervalId?: ReturnType<typeof setInterval>;
29
+ }
30
+
31
+ const _displayed = new Map<string, TrackedNotification>();
32
+ const _pending = new Map<string, ScheduledNotification>();
33
+ const _eventHandlers = new Set<NotificationEventHandler>();
34
+ let _badgeCount = 0;
35
+ let _idCounter = 0;
36
+
37
+ function generateId(): string {
38
+ return `notif_${Date.now()}_${++_idCounter}`;
39
+ }
40
+
41
+ // ============================================================================
42
+ // Display Functions
43
+ // ============================================================================
44
+
45
+ /**
46
+ * Display a notification immediately.
47
+ * Returns the notification ID.
48
+ */
49
+ export async function displayNotification(
50
+ options: DisplayNotificationOptions,
51
+ ): Promise<string> {
52
+ if (typeof Notification === 'undefined') {
53
+ throw createNotificationError(
54
+ 'not_supported',
55
+ 'Notifications are not supported in this browser',
56
+ );
57
+ }
58
+
59
+ if (Notification.permission !== 'granted') {
60
+ throw createNotificationError(
61
+ 'permission_denied',
62
+ 'Notification permission not granted',
63
+ );
64
+ }
65
+
66
+ const id = options.id ?? generateId();
67
+
68
+ const notification = new Notification(options.title, {
69
+ body: options.body,
70
+ icon: options.imageUrl,
71
+ tag: id,
72
+ data: { id, ...options.data },
73
+ silent: options.sound === undefined,
74
+ badge: options.imageUrl,
75
+ });
76
+
77
+ const tracked: TrackedNotification = {
78
+ id,
79
+ notification,
80
+ options,
81
+ displayedAt: Date.now(),
82
+ };
83
+
84
+ _displayed.set(id, tracked);
85
+
86
+ notification.onclick = () => {
87
+ emitEvent({
88
+ type: 'press',
89
+ id,
90
+ notification: {
91
+ id,
92
+ title: options.title,
93
+ body: options.body,
94
+ data: options.data,
95
+ date: tracked.displayedAt,
96
+ },
97
+ });
98
+ };
99
+
100
+ notification.onclose = () => {
101
+ _displayed.delete(id);
102
+ emitEvent({
103
+ type: 'dismissed',
104
+ id,
105
+ notification: {
106
+ id,
107
+ title: options.title,
108
+ body: options.body,
109
+ data: options.data,
110
+ date: tracked.displayedAt,
111
+ },
112
+ });
113
+ };
114
+
115
+ return id;
116
+ }
117
+
118
+ /**
119
+ * Schedule a notification.
120
+ * Returns the notification ID.
121
+ *
122
+ * Note: Web scheduling uses setTimeout/setInterval and is lost on page close.
123
+ */
124
+ export async function scheduleNotification(
125
+ options: ScheduleNotificationOptions,
126
+ ): Promise<string> {
127
+ const id = options.id ?? generateId();
128
+ const { trigger } = options;
129
+
130
+ if (trigger.type === 'timestamp' && trigger.timestamp) {
131
+ const delay = Math.max(0, trigger.timestamp - Date.now());
132
+
133
+ if (trigger.repeatFrequency && trigger.repeatFrequency !== 'none') {
134
+ // Initial delay, then repeat
135
+ const intervalMs = getRepeatIntervalMs(trigger.repeatFrequency);
136
+ const timerId = setTimeout(() => {
137
+ displayNotification({ ...options, id });
138
+ const intervalId = setInterval(() => {
139
+ displayNotification({ ...options, id: generateId() });
140
+ }, intervalMs);
141
+ const existing = _pending.get(id);
142
+ if (existing) {
143
+ existing.intervalId = intervalId;
144
+ }
145
+ }, delay);
146
+
147
+ _pending.set(id, { id, options, timerId });
148
+ } else {
149
+ const timerId = setTimeout(() => {
150
+ _pending.delete(id);
151
+ displayNotification({ ...options, id });
152
+ }, delay);
153
+ _pending.set(id, { id, options, timerId });
154
+ }
155
+ } else if (trigger.type === 'interval' && trigger.interval) {
156
+ const intervalMs = trigger.interval * 60 * 1000;
157
+
158
+ if (trigger.repeatFrequency && trigger.repeatFrequency !== 'none') {
159
+ const timerId = setTimeout(() => {
160
+ displayNotification({ ...options, id });
161
+ }, intervalMs);
162
+
163
+ const intervalId = setInterval(() => {
164
+ displayNotification({ ...options, id: generateId() });
165
+ }, intervalMs);
166
+
167
+ _pending.set(id, { id, options, timerId, intervalId });
168
+ } else {
169
+ const timerId = setTimeout(() => {
170
+ _pending.delete(id);
171
+ displayNotification({ ...options, id });
172
+ }, intervalMs);
173
+ _pending.set(id, { id, options, timerId });
174
+ }
175
+ }
176
+
177
+ return id;
178
+ }
179
+
180
+ // ============================================================================
181
+ // Cancel Functions
182
+ // ============================================================================
183
+
184
+ /**
185
+ * Cancel a specific notification by ID.
186
+ */
187
+ export async function cancelNotification(id: string): Promise<void> {
188
+ // Cancel displayed
189
+ const displayed = _displayed.get(id);
190
+ if (displayed) {
191
+ displayed.notification.close();
192
+ _displayed.delete(id);
193
+ }
194
+
195
+ // Cancel pending
196
+ const pending = _pending.get(id);
197
+ if (pending) {
198
+ clearTimeout(pending.timerId);
199
+ if (pending.intervalId) clearInterval(pending.intervalId);
200
+ _pending.delete(id);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Cancel all notifications (displayed + pending).
206
+ */
207
+ export async function cancelAllNotifications(): Promise<void> {
208
+ await cancelDisplayedNotifications();
209
+ await cancelPendingNotifications();
210
+ }
211
+
212
+ /**
213
+ * Cancel all currently displayed notifications.
214
+ */
215
+ export async function cancelDisplayedNotifications(): Promise<void> {
216
+ for (const [id, tracked] of _displayed) {
217
+ tracked.notification.close();
218
+ }
219
+ _displayed.clear();
220
+ }
221
+
222
+ /**
223
+ * Cancel all pending (scheduled) notifications.
224
+ */
225
+ export async function cancelPendingNotifications(): Promise<void> {
226
+ for (const [id, pending] of _pending) {
227
+ clearTimeout(pending.timerId);
228
+ if (pending.intervalId) clearInterval(pending.intervalId);
229
+ }
230
+ _pending.clear();
231
+ }
232
+
233
+ // ============================================================================
234
+ // Channels (Android — no-op on web)
235
+ // ============================================================================
236
+
237
+ export async function createChannel(_channel: AndroidChannel): Promise<void> {
238
+ // No-op: Android notification channels have no web equivalent.
239
+ }
240
+
241
+ export async function deleteChannel(_channelId: string): Promise<void> {
242
+ // No-op
243
+ }
244
+
245
+ export async function getChannels(): Promise<AndroidChannel[]> {
246
+ return [];
247
+ }
248
+
249
+ // ============================================================================
250
+ // Categories (iOS — no-op on web)
251
+ // ============================================================================
252
+
253
+ export async function setCategories(
254
+ _categories: NotificationCategory[],
255
+ ): Promise<void> {
256
+ // No-op: iOS notification categories have no web equivalent.
257
+ }
258
+
259
+ // ============================================================================
260
+ // Queries
261
+ // ============================================================================
262
+
263
+ /**
264
+ * Get all currently displayed notifications.
265
+ */
266
+ export async function getDisplayedNotifications(): Promise<DisplayedNotification[]> {
267
+ return Array.from(_displayed.values()).map((tracked) => ({
268
+ id: tracked.id,
269
+ title: tracked.options.title,
270
+ body: tracked.options.body,
271
+ data: tracked.options.data,
272
+ date: tracked.displayedAt,
273
+ }));
274
+ }
275
+
276
+ /**
277
+ * Get all pending (scheduled) notifications.
278
+ */
279
+ export async function getPendingNotifications(): Promise<PendingNotification[]> {
280
+ return Array.from(_pending.values()).map((pending) => ({
281
+ id: pending.id,
282
+ title: pending.options.title,
283
+ body: pending.options.body,
284
+ trigger: pending.options.trigger,
285
+ data: pending.options.data,
286
+ }));
287
+ }
288
+
289
+ // ============================================================================
290
+ // Badge
291
+ // ============================================================================
292
+
293
+ /**
294
+ * Set the app badge count (PWA Badge API).
295
+ */
296
+ export async function setBadgeCount(count: number): Promise<void> {
297
+ _badgeCount = count;
298
+
299
+ if ('setAppBadge' in navigator) {
300
+ try {
301
+ if (count > 0) {
302
+ await (navigator as Navigator & { setAppBadge: (n: number) => Promise<void> }).setAppBadge(count);
303
+ } else {
304
+ await (navigator as Navigator & { clearAppBadge: () => Promise<void> }).clearAppBadge();
305
+ }
306
+ } catch {
307
+ // Badge API may not be supported or permission may be denied
308
+ }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Get the current app badge count.
314
+ * Returns the last set count (no read API exists on web).
315
+ */
316
+ export async function getBadgeCount(): Promise<number> {
317
+ return _badgeCount;
318
+ }
319
+
320
+ // ============================================================================
321
+ // Events
322
+ // ============================================================================
323
+
324
+ /**
325
+ * Register a handler for notification events (press, dismiss).
326
+ * Returns an unsubscribe function.
327
+ */
328
+ export function onNotificationEvent(handler: NotificationEventHandler): () => void {
329
+ _eventHandlers.add(handler);
330
+ return () => {
331
+ _eventHandlers.delete(handler);
332
+ };
333
+ }
334
+
335
+ // ============================================================================
336
+ // Internal Helpers
337
+ // ============================================================================
338
+
339
+ function emitEvent(event: NotificationEvent): void {
340
+ for (const handler of _eventHandlers) {
341
+ try {
342
+ handler(event);
343
+ } catch {
344
+ // Don't let handler errors break other handlers
345
+ }
346
+ }
347
+ }
348
+
349
+ function getRepeatIntervalMs(frequency: string): number {
350
+ switch (frequency) {
351
+ case 'hourly':
352
+ return 60 * 60 * 1000;
353
+ case 'daily':
354
+ return 24 * 60 * 60 * 1000;
355
+ case 'weekly':
356
+ return 7 * 24 * 60 * 60 * 1000;
357
+ default:
358
+ return 24 * 60 * 60 * 1000;
359
+ }
360
+ }
@@ -0,0 +1,122 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import type {
3
+ UseLocalNotificationsOptions,
4
+ UseLocalNotificationsResult,
5
+ DisplayNotificationOptions,
6
+ ScheduleNotificationOptions,
7
+ AndroidChannel,
8
+ NotificationCategory,
9
+ DisplayedNotification,
10
+ PendingNotification,
11
+ NotificationEventHandler,
12
+ } from '../types';
13
+
14
+ /**
15
+ * Factory that creates a useLocalNotifications hook bound to platform-specific functions.
16
+ * Each platform entry point calls this with the correct implementations.
17
+ */
18
+ export function createUseLocalNotificationsHook(fns: {
19
+ displayNotification: (options: DisplayNotificationOptions) => Promise<string>;
20
+ scheduleNotification: (options: ScheduleNotificationOptions) => Promise<string>;
21
+ cancelNotification: (id: string) => Promise<void>;
22
+ cancelAllNotifications: () => Promise<void>;
23
+ createChannel: (channel: AndroidChannel) => Promise<void>;
24
+ deleteChannel: (channelId: string) => Promise<void>;
25
+ getChannels: () => Promise<AndroidChannel[]>;
26
+ setCategories: (categories: NotificationCategory[]) => Promise<void>;
27
+ getDisplayedNotifications: () => Promise<DisplayedNotification[]>;
28
+ getPendingNotifications: () => Promise<PendingNotification[]>;
29
+ setBadgeCount: (count: number) => Promise<void>;
30
+ getBadgeCount: () => Promise<number>;
31
+ onNotificationEvent: (handler: NotificationEventHandler) => () => void;
32
+ }) {
33
+ return function useLocalNotifications(
34
+ options: UseLocalNotificationsOptions = {},
35
+ ): UseLocalNotificationsResult {
36
+ const { onEvent } = options;
37
+ const mountedRef = useRef(true);
38
+
39
+ useEffect(() => {
40
+ mountedRef.current = true;
41
+ let unsub: (() => void) | undefined;
42
+
43
+ if (onEvent) {
44
+ unsub = fns.onNotificationEvent(onEvent);
45
+ }
46
+
47
+ return () => {
48
+ mountedRef.current = false;
49
+ unsub?.();
50
+ };
51
+ }, [onEvent]);
52
+
53
+ const displayNotification = useCallback(
54
+ (opts: DisplayNotificationOptions) => fns.displayNotification(opts),
55
+ [],
56
+ );
57
+
58
+ const scheduleNotification = useCallback(
59
+ (opts: ScheduleNotificationOptions) => fns.scheduleNotification(opts),
60
+ [],
61
+ );
62
+
63
+ const cancelNotification = useCallback(
64
+ (id: string) => fns.cancelNotification(id),
65
+ [],
66
+ );
67
+
68
+ const cancelAllNotifications = useCallback(
69
+ () => fns.cancelAllNotifications(),
70
+ [],
71
+ );
72
+
73
+ const createChannel = useCallback(
74
+ (channel: AndroidChannel) => fns.createChannel(channel),
75
+ [],
76
+ );
77
+
78
+ const deleteChannel = useCallback(
79
+ (channelId: string) => fns.deleteChannel(channelId),
80
+ [],
81
+ );
82
+
83
+ const getChannels = useCallback(() => fns.getChannels(), []);
84
+
85
+ const setCategories = useCallback(
86
+ (categories: NotificationCategory[]) => fns.setCategories(categories),
87
+ [],
88
+ );
89
+
90
+ const getDisplayedNotifications = useCallback(
91
+ () => fns.getDisplayedNotifications(),
92
+ [],
93
+ );
94
+
95
+ const getPendingNotifications = useCallback(
96
+ () => fns.getPendingNotifications(),
97
+ [],
98
+ );
99
+
100
+ const setBadgeCount = useCallback(
101
+ (count: number) => fns.setBadgeCount(count),
102
+ [],
103
+ );
104
+
105
+ const getBadgeCount = useCallback(() => fns.getBadgeCount(), []);
106
+
107
+ return {
108
+ displayNotification,
109
+ scheduleNotification,
110
+ cancelNotification,
111
+ cancelAllNotifications,
112
+ createChannel,
113
+ deleteChannel,
114
+ getChannels,
115
+ setCategories,
116
+ getDisplayedNotifications,
117
+ getPendingNotifications,
118
+ setBadgeCount,
119
+ getBadgeCount,
120
+ };
121
+ };
122
+ }
@@ -0,0 +1,148 @@
1
+ import { Platform, Linking, PermissionsAndroid } from 'react-native';
2
+ import type { PermissionResult, RequestPermissionOptions } from '../types';
3
+ import { createNotificationError } from '../errors';
4
+
5
+ // Graceful optional imports
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ let Notifee: any = null;
8
+ try {
9
+ Notifee = require('@notifee/react-native').default;
10
+ } catch {
11
+ // Will degrade gracefully
12
+ }
13
+
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ let Messaging: any = null;
16
+ try {
17
+ Messaging = require('@react-native-firebase/messaging').default;
18
+ } catch {
19
+ // Will degrade gracefully
20
+ }
21
+
22
+ /**
23
+ * Check current notification permission without prompting.
24
+ */
25
+ export async function checkPermission(): Promise<PermissionResult> {
26
+ if (Notifee) {
27
+ const settings = await Notifee.getNotificationSettings();
28
+ return mapNotifeeSettings(settings);
29
+ }
30
+
31
+ if (Messaging) {
32
+ const status = await Messaging().hasPermission();
33
+ return mapFirebaseAuthStatus(status);
34
+ }
35
+
36
+ return { status: 'undetermined', canNotify: false };
37
+ }
38
+
39
+ /**
40
+ * Request notification permission from the user.
41
+ */
42
+ export async function requestPermission(
43
+ options?: RequestPermissionOptions,
44
+ ): Promise<PermissionResult> {
45
+ // On Android 13+ (API 33), request the POST_NOTIFICATIONS runtime permission
46
+ if (Platform.OS === 'android' && Platform.Version >= 33) {
47
+ const result = await PermissionsAndroid.request(
48
+ 'android.permission.POST_NOTIFICATIONS' as any,
49
+ );
50
+ if (result === PermissionsAndroid.RESULTS.DENIED) {
51
+ return { status: 'denied', canNotify: false };
52
+ }
53
+ if (result === PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN) {
54
+ return { status: 'blocked', canNotify: false };
55
+ }
56
+ }
57
+
58
+ // Use Notifee for permission request (covers both local + push on iOS)
59
+ if (Notifee) {
60
+ const settings = await Notifee.requestPermission(
61
+ options?.ios
62
+ ? {
63
+ alert: options.ios.alert,
64
+ badge: options.ios.badge,
65
+ sound: options.ios.sound,
66
+ criticalAlert: options.ios.criticalAlert,
67
+ provisional: options.ios.provisional ?? options.provisional,
68
+ carPlay: options.ios.carPlay,
69
+ announcement: options.ios.announcement,
70
+ }
71
+ : options?.provisional
72
+ ? { provisional: true }
73
+ : undefined,
74
+ );
75
+ return mapNotifeeSettings(settings);
76
+ }
77
+
78
+ // Fallback to Firebase Messaging permission request
79
+ if (Messaging) {
80
+ const status = await Messaging().requestPermission();
81
+ return mapFirebaseAuthStatus(status);
82
+ }
83
+
84
+ throw createNotificationError(
85
+ 'not_available',
86
+ 'Neither @notifee/react-native nor @react-native-firebase/messaging is installed',
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Open system notification settings for this app.
92
+ */
93
+ export async function openNotificationSettings(): Promise<void> {
94
+ if (Platform.OS === 'android' && Notifee) {
95
+ await Notifee.openNotificationSettings();
96
+ return;
97
+ }
98
+
99
+ // iOS: open app settings
100
+ await Linking.openSettings();
101
+ }
102
+
103
+ // ============================================================================
104
+ // Internal Helpers
105
+ // ============================================================================
106
+
107
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
+ function mapNotifeeSettings(settings: any): PermissionResult {
109
+ // Notifee AuthorizationStatus: 0=NOT_DETERMINED, 1=DENIED, 2=AUTHORIZED, 3=PROVISIONAL
110
+ const authStatus = settings?.authorizationStatus ?? 0;
111
+ const iosSettings = Platform.OS === 'ios'
112
+ ? {
113
+ alert: Boolean(settings?.ios?.alert),
114
+ badge: Boolean(settings?.ios?.badge),
115
+ sound: Boolean(settings?.ios?.sound),
116
+ criticalAlert: Boolean(settings?.ios?.criticalAlert),
117
+ provisional: authStatus === 3,
118
+ }
119
+ : undefined;
120
+
121
+ switch (authStatus) {
122
+ case 2: // AUTHORIZED
123
+ return { status: 'granted', canNotify: true, ios: iosSettings };
124
+ case 3: // PROVISIONAL
125
+ return { status: 'provisional', canNotify: true, ios: iosSettings };
126
+ case 1: // DENIED
127
+ return { status: 'denied', canNotify: false, ios: iosSettings };
128
+ case 0: // NOT_DETERMINED
129
+ default:
130
+ return { status: 'undetermined', canNotify: false, ios: iosSettings };
131
+ }
132
+ }
133
+
134
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
135
+ function mapFirebaseAuthStatus(status: any): PermissionResult {
136
+ // Firebase AuthorizationStatus: -1=NOT_DETERMINED, 0=DENIED, 1=AUTHORIZED, 2=PROVISIONAL
137
+ switch (status) {
138
+ case 1: // AUTHORIZED
139
+ return { status: 'granted', canNotify: true };
140
+ case 2: // PROVISIONAL
141
+ return { status: 'provisional', canNotify: true };
142
+ case 0: // DENIED
143
+ return { status: 'denied', canNotify: false };
144
+ case -1: // NOT_DETERMINED
145
+ default:
146
+ return { status: 'undetermined', canNotify: false };
147
+ }
148
+ }
@@ -0,0 +1,49 @@
1
+ import type { PermissionResult, RequestPermissionOptions } from '../types';
2
+ import { createNotificationError } from '../errors';
3
+
4
+ function mapWebPermission(permission: NotificationPermission): PermissionResult {
5
+ switch (permission) {
6
+ case 'granted':
7
+ return { status: 'granted', canNotify: true };
8
+ case 'denied':
9
+ return { status: 'denied', canNotify: false };
10
+ case 'default':
11
+ default:
12
+ return { status: 'undetermined', canNotify: false };
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Check current notification permission without prompting.
18
+ */
19
+ export async function checkPermission(): Promise<PermissionResult> {
20
+ if (typeof Notification === 'undefined') {
21
+ return { status: 'denied', canNotify: false };
22
+ }
23
+ return mapWebPermission(Notification.permission);
24
+ }
25
+
26
+ /**
27
+ * Request notification permission from the user.
28
+ */
29
+ export async function requestPermission(
30
+ _options?: RequestPermissionOptions,
31
+ ): Promise<PermissionResult> {
32
+ if (typeof Notification === 'undefined') {
33
+ throw createNotificationError(
34
+ 'not_supported',
35
+ 'Notifications are not supported in this browser',
36
+ );
37
+ }
38
+
39
+ const permission = await Notification.requestPermission();
40
+ return mapWebPermission(permission);
41
+ }
42
+
43
+ /**
44
+ * Open system notification settings.
45
+ * Browsers do not support programmatic settings access — this is a no-op.
46
+ */
47
+ export async function openNotificationSettings(): Promise<void> {
48
+ // Browsers have no API to open notification settings programmatically.
49
+ }