feat: pwa with push notifications (#337)

This commit is contained in:
Joaquín Sánchez 2022-12-18 00:29:16 +01:00 committed by GitHub
parent a18e5e2332
commit f0c91a3974
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 2903 additions and 14 deletions

View file

@ -0,0 +1,119 @@
import type {
CreatePushSubscriptionParams,
PushSubscription as MastoPushSubscription,
} from 'masto'
import type {
CreatePushNotification,
PushManagerSubscriptionInfo,
RequiredUserLogin,
} from '~/composables/push-notifications/types'
import { useMasto } from '~/composables/masto'
import { currentUser, removePushNotifications } from '~/composables/users'
export const createPushSubscription = async (
user: RequiredUserLogin,
notificationData: CreatePushNotification,
): Promise<MastoPushSubscription | undefined> => {
const { server: serverEndpoint, vapidKey } = user
return await getRegistration()
.then(getPushSubscription)
.then(({ registration, subscription }): Promise<MastoPushSubscription | undefined> => {
if (subscription) {
const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey!)).toString()
const subscriptionServerKey = urlBase64ToUint8Array(vapidKey).toString()
// If the VAPID public key did not change and the endpoint corresponds
// to the endpoint saved in the backend, the subscription is valid
// If push subscription is not there, we need to create it: it is fetched on login
if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint && user.pushSubscription) {
return Promise.resolve(user.pushSubscription)
}
else if (user.pushSubscription) {
// if we have a subscription, but it is not valid, we need to remove it
return unsubscribeFromBackend(false)
.then(() => subscribe(registration, vapidKey))
.then(subscription => sendSubscriptionToBackend(subscription, notificationData))
}
}
return subscribe(registration, vapidKey).then(
subscription => sendSubscriptionToBackend(subscription, notificationData),
)
})
.catch((error) => {
if (error.code === 20 && error.name === 'AbortError')
console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.')
else if (error.code === 5 && error.name === 'InvalidCharacterError')
console.error('The VAPID public key seems to be invalid:', vapidKey)
return getRegistration()
.then(getPushSubscription)
.then(() => unsubscribeFromBackend(true))
.then(() => Promise.resolve(undefined))
.catch((e) => {
console.error(e)
return Promise.resolve(undefined)
})
})
}
// Taken from https://www.npmjs.com/package/web-push
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = `${base64String}${padding}`
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i)
outputArray[i] = rawData.charCodeAt(i)
return outputArray
}
function getRegistration() {
return navigator.serviceWorker.ready
}
async function getPushSubscription(registration: ServiceWorkerRegistration): Promise<PushManagerSubscriptionInfo> {
const subscription = await registration.pushManager.getSubscription()
return { registration, subscription }
}
async function subscribe(
registration: ServiceWorkerRegistration,
applicationServerKey: string,
): Promise<PushSubscription> {
return await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(applicationServerKey),
})
}
async function unsubscribeFromBackend(fromSWPushManager: boolean) {
const cu = currentUser.value
if (cu)
await removePushNotifications(cu, fromSWPushManager)
}
async function sendSubscriptionToBackend(
subscription: PushSubscription,
data: CreatePushNotification,
): Promise<MastoPushSubscription> {
const { endpoint, keys } = subscription.toJSON()
const params: CreatePushSubscriptionParams = {
subscription: {
endpoint: endpoint!,
keys: {
p256dh: keys!.p256dh!,
auth: keys!.auth!,
},
},
data,
}
return await useMasto().pushSubscriptions.create(params)
}

View file

@ -0,0 +1,22 @@
import type { PushSubscription as MastoPushSubscription, PushSubscriptionAlerts, SubscriptionPolicy } from 'masto'
import type { UserLogin } from '~/types'
export type SubscriptionResult = 'subscribed' | 'notification-denied' | 'invalid-state'
export interface PushManagerSubscriptionInfo {
registration: ServiceWorkerRegistration
subscription: PushSubscription | null
}
export interface RequiredUserLogin extends Required<Omit<UserLogin, 'account' | 'pushSubscription'>> {
pushSubscription?: MastoPushSubscription
}
export interface CreatePushNotification {
alerts?: Partial<PushSubscriptionAlerts> | null
policy?: SubscriptionPolicy
}
export type PushNotificationRequest = Record<string, boolean>
export type PushNotificationPolicy = Record<string, SubscriptionPolicy>

View file

