2597eb56f3
* Parse internal refs to links * Item mention notifications * Also parse item mentions as URLs * Fix subType determined by referrer item instead of referee item * Ignore subType Considering if the item that was referred to was a post or comment made the code more complex than initially necessary. For example, notifications for /notifications are deduplicated based on item id and the same item could refer to posts and comments, so to include "one of your posts" or "one of your comments" in the title would require splitting notifications based on the type of referred item. I didn't want to do this but also wanted to have consistent notification titles between push and /notifications, so I use "items" in both places now, even though I think using "items" isn't ideal from a user perspective. I think it might be confusing. * Fix rootText * Replace full links to #<id> syntax in push notifications * Refactor mention code into separate functions
290 lines
13 KiB
JavaScript
290 lines
13 KiB
JavaScript
import ServiceWorkerStorage from 'serviceworker-storage'
|
|
import { numWithUnits } from '@/lib/format'
|
|
import { CLEAR_NOTIFICATIONS, clearAppBadge, setAppBadge } from '@/lib/badge'
|
|
import { ACTION_PORT, DELETE_SUBSCRIPTION, MESSAGE_PORT, STORE_OS, STORE_SUBSCRIPTION, SYNC_SUBSCRIPTION } from '@/components/serviceworker'
|
|
|
|
// we store existing push subscriptions to keep them in sync with server
|
|
const storage = new ServiceWorkerStorage('sw:storage', 1)
|
|
|
|
// for communication between app and service worker
|
|
// see https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel
|
|
let messageChannelPort
|
|
let actionChannelPort
|
|
|
|
// operating system. the value will be received via a STORE_OS message from app since service workers don't have access to window.navigator
|
|
let os = ''
|
|
const iOS = () => os === 'iOS'
|
|
|
|
// current push notification count for badge purposes
|
|
let activeCount = 0
|
|
|
|
const log = (message, level = 'info', context) => {
|
|
messageChannelPort?.postMessage({ level, message, context })
|
|
}
|
|
|
|
export function onPush (sw) {
|
|
return async (event) => {
|
|
const payload = event.data?.json()
|
|
if (!payload) return
|
|
const { tag } = payload.options
|
|
event.waitUntil((async () => {
|
|
// generate random ID for every incoming push for better tracing in logs
|
|
const nid = crypto.randomUUID()
|
|
log(`[sw:push] ${nid} - received notification with tag ${tag}`)
|
|
|
|
// due to missing proper tag support in Safari on iOS, we can't rely on the tag built-in filter.
|
|
// we therefore fetch all notifications with the same tag and manually filter them, too.
|
|
// see https://bugs.webkit.org/show_bug.cgi?id=258922
|
|
const notifications = await sw.registration.getNotifications({ tag })
|
|
log(`[sw:push] ${nid} - found ${notifications.length} ${tag} notifications`)
|
|
log(`[sw:push] ${nid} - built-in tag filter: ${JSON.stringify(notifications.map(({ tag }) => tag))}`)
|
|
|
|
// we're not sure if the built-in tag filter actually filters by tag on iOS
|
|
// or if it just returns all currently displayed notifications (?)
|
|
const filtered = notifications.filter(({ tag: nTag }) => nTag === tag)
|
|
log(`[sw:push] ${nid} - found ${filtered.length} ${tag} notifications after manual tag filter`)
|
|
log(`[sw:push] ${nid} - manual tag filter: ${JSON.stringify(filtered.map(({ tag }) => tag))}`)
|
|
|
|
if (immediatelyShowNotification(tag)) {
|
|
// we can't rely on the tag property to replace notifications on Safari on iOS.
|
|
// we therefore close them manually and then we display the notification.
|
|
log(`[sw:push] ${nid} - ${tag} notifications replace previous notifications`)
|
|
setAppBadge(sw, ++activeCount)
|
|
// due to missing proper tag support in Safari on iOS, we can't rely on the tag property to replace notifications.
|
|
// see https://bugs.webkit.org/show_bug.cgi?id=258922 for more information
|
|
// we therefore fetch all notifications with the same tag (+ manual filter),
|
|
// close them and then we display the notification.
|
|
const notifications = await sw.registration.getNotifications({ tag })
|
|
// we only close notifications manually on iOS because we don't want to degrade android UX just because iOS is behind in their support.
|
|
if (iOS()) {
|
|
log(`[sw:push] ${nid} - closing existing notifications`)
|
|
notifications.filter(({ tag: nTag }) => nTag === tag).forEach(n => n.close())
|
|
}
|
|
log(`[sw:push] ${nid} - show notification with title "${payload.title}"`)
|
|
return await sw.registration.showNotification(payload.title, payload.options)
|
|
}
|
|
|
|
// according to the spec, there should only be zero or one notification since we used a tag filter
|
|
// handle zero case here
|
|
if (notifications.length === 0) {
|
|
// incoming notification is first notification with this tag
|
|
log(`[sw:push] ${nid} - no existing ${tag} notifications found`)
|
|
setAppBadge(sw, ++activeCount)
|
|
log(`[sw:push] ${nid} - show notification with title "${payload.title}"`)
|
|
return await sw.registration.showNotification(payload.title, payload.options)
|
|
}
|
|
|
|
// handle unexpected case here
|
|
if (notifications.length > 1) {
|
|
log(`[sw:push] ${nid} - more than one notification with tag ${tag} found`, 'error')
|
|
// due to missing proper tag support in Safari on iOS,
|
|
// we only acknowledge this error in our logs and don't bail here anymore
|
|
// see https://bugs.webkit.org/show_bug.cgi?id=258922 for more information
|
|
log(`[sw:push] ${nid} - skip bail -- merging notifications with tag ${tag} manually`)
|
|
// return null
|
|
}
|
|
|
|
return await mergeAndShowNotification(sw, payload, notifications, tag, nid)
|
|
})())
|
|
}
|
|
}
|
|
|
|
// if there is no tag or it's a TIP, FORWARDEDTIP or EARN notification
|
|
// we don't need to merge notifications and thus the notification should be immediately shown using `showNotification`
|
|
const immediatelyShowNotification = (tag) =>
|
|
!tag || ['TIP', 'FORWARDEDTIP', 'EARN', 'STREAK', 'TERRITORY_TRANSFER'].includes(tag.split('-')[0])
|
|
|
|
const mergeAndShowNotification = async (sw, payload, currentNotifications, tag, nid) => {
|
|
// sanity check
|
|
const otherTagNotifications = currentNotifications.filter(({ tag: nTag }) => nTag !== tag)
|
|
if (otherTagNotifications.length > 0) {
|
|
// we can't recover from this here. bail.
|
|
const message = `[sw:push] ${nid} - bailing -- more than one notification with tag ${tag} found after manual filter`
|
|
log(message, 'error')
|
|
return
|
|
}
|
|
|
|
const { data: incomingData } = payload.options
|
|
log(`[sw:push] ${nid} - incoming payload.options.data: ${JSON.stringify(incomingData)}`)
|
|
|
|
// we can ignore everything after the first dash in the tag for our control flow
|
|
const compareTag = tag.split('-')[0]
|
|
log(`[sw:push] ${nid} - using ${compareTag} for control flow`)
|
|
|
|
// merge notifications into single notification payload
|
|
// ---
|
|
// tags that need to know the amount of notifications with same tag for merging
|
|
const AMOUNT_TAGS = ['REPLY', 'MENTION', 'ITEM_MENTION', 'REFERRAL', 'INVITE', 'FOLLOW', 'TERRITORY_POST']
|
|
// tags that need to know the sum of sats of notifications with same tag for merging
|
|
const SUM_SATS_TAGS = ['DEPOSIT', 'WITHDRAWAL']
|
|
// this should reflect the amount of notifications that were already merged before
|
|
let initialAmount = currentNotifications[0]?.data?.amount || 1
|
|
if (iOS()) initialAmount = 1
|
|
log(`[sw:push] ${nid} - initial amount: ${initialAmount}`)
|
|
const mergedPayload = currentNotifications.reduce((acc, { data }) => {
|
|
let newAmount, newSats
|
|
if (AMOUNT_TAGS.includes(compareTag)) {
|
|
newAmount = acc.amount + 1
|
|
}
|
|
if (SUM_SATS_TAGS.includes(compareTag)) {
|
|
newSats = acc.sats + data.sats
|
|
}
|
|
const newPayload = { ...data, amount: newAmount, sats: newSats }
|
|
return newPayload
|
|
}, { ...incomingData, amount: initialAmount })
|
|
|
|
log(`[sw:push] ${nid} - merged payload: ${JSON.stringify(mergedPayload)}`)
|
|
|
|
// calculate title from merged payload
|
|
const { amount, followeeName, subName, subType, sats } = mergedPayload
|
|
let title = ''
|
|
if (AMOUNT_TAGS.includes(compareTag)) {
|
|
if (compareTag === 'REPLY') {
|
|
title = `you have ${amount} new replies`
|
|
} else if (compareTag === 'MENTION') {
|
|
title = `you were mentioned ${amount} times`
|
|
} else if (compareTag === 'ITEM_MENTION') {
|
|
title = `your items were mentioned ${amount} times`
|
|
} else if (compareTag === 'REFERRAL') {
|
|
title = `${amount} stackers joined via your referral links`
|
|
} else if (compareTag === 'INVITE') {
|
|
title = `your invite has been redeemed by ${amount} stackers`
|
|
} else if (compareTag === 'FOLLOW') {
|
|
title = `@${followeeName} ${subType === 'POST' ? `created ${amount} posts` : `replied ${amount} times`}`
|
|
} else if (compareTag === 'TERRITORY_POST') {
|
|
title = `you have ${amount} new posts in ~${subName}`
|
|
}
|
|
} else if (SUM_SATS_TAGS.includes(compareTag)) {
|
|
if (compareTag === 'DEPOSIT') {
|
|
title = `${numWithUnits(sats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account`
|
|
} else if (compareTag === 'WITHDRAWAL') {
|
|
title = `${numWithUnits(sats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account`
|
|
}
|
|
}
|
|
log(`[sw:push] ${nid} - calculated title: ${title}`)
|
|
|
|
// close all current notifications before showing new one to "merge" notifications
|
|
// we only do this on iOS because we don't want to degrade android UX just because iOS is behind in their support.
|
|
if (iOS()) {
|
|
log(`[sw:push] ${nid} - closing existing notifications`)
|
|
currentNotifications.forEach(n => n.close())
|
|
}
|
|
|
|
const options = { icon: payload.options?.icon, tag, data: { url: '/notifications', ...mergedPayload } }
|
|
log(`[sw:push] ${nid} - show notification with title "${title}"`)
|
|
return await sw.registration.showNotification(title, options)
|
|
}
|
|
|
|
export function onNotificationClick (sw) {
|
|
return (event) => {
|
|
const url = event.notification.data?.url
|
|
if (url) {
|
|
event.waitUntil(sw.clients.openWindow(url))
|
|
}
|
|
activeCount = Math.max(0, activeCount - 1)
|
|
if (activeCount === 0) {
|
|
clearAppBadge(sw)
|
|
} else {
|
|
setAppBadge(sw, activeCount)
|
|
}
|
|
event.notification.close()
|
|
}
|
|
}
|
|
|
|
export function onPushSubscriptionChange (sw) {
|
|
// https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f
|
|
// `isSync` is passed if function was called because of 'SYNC_SUBSCRIPTION' event
|
|
// this makes sure we can differentiate between 'pushsubscriptionchange' events and our custom 'SYNC_SUBSCRIPTION' event
|
|
return async (event, isSync) => {
|
|
let { oldSubscription, newSubscription } = event
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event
|
|
// fallbacks since browser may not set oldSubscription and newSubscription
|
|
log('[sw:handlePushSubscriptionChange] invoked')
|
|
oldSubscription ??= await storage.getItem('subscription')
|
|
newSubscription ??= await sw.registration.pushManager.getSubscription()
|
|
if (!newSubscription) {
|
|
if (isSync && oldSubscription?.swVersion === 2) {
|
|
// service worker lost the push subscription somehow, we assume this is a bug -> resubscribe
|
|
// see https://github.com/stackernews/stacker.news/issues/411#issuecomment-1790675861
|
|
// NOTE: this is only run on IndexedDB subscriptions stored under service worker version 2 since this is not backwards compatible
|
|
// see discussion in https://github.com/stackernews/stacker.news/pull/597
|
|
log('[sw:handlePushSubscriptionChange] service worker lost subscription')
|
|
actionChannelPort?.postMessage({ action: 'RESUBSCRIBE' })
|
|
return
|
|
}
|
|
// no subscription exists at the moment
|
|
log('[sw:handlePushSubscriptionChange] no existing subscription found')
|
|
return
|
|
}
|
|
if (oldSubscription?.endpoint === newSubscription.endpoint) {
|
|
// subscription did not change. no need to sync with server
|
|
log('[sw:handlePushSubscriptionChange] old subscription matches existing subscription')
|
|
return
|
|
}
|
|
// convert keys from ArrayBuffer to string
|
|
newSubscription = JSON.parse(JSON.stringify(newSubscription))
|
|
const variables = {
|
|
endpoint: newSubscription.endpoint,
|
|
p256dh: newSubscription.keys.p256dh,
|
|
auth: newSubscription.keys.auth,
|
|
oldEndpoint: oldSubscription?.endpoint
|
|
}
|
|
const query = `
|
|
mutation savePushSubscription($endpoint: String!, $p256dh: String!, $auth: String!, $oldEndpoint: String!) {
|
|
savePushSubscription(endpoint: $endpoint, p256dh: $p256dh, auth: $auth, oldEndpoint: $oldEndpoint) {
|
|
id
|
|
}
|
|
}`
|
|
const body = JSON.stringify({ query, variables })
|
|
await fetch('/api/graphql', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-type': 'application/json'
|
|
},
|
|
body
|
|
})
|
|
log('[sw:handlePushSubscriptionChange] synced push subscription with server', 'info', { endpoint: variables.endpoint, oldEndpoint: variables.oldEndpoint })
|
|
await storage.setItem('subscription', JSON.parse(JSON.stringify(newSubscription)))
|
|
}
|
|
}
|
|
|
|
export function onMessage (sw) {
|
|
return (event) => {
|
|
if (event.data.action === ACTION_PORT) {
|
|
actionChannelPort = event.ports[0]
|
|
return
|
|
}
|
|
if (event.data.action === STORE_OS) {
|
|
os = event.data.os
|
|
return
|
|
}
|
|
if (event.data.action === MESSAGE_PORT) {
|
|
messageChannelPort = event.ports[0]
|
|
}
|
|
log('[sw:message] received message', 'info', { action: event.data.action })
|
|
if (event.data.action === STORE_SUBSCRIPTION) {
|
|
log('[sw:message] storing subscription in IndexedDB', 'info', { endpoint: event.data.subscription.endpoint })
|
|
return event.waitUntil(storage.setItem('subscription', { ...event.data.subscription, swVersion: 2 }))
|
|
}
|
|
if (event.data.action === SYNC_SUBSCRIPTION) {
|
|
return event.waitUntil(onPushSubscriptionChange(sw)(event, true))
|
|
}
|
|
if (event.data.action === DELETE_SUBSCRIPTION) {
|
|
return event.waitUntil(storage.removeItem('subscription'))
|
|
}
|
|
if (event.data.action === CLEAR_NOTIFICATIONS) {
|
|
return event.waitUntil((async () => {
|
|
let notifications = []
|
|
try {
|
|
notifications = await sw.registration.getNotifications()
|
|
} catch (err) {
|
|
console.error('failed to get notifications')
|
|
}
|
|
notifications.forEach(notification => notification.close())
|
|
activeCount = 0
|
|
return await clearAppBadge(sw)
|
|
})())
|
|
}
|
|
}
|
|
}
|