Compare commits

...

4 Commits

4 changed files with 86 additions and 14 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.94 0.1.0.96
@@ -58,7 +58,8 @@ export default function PushNotificationSettings() {
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED) trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
} }
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : t('profile.push_error') console.error('Failed to toggle push notifications:', err)
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
showAlert(message) showAlert(message)
void loadPrefs() void loadPrefs()
} finally { } finally {
+2 -1
View File
@@ -193,7 +193,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED) trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to enable push after invite:', err) console.error('Failed to enable push after invite:', err)
await showAlert(err instanceof Error ? err.message : t('profile.push_error')) const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
await showAlert(message)
} }
} }
+81 -11
View File
@@ -96,11 +96,61 @@ export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promis
}) })
} }
async function requestNotificationPermission(): Promise<NotificationPermission> {
if (typeof Notification === 'undefined') return 'denied'
// Try promise-based signature first
try {
const result = Notification.requestPermission()
if (result !== undefined) {
return await result
}
} catch {
// Ignore and fall back to callback
}
// Callback-based fallback
return new Promise<NotificationPermission>((resolve) => {
Notification.requestPermission((permission) => {
resolve(permission)
})
})
}
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> { async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated') if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
const endpoint = subscription.endpoint
const json = subscription.toJSON() const json = subscription.toJSON()
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { let p256dh = json.keys?.p256dh
let auth = json.keys?.auth
// Fallback for browsers (like Safari) that might not serialize keys in toJSON()
if (!p256dh && typeof subscription.getKey === 'function') {
try {
const rawKey = subscription.getKey('p256dh')
if (rawKey) {
p256dh = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
} catch (e) {
console.warn('Failed to extract p256dh key manually:', e)
}
}
if (!auth && typeof subscription.getKey === 'function') {
try {
const rawAuth = subscription.getKey('auth')
if (rawAuth) {
auth = btoa(String.fromCharCode(...new Uint8Array(rawAuth)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
} catch (e) {
console.warn('Failed to extract auth key manually:', e)
}
}
if (!endpoint || !p256dh || !auth) {
throw new Error('Invalid push subscription') throw new Error('Invalid push subscription')
} }
@@ -109,8 +159,8 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
await apiJson(`${API_BASE}/subscription`, { await apiJson(`${API_BASE}/subscription`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
endpoint: json.endpoint, endpoint,
keys: json.keys, keys: { p256dh, auth },
locale, locale,
userAgent: navigator.userAgent userAgent: navigator.userAgent
}) })
@@ -122,16 +172,23 @@ export async function subscribeToPush(): Promise<void> {
throw new Error('Push notifications are not supported on this device') throw new Error('Push notifications are not supported on this device')
} }
// Pre-resolve registration and VAPID key synchronously if preloaded. // Pre-resolve registration with timeout to prevent silent hangs
// This keeps the user gesture active for iOS Safari. let registration = cachedRegistration
const registration = cachedRegistration || await navigator.serviceWorker.ready if (!registration) {
const publicKey = cachedVapidKey || await fetchVapidPublicKey() const readyPromise = navigator.serviceWorker.ready
const readyTimeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout waiting for Service Worker ready state')), 8000)
)
registration = await Promise.race([readyPromise, readyTimeout])
cachedRegistration = registration
}
const publicKey = cachedVapidKey || await fetchVapidPublicKey()
if (!publicKey) { if (!publicKey) {
throw new Error('Push notifications are not configured on this server') throw new Error('Push notifications are not configured on this server')
} }
const permission = await Notification.requestPermission() const permission = await requestNotificationPermission()
if (permission !== 'granted') { if (permission !== 'granted') {
throw new Error('Notification permission denied') throw new Error('Notification permission denied')
} }
@@ -139,11 +196,15 @@ export async function subscribeToPush(): Promise<void> {
const keyBytes = urlBase64ToUint8Array(publicKey) const keyBytes = urlBase64ToUint8Array(publicKey)
const applicationServerKey = new Uint8Array(keyBytes) const applicationServerKey = new Uint8Array(keyBytes)
// Always call subscribe to renew/ensure subscription without reusing stale state // Always call subscribe with timeout to prevent silent hangs on push network errors
const subscription = await registration.pushManager.subscribe({ const subscribePromise = registration.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey applicationServerKey
}) })
const subscribeTimeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout establishing subscription with push service (FCM/APNs)')), 12000)
)
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
await saveSubscriptionToServer(subscription) await saveSubscriptionToServer(subscription)
} }
@@ -151,7 +212,16 @@ export async function subscribeToPush(): Promise<void> {
export async function unsubscribeFromPush(): Promise<void> { export async function unsubscribeFromPush(): Promise<void> {
if (!isPushSupported()) return if (!isPushSupported()) return
const registration = cachedRegistration || await navigator.serviceWorker.ready let registration = cachedRegistration
if (!registration) {
const readyPromise = navigator.serviceWorker.ready
const readyTimeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout waiting for Service Worker ready state')), 8000)
)
registration = await Promise.race([readyPromise, readyTimeout])
cachedRegistration = registration
}
const subscription = await registration.pushManager.getSubscription() const subscription = await registration.pushManager.getSubscription()
if (!subscription) return if (!subscription) return