@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,108 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
+ import type {
3
+ UseNotificationPermissionsOptions,
4
+ UseNotificationPermissionsResult,
5
+ PermissionStatus,
6
+ PermissionResult,
7
+ RequestPermissionOptions,
8
+ NotificationError,
9
+ } from '../types';
10
+ import { DEFAULT_PERMISSION_RESULT } from '../constants';
11
+ import { createNotificationError } from '../errors';
12
+
13
+ /**
14
+ * Factory that creates a useNotificationPermissions hook bound to platform-specific functions.
15
+ */
16
+ export function createUseNotificationPermissionsHook(fns: {
17
+ checkPermission: () => Promise<PermissionResult>;
18
+ requestPermission: (options?: RequestPermissionOptions) => Promise<PermissionResult>;
19
+ openNotificationSettings: () => Promise<void>;
20
+ }) {
21
+ return function useNotificationPermissions(
22
+ options: UseNotificationPermissionsOptions = {},
23
+ ): UseNotificationPermissionsResult {
24
+ const { autoCheck = false } = options;
25
+
26
+ const [status, setStatus] = useState<PermissionStatus>('undetermined');
27
+ const [permission, setPermission] = useState<PermissionResult | null>(null);
28
+ const [isLoading, setIsLoading] = useState(false);
29
+ const initializedRef = useRef(false);
30
+ const mountedRef = useRef(true);
31
+
32
+ useEffect(() => {
33
+ mountedRef.current = true;
34
+
35
+ if (autoCheck && !initializedRef.current) {
36
+ initializedRef.current = true;
37
+ fns.checkPermission().then((result) => {
38
+ if (mountedRef.current) {
39
+ setStatus(result.status);
40
+ setPermission(result);
41
+ }
42
+ }).catch(() => {
43
+ // Silently fail on auto-check
44
+ });
45
+ }
46
+
47
+ return () => {
48
+ mountedRef.current = false;
49
+ };
50
+ }, [autoCheck]);
51
+
52
+ const checkPermission = useCallback(async (): Promise<PermissionResult> => {
53
+ setIsLoading(true);
54
+ try {
55
+ const result = await fns.checkPermission();
56
+ if (mountedRef.current) {
57
+ setStatus(result.status);
58
+ setPermission(result);
59
+ }
60
+ return result;
61
+ } catch (error) {
62
+ throw createNotificationError(
63
+ 'unknown',
64
+ error instanceof Error ? error.message : String(error),
65
+ error,
66
+ );
67
+ } finally {
68
+ if (mountedRef.current) setIsLoading(false);
69
+ }
70
+ }, []);
71
+
72
+ const requestPermission = useCallback(
73
+ async (reqOptions?: RequestPermissionOptions): Promise<PermissionResult> => {
74
+ setIsLoading(true);
75
+ try {
76
+ const result = await fns.requestPermission(reqOptions);
77
+ if (mountedRef.current) {
78
+ setStatus(result.status);
79
+ setPermission(result);
80
+ }
81
+ return result;
82
+ } catch (error) {
83
+ throw createNotificationError(
84
+ 'permission_denied',
85
+ error instanceof Error ? error.message : String(error),
86
+ error,
87
+ );
88
+ } finally {
89
+ if (mountedRef.current) setIsLoading(false);
90
+ }
91
+ },
92
+ [],
93
+ );
94
+
95
+ const openSettings = useCallback(async (): Promise<void> => {
96
+ await fns.openNotificationSettings();
97
+ }, []);
98
+
99
+ return {
100
+ status,
101
+ permission,
102
+ isLoading,
103
+ checkPermission,
104
+ requestPermission,
105
+ openSettings,
106
+ };
107
+ };
108
+ }
@@ -0,0 +1,248 @@
1
+ // ============================================================================
2
+ // Native Push Notification Implementation
3
+ // Wraps @react-native-firebase/messaging for FCM (Android) and APNs (iOS).
4
+ //
5
+ // FCM is the only push transport on Android (GCM was deprecated in 2019).
6
+ // On iOS, Firebase Messaging bridges to APNs under the hood.
7
+ // ============================================================================
8
+
9
+ import { Platform } from 'react-native';
10
+ import type {
11
+ PermissionResult,
12
+ RequestPermissionOptions,
13
+ PushToken,
14
+ RemoteMessage,
15
+ MessageHandler,
16
+ TokenRefreshHandler,
17
+ } from '../types';
18
+ import { createNotificationError, normalizeFirebaseError } from '../errors';
19
+
20
+ // Graceful optional import — @react-native-firebase/messaging may not be installed
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ let Messaging: any = null;
23
+ try {
24
+ Messaging = require('@react-native-firebase/messaging').default;
25
+ } catch {
26
+ // Will degrade gracefully when methods are called
27
+ }
28
+
29
+ function assertMessaging(): void {
30
+ if (!Messaging) {
31
+ throw createNotificationError(
32
+ 'not_available',
33
+ '@react-native-firebase/messaging is not installed. Run: yarn add @react-native-firebase/app @react-native-firebase/messaging',
34
+ );
35
+ }
36
+ }
37
+
38
+ // ============================================================================
39
+ // Push Functions
40
+ // ============================================================================
41
+
42
+ /**
43
+ * Request push notification permission.
44
+ * On iOS, prompts the user. On Android 12 and below, this is a no-op (auto-granted).
45
+ * Android 13+ (API 33) POST_NOTIFICATIONS permission should be handled via permissions layer.
46
+ */
47
+ export async function requestPushPermission(
48
+ _options?: RequestPermissionOptions,
49
+ ): Promise<PermissionResult> {
50
+ assertMessaging();
51
+
52
+ try {
53
+ const status = await Messaging().requestPermission();
54
+ return mapAuthStatus(status);
55
+ } catch (error) {
56
+ throw normalizeFirebaseError(error);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Check if push notification permission is granted.
62
+ */
63
+ export async function hasPermission(): Promise<boolean> {
64
+ assertMessaging();
65
+
66
+ try {
67
+ const status = await Messaging().hasPermission();
68
+ // 1 = AUTHORIZED, 2 = PROVISIONAL
69
+ return status === 1 || status === 2;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Get the FCM push token.
77
+ */
78
+ export async function getPushToken(): Promise<PushToken> {
79
+ assertMessaging();
80
+
81
+ try {
82
+ const token = await Messaging().getToken();
83
+ return { token, type: 'fcm' };
84
+ } catch (error) {
85
+ throw normalizeFirebaseError(error);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Delete the FCM push token (unregister).
91
+ */
92
+ export async function deletePushToken(): Promise<void> {
93
+ assertMessaging();
94
+
95
+ try {
96
+ await Messaging().deleteToken();
97
+ } catch (error) {
98
+ throw normalizeFirebaseError(error);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Register a handler for foreground push messages.
104
+ * Returns an unsubscribe function.
105
+ */
106
+ export function onForegroundMessage(handler: MessageHandler): () => void {
107
+ assertMessaging();
108
+
109
+ return Messaging().onMessage((rawMessage: unknown) => {
110
+ const message = normalizeFirebaseMessage(rawMessage, 'foreground');
111
+ handler(message);
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Register a handler for notification open events (when app is in background).
117
+ * Also checks for initial notification (when app was launched from quit state).
118
+ * Returns an unsubscribe function.
119
+ */
120
+ export function onNotificationOpened(handler: MessageHandler): () => void {
121
+ assertMessaging();
122
+
123
+ // Check for initial notification (app opened from quit state)
124
+ Messaging()
125
+ .getInitialNotification()
126
+ .then((rawMessage: unknown) => {
127
+ if (rawMessage) {
128
+ const message = normalizeFirebaseMessage(rawMessage, 'quit');
129
+ handler(message);
130
+ }
131
+ })
132
+ .catch(() => {
133
+ // Silently fail — initial notification check is best-effort
134
+ });
135
+
136
+ // Listen for notification opens while app is in background
137
+ return Messaging().onNotificationOpenedApp((rawMessage: unknown) => {
138
+ const message = normalizeFirebaseMessage(rawMessage, 'background');
139
+ handler(message);
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Register a handler for token refresh events.
145
+ * Returns an unsubscribe function.
146
+ */
147
+ export function onTokenRefresh(handler: TokenRefreshHandler): () => void {
148
+ assertMessaging();
149
+
150
+ return Messaging().onTokenRefresh((newToken: string) => {
151
+ handler({ token: newToken, type: 'fcm' });
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Set background message handler.
157
+ * MUST be called at the top level (outside React tree), typically in index.js.
158
+ */
159
+ export function setBackgroundMessageHandler(handler: MessageHandler): void {
160
+ assertMessaging();
161
+
162
+ Messaging().setBackgroundMessageHandler(async (rawMessage: unknown) => {
163
+ const message = normalizeFirebaseMessage(rawMessage, 'background');
164
+ await handler(message);
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Subscribe to an FCM topic.
170
+ */
171
+ export async function subscribeToTopic(topic: string): Promise<void> {
172
+ assertMessaging();
173
+
174
+ try {
175
+ await Messaging().subscribeToTopic(topic);
176
+ } catch (error) {
177
+ throw normalizeFirebaseError(error);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Unsubscribe from an FCM topic.
183
+ */
184
+ export async function unsubscribeFromTopic(topic: string): Promise<void> {
185
+ assertMessaging();
186
+
187
+ try {
188
+ await Messaging().unsubscribeFromTopic(topic);
189
+ } catch (error) {
190
+ throw normalizeFirebaseError(error);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Get the APNs token (iOS only).
196
+ * Returns null on Android.
197
+ */
198
+ export async function getAPNsToken(): Promise<string | null> {
199
+ if (Platform.OS !== 'ios' || !Messaging) return null;
200
+
201
+ try {
202
+ return await Messaging().getAPNSToken();
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ // ============================================================================
209
+ // Internal Helpers
210
+ // ============================================================================
211
+
212
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
213
+ function mapAuthStatus(status: any): PermissionResult {
214
+ switch (status) {
215
+ case 1: // AUTHORIZED
216
+ return { status: 'granted', canNotify: true };
217
+ case 2: // PROVISIONAL
218
+ return { status: 'provisional', canNotify: true };
219
+ case 0: // DENIED
220
+ return { status: 'denied', canNotify: false };
221
+ case -1: // NOT_DETERMINED
222
+ default:
223
+ return { status: 'undetermined', canNotify: false };
224
+ }
225
+ }
226
+
227
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
228
+ function normalizeFirebaseMessage(raw: any, origin: RemoteMessage['origin']): RemoteMessage {
229
+ return {
230
+ messageId: raw.messageId ?? '',
231
+ data: raw.data ?? {},
232
+ notification: raw.notification
233
+ ? {
234
+ title: raw.notification.title,
235
+ body: raw.notification.body,
236
+ imageUrl:
237
+ raw.notification.android?.imageUrl ??
238
+ raw.notification.ios?.imageUrl ??
239
+ raw.notification.imageUrl,
240
+ }
241
+ : undefined,
242
+ topic: raw.topic,
243
+ collapseKey: raw.collapseKey,
244
+ sentTime: raw.sentTime,
245
+ ttl: raw.ttl,
246
+ origin,
247
+ };
248
+ }
@@ -0,0 +1,250 @@
1
+ import type {
2
+ PermissionResult,
3
+ RequestPermissionOptions,
4
+ PushToken,
5
+ RemoteMessage,
6
+ MessageHandler,
7
+ TokenRefreshHandler,
8
+ WebPushConfig,
9
+ } from '../types';
10
+ import { DEFAULT_SERVICE_WORKER_PATH } from '../constants';
11
+ import { createNotificationError } from '../errors';
12
+
13
+ // ============================================================================
14
+ // Configuration
15
+ // ============================================================================
16
+
17
+ let _config: WebPushConfig | null = null;
18
+ let _swRegistration: ServiceWorkerRegistration | null = null;
19
+ let _subscription: PushSubscription | null = null;
20
+ let _foregroundHandler: MessageHandler | null = null;
21
+ let _openedHandler: MessageHandler | null = null;
22
+ let _tokenRefreshHandler: TokenRefreshHandler | null = null;
23
+
24
+ /**
25
+ * Configure web push with VAPID key and service worker path.
26
+ * Must be called before requesting a push token.
27
+ */
28
+ export function configurePush(config: WebPushConfig): void {
29
+ _config = config;
30
+ }
31
+
32
+ // ============================================================================
33
+ // Push Functions
34
+ // ============================================================================
35
+
36
+ /**
37
+ * Request push notification permission.
38
+ */
39
+ export async function requestPushPermission(
40
+ _options?: RequestPermissionOptions,
41
+ ): Promise<PermissionResult> {
42
+ if (typeof Notification === 'undefined') {
43
+ throw createNotificationError(
44
+ 'not_supported',
45
+ 'Notifications are not supported in this browser',
46
+ );
47
+ }
48
+
49
+ const permission = await Notification.requestPermission();
50
+ return {
51
+ status: permission === 'granted' ? 'granted' : permission === 'denied' ? 'denied' : 'undetermined',
52
+ canNotify: permission === 'granted',
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Check if push notification permission is granted.
58
+ */
59
+ export async function hasPermission(): Promise<boolean> {
60
+ if (typeof Notification === 'undefined') return false;
61
+ return Notification.permission === 'granted';
62
+ }
63
+
64
+ /**
65
+ * Get push token (PushSubscription) from the browser.
66
+ * Requires configurePush() to have been called with a VAPID key.
67
+ */
68
+ export async function getPushToken(): Promise<PushToken> {
69
+ if (!_config?.vapidKey) {
70
+ throw createNotificationError(
71
+ 'token_failed',
72
+ 'VAPID key not configured. Call configurePush({ vapidKey }) first.',
73
+ );
74
+ }
75
+
76
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
77
+ throw createNotificationError(
78
+ 'not_supported',
79
+ 'Push notifications are not supported in this browser',
80
+ );
81
+ }
82
+
83
+ try {
84
+ const registration = await navigator.serviceWorker.register(
85
+ _config.serviceWorkerPath ?? DEFAULT_SERVICE_WORKER_PATH,
86
+ );
87
+ await navigator.serviceWorker.ready;
88
+ _swRegistration = registration;
89
+
90
+ const subscription = await registration.pushManager.subscribe({
91
+ userVisibleOnly: true,
92
+ applicationServerKey: urlBase64ToUint8Array(_config.vapidKey),
93
+ });
94
+
95
+ _subscription = subscription;
96
+
97
+ return {
98
+ token: JSON.stringify(subscription.toJSON()),
99
+ type: 'web',
100
+ };
101
+ } catch (error) {
102
+ throw createNotificationError(
103
+ 'token_failed',
104
+ error instanceof Error ? error.message : 'Failed to get push subscription',
105
+ error,
106
+ );
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Delete push token (unsubscribe from push).
112
+ */
113
+ export async function deletePushToken(): Promise<void> {
114
+ if (_subscription) {
115
+ try {
116
+ await _subscription.unsubscribe();
117
+ } catch {
118
+ // Ignore unsubscribe errors
119
+ }
120
+ _subscription = null;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Register a handler for foreground push messages.
126
+ * Returns an unsubscribe function.
127
+ */
128
+ export function onForegroundMessage(handler: MessageHandler): () => void {
129
+ _foregroundHandler = handler;
130
+
131
+ const listener = (event: MessageEvent) => {
132
+ if (event.data?.type === 'push-message' && _foregroundHandler) {
133
+ const message = normalizeWebMessage(event.data, 'foreground');
134
+ _foregroundHandler(message);
135
+ }
136
+ };
137
+
138
+ navigator.serviceWorker?.addEventListener('message', listener);
139
+
140
+ return () => {
141
+ _foregroundHandler = null;
142
+ navigator.serviceWorker?.removeEventListener('message', listener);
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Register a handler for notification open events.
148
+ * Returns an unsubscribe function.
149
+ */
150
+ export function onNotificationOpened(handler: MessageHandler): () => void {
151
+ _openedHandler = handler;
152
+
153
+ const listener = (event: MessageEvent) => {
154
+ if (event.data?.type === 'notification-click' && _openedHandler) {
155
+ const message = normalizeWebMessage(event.data, 'background');
156
+ _openedHandler(message);
157
+ }
158
+ };
159
+
160
+ navigator.serviceWorker?.addEventListener('message', listener);
161
+
162
+ return () => {
163
+ _openedHandler = null;
164
+ navigator.serviceWorker?.removeEventListener('message', listener);
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Register a handler for token refresh events.
170
+ * Returns an unsubscribe function.
171
+ */
172
+ export function onTokenRefresh(handler: TokenRefreshHandler): () => void {
173
+ _tokenRefreshHandler = handler;
174
+
175
+ // Web push subscriptions can change when service worker updates.
176
+ // Listen for pushsubscriptionchange via the service worker.
177
+ const listener = async () => {
178
+ if (_tokenRefreshHandler && _config) {
179
+ try {
180
+ const token = await getPushToken();
181
+ _tokenRefreshHandler(token);
182
+ } catch {
183
+ // Silently fail on token refresh
184
+ }
185
+ }
186
+ };
187
+
188
+ navigator.serviceWorker?.addEventListener('controllerchange', listener);
189
+
190
+ return () => {
191
+ _tokenRefreshHandler = null;
192
+ navigator.serviceWorker?.removeEventListener('controllerchange', listener);
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Set background message handler.
198
+ * On web, background push messages are handled by the Service Worker.
199
+ * This is a no-op — implement background handling in your service worker script.
200
+ */
201
+ export function setBackgroundMessageHandler(_handler: MessageHandler): void {
202
+ // No-op: background messages are handled by the Service Worker.
203
+ }
204
+
205
+ /**
206
+ * Subscribe to a topic.
207
+ * Not supported on web — topic management should be done server-side.
208
+ */
209
+ export async function subscribeToTopic(_topic: string): Promise<void> {
210
+ // No-op: topic subscriptions are managed server-side for web push.
211
+ }
212
+
213
+ /**
214
+ * Unsubscribe from a topic.
215
+ * Not supported on web — topic management should be done server-side.
216
+ */
217
+ export async function unsubscribeFromTopic(_topic: string): Promise<void> {
218
+ // No-op: topic subscriptions are managed server-side for web push.
219
+ }
220
+
221
+ // ============================================================================
222
+ // Internal Helpers
223
+ // ============================================================================
224
+
225
+ function urlBase64ToUint8Array(base64String: string): Uint8Array {
226
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
227
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
228
+ const rawData = atob(base64);
229
+ const outputArray = new Uint8Array(rawData.length);
230
+ for (let i = 0; i < rawData.length; i++) {
231
+ outputArray[i] = rawData.charCodeAt(i);
232
+ }
233
+ return outputArray;
234
+ }
235
+
236
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
237
+ function normalizeWebMessage(data: any, origin: RemoteMessage['origin']): RemoteMessage {
238
+ return {
239
+ messageId: data.messageId ?? data.id ?? String(Date.now()),
240
+ data: data.data ?? {},
241
+ notification: data.notification
242
+ ? {
243
+ title: data.notification.title,
244
+ body: data.notification.body,
245
+ imageUrl: data.notification.image ?? data.notification.imageUrl,
246
+ }
247
+ : undefined,
248
+ origin,
249
+ };
250
+ }