Compare commits
No commits in common. "27e8344e7a324ea43b8e621398fbeb21287188f3" and "8eed0deadf7d4f5690c515456f237bee9a63a1ef" have entirely different histories.
27e8344e7a
...
8eed0deadf
|
|
@ -12,10 +12,5 @@ C3NAV_BASE_URL=https://39c3.c3nav.de
|
||||||
# For production: wss://sync.rmaps.online
|
# For production: wss://sync.rmaps.online
|
||||||
NEXT_PUBLIC_SYNC_URL=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)
|
# Analytics (optional)
|
||||||
# NEXT_PUBLIC_PLAUSIBLE_DOMAIN=rmaps.online
|
# NEXT_PUBLIC_PLAUSIBLE_DOMAIN=rmaps.online
|
||||||
|
|
|
||||||
167
public/sw.js
167
public/sw.js
|
|
@ -1,167 +0,0 @@
|
||||||
// rMaps Service Worker for Push Notifications & Background Location
|
|
||||||
const CACHE_NAME = 'rmaps-v1';
|
|
||||||
const SYNC_TAG = 'rmaps-location-sync';
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Background Sync - triggered when device comes back online
|
|
||||||
self.addEventListener('sync', (event) => {
|
|
||||||
console.log('[SW] Sync event:', event.tag);
|
|
||||||
|
|
||||||
if (event.tag === SYNC_TAG) {
|
|
||||||
event.waitUntil(syncLocation());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync location to server when coming back online
|
|
||||||
async function syncLocation() {
|
|
||||||
try {
|
|
||||||
// Get stored location data from IndexedDB or localStorage via client
|
|
||||||
const clients = await self.clients.matchAll({ type: 'window' });
|
|
||||||
|
|
||||||
for (const client of clients) {
|
|
||||||
// Ask the client to send its current location
|
|
||||||
client.postMessage({ type: 'REQUEST_LOCATION_SYNC' });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[SW] Location sync requested from clients');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[SW] Location sync failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request location from any available client
|
|
||||||
async function requestLocationFromClient() {
|
|
||||||
const clients = await self.clients.matchAll({
|
|
||||||
type: 'window',
|
|
||||||
includeUncontrolled: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (clients.length > 0) {
|
|
||||||
// Send message to first available client to get location
|
|
||||||
clients[0].postMessage({ type: 'REQUEST_LOCATION_UPDATE' });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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: {},
|
|
||||||
silent: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (event.data) {
|
|
||||||
try {
|
|
||||||
const payload = event.data.json();
|
|
||||||
data = { ...data, ...payload };
|
|
||||||
} catch (e) {
|
|
||||||
data.body = event.data.text();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle silent push - request location update without showing notification
|
|
||||||
if (data.silent || data.data?.type === 'location_request') {
|
|
||||||
event.waitUntil(
|
|
||||||
requestLocationFromClient().then((sent) => {
|
|
||||||
console.log('[SW] Silent push - location request sent:', sent);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { useParams, useRouter } from 'next/navigation';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useRoom } from '@/hooks/useRoom';
|
import { useRoom } from '@/hooks/useRoom';
|
||||||
import { useLocationSharing } from '@/hooks/useLocationSharing';
|
import { useLocationSharing } from '@/hooks/useLocationSharing';
|
||||||
import { useServiceWorkerMessages } from '@/hooks/useServiceWorkerMessages';
|
|
||||||
import ParticipantList from '@/components/room/ParticipantList';
|
import ParticipantList from '@/components/room/ParticipantList';
|
||||||
import RoomHeader from '@/components/room/RoomHeader';
|
import RoomHeader from '@/components/room/RoomHeader';
|
||||||
import ShareModal from '@/components/room/ShareModal';
|
import ShareModal from '@/components/room/ShareModal';
|
||||||
|
|
@ -113,31 +112,12 @@ export default function RoomPage() {
|
||||||
currentLocation,
|
currentLocation,
|
||||||
startSharing,
|
startSharing,
|
||||||
stopSharing,
|
stopSharing,
|
||||||
requestUpdate,
|
|
||||||
} = useLocationSharing({
|
} = useLocationSharing({
|
||||||
onLocationUpdate: handleLocationUpdate,
|
onLocationUpdate: handleLocationUpdate,
|
||||||
updateInterval: 5000,
|
updateInterval: 5000,
|
||||||
highAccuracy: true,
|
highAccuracy: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Service worker messages for background location sync
|
|
||||||
useServiceWorkerMessages({
|
|
||||||
onLocationRequest: () => {
|
|
||||||
// Silent push notification requested location update
|
|
||||||
console.log('Service worker requested location update');
|
|
||||||
if (isSharing) {
|
|
||||||
requestUpdate();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLocationSync: () => {
|
|
||||||
// Device came back online, sync location
|
|
||||||
console.log('Service worker requested location sync');
|
|
||||||
if (isSharing) {
|
|
||||||
requestUpdate();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore last known location immediately when connected
|
// Restore last known location immediately when connected
|
||||||
const hasRestoredLocationRef = useRef(false);
|
const hasRestoredLocationRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -278,7 +258,6 @@ export default function RoomPage() {
|
||||||
onToggleSharing={handleToggleSharing}
|
onToggleSharing={handleToggleSharing}
|
||||||
onShare={() => setShowShare(true)}
|
onShare={() => setShowShare(true)}
|
||||||
onToggleParticipants={() => setShowParticipants(!showParticipants)}
|
onToggleParticipants={() => setShowParticipants(!showParticipants)}
|
||||||
syncUrl={process.env.NEXT_PUBLIC_SYNC_URL}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
'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 (
|
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={handleClick}
|
|
||||||
disabled={isLoading || permission === 'denied'}
|
|
||||||
onMouseEnter={() => setShowTooltip(true)}
|
|
||||||
onMouseLeave={() => setShowTooltip(false)}
|
|
||||||
className={`p-2 rounded-lg transition-colors ${
|
|
||||||
isSubscribed
|
|
||||||
? 'bg-rmaps-primary text-white'
|
|
||||||
: 'bg-white/10 text-white/60 hover:bg-white/20 hover:text-white'
|
|
||||||
} ${isLoading ? 'opacity-50 cursor-wait' : ''} ${
|
|
||||||
permission === 'denied' ? 'opacity-30 cursor-not-allowed' : ''
|
|
||||||
}`}
|
|
||||||
title={getStatusText()}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
|
||||||
) : (
|
|
||||||
getIcon()
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
{showTooltip && (
|
|
||||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-black/90 text-white text-xs rounded whitespace-nowrap z-50">
|
|
||||||
{getStatusText()}
|
|
||||||
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-black/90" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import NotificationToggle from './NotificationToggle';
|
|
||||||
|
|
||||||
interface RoomHeaderProps {
|
interface RoomHeaderProps {
|
||||||
roomSlug: string;
|
roomSlug: string;
|
||||||
participantCount: number;
|
participantCount: number;
|
||||||
|
|
@ -9,7 +7,6 @@ interface RoomHeaderProps {
|
||||||
onToggleSharing: () => void;
|
onToggleSharing: () => void;
|
||||||
onShare: () => void;
|
onShare: () => void;
|
||||||
onToggleParticipants: () => void;
|
onToggleParticipants: () => void;
|
||||||
syncUrl?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RoomHeader({
|
export default function RoomHeader({
|
||||||
|
|
@ -19,7 +16,6 @@ export default function RoomHeader({
|
||||||
onToggleSharing,
|
onToggleSharing,
|
||||||
onShare,
|
onShare,
|
||||||
onToggleParticipants,
|
onToggleParticipants,
|
||||||
syncUrl,
|
|
||||||
}: RoomHeaderProps) {
|
}: RoomHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="bg-rmaps-dark/95 backdrop-blur border-b border-white/10 px-4 py-3 flex items-center justify-between z-10">
|
<header className="bg-rmaps-dark/95 backdrop-blur border-b border-white/10 px-4 py-3 flex items-center justify-between z-10">
|
||||||
|
|
@ -43,9 +39,6 @@ export default function RoomHeader({
|
||||||
|
|
||||||
{/* Right: Actions */}
|
{/* Right: Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Notification toggle */}
|
|
||||||
<NotificationToggle roomSlug={roomSlug} syncUrl={syncUrl} />
|
|
||||||
|
|
||||||
{/* Share button */}
|
{/* Share button */}
|
||||||
<button
|
<button
|
||||||
onClick={onShare}
|
onClick={onShare}
|
||||||
|
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
'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 pushSubscription = await registration.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useCallback, useRef } from 'react';
|
|
||||||
|
|
||||||
interface UseServiceWorkerMessagesOptions {
|
|
||||||
onLocationRequest?: () => void;
|
|
||||||
onLocationSync?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to handle messages from the service worker
|
|
||||||
* Used for background location sync and silent push notifications
|
|
||||||
*/
|
|
||||||
export function useServiceWorkerMessages(options: UseServiceWorkerMessagesOptions = {}) {
|
|
||||||
const optionsRef = useRef(options);
|
|
||||||
optionsRef.current = options;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!('serviceWorker' in navigator)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
|
||||||
console.log('[App] Service worker message:', event.data);
|
|
||||||
|
|
||||||
switch (event.data?.type) {
|
|
||||||
case 'REQUEST_LOCATION_UPDATE':
|
|
||||||
// Service worker is requesting a location update (from silent push)
|
|
||||||
optionsRef.current.onLocationRequest?.();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'REQUEST_LOCATION_SYNC':
|
|
||||||
// Service worker wants to sync location (device came back online)
|
|
||||||
optionsRef.current.onLocationSync?.();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
navigator.serviceWorker.addEventListener('message', handleMessage);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
navigator.serviceWorker.removeEventListener('message', handleMessage);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Register for background sync
|
|
||||||
const registerBackgroundSync = useCallback(async () => {
|
|
||||||
if (!('serviceWorker' in navigator) || !('SyncManager' in window)) {
|
|
||||||
console.log('[App] Background sync not supported');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const registration = await navigator.serviceWorker.ready;
|
|
||||||
await registration.sync.register('rmaps-location-sync');
|
|
||||||
console.log('[App] Background sync registered');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[App] Background sync registration failed:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
registerBackgroundSync,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -5,10 +5,6 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
# VAPID keys for push notifications
|
|
||||||
- VAPID_PUBLIC_KEY=BNWACJudUOeHEZKEFB-0Wz086nHYsWzj12LqQ7lsUNT38ThtNUoZTJYEH9lttQitCROE2G3Ob71ZUww47yvCDbk
|
|
||||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-x3yCse1Q4rbZ1XLgnJ1KpSuRlw2ccHDW0fMcKtQ1qcw}
|
|
||||||
- VAPID_SUBJECT=mailto:push@rmaps.online
|
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# HTTP router (redirects to HTTPS via Cloudflare)
|
# HTTP router (redirects to HTTPS via Cloudflare)
|
||||||
|
|
|
||||||
|
|
@ -8,184 +8,9 @@
|
||||||
"name": "rmaps-sync-server",
|
"name": "rmaps-sync-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"web-push": "^3.6.7",
|
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/agent-base": {
|
|
||||||
"version": "7.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
|
||||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/asn1.js": {
|
|
||||||
"version": "5.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
|
||||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bn.js": "^4.0.0",
|
|
||||||
"inherits": "^2.0.1",
|
|
||||||
"minimalistic-assert": "^1.0.0",
|
|
||||||
"safer-buffer": "^2.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bn.js": {
|
|
||||||
"version": "4.12.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
|
||||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/buffer-equal-constant-time": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/debug": {
|
|
||||||
"version": "4.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ecdsa-sig-formatter": {
|
|
||||||
"version": "1.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
|
||||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "^5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/http_ece": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/https-proxy-agent": {
|
|
||||||
"version": "7.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
|
||||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"agent-base": "^7.1.2",
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/inherits": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/jwa": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"buffer-equal-constant-time": "^1.0.1",
|
|
||||||
"ecdsa-sig-formatter": "1.0.11",
|
|
||||||
"safe-buffer": "^5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jws": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"jwa": "^2.0.1",
|
|
||||||
"safe-buffer": "^5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minimalistic-assert": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/minimist": {
|
|
||||||
"version": "1.2.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/safe-buffer": {
|
|
||||||
"version": "5.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/safer-buffer": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/web-push": {
|
|
||||||
"version": "3.6.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
|
||||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"asn1.js": "^5.3.0",
|
|
||||||
"http_ece": "1.2.0",
|
|
||||||
"https-proxy-agent": "^7.0.0",
|
|
||||||
"jws": "^4.0.0",
|
|
||||||
"minimist": "^1.2.5"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"web-push": "src/cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.18.3",
|
"version": "8.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,9 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "node --watch server.js",
|
"dev": "node --watch server.js"
|
||||||
"generate-vapid": "node -e \"const wp = require('web-push'); const keys = wp.generateVAPIDKeys(); console.log('VAPID_PUBLIC_KEY=' + keys.publicKey); console.log('VAPID_PRIVATE_KEY=' + keys.privateKey);\""
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"web-push": "^3.6.7",
|
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,34 +2,16 @@ import { WebSocketServer } from 'ws';
|
||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
import { parse } from 'url';
|
import { parse } from 'url';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import webpush from 'web-push';
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
|
const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
// VAPID keys for push notifications
|
|
||||||
// Generate with: npx web-push generate-vapid-keys
|
|
||||||
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || '';
|
|
||||||
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || '';
|
|
||||||
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:push@rmaps.online';
|
|
||||||
|
|
||||||
// Configure web-push if keys are available
|
|
||||||
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
|
|
||||||
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
|
|
||||||
console.log('Push notifications enabled');
|
|
||||||
} else {
|
|
||||||
console.log('Push notifications disabled (VAPID keys not configured)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Room state storage: Map<roomSlug, RoomState>
|
// Room state storage: Map<roomSlug, RoomState>
|
||||||
const rooms = new Map();
|
const rooms = new Map();
|
||||||
|
|
||||||
// Client tracking: Map<WebSocket, { roomSlug, participantId }>
|
// Client tracking: Map<WebSocket, { roomSlug, participantId }>
|
||||||
const clients = new Map();
|
const clients = new Map();
|
||||||
|
|
||||||
// Push subscriptions: Map<roomSlug, Set<subscription>>
|
|
||||||
const pushSubscriptions = new Map();
|
|
||||||
|
|
||||||
function getRoomState(slug) {
|
function getRoomState(slug) {
|
||||||
if (!rooms.has(slug)) {
|
if (!rooms.has(slug)) {
|
||||||
rooms.set(slug, {
|
rooms.set(slug, {
|
||||||
|
|
@ -38,7 +20,7 @@ function getRoomState(slug) {
|
||||||
name: slug,
|
name: slug,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
participants: {},
|
participants: {},
|
||||||
waypoints: [],
|
waypoints: [], // Array to match client expectation
|
||||||
lastActivity: Date.now()
|
lastActivity: Date.now()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -79,42 +61,6 @@ function broadcast(roomSlug, message, excludeWs = null) {
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send push notification to all subscriptions for a room
|
|
||||||
async function sendPushToRoom(roomSlug, notification, excludeEndpoint = null) {
|
|
||||||
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) return;
|
|
||||||
|
|
||||||
const subs = pushSubscriptions.get(roomSlug);
|
|
||||||
if (!subs || subs.size === 0) return;
|
|
||||||
|
|
||||||
const payload = JSON.stringify(notification);
|
|
||||||
const failedEndpoints = [];
|
|
||||||
|
|
||||||
for (const sub of subs) {
|
|
||||||
if (excludeEndpoint && sub.endpoint === excludeEndpoint) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await webpush.sendNotification(sub, payload);
|
|
||||||
console.log(`[${roomSlug}] Push sent to ${sub.endpoint.slice(-20)}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[${roomSlug}] Push failed:`, error.statusCode || error.message);
|
|
||||||
// Remove invalid subscriptions
|
|
||||||
if (error.statusCode === 404 || error.statusCode === 410) {
|
|
||||||
failedEndpoints.push(sub.endpoint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up failed subscriptions
|
|
||||||
for (const endpoint of failedEndpoints) {
|
|
||||||
for (const sub of subs) {
|
|
||||||
if (sub.endpoint === endpoint) {
|
|
||||||
subs.delete(sub);
|
|
||||||
console.log(`[${roomSlug}] Removed invalid subscription`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMessage(ws, data) {
|
function handleMessage(ws, data) {
|
||||||
const clientInfo = clients.get(ws);
|
const clientInfo = clients.get(ws);
|
||||||
if (!clientInfo) return;
|
if (!clientInfo) return;
|
||||||
|
|
@ -143,19 +89,6 @@ function handleMessage(ws, data) {
|
||||||
// Broadcast join to others
|
// Broadcast join to others
|
||||||
broadcast(clientInfo.roomSlug, message, ws);
|
broadcast(clientInfo.roomSlug, message, ws);
|
||||||
|
|
||||||
// Send push notification to others
|
|
||||||
sendPushToRoom(clientInfo.roomSlug, {
|
|
||||||
title: 'Friend Joined! 👋',
|
|
||||||
body: `${participant.name} ${participant.emoji} joined the room`,
|
|
||||||
tag: `join-${participant.id}`,
|
|
||||||
data: {
|
|
||||||
type: 'join',
|
|
||||||
roomSlug: clientInfo.roomSlug,
|
|
||||||
participantId: participant.id,
|
|
||||||
participantName: participant.name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send current state to the new participant
|
// Send current state to the new participant
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'full_state',
|
type: 'full_state',
|
||||||
|
|
@ -165,24 +98,9 @@ function handleMessage(ws, data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'leave': {
|
case 'leave': {
|
||||||
const leavingParticipant = room.participants[message.participantId];
|
|
||||||
delete room.participants[message.participantId];
|
delete room.participants[message.participantId];
|
||||||
console.log(`[${clientInfo.roomSlug}] Participant left: ${message.participantId}`);
|
console.log(`[${clientInfo.roomSlug}] Participant left: ${message.participantId}`);
|
||||||
broadcast(clientInfo.roomSlug, message, ws);
|
broadcast(clientInfo.roomSlug, message, ws);
|
||||||
|
|
||||||
// Send push notification
|
|
||||||
if (leavingParticipant) {
|
|
||||||
sendPushToRoom(clientInfo.roomSlug, {
|
|
||||||
title: 'Friend Left',
|
|
||||||
body: `${leavingParticipant.name} ${leavingParticipant.emoji} left the room`,
|
|
||||||
tag: `leave-${message.participantId}`,
|
|
||||||
data: {
|
|
||||||
type: 'leave',
|
|
||||||
roomSlug: clientInfo.roomSlug,
|
|
||||||
participantId: message.participantId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,27 +129,6 @@ function handleMessage(ws, data) {
|
||||||
room.waypoints.push(message.waypoint);
|
room.waypoints.push(message.waypoint);
|
||||||
console.log(`[${clientInfo.roomSlug}] Waypoint added: ${message.waypoint.id}`);
|
console.log(`[${clientInfo.roomSlug}] Waypoint added: ${message.waypoint.id}`);
|
||||||
broadcast(clientInfo.roomSlug, message, ws);
|
broadcast(clientInfo.roomSlug, message, ws);
|
||||||
|
|
||||||
// Send push notification for meeting points
|
|
||||||
if (message.waypoint.type === 'meeting_point') {
|
|
||||||
const creator = room.participants[clientInfo.participantId];
|
|
||||||
sendPushToRoom(clientInfo.roomSlug, {
|
|
||||||
title: 'Meeting Point Set! 📍',
|
|
||||||
body: `${creator?.name || 'Someone'} set a meeting point: ${message.waypoint.name}`,
|
|
||||||
tag: `waypoint-${message.waypoint.id}`,
|
|
||||||
requireInteraction: true,
|
|
||||||
data: {
|
|
||||||
type: 'waypoint',
|
|
||||||
roomSlug: clientInfo.roomSlug,
|
|
||||||
waypointId: message.waypoint.id,
|
|
||||||
waypointName: message.waypoint.name,
|
|
||||||
},
|
|
||||||
actions: [
|
|
||||||
{ action: 'view', title: 'View on Map' },
|
|
||||||
{ action: 'dismiss', title: 'Dismiss' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -286,46 +183,14 @@ setInterval(() => {
|
||||||
if (Object.keys(room.participants).length === 0 &&
|
if (Object.keys(room.participants).length === 0 &&
|
||||||
now - room.lastActivity > 24 * 60 * 60 * 1000) {
|
now - room.lastActivity > 24 * 60 * 60 * 1000) {
|
||||||
rooms.delete(slug);
|
rooms.delete(slug);
|
||||||
pushSubscriptions.delete(slug);
|
|
||||||
console.log(`Cleaned up empty room: ${slug}`);
|
console.log(`Cleaned up empty room: ${slug}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 5 * 60 * 1000); // Every 5 minutes
|
}, 5 * 60 * 1000); // Every 5 minutes
|
||||||
|
|
||||||
// Parse JSON body from request
|
// Create HTTP server for health checks
|
||||||
async function parseJsonBody(req) {
|
const server = createServer((req, res) => {
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let body = '';
|
|
||||||
req.on('data', chunk => body += chunk);
|
|
||||||
req.on('end', () => {
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(body));
|
|
||||||
} catch (e) {
|
|
||||||
reject(new Error('Invalid JSON'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add CORS headers
|
|
||||||
function addCorsHeaders(res) {
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create HTTP server for health checks and push subscription endpoints
|
|
||||||
const server = createServer(async (req, res) => {
|
|
||||||
const { pathname } = parse(req.url);
|
const { pathname } = parse(req.url);
|
||||||
addCorsHeaders(res);
|
|
||||||
|
|
||||||
// Handle CORS preflight
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
res.writeHead(204);
|
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === '/health') {
|
if (pathname === '/health') {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
|
@ -333,7 +198,6 @@ const server = createServer(async (req, res) => {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
rooms: rooms.size,
|
rooms: rooms.size,
|
||||||
clients: clients.size,
|
clients: clients.size,
|
||||||
pushEnabled: !!(VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY),
|
|
||||||
uptime: process.uptime()
|
uptime: process.uptime()
|
||||||
}));
|
}));
|
||||||
} else if (pathname === '/stats') {
|
} else if (pathname === '/stats') {
|
||||||
|
|
@ -342,87 +206,11 @@ const server = createServer(async (req, res) => {
|
||||||
roomStats[slug] = {
|
roomStats[slug] = {
|
||||||
participants: Object.keys(room.participants).length,
|
participants: Object.keys(room.participants).length,
|
||||||
waypoints: room.waypoints.length,
|
waypoints: room.waypoints.length,
|
||||||
pushSubscriptions: pushSubscriptions.get(slug)?.size || 0,
|
|
||||||
lastActivity: room.lastActivity
|
lastActivity: room.lastActivity
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ rooms: roomStats, totalClients: clients.size }));
|
res.end(JSON.stringify({ rooms: roomStats, totalClients: clients.size }));
|
||||||
} else if (pathname === '/push/vapid-public-key') {
|
|
||||||
// Return VAPID public key for client
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ publicKey: VAPID_PUBLIC_KEY }));
|
|
||||||
} else if (pathname === '/push/subscribe' && req.method === 'POST') {
|
|
||||||
// Subscribe to push notifications
|
|
||||||
try {
|
|
||||||
const { subscription, roomSlug } = await parseJsonBody(req);
|
|
||||||
|
|
||||||
if (!subscription || !subscription.endpoint) {
|
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ error: 'Invalid subscription' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store subscription for the room
|
|
||||||
if (roomSlug) {
|
|
||||||
if (!pushSubscriptions.has(roomSlug)) {
|
|
||||||
pushSubscriptions.set(roomSlug, new Set());
|
|
||||||
}
|
|
||||||
pushSubscriptions.get(roomSlug).add(subscription);
|
|
||||||
console.log(`[${roomSlug}] Push subscription added`);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ success: true }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Push subscribe error:', error);
|
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ error: 'Failed to subscribe' }));
|
|
||||||
}
|
|
||||||
} else if (pathname === '/push/unsubscribe' && req.method === 'POST') {
|
|
||||||
// Unsubscribe from push notifications
|
|
||||||
try {
|
|
||||||
const { endpoint, roomSlug } = await parseJsonBody(req);
|
|
||||||
|
|
||||||
if (roomSlug && pushSubscriptions.has(roomSlug)) {
|
|
||||||
const subs = pushSubscriptions.get(roomSlug);
|
|
||||||
for (const sub of subs) {
|
|
||||||
if (sub.endpoint === endpoint) {
|
|
||||||
subs.delete(sub);
|
|
||||||
console.log(`[${roomSlug}] Push subscription removed`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ success: true }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Push unsubscribe error:', error);
|
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ error: 'Failed to unsubscribe' }));
|
|
||||||
}
|
|
||||||
} else if (pathname === '/push/test' && req.method === 'POST') {
|
|
||||||
// Send test push notification
|
|
||||||
try {
|
|
||||||
const { roomSlug } = await parseJsonBody(req);
|
|
||||||
|
|
||||||
if (roomSlug) {
|
|
||||||
await sendPushToRoom(roomSlug, {
|
|
||||||
title: 'Test Notification 🔔',
|
|
||||||
body: 'Push notifications are working!',
|
|
||||||
tag: 'test',
|
|
||||||
data: { type: 'test', roomSlug },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ success: true }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Push test error:', error);
|
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ error: 'Failed to send test' }));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end('Not found');
|
res.end('Not found');
|
||||||
|
|
@ -473,5 +261,4 @@ server.listen(PORT, () => {
|
||||||
console.log(`rmaps sync server listening on port ${PORT}`);
|
console.log(`rmaps sync server listening on port ${PORT}`);
|
||||||
console.log(`WebSocket: ws://localhost:${PORT}/room/{slug}`);
|
console.log(`WebSocket: ws://localhost:${PORT}/room/{slug}`);
|
||||||
console.log(`Health check: http://localhost:${PORT}/health`);
|
console.log(`Health check: http://localhost:${PORT}/health`);
|
||||||
console.log(`Push API: http://localhost:${PORT}/push/subscribe`);
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue