Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a85d6e42fc | |||
| 53da4a14a0 | |||
| 2453134c51 | |||
| 671cb2dd9a | |||
| 1d511e0f8c | |||
| 18a68367bc |
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -42,12 +42,14 @@ function scheduleUpdateChecks(
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForUpdate()
|
||||
// Delay check on wake-up to allow the mobile network stack to stabilize
|
||||
setTimeout(checkForUpdate, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const onOnline = () => {
|
||||
checkForUpdate()
|
||||
// Small delay to ensure connection is fully established
|
||||
setTimeout(checkForUpdate, 500)
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
|
||||
@@ -10,22 +10,43 @@ export class ApiError extends Error {
|
||||
|
||||
export async function apiFetch(
|
||||
input: string,
|
||||
init: RequestInit = {}
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 15000
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (init.body !== undefined && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include'
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
if (init.signal) {
|
||||
if (init.signal.aborted) {
|
||||
controller.abort()
|
||||
} else {
|
||||
init.signal.addEventListener('abort', () => controller.abort())
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
signal: controller.signal
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await apiFetch(input, init)
|
||||
export async function apiJson<T>(
|
||||
input: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 15000
|
||||
): Promise<T> {
|
||||
const res = await apiFetch(input, init, timeoutMs)
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
|
||||
@@ -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<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
export async function unsubscribeFromPush(): Promise<void> {
|
||||
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<void> {
|
||||
await savePushPrefs(false)
|
||||
await unsubscribeFromPush()
|
||||
}
|
||||
|
||||
if (isPushSupported()) {
|
||||
void preloadPushService()
|
||||
}
|
||||
|
||||
@@ -131,12 +131,7 @@ async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
|
||||
}
|
||||
|
||||
function scheduleResync(logbookId: string) {
|
||||
if (pendingResync.has(logbookId)) return
|
||||
pendingResync.add(logbookId)
|
||||
queueMicrotask(() => {
|
||||
pendingResync.delete(logbookId)
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
||||
})
|
||||
}
|
||||
|
||||
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
|
||||
@@ -540,6 +535,12 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
||||
} finally {
|
||||
syncingLogbooks.delete(logbookId)
|
||||
recomputeSyncingState()
|
||||
if (pendingResync.has(logbookId)) {
|
||||
pendingResync.delete(logbookId)
|
||||
setTimeout(() => {
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user