198 lines
6.3 KiB
TypeScript
198 lines
6.3 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
import { ReminderSettings } from '@/lib/storage';
|
|
|
|
const LAST_NOTIFICATION_KEY = 'quittraq_last_notification_date';
|
|
|
|
export type NotificationPermission = 'default' | 'granted' | 'denied';
|
|
|
|
// Helper to convert VAPID key
|
|
function urlBase64ToUint8Array(base64String: string) {
|
|
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 useNotifications(reminderSettings: ReminderSettings) {
|
|
const [permission, setPermission] = useState<NotificationPermission>('default');
|
|
const [isSupported, setIsSupported] = useState(false);
|
|
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null);
|
|
|
|
// Register Service Worker
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window) {
|
|
setIsSupported(true);
|
|
setPermission(Notification.permission as NotificationPermission);
|
|
|
|
navigator.serviceWorker.register('/sw.js')
|
|
.then(registration => {
|
|
console.log('Service Worker registered:', registration);
|
|
setSwRegistration(registration);
|
|
})
|
|
.catch(error => {
|
|
console.error('Service Worker registration failed:', error);
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
const subscribeToPush = useCallback(async () => {
|
|
try {
|
|
const registration = await navigator.serviceWorker.ready;
|
|
if (!registration) {
|
|
throw new Error('Service Worker not ready');
|
|
}
|
|
|
|
let publicVapidKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
|
|
|
// If missing from build-time env, fetch it from runtime API
|
|
if (!publicVapidKey) {
|
|
console.log('VAPID key missing from build-time env, fetching from runtime API...');
|
|
const configRes = await fetch('/api/notifications/config');
|
|
if (configRes.ok) {
|
|
const config = await configRes.json() as { publicKey?: string };
|
|
publicVapidKey = config.publicKey || '';
|
|
}
|
|
}
|
|
|
|
if (!publicVapidKey) {
|
|
throw new Error('Missing VAPID key (neither in build env nor runtime API). Please ensure NEXT_PUBLIC_VAPID_PUBLIC_KEY is set in Cloudflare secrets.');
|
|
}
|
|
|
|
// 1. Ensure we have a subscription
|
|
let subscription = await registration.pushManager.getSubscription();
|
|
|
|
if (!subscription) {
|
|
subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(publicVapidKey)
|
|
});
|
|
console.log('New push notification subscribed');
|
|
} else {
|
|
console.log('Existing push subscription found, syncing with server...');
|
|
}
|
|
|
|
// 2. Refresh it on the backend (ALWAYS)
|
|
const res = await fetch('/api/notifications/subscribe', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ subscription }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json() as { error?: string };
|
|
throw new Error(data.error || 'Failed to sync subscription to server');
|
|
}
|
|
|
|
// Success
|
|
console.log('Subscription synced with server');
|
|
} catch (error) {
|
|
console.error('Failed to subscribe:', error);
|
|
throw error; // Propagate to caller
|
|
}
|
|
}, []);
|
|
|
|
// Request notification permission
|
|
const requestPermission = useCallback(async () => {
|
|
if (!isSupported) return 'denied';
|
|
|
|
try {
|
|
const result = await Notification.requestPermission();
|
|
setPermission(result as NotificationPermission);
|
|
|
|
if (result === 'granted') {
|
|
// Verify we can subscribe and save to backend
|
|
await subscribeToPush();
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Error requesting notification permission:', error);
|
|
alert('Error enabling notifications: ' + (error instanceof Error ? error.message : 'Unknown error'));
|
|
return 'denied';
|
|
}
|
|
}, [isSupported, subscribeToPush]);
|
|
|
|
// Send a notification
|
|
const sendNotification = useCallback(
|
|
(title: string, options?: NotificationOptions) => {
|
|
if (!isSupported || permission !== 'granted') return;
|
|
|
|
try {
|
|
const notification = new Notification(title, {
|
|
icon: '/icon-192.png',
|
|
badge: '/icon-192.png',
|
|
...options,
|
|
});
|
|
|
|
notification.onclick = () => {
|
|
window.focus();
|
|
notification.close();
|
|
};
|
|
|
|
return notification;
|
|
} catch (error) {
|
|
console.error('Error sending notification:', error);
|
|
}
|
|
},
|
|
[isSupported, permission]
|
|
);
|
|
|
|
// Play a "bubble pop" sound using Web Audio API
|
|
const playNotificationSound = useCallback(() => {
|
|
try {
|
|
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
|
if (!AudioContext) return;
|
|
|
|
const ctx = new AudioContext();
|
|
const oscillator = ctx.createOscillator();
|
|
const gainNode = ctx.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(ctx.destination);
|
|
|
|
// Bubble 'pop' effect: sine wave with rapid frequency drop
|
|
oscillator.type = 'sine';
|
|
oscillator.frequency.setValueAtTime(800, ctx.currentTime);
|
|
oscillator.frequency.exponentialRampToValueAtTime(100, ctx.currentTime + 0.1);
|
|
|
|
// Envelope: quick attack, quick decay
|
|
gainNode.gain.setValueAtTime(0, ctx.currentTime);
|
|
gainNode.gain.linearRampToValueAtTime(0.3, ctx.currentTime + 0.01);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
|
|
|
|
oscillator.start();
|
|
oscillator.stop(ctx.currentTime + 0.15);
|
|
} catch (e) {
|
|
console.error('Error playing notification sound:', e);
|
|
}
|
|
}, []);
|
|
|
|
// Track previous settings to detect changes
|
|
const [prevSettings, setPrevSettings] = useState(reminderSettings);
|
|
|
|
// If settings change, we record it
|
|
useEffect(() => {
|
|
if (JSON.stringify(prevSettings) !== JSON.stringify(reminderSettings)) {
|
|
setPrevSettings(reminderSettings);
|
|
}
|
|
}, [reminderSettings, prevSettings]);
|
|
|
|
return {
|
|
isSupported,
|
|
permission,
|
|
requestPermission,
|
|
sendNotification,
|
|
};
|
|
}
|