import { useCallback, useEffect, useRef, useState } from 'react'; import { type FirebaseApp, getApps, initializeApp } from 'firebase/app'; import { type Messaging, deleteToken as fcmDeleteToken, getMessaging, getToken, isSupported, onMessage } from 'firebase/messaging'; import { AuthProvider, useAuthProvider } from 'ra-core'; type NotificationStatus = 'enabled' | 'disabled' | 'pending' | 'error'; /** * PushNotificationConfig contains all the information required to connect the web application * to a Firebase project and enable Firebase Cloud Messaging (FCM) for push notifications. * * All Firebase-related fields are obtained from the Firebase Console. * * How to get these values: * * 1. Go to https://console.firebase.google.com * 2. Select your Firebase project * 3. Click on the gear icon (⚙️) → "Project settings" * 4. Scroll to the "Your apps" section * 5. Register a new Web App () or select an existing one * 6. Firebase will show you a configuration object like: * * const firebaseConfig = { * apiKey: "AIza...", * authDomain: "my-project.firebaseapp.com", * projectId: "my-project", * storageBucket: "my-project.appspot.com", * messagingSenderId: "1234567890", * appId: "1:1234567890:web:abcdef..." * }; * * Map those values to PushNotificationConfig as follows: * * - apiKey -> firebaseConfig.apiKey * - authDomain -> firebaseConfig.authDomain * - projectId -> firebaseConfig.projectId * - storageBucket -> firebaseConfig.storageBucket * - messagingSenderId -> firebaseConfig.messagingSenderId * - appId -> firebaseConfig.appId * - measurementId -> (optional, only if present in Firebase config) * * The vapidKey is required for Web Push authentication and is obtained from: * * Project settings → Cloud Messaging → Web configuration → Web Push certificates → Public key * * Map it as: * * - vapidKey -> the "Public key" shown in the Web Push certificates section * * The apiUrl is the base URL of your backend that exposes the FCM endpoints: * * - POST {apiUrl}/fcm/register → registers the FCM token for the logged user * - DELETE {apiUrl}/fcm/{token} → removes a previously registered token * * Example configuration: * * const config: PushNotificationConfig = { * apiKey: "AIza...", * authDomain: "my-project.firebaseapp.com", * projectId: "my-project", * storageBucket: "my-project.appspot.com", * messagingSenderId: "1234567890", * appId: "1:1234567890:web:abcdef...", * vapidKey: "BD3pR...your-public-vapid-key...", * apiUrl: "https://api.myapp.com" * }; * * This configuration is then passed to the hook: * * usePushNotifications(config) * * Once provided, the hook will: * - Initialize Firebase * - Register the service worker for background notifications * - Obtain and manage the FCM token * - Register/remove the token on the backend * - Enable browser notifications even when the app is closed */ type PushNotificationConfig = { apiKey: string; authDomain: string; projectId: string; storageBucket: string; messagingSenderId: string; appId: string; measurementId?: string; vapidKey: string; apiUrl: string; // backend API url }; async function getFirebaseMessagingInstance(config: PushNotificationConfig): Promise { const supported = await isSupported(); if (!supported) return null; let app: FirebaseApp; if (getApps().length === 0) { app = initializeApp({ apiKey: config.apiKey, authDomain: config.authDomain, projectId: config.projectId, storageBucket: config.storageBucket, messagingSenderId: config.messagingSenderId, appId: config.appId, measurementId: config.measurementId }); } else { app = getApps()[0]; } return getMessaging(app); } /** Garantisce che il SW sia registrato e pronto (fondamentale per background). */ async function ensureServiceWorkerReady(): Promise { // registra (idempotente: se già registrato non fa danni) const reg = await navigator.serviceWorker.register('/firebase-messaging-sw.js'); // aspetta che sia pronto/controllante await navigator.serviceWorker.ready; // in alcuni casi reg.active non è subito disponibile, quindi usiamo ready return reg; } /** Check "realistico": permesso + SW presente. */ async function checkPushEnabled(config: PushNotificationConfig): Promise { if (!('Notification' in window)) return false; if (Notification.permission !== 'granted') return false; const messaging = await getFirebaseMessagingInstance(config); if (!messaging) return false; const reg = await navigator.serviceWorker.getRegistration('/firebase-messaging-sw.js'); if (!reg) return false; // opzionale: verifica token (può creare/aggiornare token) try { const token = await getToken(messaging, { vapidKey: config.vapidKey, serviceWorkerRegistration: reg }); return !!token; } catch { return false; } } async function enablePushNotifications( config: PushNotificationConfig, authProvider: AuthProvider & { getToken: () => Promise } ) { if (!('Notification' in window)) return null; const permission = await Notification.requestPermission(); if (permission !== 'granted') return null; const messaging = await getFirebaseMessagingInstance(config); if (!messaging) return null; const swReg = await ensureServiceWorkerReady(); const token = await getToken(messaging, { vapidKey: config.vapidKey, serviceWorkerRegistration: swReg }); if (!token) return null; await fetch(`${config.apiUrl}/fcm/register`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${await authProvider.getToken()}` }, body: JSON.stringify({ token }) }); return token; } async function disablePushNotifications( config: PushNotificationConfig, authProvider: AuthProvider & { getToken: () => Promise } ) { const messaging = await getFirebaseMessagingInstance(config); if (!messaging) return false; const registration = await navigator.serviceWorker.getRegistration('/firebase-messaging-sw.js'); if (!registration) return true; try { // recupero token corrente const token = await getToken(messaging, { vapidKey: config.vapidKey, serviceWorkerRegistration: registration }); if (token) { await fetch(`${config.apiUrl}/fcm/${encodeURIComponent(token)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${await authProvider.getToken()}` } }); await fcmDeleteToken(messaging); await registration.unregister(); } return true; } catch { return false; } } function listenForegroundMessages( config: PushNotificationConfig, onMsg: (data: { title?: string; message?: string; url?: string }) => void ) { let unsubscribe: (() => void) | null = null; (async () => { const messaging = await getFirebaseMessagingInstance(config); if (!messaging) return; unsubscribe = onMessage(messaging, (payload) => { onMsg({ title: payload?.data?.title, message: payload?.data?.message, url: payload?.data?.resource }); }); })(); return () => { if (unsubscribe) unsubscribe(); }; } /** * usePushNotifications * * React hook for managing push notifications using Firebase Cloud Messaging (FCM). * Allows enabling/disabling push notifications, tracking activation status, * listening to foreground messages, and handling errors. * * Main features: * - Requests user permission and registers the FCM token with the backend * - Disables and deregisters the token and service worker * - Exposes the current notification status ('enabled', 'disabled', 'pending', 'error') * - Allows listening to messages received while the app is in the foreground * * @param {PushNotificationConfig} config - Firebase and backend configuration (see dedicated type) * @returns { * status: NotificationStatus, // current status ('enabled', 'disabled', 'pending', 'error') * error: string | null, // error message if any * enable: () => Promise,// enables notifications (requests permission and registers token) * disable: () => Promise,// disables notifications (deregisters token and SW) * toggle: () => void, // enables/disables based on current status * listen: (onMsg) => () => void // listens for foreground messages, returns cleanup function * } * * @example * const { status, enable, disable, toggle, listen } = usePushNotifications(config); * * useEffect(() => { * const unsubscribe = listen(({ title, message, url }) => { * // Show custom notification or update UI * }); * return unsubscribe; * }, [listen]); * * // In a button: * // */ function usePushNotifications(config: PushNotificationConfig) { const authProvider = useAuthProvider() as AuthProvider & { getToken: () => Promise }; const configRef = useRef(config); configRef.current = config; const [status, setStatus] = useState('pending'); const [error, setError] = useState(null); useEffect(() => { let mounted = true; (async () => { const enabled = await checkPushEnabled(configRef.current); if (mounted) setStatus(enabled ? 'enabled' : 'disabled'); })(); return () => { mounted = false; }; }, []); const enable = useCallback(async () => { setStatus('pending'); setError(null); try { const token = await enablePushNotifications(configRef.current, authProvider); if (token) { setStatus('enabled'); } else { setStatus('disabled'); setError('Permission denied or token unavailable'); } } catch (e: any) { setStatus('error'); setError(e?.message || 'Failed to enable notifications'); } }, [authProvider]); const disable = useCallback(async () => { setStatus('pending'); setError(null); try { const ok = await disablePushNotifications(configRef.current, authProvider); setStatus(ok ? 'disabled' : 'error'); if (!ok) setError('Failed to disable notifications'); } catch (e: any) { setStatus('error'); setError(e?.message || 'Failed to disable notifications'); } }, [authProvider]); const toggle = useCallback(() => { if (status === 'enabled') disable(); else if (status === 'disabled') enable(); }, [status, enable, disable]); const listen = useCallback((onMsg: (data: { title?: string; message?: string; url?: string }) => void) => { return listenForegroundMessages(configRef.current, onMsg); }, []); return { status, error, enable, disable, toggle, listen }; } export type { NotificationStatus, PushNotificationConfig }; export { usePushNotifications };