feat: implement complete web push notification system

- Add self-hosted push notifications using VAPID (no external services)
- Create PushSubscription model in database
- Implement server-side push logic with web-push library
- Add notification helpers for list events (items, invitations, collaborators)
- Create NotificationSettings UI component for profile page
- Add usePushNotifications React hook for subscription management
- Create API routes for subscription management and VAPID key
- Update service worker with push event handlers
- Integrate notifications into item actions (add, remove, complete)
- Integrate notifications into sharing actions (invite, accept, remove)
- Add VAPID key generation script
- Add comprehensive documentation and setup guides

Notifications are sent when:
- Items are added/removed/completed in shared lists
- Users are invited to lists
- Invitations are accepted
- Collaborators are removed from lists

Works on iOS 16.4+ (when installed to home screen), Android, and desktop browsers.
This commit is contained in:
2025-12-07 01:22:16 +00:00
parent 625ca40e48
commit 7cda58440f
21 changed files with 2144 additions and 8 deletions

View File

@@ -0,0 +1,170 @@
'use client';
import { useState, useEffect } from 'react';
export function usePushNotifications() {
const [permission, setPermission] = useState<NotificationPermission>('default');
const [isSubscribed, setIsSubscribed] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if ('Notification' in window) {
setPermission(Notification.permission);
checkSubscription();
}
}, []);
const checkSubscription = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
setIsSubscribed(!!subscription);
} catch (err) {
console.error('Failed to check subscription:', err);
}
};
const requestPermission = async () => {
if (!('Notification' in window)) {
setError('Notifications not supported');
return false;
}
try {
const result = await Notification.requestPermission();
setPermission(result);
return result === 'granted';
} catch (err) {
console.error('Failed to request permission:', err);
setError('Failed to request permission');
return false;
}
};
const subscribe = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
setError('Push notifications not supported');
return false;
}
setIsLoading(true);
setError(null);
try {
// Request permission if not granted
if (permission !== 'granted') {
const granted = await requestPermission();
if (!granted) {
setError('Permission denied');
setIsLoading(false);
return false;
}
}
// Get service worker registration
const registration = await navigator.serviceWorker.ready;
// Get VAPID public key from server
const response = await fetch('/api/push/vapid-key');
const { publicKey } = await response.json();
if (!publicKey) {
throw new Error('VAPID public key not available');
}
// Subscribe to push notifications
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
// Send subscription to server
const saveResponse = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription.toJSON()),
});
if (!saveResponse.ok) {
throw new Error('Failed to save subscription');
}
setIsSubscribed(true);
setIsLoading(false);
return true;
} catch (err) {
console.error('Failed to subscribe:', err);
setError(err instanceof Error ? err.message : 'Failed to subscribe');
setIsLoading(false);
return false;
}
};
const unsubscribe = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return false;
}
setIsLoading(true);
setError(null);
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
// Unsubscribe from push manager
await subscription.unsubscribe();
// Remove subscription from server
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
}
setIsSubscribed(false);
setIsLoading(false);
return true;
} catch (err) {
console.error('Failed to unsubscribe:', err);
setError(err instanceof Error ? err.message : 'Failed to unsubscribe');
setIsLoading(false);
return false;
}
};
return {
permission,
isSubscribed,
isLoading,
error,
subscribe,
unsubscribe,
requestPermission,
};
}
// Helper function to convert VAPID 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;
}