/* Service Worker for Chorly push notifications */ /* Activate immediately — no waiting for old tabs to close */ self.addEventListener('install', function (event) { event.waitUntil(self.skipWaiting()) }) self.addEventListener('activate', function (event) { event.waitUntil(self.clients.claim()) }) self.addEventListener('push', function (event) { if (!event.data) return let payload try { payload = event.data.json() } catch (e) { payload = { title: 'Chorly', body: event.data.text() } } const title = payload.title || 'Chorly' // Android Chrome has a bug where event.action is unreliable — only show Approve // so tapping it always executes the correct action. const isAndroidChrome = /Android/.test(self.navigator.userAgent) && /Chrome\//.test(self.navigator.userAgent) // Expiry-warning notifications are informational only — no action buttons const actions = payload.type === 'chore_expiring_soon' ? [] : isAndroidChrome ? [{ action: 'approve', title: 'Approve' }] : [ { action: 'approve', title: 'Approve' }, { action: 'deny', title: 'Reject' }, ] const options = { body: payload.body || '', icon: '/icons/icon-192x192.png', data: payload, actions, } event.waitUntil(self.registration.showNotification(title, options)) }) self.addEventListener('notificationclick', function (event) { event.notification.close() const payload = event.notification.data || {} const approveToken = payload.approve_token const denyToken = payload.deny_token const childId = payload.child_id const entityId = payload.entity_id const entityType = payload.entity_type function navigateOrOpen(url) { return clients .matchAll({ type: 'window', includeUncontrolled: true }) .then(function (clientList) { const sameOriginClients = clientList.filter(function (c) { return new URL(c.url).origin === self.location.origin }) if (sameOriginClients.length > 0) { const client = sameOriginClients[0] return client.focus().then(function () { return client.navigate(url) }) } return clients.openWindow(url) }) } function executeDigestAction(token) { return fetch('/api/digest-action/' + token, { method: 'POST', credentials: 'include', }) .then(function (res) { if (res.ok) { // Action executed — navigate to parent view to show the result var successUrl = childId && entityId && entityType ? '/parent/' + childId + '?scrollTo=' + entityId + '&entityType=' + entityType : '/parent' return navigateOrOpen(successUrl) } if (res.status === 401) { // Not authenticated — navigate with digestToken so the PIN flow executes the action var authUrl = childId && entityId && entityType ? '/parent/' + childId + '?digestToken=' + token + '&scrollTo=' + entityId + '&entityType=' + entityType : '/parent' return navigateOrOpen(authUrl) } // Token invalid/used/expired — navigate to parent return navigateOrOpen(childId ? '/parent/' + childId : '/parent') }) .catch(function () { return navigateOrOpen('/parent') }) } if (event.action === 'approve' && approveToken) { event.waitUntil(executeDigestAction(approveToken)) return } if (event.action === 'deny' && denyToken) { event.waitUntil(executeDigestAction(denyToken)) return } // Body tap — open or focus the app then navigate to the deep link const deepLinkUrl = childId && entityId && entityType ? '/parent/' + childId + '?scrollTo=' + entityId + '&entityType=' + entityType : '/parent' event.waitUntil(navigateOrOpen(deepLinkUrl)) })