270 lines
7.4 KiB
TypeScript
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,
|
|
};
|
|
}
|