'use client'; import { useState, useEffect, useCallback } from 'react'; interface PushSubscriptionState { isSupported: boolean; isSubscribed: boolean; isLoading: boolean; permission: NotificationPermission | 'default'; error: string | null; } interface UsePushNotificationsOptions { /** Room slug to associate subscription with */ roomSlug?: string; /** Sync server URL for storing subscriptions */ syncUrl?: string; } // VAPID public key - should match the server's public key // Generate with: npx web-push generate-vapid-keys const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || ''; function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding) .replace(/-/g, '+') .replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } export function usePushNotifications(options: UsePushNotificationsOptions = {}) { const { roomSlug, syncUrl } = options; const [state, setState] = useState({ isSupported: false, isSubscribed: false, isLoading: true, permission: 'default', error: null, }); const [subscription, setSubscription] = useState(null); // Check if push notifications are supported useEffect(() => { const checkSupport = async () => { const isSupported = 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; if (!isSupported) { setState((prev) => ({ ...prev, isSupported: false, isLoading: false, error: 'Push notifications are not supported in this browser', })); return; } // Check current permission const permission = Notification.permission; setState((prev) => ({ ...prev, isSupported: true, permission, isLoading: true, })); // Register service worker and check subscription try { const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/', }); console.log('Service worker registered:', registration); // Wait for the service worker to be ready await navigator.serviceWorker.ready; // Check existing subscription const existingSubscription = await registration.pushManager.getSubscription(); if (existingSubscription) { setSubscription(existingSubscription); setState((prev) => ({ ...prev, isSubscribed: true, isLoading: false, })); } else { setState((prev) => ({ ...prev, isSubscribed: false, isLoading: false, })); } } catch (error) { console.error('Service worker registration failed:', error); setState((prev) => ({ ...prev, isLoading: false, error: 'Failed to register service worker', })); } }; checkSupport(); }, []); // Subscribe to push notifications const subscribe = useCallback(async () => { if (!state.isSupported) { throw new Error('Push notifications not supported'); } setState((prev) => ({ ...prev, isLoading: true, error: null })); try { // Request notification permission const permission = await Notification.requestPermission(); setState((prev) => ({ ...prev, permission })); if (permission !== 'granted') { throw new Error('Notification permission denied'); } // Get service worker registration const registration = await navigator.serviceWorker.ready; // Check if VAPID key is configured if (!VAPID_PUBLIC_KEY) { throw new Error('VAPID public key not configured'); } // Subscribe to push notifications const applicationServerKey = urlBase64ToUint8Array(VAPID_PUBLIC_KEY); const pushSubscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: applicationServerKey.buffer as ArrayBuffer, }); console.log('Push subscription created:', pushSubscription); // Send subscription to server if (syncUrl) { const response = await fetch(`${syncUrl.replace('wss://', 'https://').replace('ws://', 'http://')}/push/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ subscription: pushSubscription.toJSON(), roomSlug, }), }); if (!response.ok) { console.warn('Failed to save subscription to server'); } } // Also save to localStorage for persistence localStorage.setItem('rmaps_push_subscription', JSON.stringify(pushSubscription.toJSON())); if (roomSlug) { localStorage.setItem(`rmaps_push_room_${roomSlug}`, 'true'); } setSubscription(pushSubscription); setState((prev) => ({ ...prev, isSubscribed: true, isLoading: false, })); return pushSubscription; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to subscribe'; console.error('Push subscription failed:', error); setState((prev) => ({ ...prev, isLoading: false, error: message, })); throw error; } }, [state.isSupported, syncUrl, roomSlug]); // Unsubscribe from push notifications const unsubscribe = useCallback(async () => { if (!subscription) { return; } setState((prev) => ({ ...prev, isLoading: true, error: null })); try { // Unsubscribe from push manager await subscription.unsubscribe(); // Remove from server if (syncUrl) { try { await fetch(`${syncUrl.replace('wss://', 'https://').replace('ws://', 'http://')}/push/unsubscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ endpoint: subscription.endpoint, roomSlug, }), }); } catch { // Ignore server errors on unsubscribe } } // Remove from localStorage localStorage.removeItem('rmaps_push_subscription'); if (roomSlug) { localStorage.removeItem(`rmaps_push_room_${roomSlug}`); } setSubscription(null); setState((prev) => ({ ...prev, isSubscribed: false, isLoading: false, })); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to unsubscribe'; console.error('Push unsubscribe failed:', error); setState((prev) => ({ ...prev, isLoading: false, error: message, })); throw error; } }, [subscription, syncUrl, roomSlug]); // Toggle subscription const toggleSubscription = useCallback(async () => { if (state.isSubscribed) { await unsubscribe(); } else { await subscribe(); } }, [state.isSubscribed, subscribe, unsubscribe]); return { ...state, subscription, subscribe, unsubscribe, toggleSubscription, }; }