rmaps-online/src/hooks/usePushNotifications.ts

270 lines
7.4 KiB
TypeScript

'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<PushSubscriptionState>({
isSupported: false,
isSubscribed: false,
isLoading: true,
permission: 'default',
error: null,
});
const [subscription, setSubscription] = useState<PushSubscription | null>(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,
};
}