@ -0,0 +1,171 @@
import type {
CreatePushNotification,
PushNotificationPolicy,
PushNotificationRequest,
SubscriptionResult,
} from '~/composables/push-notifications/types'
import { createPushSubscription } from '~/composables/push-notifications/createPushSubscription'
import { STORAGE_KEY_NOTIFICATION, STORAGE_KEY_NOTIFICATION_POLICY } from '~/constants'
import { currentUser, removePushNotifications } from '~/composables/users'
const supportsPushNotifications = typeof window !== 'undefined'
&& 'serviceWorker' in navigator
&& 'PushManager' in window
&& 'getKey' in PushSubscription.prototype
export const usePushManager = () => {
const isSubscribed = ref(false)
const notificationPermission = ref<PermissionState | undefined>(
Notification.permission === 'denied'
? 'denied'
: Notification.permission === 'granted'
? 'granted'
: Notification.permission === 'default'
? 'prompt'
: undefined,
)
const isSupported = $computed(() => supportsPushNotifications)
const hiddenNotification = useLocalStorage<PushNotificationRequest>(STORAGE_KEY_NOTIFICATION, {})
const configuredPolicy = useLocalStorage<PushNotificationPolicy>(STORAGE_KEY_NOTIFICATION_POLICY, {})
const pushNotificationData = ref({
follow: currentUser.value?.pushSubscription?.alerts.follow ?? true,
favourite: currentUser.value?.pushSubscription?.alerts.favourite ?? true,
reblog: currentUser.value?.pushSubscription?.alerts.reblog ?? true,
mention: currentUser.value?.pushSubscription?.alerts.mention ?? true,
poll: currentUser.value?.pushSubscription?.alerts.poll ?? true,
policy: configuredPolicy.value[currentUser.value?.account?.acct ?? ''] ?? 'all',
})
const { history, commit, clear } = useManualRefHistory(pushNotificationData, { clone: true })
const saveEnabled = computed(() => {
const current = pushNotificationData.value
const previous = history.value?.[0]?.snapshot
return current.favourite !== previous.favourite
|| current.reblog !== previous.reblog
|| current.mention !== previous.mention
|| current.follow !== previous.follow
|| current.poll !== previous.poll
|| current.policy !== previous.policy
})
watch(() => currentUser.value?.pushSubscription, (subscription) => {
isSubscribed.value = !!subscription
pushNotificationData.value = {
follow: subscription?.alerts.follow ?? false,
favourite: subscription?.alerts.favourite ?? false,
reblog: subscription?.alerts.reblog ?? false,
mention: subscription?.alerts.mention ?? false,
poll: subscription?.alerts.poll ?? false,
policy: configuredPolicy.value[currentUser.value?.account?.acct ?? ''] ?? 'all',
}
}, { immediate: true, flush: 'post' })
const subscribe = async (notificationData?: CreatePushNotification): Promise<SubscriptionResult> => {
if (!isSupported || !currentUser.value)
return 'invalid-state'
const { pushSubscription, server, token, vapidKey, account: { acct } } = currentUser.value
if (!token || !server || !vapidKey)
return 'invalid-state'
let permission: PermissionState | undefined
if (!notificationPermission.value || (notificationPermission.value === 'prompt' && !hiddenNotification.value[acct])) {
// safari 16 does not support navigator.permissions.query for notifications
try {
permission = (await navigator.permissions?.query({ name: 'notifications' }))?.state
}
catch {
permission = await Promise.resolve(Notification.requestPermission()).then((p: NotificationPermission) => {
return p === 'default' ? 'prompt' : p
})
}
}
else {
permission = notificationPermission.value
}
if (!permission || permission === 'denied') {
notificationPermission.value = permission
return 'notification-denied'
}
currentUser.value.pushSubscription = await createPushSubscription({
pushSubscription, server, token, vapidKey,
}, notificationData ?? {
alerts: {
follow: true,
favourite: true,
reblog: true,
mention: true,
poll: true,
},
policy: 'all',
})
await nextTick()
notificationPermission.value = permission
hiddenNotification.value[acct] = true
return 'subscribed'
}
const unsubscribe = async () => {
if (!isSupported || !isSubscribed || !currentUser.value)
return false
await removePushNotifications(currentUser.value)
}
const saveSettings = async () => {
commit()
configuredPolicy.value[currentUser.value!.account.acct ?? ''] = pushNotificationData.value.policy
await nextTick()
clear()
await nextTick()
}
const undoChanges = () => {
const current = pushNotificationData.value
const previous = history.value[0].snapshot
current.favourite = previous.favourite
current.reblog = previous.reblog
current.mention = previous.mention
current.follow = previous.follow
current.poll = previous.poll
current.policy = previous.policy
configuredPolicy.value[currentUser.value!.account.acct ?? ''] = previous.policy
commit()
clear()
}
const updateSubscription = async () => {
if (currentUser.value) {
currentUser.value.pushSubscription = await useMasto().pushSubscriptions.update({
data: {
alerts: {
follow: pushNotificationData.value.follow,
favourite: pushNotificationData.value.favourite,
reblog: pushNotificationData.value.reblog,
mention: pushNotificationData.value.mention,
poll: pushNotificationData.value.poll,
},
policy: pushNotificationData.value.policy,
},
})
await saveSettings()
}
}
return {
pushNotificationData,
saveEnabled,
undoChanges,
hiddenNotification,
isSupported,
isSubscribed,
notificationPermission,
updateSubscription,
subscribe,
unsubscribe,
}
}