From dcb6657966e07f0cf4eab777bab32afa952e88cf Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 29 Dec 2025 01:13:16 +0100 Subject: [PATCH] feat: Add PWA push notifications for room events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add service worker (sw.js) for push notification handling - Add usePushNotifications hook for subscription management - Add NotificationToggle component in room header - Update sync server with web-push for sending notifications - Add VAPID keys configuration - Notifications for: friend joins, friend leaves, meeting points set Push notification events: - Friend joins room: "Friend Joined! 👋" - Friend leaves room: "Friend Left" - Meeting point set: "Meeting Point Set! 📍" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .env.example | 5 + public/sw.js | 114 +++++++++ src/app/[slug]/page.tsx | 1 + src/components/room/NotificationToggle.tsx | 95 ++++++++ src/components/room/RoomHeader.tsx | 7 + src/hooks/usePushNotifications.ts | 268 +++++++++++++++++++++ sync-server/docker-compose.yml | 4 + sync-server/package-lock.json | 175 ++++++++++++++ sync-server/package.json | 4 +- sync-server/server.js | 219 ++++++++++++++++- 10 files changed, 888 insertions(+), 4 deletions(-) create mode 100644 public/sw.js create mode 100644 src/components/room/NotificationToggle.tsx create mode 100644 src/hooks/usePushNotifications.ts 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 ( +
+ + + {/* Tooltip */} + {showTooltip && ( +
+ {getStatusText()} +
+
+ )} +
+ ); +} diff --git a/src/components/room/RoomHeader.tsx b/src/components/room/RoomHeader.tsx index aacf9d0..33adc87 100644 --- a/src/components/room/RoomHeader.tsx +++ b/src/components/room/RoomHeader.tsx @@ -1,5 +1,7 @@ 'use client'; +import NotificationToggle from './NotificationToggle'; + interface RoomHeaderProps { roomSlug: string; participantCount: number; @@ -7,6 +9,7 @@ interface RoomHeaderProps { onToggleSharing: () => void; onShare: () => void; onToggleParticipants: () => void; + syncUrl?: string; } export default function RoomHeader({ @@ -16,6 +19,7 @@ export default function RoomHeader({ onToggleSharing, onShare, onToggleParticipants, + syncUrl, }: RoomHeaderProps) { return (
@@ -39,6 +43,9 @@ export default function RoomHeader({ {/* Right: Actions */}
+ {/* Notification toggle */} + + {/* Share button */}