diff --git a/.env.example b/.env.example index b172816..9ce975a 100644 --- a/.env.example +++ b/.env.example @@ -12,5 +12,10 @@ C3NAV_BASE_URL=https://39c3.c3nav.de # For production: wss://sync.rmaps.online NEXT_PUBLIC_SYNC_URL=wss://sync.rmaps.online +# Push Notifications (VAPID keys) +# Generate with: cd sync-server && npx web-push generate-vapid-keys +# The public key must be shared with the client (NEXT_PUBLIC_) +NEXT_PUBLIC_VAPID_PUBLIC_KEY=BNWACJudUOeHEZKEFB-0Wz086nHYsWzj12LqQ7lsUNT38ThtNUoZTJYEH9lttQitCROE2G3Ob71ZUww47yvCDbk + # Analytics (optional) # NEXT_PUBLIC_PLAUSIBLE_DOMAIN=rmaps.online diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..e64bfe3 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,114 @@ +// rMaps Service Worker for Push Notifications +const CACHE_NAME = 'rmaps-v1'; + +// Install event - cache essential assets +self.addEventListener('install', (event) => { + console.log('[SW] Installing service worker...'); + self.skipWaiting(); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + console.log('[SW] Activating service worker...'); + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ); + }) + ); + self.clients.claim(); +}); + +// Push event - handle incoming push notifications +self.addEventListener('push', (event) => { + console.log('[SW] Push received:', event); + + let data = { + title: 'rMaps', + body: 'You have a new notification', + icon: '/icon-192.png', + badge: '/icon-192.png', + tag: 'rmaps-notification', + data: {}, + }; + + if (event.data) { + try { + const payload = event.data.json(); + data = { ...data, ...payload }; + } catch (e) { + data.body = event.data.text(); + } + } + + const options = { + body: data.body, + icon: data.icon || '/icon-192.png', + badge: data.badge || '/icon-192.png', + tag: data.tag || 'rmaps-notification', + data: data.data || {}, + vibrate: [100, 50, 100], + actions: data.actions || [], + requireInteraction: data.requireInteraction || false, + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +// Notification click event - handle user interaction +self.addEventListener('notificationclick', (event) => { + console.log('[SW] Notification clicked:', event); + + event.notification.close(); + + const data = event.notification.data || {}; + let targetUrl = '/'; + + // Determine URL based on notification type + if (data.roomSlug) { + targetUrl = `/${data.roomSlug}`; + } else if (data.url) { + targetUrl = data.url; + } + + // Handle action buttons + if (event.action === 'view') { + targetUrl = data.url || targetUrl; + } else if (event.action === 'dismiss') { + return; + } + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + // Try to focus an existing window + for (const client of clientList) { + if (client.url.includes(targetUrl) && 'focus' in client) { + return client.focus(); + } + } + // Open a new window if none exists + if (clients.openWindow) { + return clients.openWindow(targetUrl); + } + }) + ); +}); + +// Handle notification close +self.addEventListener('notificationclose', (event) => { + console.log('[SW] Notification closed:', event); +}); + +// Handle messages from the main thread +self.addEventListener('message', (event) => { + console.log('[SW] Message received:', event.data); + + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index 2429191..14ba3b8 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -258,6 +258,7 @@ export default function RoomPage() { onToggleSharing={handleToggleSharing} onShare={() => setShowShare(true)} onToggleParticipants={() => setShowParticipants(!showParticipants)} + syncUrl={process.env.NEXT_PUBLIC_SYNC_URL} /> {/* Main Content */} diff --git a/src/components/room/NotificationToggle.tsx b/src/components/room/NotificationToggle.tsx new file mode 100644 index 0000000..253cfb8 --- /dev/null +++ b/src/components/room/NotificationToggle.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useState } from 'react'; +import { usePushNotifications } from '@/hooks/usePushNotifications'; + +interface NotificationToggleProps { + roomSlug: string; + syncUrl?: string; +} + +export default function NotificationToggle({ roomSlug, syncUrl }: NotificationToggleProps) { + const { + isSupported, + isSubscribed, + isLoading, + permission, + error, + toggleSubscription, + } = usePushNotifications({ roomSlug, syncUrl }); + + const [showTooltip, setShowTooltip] = useState(false); + + if (!isSupported) { + return null; // Don't show if not supported + } + + const handleClick = async () => { + try { + await toggleSubscription(); + } catch (err) { + console.error('Failed to toggle notifications:', err); + } + }; + + const getStatusText = () => { + if (isLoading) return 'Loading...'; + if (error) return error; + if (permission === 'denied') return 'Notifications blocked'; + if (isSubscribed) return 'Notifications on'; + return 'Enable notifications'; + }; + + const getIcon = () => { + if (isSubscribed) { + return ( + + ); + } + return ( + + ); + }; + + return ( +