Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 261c377

Browse files
committedJan 6, 2025·
fix: iOS pwa push notifications
1 parent 29c31e3 commit 261c377

File tree

2 files changed

+59
-101
lines changed

2 files changed

+59
-101
lines changed
 

‎lib/badge.js

+7-6
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,20 @@ const permissionGranted = async (sw = window) => {
1515
return permission?.state === 'granted' || sw.Notification?.permission === 'granted'
1616
}
1717

18-
export const setAppBadge = async (sw = window, count) => {
19-
if (!badgingApiSupported(sw) || !(await permissionGranted(sw))) return
18+
// Apple requirement: onPush doesn't accept async functions
19+
export const setAppBadge = (sw = window, count) => {
20+
if (!badgingApiSupported(sw)) return
2021
try {
21-
await sw.navigator.setAppBadge(count)
22+
return sw.navigator.setAppBadge(count) // Return a Promise to be handled
2223
} catch (err) {
2324
console.error('Failed to set app badge', err)
2425
}
2526
}
2627

27-
export const clearAppBadge = async (sw = window) => {
28-
if (!badgingApiSupported(sw) || !(await permissionGranted(sw))) return
28+
export const clearAppBadge = (sw = window) => {
29+
if (!badgingApiSupported(sw)) return
2930
try {
30-
await sw.navigator.clearAppBadge()
31+
return sw.navigator.clearAppBadge() // Return a Promise to be handled
3132
} catch (err) {
3233
console.error('Failed to clear app badge', err)
3334
}

‎sw/eventListener.js

+52-95
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import ServiceWorkerStorage from 'serviceworker-storage'
22
import { numWithUnits } from '@/lib/format'
33
import { CLEAR_NOTIFICATIONS, clearAppBadge, setAppBadge } from '@/lib/badge'
44
import { ACTION_PORT, DELETE_SUBSCRIPTION, MESSAGE_PORT, STORE_OS, STORE_SUBSCRIPTION, SYNC_SUBSCRIPTION } from '@/components/serviceworker'
5+
// import { getLogger } from '@/lib/logger'
56

67
// we store existing push subscriptions to keep them in sync with server
78
const storage = new ServiceWorkerStorage('sw:storage', 1)
@@ -28,70 +29,34 @@ const log = (message, level = 'info', context) => {
2829
}
2930

3031
export function onPush (sw) {
31-
return async (event) => {
32-
const payload = event.data?.json()
32+
return (event) => {
33+
// const logger = getLogger('sw:push', ['onPush'])
34+
let payload = event.data?.json()
3335
if (!payload) return
3436
const { tag } = payload.options
35-
event.waitUntil((async () => {
36-
const iOS = await getOS() === 'iOS'
37-
// generate random ID for every incoming push for better tracing in logs
38-
const nid = crypto.randomUUID()
39-
log(`[sw:push] ${nid} - received notification with tag ${tag}`)
40-
41-
// due to missing proper tag support in Safari on iOS, we can't rely on the tag built-in filter.
42-
// we therefore fetch all notifications with the same tag and manually filter them, too.
43-
// see https://bugs.webkit.org/show_bug.cgi?id=258922
44-
const notifications = await sw.registration.getNotifications({ tag })
45-
log(`[sw:push] ${nid} - found ${notifications.length} ${tag} notifications`)
46-
log(`[sw:push] ${nid} - built-in tag filter: ${JSON.stringify(notifications.map(({ tag }) => tag))}`)
47-
48-
// we're not sure if the built-in tag filter actually filters by tag on iOS
49-
// or if it just returns all currently displayed notifications (?)
50-
const filtered = notifications.filter(({ tag: nTag }) => nTag === tag)
51-
log(`[sw:push] ${nid} - found ${filtered.length} ${tag} notifications after manual tag filter`)
52-
log(`[sw:push] ${nid} - manual tag filter: ${JSON.stringify(filtered.map(({ tag }) => tag))}`)
53-
54-
if (immediatelyShowNotification(tag)) {
55-
// we can't rely on the tag property to replace notifications on Safari on iOS.
56-
// we therefore close them manually and then we display the notification.
57-
log(`[sw:push] ${nid} - ${tag} notifications replace previous notifications`)
58-
setAppBadge(sw, ++activeCount)
59-
// due to missing proper tag support in Safari on iOS, we can't rely on the tag property to replace notifications.
60-
// see https://bugs.webkit.org/show_bug.cgi?id=258922 for more information
61-
// we therefore fetch all notifications with the same tag (+ manual filter),
62-
// close them and then we display the notification.
63-
const notifications = await sw.registration.getNotifications({ tag })
64-
// we only close notifications manually on iOS because we don't want to degrade android UX just because iOS is behind in their support.
65-
if (iOS) {
66-
log(`[sw:push] ${nid} - closing existing notifications`)
67-
notifications.filter(({ tag: nTag }) => nTag === tag).forEach(n => n.close())
37+
const nid = crypto.randomUUID()
38+
39+
// iOS requirement: group all promises
40+
const promises = []
41+
42+
// On immediate notifications we update the counter
43+
if (immediatelyShowNotification(tag)) {
44+
// logger.info(`[${nid}] showing immediate notification with title: ${payload.title}`)
45+
promises.push(setAppBadge(sw, ++activeCount))
46+
} else { // Check if there are already notifications with the same tag and merge them
47+
// logger.info(`[${nid}] checking for existing notification with tag ${tag}`)
48+
promises.push(sw.registration.getNotifications({ tag }).then((notifications) => {
49+
if (notifications.length) {
50+
// logger.info(`[${nid}] found ${notifications.length} notifications with tag ${tag}`)
51+
payload = mergeNotification(event, sw, payload, notifications, tag, nid)
6852
}
69-
log(`[sw:push] ${nid} - show notification with title "${payload.title}"`)
70-
return await sw.registration.showNotification(payload.title, payload.options)
71-
}
72-
73-
// according to the spec, there should only be zero or one notification since we used a tag filter
74-
// handle zero case here
75-
if (notifications.length === 0) {
76-
// incoming notification is first notification with this tag
77-
log(`[sw:push] ${nid} - no existing ${tag} notifications found`)
78-
setAppBadge(sw, ++activeCount)
79-
log(`[sw:push] ${nid} - show notification with title "${payload.title}"`)
80-
return await sw.registration.showNotification(payload.title, payload.options)
81-
}
82-
83-
// handle unexpected case here
84-
if (notifications.length > 1) {
85-
log(`[sw:push] ${nid} - more than one notification with tag ${tag} found`, 'error')
86-
// due to missing proper tag support in Safari on iOS,
87-
// we only acknowledge this error in our logs and don't bail here anymore
88-
// see https://bugs.webkit.org/show_bug.cgi?id=258922 for more information
89-
log(`[sw:push] ${nid} - skip bail -- merging notifications with tag ${tag} manually`)
90-
// return null
91-
}
53+
}))
54+
}
9255

93-
return await mergeAndShowNotification(sw, payload, notifications, tag, nid, iOS)
94-
})())
56+
// Apple requirement: wait for all promises to resolve
57+
event.waitUntil(Promise.all(promises).then(() => {
58+
sw.registration.showNotification(payload.title, payload.options)
59+
}))
9560
}
9661
}
9762

@@ -100,22 +65,22 @@ export function onPush (sw) {
10065
const immediatelyShowNotification = (tag) =>
10166
!tag || ['TIP', 'FORWARDEDTIP', 'EARN', 'STREAK', 'TERRITORY_TRANSFER'].includes(tag.split('-')[0])
10267

103-
const mergeAndShowNotification = async (sw, payload, currentNotifications, tag, nid, iOS) => {
68+
const mergeNotification = (event, sw, payload, currentNotifications, tag, nid) => {
69+
// const logger = getLogger('sw:push:mergeNotification', ['mergeNotification'])
10470
// sanity check
10571
const otherTagNotifications = currentNotifications.filter(({ tag: nTag }) => nTag !== tag)
10672
if (otherTagNotifications.length > 0) {
10773
// we can't recover from this here. bail.
108-
const message = `[sw:push] ${nid} - bailing -- more than one notification with tag ${tag} found after manual filter`
109-
log(message, 'error')
74+
// logger.error(`${nid} - bailing -- more than one notification with tag ${tag} found after manual filter`)
11075
return
11176
}
11277

11378
const { data: incomingData } = payload.options
114-
log(`[sw:push] ${nid} - incoming payload.options.data: ${JSON.stringify(incomingData)}`)
79+
// logger.info(`[sw:push] ${nid} - incoming payload.options.data: ${JSON.stringify(incomingData)}`)
11580

11681
// we can ignore everything after the first dash in the tag for our control flow
11782
const compareTag = tag.split('-')[0]
118-
log(`[sw:push] ${nid} - using ${compareTag} for control flow`)
83+
// logger.info(`[sw:push] ${nid} - using ${compareTag} for control flow`)
11984

12085
// merge notifications into single notification payload
12186
// ---
@@ -124,22 +89,18 @@ const mergeAndShowNotification = async (sw, payload, currentNotifications, tag,
12489
// tags that need to know the sum of sats of notifications with same tag for merging
12590
const SUM_SATS_TAGS = ['DEPOSIT', 'WITHDRAWAL']
12691
// this should reflect the amount of notifications that were already merged before
127-
let initialAmount = currentNotifications[0]?.data?.amount || 1
128-
if (iOS) initialAmount = 1
129-
log(`[sw:push] ${nid} - initial amount: ${initialAmount}`)
130-
const mergedPayload = currentNotifications.reduce((acc, { data }) => {
131-
let newAmount, newSats
132-
if (AMOUNT_TAGS.includes(compareTag)) {
133-
newAmount = acc.amount + 1
134-
}
135-
if (SUM_SATS_TAGS.includes(compareTag)) {
136-
newSats = acc.sats + data.sats
137-
}
138-
const newPayload = { ...data, amount: newAmount, sats: newSats }
139-
return newPayload
140-
}, { ...incomingData, amount: initialAmount })
92+
const initialAmount = currentNotifications[0]?.data?.amount || 1
93+
const initialSats = currentNotifications[0]?.data?.sats || 0
94+
// logger.info(`[sw:push] ${nid} - initial amount: ${initialAmount}`)
95+
// logger.info(`[sw:push] ${nid} - initial sats: ${initialSats}`)
96+
97+
const mergedPayload = {
98+
...incomingData,
99+
amount: initialAmount + 1,
100+
sats: initialSats + incomingData.sats
101+
}
141102

142-
log(`[sw:push] ${nid} - merged payload: ${JSON.stringify(mergedPayload)}`)
103+
// logger.info(`[sw:push] ${nid} - merged payload: ${JSON.stringify(mergedPayload)}`)
143104

144105
// calculate title from merged payload
145106
const { amount, followeeName, subName, subType, sats } = mergedPayload
@@ -167,23 +128,18 @@ const mergeAndShowNotification = async (sw, payload, currentNotifications, tag,
167128
title = `${numWithUnits(sats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account`
168129
}
169130
}
170-
log(`[sw:push] ${nid} - calculated title: ${title}`)
171-
172-
// close all current notifications before showing new one to "merge" notifications
173-
// we only do this on iOS because we don't want to degrade android UX just because iOS is behind in their support.
174-
if (iOS) {
175-
log(`[sw:push] ${nid} - closing existing notifications`)
176-
currentNotifications.forEach(n => n.close())
177-
}
131+
// logger.info(`[sw:push] ${nid} - calculated title: ${title}`)
178132

179133
const options = { icon: payload.options?.icon, tag, data: { url: '/notifications', ...mergedPayload } }
180-
log(`[sw:push] ${nid} - show notification with title "${title}"`)
181-
return await sw.registration.showNotification(title, options)
134+
// logger.info(`[sw:push] ${nid} - show notification with title "${title}"`)
135+
return { title, options }
182136
}
183137

184138
export function onNotificationClick (sw) {
185139
return (event) => {
140+
// const logger = getLogger('sw:onNotificationClick', ['onNotificationClick'])
186141
const url = event.notification.data?.url
142+
// logger.info(`[sw:onNotificationClick] clicked notification with url ${url}`)
187143
if (url) {
188144
event.waitUntil(sw.clients.openWindow(url))
189145
}
@@ -202,10 +158,11 @@ export function onPushSubscriptionChange (sw) {
202158
// `isSync` is passed if function was called because of 'SYNC_SUBSCRIPTION' event
203159
// this makes sure we can differentiate between 'pushsubscriptionchange' events and our custom 'SYNC_SUBSCRIPTION' event
204160
return async (event, isSync) => {
161+
// const logger = getLogger('sw:onPushSubscriptionChange', ['onPushSubscriptionChange'])
205162
let { oldSubscription, newSubscription } = event
206163
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event
207164
// fallbacks since browser may not set oldSubscription and newSubscription
208-
log('[sw:handlePushSubscriptionChange] invoked')
165+
// logger.info('[sw:handlePushSubscriptionChange] invoked')
209166
oldSubscription ??= await storage.getItem('subscription')
210167
newSubscription ??= await sw.registration.pushManager.getSubscription()
211168
if (!newSubscription) {
@@ -214,17 +171,17 @@ export function onPushSubscriptionChange (sw) {
214171
// see https://github.com/stackernews/stacker.news/issues/411#issuecomment-1790675861
215172
// NOTE: this is only run on IndexedDB subscriptions stored under service worker version 2 since this is not backwards compatible
216173
// see discussion in https://github.com/stackernews/stacker.news/pull/597
217-
log('[sw:handlePushSubscriptionChange] service worker lost subscription')
174+
// logger.info('[sw:handlePushSubscriptionChange] service worker lost subscription')
218175
actionChannelPort?.postMessage({ action: 'RESUBSCRIBE' })
219176
return
220177
}
221178
// no subscription exists at the moment
222-
log('[sw:handlePushSubscriptionChange] no existing subscription found')
179+
// logger.info('[sw:handlePushSubscriptionChange] no existing subscription found')
223180
return
224181
}
225182
if (oldSubscription?.endpoint === newSubscription.endpoint) {
226183
// subscription did not change. no need to sync with server
227-
log('[sw:handlePushSubscriptionChange] old subscription matches existing subscription')
184+
// logger.info('[sw:handlePushSubscriptionChange] old subscription matches existing subscription')
228185
return
229186
}
230187
// convert keys from ArrayBuffer to string
@@ -249,7 +206,7 @@ export function onPushSubscriptionChange (sw) {
249206
},
250207
body
251208
})
252-
log('[sw:handlePushSubscriptionChange] synced push subscription with server', 'info', { endpoint: variables.endpoint, oldEndpoint: variables.oldEndpoint })
209+
// logger.info('[sw:handlePushSubscriptionChange] synced push subscription with server', 'info', { endpoint: variables.endpoint, oldEndpoint: variables.oldEndpoint })
253210
await storage.setItem('subscription', JSON.parse(JSON.stringify(newSubscription)))
254211
}
255212
}

0 commit comments

Comments
 (0)
Please sign in to comment.