diff --git a/client/src/components/PushNotificationSettings.tsx b/client/src/components/PushNotificationSettings.tsx index ddd9762..06a9bbe 100644 --- a/client/src/components/PushNotificationSettings.tsx +++ b/client/src/components/PushNotificationSettings.tsx @@ -6,7 +6,8 @@ import { enableCollaboratorChangePush, fetchPushPrefs, getNotificationPermission, - isPushSupported + isPushSupported, + preloadPushService } from '../services/pushNotifications.js' import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' @@ -28,6 +29,7 @@ export default function PushNotificationSettings() { setLoading(false) return } + void preloadPushService() try { const prefs = await fetchPushPrefs() setEnabled(prefs.collaboratorChangesEnabled) diff --git a/client/src/components/SettingsForm.tsx b/client/src/components/SettingsForm.tsx index b3796a0..1317914 100644 --- a/client/src/components/SettingsForm.tsx +++ b/client/src/components/SettingsForm.tsx @@ -10,7 +10,8 @@ import { apiFetch } from '../services/api.js' import { enableCollaboratorChangePush, isCollaboratorPushActive, - isPushSupported + isPushSupported, + preloadPushService } from '../services/pushNotifications.js' import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js' @@ -55,6 +56,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF loadCollaborators() loadShareLink() } + void preloadPushService() }, [logbookId]) const loadShareLink = async () => { diff --git a/client/src/services/pushNotifications.ts b/client/src/services/pushNotifications.ts index 3c274ff..423c813 100644 --- a/client/src/services/pushNotifications.ts +++ b/client/src/services/pushNotifications.ts @@ -27,17 +27,41 @@ export function getNotificationPermission(): NotificationPermission | 'unsupport return Notification.permission } +let cachedVapidKey: string | null = null +let cachedRegistration: ServiceWorkerRegistration | null = null + +export async function preloadPushService(): Promise { + if (!isPushSupported()) return + try { + if (!cachedVapidKey) { + await fetchVapidPublicKey() + } + if (!cachedRegistration) { + cachedRegistration = await navigator.serviceWorker.ready + } + } catch (err) { + console.warn('Failed to preload push service:', err) + } +} + async function fetchVapidPublicKey(): Promise { + if (cachedVapidKey) return cachedVapidKey + const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY if (typeof envKey === 'string' && envKey.trim()) { - return envKey.trim() + cachedVapidKey = envKey.trim() + return cachedVapidKey } try { const res = await fetch(`${API_BASE}/vapid-public-key`) if (!res.ok) return null const data = await res.json() - return typeof data.publicKey === 'string' ? data.publicKey : null + if (typeof data.publicKey === 'string') { + cachedVapidKey = data.publicKey.trim() + return cachedVapidKey + } + return null } catch { return null } @@ -98,27 +122,28 @@ export async function subscribeToPush(): Promise { throw new Error('Push notifications are not supported on this device') } + // Pre-resolve registration and VAPID key synchronously if preloaded. + // This keeps the user gesture active for iOS Safari. + const registration = cachedRegistration || await navigator.serviceWorker.ready + const publicKey = cachedVapidKey || await fetchVapidPublicKey() + + if (!publicKey) { + throw new Error('Push notifications are not configured on this server') + } + const permission = await Notification.requestPermission() if (permission !== 'granted') { throw new Error('Notification permission denied') } - const publicKey = await fetchVapidPublicKey() - if (!publicKey) { - throw new Error('Push notifications are not configured on this server') - } - - const registration = await navigator.serviceWorker.ready - let subscription = await registration.pushManager.getSubscription() - - if (!subscription) { - const keyBytes = urlBase64ToUint8Array(publicKey) - const applicationServerKey = new Uint8Array(keyBytes) - subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey - }) - } + const keyBytes = urlBase64ToUint8Array(publicKey) + const applicationServerKey = new Uint8Array(keyBytes) + + // Always call subscribe to renew/ensure subscription without reusing stale state + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey + }) await saveSubscriptionToServer(subscription) } @@ -126,7 +151,7 @@ export async function subscribeToPush(): Promise { export async function unsubscribeFromPush(): Promise { if (!isPushSupported()) return - const registration = await navigator.serviceWorker.ready + const registration = cachedRegistration || await navigator.serviceWorker.ready const subscription = await registration.pushManager.getSubscription() if (!subscription) return @@ -164,3 +189,7 @@ export async function disableCollaboratorChangePush(): Promise { await savePushPrefs(false) await unsubscribeFromPush() } + +if (isPushSupported()) { + void preloadPushService() +}