Push notifications (#579)
* feat: Push notifications * feat: Feature-detect push notifications support * feat: Prompt user to reauthenticate when missing push scope * fix(service-worker): Add tags to notifications * feat: Push notification actions for mentions
This commit is contained in:
parent
50f2cadf50
commit
e45af16bf9
|
@ -138,7 +138,8 @@
|
|||
"btoa",
|
||||
"Blob",
|
||||
"Element",
|
||||
"Image"
|
||||
"Image",
|
||||
"NotificationEvent"
|
||||
],
|
||||
"ignore": [
|
||||
"dist",
|
||||
|
|
89
routes/_actions/pushSubscription.js
Normal file
89
routes/_actions/pushSubscription.js
Normal file
|
@ -0,0 +1,89 @@
|
|||
import { getSubscription, deleteSubscription, postSubscription, putSubscription } from '../_api/pushSubscription'
|
||||
import { store } from '../_store/store'
|
||||
import { urlBase64ToUint8Array } from '../_utils/base64'
|
||||
|
||||
const dummyApplicationServerKey = 'BImgAz4cF_yvNFp8uoBJCaGpCX4d0atNIFMHfBvAAXCyrnn9IMAFQ10DW_ZvBCzGeR4fZI5FnEi2JVcRE-L88jY='
|
||||
|
||||
export async function updatePushSubscriptionForInstance (instanceName) {
|
||||
const { loggedInInstances, pushSubscription } = store.get()
|
||||
const accessToken = loggedInInstances[instanceName].access_token
|
||||
|
||||
if (pushSubscription === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
|
||||
if (subscription === null) {
|
||||
store.set({ pushSubscription: null })
|
||||
store.save()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const backendSubscription = await getSubscription(instanceName, accessToken)
|
||||
|
||||
// Check if applicationServerKey changed (need to get another subscription from the browser)
|
||||
if (btoa(urlBase64ToUint8Array(backendSubscription.server_key).buffer) !== btoa(subscription.options.applicationServerKey)) {
|
||||
await subscription.unsubscribe()
|
||||
await deleteSubscription(instanceName, accessToken)
|
||||
await updateAlerts(instanceName, pushSubscription.alerts)
|
||||
} else {
|
||||
store.set({ pushSubscription: backendSubscription })
|
||||
store.save()
|
||||
}
|
||||
} catch (e) {
|
||||
// TODO: Better way to detect 404
|
||||
if (e.message.startsWith('404:')) {
|
||||
await subscription.unsubscribe()
|
||||
store.set({ pushSubscription: null })
|
||||
store.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAlerts (instanceName, alerts) {
|
||||
const { loggedInInstances } = store.get()
|
||||
const accessToken = loggedInInstances[instanceName].access_token
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
let subscription = await registration.pushManager.getSubscription()
|
||||
|
||||
if (subscription === null) {
|
||||
// We need applicationServerKey in order to register a push subscription
|
||||
// but the API doesn't expose it as a constant (as it should).
|
||||
// So we need to register a subscription with a dummy applicationServerKey,
|
||||
// send it to the backend saves it and return applicationServerKey, which
|
||||
// we use to register a new subscription.
|
||||
// https://github.com/tootsuite/mastodon/issues/8785
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
applicationServerKey: urlBase64ToUint8Array(dummyApplicationServerKey),
|
||||
userVisibleOnly: true
|
||||
})
|
||||
|
||||
let backendSubscription = await postSubscription(instanceName, accessToken, subscription, alerts)
|
||||
|
||||
await subscription.unsubscribe()
|
||||
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
applicationServerKey: urlBase64ToUint8Array(backendSubscription.server_key),
|
||||
userVisibleOnly: true
|
||||
})
|
||||
|
||||
backendSubscription = await postSubscription(instanceName, accessToken, subscription, alerts)
|
||||
|
||||
store.set({ pushSubscription: backendSubscription })
|
||||
store.save()
|
||||
} else {
|
||||
try {
|
||||
const backendSubscription = await putSubscription(instanceName, accessToken, alerts)
|
||||
store.set({ pushSubscription: backendSubscription })
|
||||
store.save()
|
||||
} catch (e) {
|
||||
const backendSubscription = await postSubscription(instanceName, accessToken, subscription, alerts)
|
||||
store.set({ pushSubscription: backendSubscription })
|
||||
store.save()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@ import { post, paramsString, WRITE_TIMEOUT } from '../_utils/ajax'
|
|||
import { basename } from './utils'
|
||||
|
||||
const WEBSITE = 'https://pinafore.social'
|
||||
const SCOPES = 'read write follow'
|
||||
const SCOPES = 'read write follow push'
|
||||
const CLIENT_NAME = 'Pinafore'
|
||||
|
||||
export function registerApplication (instanceName, redirectUri) {
|
||||
|
|
26
routes/_api/pushSubscription.js
Normal file
26
routes/_api/pushSubscription.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { auth, basename } from './utils'
|
||||
import { post, put, get, del } from '../_utils/ajax'
|
||||
|
||||
export async function postSubscription (instanceName, accessToken, subscription, alerts) {
|
||||
const url = `${basename(instanceName)}/api/v1/push/subscription`
|
||||
|
||||
return post(url, { subscription: subscription.toJSON(), data: { alerts } }, auth(accessToken))
|
||||
}
|
||||
|
||||
export async function putSubscription (instanceName, accessToken, alerts) {
|
||||
const url = `${basename(instanceName)}/api/v1/push/subscription`
|
||||
|
||||
return put(url, { data: { alerts } }, auth(accessToken))
|
||||
}
|
||||
|
||||
export async function getSubscription (instanceName, accessToken) {
|
||||
const url = `${basename(instanceName)}/api/v1/push/subscription`
|
||||
|
||||
return get(url, auth(accessToken))
|
||||
}
|
||||
|
||||
export async function deleteSubscription (instanceName, accessToken) {
|
||||
const url = `${basename(instanceName)}/api/v1/push/subscription`
|
||||
|
||||
return del(url, auth(accessToken))
|
||||
}
|
|
@ -15,6 +15,27 @@
|
|||
<AccountDisplayName account={verifyCredentials} />
|
||||
</span>
|
||||
</div>
|
||||
<h2>Push notifications:</h2>
|
||||
<div class="push-notifications">
|
||||
{#if pushNotificationsSupport === false}
|
||||
<p>Your browser doesn't support push notifications.</p>
|
||||
{:elseif $notificationPermission === "denied"}
|
||||
<p role="alert">You have denied permission to show notifications.</p>
|
||||
{/if}
|
||||
<form id="push-notification-settings" disabled="{!pushNotificationsSupport}" ref:pushNotificationsForm aria-label="Push notification settings">
|
||||
<input type="checkbox" id="push-notifications-follow" name="follow" disabled="{!pushNotificationsSupport}" on:change="onPushSettingsChange(event)">
|
||||
<label for="push-notifications-follow">New followers</label>
|
||||
<br>
|
||||
<input type="checkbox" id="push-notifications-favourite" name="favourite" disabled="{!pushNotificationsSupport}" on:change="onPushSettingsChange(event)">
|
||||
<label for="push-notifications-favourite">Favourites</label>
|
||||
<br>
|
||||
<input type="checkbox" id="push-notifications-reblog" name="reblog" disabled="{!pushNotificationsSupport}" on:change="onPushSettingsChange(event)">
|
||||
<label for="push-notifications-reblog">Boosts</label>
|
||||
<br>
|
||||
<input type="checkbox" id="push-notifications-mention" name="mention" disabled="{!pushNotificationsSupport}" on:change="onPushSettingsChange(event)">
|
||||
<label for="push-notifications-mention">Mentions</label>
|
||||
</form>
|
||||
</div>
|
||||
<h2>Theme:</h2>
|
||||
<form class="theme-chooser" aria-label="Choose a theme">
|
||||
<div class="theme-groups">
|
||||
|
@ -74,6 +95,20 @@
|
|||
.acct-display-name {
|
||||
grid-area: display-name;
|
||||
}
|
||||
.push-notifications {
|
||||
background: var(--form-bg);
|
||||
border: 1px solid var(--main-border);
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
padding: 20px;
|
||||
line-height: 2em;
|
||||
}
|
||||
.push-notifications form[disabled="true"] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.push-notifications p {
|
||||
margin: 0;
|
||||
}
|
||||
.theme-chooser {
|
||||
background: var(--form-bg);
|
||||
border: 1px solid var(--main-border);
|
||||
|
@ -148,8 +183,10 @@
|
|||
logOutOfInstance,
|
||||
updateVerifyCredentialsForInstance
|
||||
} from '../../../_actions/instances'
|
||||
import { updatePushSubscriptionForInstance, updateAlerts } from '../../../_actions/pushSubscription'
|
||||
import { themes } from '../../../_static/themes'
|
||||
import AccountDisplayName from '../../../_components/profile/AccountDisplayName.html'
|
||||
import { toast } from '../../../_utils/toast'
|
||||
|
||||
export default {
|
||||
async oncreate () {
|
||||
|
@ -159,6 +196,15 @@
|
|||
selectedTheme: instanceThemes[instanceName] || 'default'
|
||||
})
|
||||
await updateVerifyCredentialsForInstance(instanceName)
|
||||
await updatePushSubscriptionForInstance(instanceName)
|
||||
|
||||
const form = this.refs.pushNotificationsForm
|
||||
const { pushSubscription } = this.store.get()
|
||||
|
||||
form.elements.follow.checked = pushSubscription && pushSubscription.alerts && pushSubscription.alerts.follow
|
||||
form.elements.favourite.checked = pushSubscription && pushSubscription.alerts && pushSubscription.alerts.favourite
|
||||
form.elements.reblog.checked = pushSubscription && pushSubscription.alerts && pushSubscription.alerts.reblog
|
||||
form.elements.mention.checked = pushSubscription && pushSubscription.alerts && pushSubscription.alerts.mention
|
||||
},
|
||||
store: () => store,
|
||||
data: () => ({
|
||||
|
@ -168,6 +214,7 @@
|
|||
computed: {
|
||||
instanceName: ({ params }) => params.instanceName,
|
||||
verifyCredentials: ({ $verifyCredentials, instanceName }) => $verifyCredentials && $verifyCredentials[instanceName],
|
||||
pushNotificationsSupport: ({ $pushNotificationsSupport }) => $pushNotificationsSupport,
|
||||
themeGroups: ({ themes }) => ([
|
||||
{
|
||||
dark: false,
|
||||
|
@ -189,6 +236,35 @@
|
|||
let { instanceName } = this.get()
|
||||
switchToInstance(instanceName)
|
||||
},
|
||||
async onPushSettingsChange (e) {
|
||||
const { instanceName } = this.get()
|
||||
const form = this.refs.pushNotificationsForm
|
||||
const alerts = {
|
||||
follow: form.elements.follow.checked,
|
||||
favourite: form.elements.favourite.checked,
|
||||
reblog: form.elements.reblog.checked,
|
||||
mention: form.elements.mention.checked
|
||||
}
|
||||
|
||||
try {
|
||||
await updateAlerts(instanceName, alerts)
|
||||
} catch (err) {
|
||||
e.target.checked = !e.target.checked
|
||||
|
||||
// TODO: Better way to detect missing authorization scope
|
||||
if (err.message.startsWith('403:')) {
|
||||
let showConfirmationDialog = await importShowConfirmationDialog()
|
||||
showConfirmationDialog({
|
||||
text: `You need to reauthenticate in order to enable push notification. Log out of ${instanceName}?`,
|
||||
onPositive () {
|
||||
logOutOfInstance(instanceName)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
toast.say(`Failed to update push notification settings: ${err.message}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
async onLogOut (e) {
|
||||
e.preventDefault()
|
||||
let { instanceName } = this.get()
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { updateInstanceInfo, updateVerifyCredentialsForInstance } from '../../_actions/instances'
|
||||
import { updateLists } from '../../_actions/lists'
|
||||
import { createStream } from '../../_actions/streaming'
|
||||
import { updatePushSubscriptionForInstance } from '../../_actions/pushSubscription'
|
||||
import { updateCustomEmojiForInstance } from '../../_actions/emoji'
|
||||
import { addStatusesOrNotifications } from '../../_actions/addStatusOrNotification'
|
||||
import { getTimeline } from '../../_api/timelines'
|
||||
|
@ -28,6 +29,7 @@ export function instanceObservers (store) {
|
|||
updateInstanceInfo(currentInstance)
|
||||
updateCustomEmojiForInstance(currentInstance)
|
||||
updateLists()
|
||||
updatePushSubscriptionForInstance(currentInstance)
|
||||
|
||||
await updateInstanceInfo(currentInstance)
|
||||
|
||||
|
|
13
routes/_store/observers/notificationPermissionObservers.js
Normal file
13
routes/_store/observers/notificationPermissionObservers.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
export function notificationPermissionObservers (store) {
|
||||
if (!process.browser) {
|
||||
return
|
||||
}
|
||||
|
||||
navigator.permissions.query({ name: 'notifications' }).then(permission => {
|
||||
store.set({ notificationPermission: permission.state })
|
||||
|
||||
permission.onchange = event => {
|
||||
store.set({ notificationPermission: event.target.state })
|
||||
}
|
||||
})
|
||||
}
|
|
@ -6,6 +6,7 @@ import { navObservers } from './navObservers'
|
|||
import { autosuggestObservers } from './autosuggestObservers'
|
||||
import { pageVisibilityObservers } from './pageVisibilityObservers'
|
||||
import { resizeObservers } from './resizeObservers'
|
||||
import { notificationPermissionObservers } from './notificationPermissionObservers'
|
||||
|
||||
export function observers (store) {
|
||||
instanceObservers(store)
|
||||
|
@ -16,4 +17,5 @@ export function observers (store) {
|
|||
autosuggestObservers(store)
|
||||
pageVisibilityObservers(store)
|
||||
resizeObservers(store)
|
||||
notificationPermissionObservers(store)
|
||||
}
|
||||
|
|
|
@ -17,7 +17,8 @@ const KEYS_TO_STORE_IN_LOCAL_STORAGE = new Set([
|
|||
'reduceMotion',
|
||||
'omitEmojiInDisplayNames',
|
||||
'pinnedPages',
|
||||
'composeData'
|
||||
'composeData',
|
||||
'pushSubscription'
|
||||
])
|
||||
|
||||
class PinaforeStore extends LocalStorageStore {
|
||||
|
@ -49,7 +50,9 @@ export const store = new PinaforeStore({
|
|||
customEmoji: {},
|
||||
composeData: {},
|
||||
verifyCredentials: {},
|
||||
online: !process.browser || navigator.onLine
|
||||
online: !process.browser || navigator.onLine,
|
||||
pushNotificationsSupport: process.browser && ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in window.PushSubscription.prototype),
|
||||
pushSubscription: null
|
||||
})
|
||||
|
||||
mixins(PinaforeStore)
|
||||
|
|
20
routes/_utils/base64.js
Normal file
20
routes/_utils/base64.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
const decodeBase64 = base64 => {
|
||||
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
|
||||
}
|
||||
|
||||
// Taken from https://www.npmjs.com/package/web-push
|
||||
export const urlBase64ToUint8Array = (base64String) => {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4)
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/')
|
||||
|
||||
return decodeBase64(base64)
|
||||
}
|
|
@ -94,3 +94,206 @@ self.addEventListener('fetch', event => {
|
|||
return fetch(req)
|
||||
})())
|
||||
})
|
||||
|
||||
self.addEventListener('push', event => {
|
||||
event.waitUntil((async () => {
|
||||
const data = event.data.json()
|
||||
const { origin } = new URL(data.icon)
|
||||
|
||||
try {
|
||||
const notification = await get(`${origin}/api/v1/notifications/${data.notification_id}`, {
|
||||
'Authorization': `Bearer ${data.access_token}`
|
||||
}, { timeout: 2000 })
|
||||
|
||||
await showRichNotification(data, notification)
|
||||
} catch (e) {
|
||||
await showSimpleNotification(data)
|
||||
}
|
||||
})())
|
||||
})
|
||||
|
||||
async function showSimpleNotification (data) {
|
||||
await self.registration.showNotification(data.title, {
|
||||
icon: data.icon,
|
||||
body: data.body
|
||||
})
|
||||
}
|
||||
|
||||
async function showRichNotification (data, notification) {
|
||||
const { origin } = new URL(data.icon)
|
||||
|
||||
switch (notification.type) {
|
||||
case 'follow': {
|
||||
await self.registration.showNotification(data.title, {
|
||||
icon: data.icon,
|
||||
body: data.body,
|
||||
tag: notification.id,
|
||||
data: {
|
||||
url: `${self.location.origin}/accounts/${notification.account.id}`
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'mention': {
|
||||
const actions = [{
|
||||
action: 'favourite',
|
||||
title: 'Favourite'
|
||||
}, {
|
||||
action: 'reblog',
|
||||
title: 'Boost'
|
||||
}]
|
||||
|
||||
if ('reply' in NotificationEvent.prototype) {
|
||||
actions.splice(0, 0, {
|
||||
action: 'reply',
|
||||
type: 'text',
|
||||
title: 'Reply'
|
||||
})
|
||||
}
|
||||
|
||||
await self.registration.showNotification(data.title, {
|
||||
icon: data.icon,
|
||||
body: data.body,
|
||||
tag: notification.id,
|
||||
data: {
|
||||
instance: origin,
|
||||
status_id: notification.status.id,
|
||||
access_token: data.access_token,
|
||||
url: `${self.location.origin}/statuses/${notification.status.id}`
|
||||
},
|
||||
actions
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'reblog': {
|
||||
await self.registration.showNotification(data.title, {
|
||||
icon: data.icon,
|
||||
body: data.body,
|
||||
tag: notification.id,
|
||||
data: {
|
||||
url: `${self.location.origin}/statuses/${notification.status.id}`
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'favourite': {
|
||||
await self.registration.showNotification(data.title, {
|
||||
icon: data.icon,
|
||||
body: data.body,
|
||||
tag: notification.id,
|
||||
data: {
|
||||
url: `${self.location.origin}/statuses/${notification.status.id}`
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cloneNotification = notification => {
|
||||
const clone = { }
|
||||
|
||||
// Object.assign() does not work with notifications
|
||||
for (let k in notification) {
|
||||
clone[k] = notification[k]
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
const updateNotificationWithoutAction = (notification, action) => {
|
||||
const newNotification = cloneNotification(notification)
|
||||
|
||||
newNotification.actions = newNotification.actions.filter(item => item.action !== action)
|
||||
|
||||
return self.registration.showNotification(newNotification.title, newNotification)
|
||||
}
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.waitUntil((async () => {
|
||||
switch (event.action) {
|
||||
case 'reply': {
|
||||
await post(`${event.notification.data.instance}/api/v1/statuses/`, {
|
||||
status: event.reply,
|
||||
in_reply_to_id: event.notification.data.status_id
|
||||
}, { 'Authorization': `Bearer ${event.notification.data.access_token}` })
|
||||
await updateNotificationWithoutAction(event.notification, 'reply')
|
||||
break
|
||||
}
|
||||
case 'reblog': {
|
||||
await post(`${event.notification.data.instance}/api/v1/statuses/${event.notification.data.status_id}/reblog`, null, { 'Authorization': `Bearer ${event.notification.data.access_token}` })
|
||||
await updateNotificationWithoutAction(event.notification, 'reblog')
|
||||
break
|
||||
}
|
||||
case 'favourite': {
|
||||
await post(`${event.notification.data.instance}/api/v1/statuses/${event.notification.data.status_id}/favourite`, null, { 'Authorization': `Bearer ${event.notification.data.access_token}` })
|
||||
await updateNotificationWithoutAction(event.notification, 'favourite')
|
||||
break
|
||||
}
|
||||
default: {
|
||||
await self.clients.openWindow(event.notification.data.url)
|
||||
await event.notification.close()
|
||||
break
|
||||
}
|
||||
}
|
||||
})())
|
||||
})
|
||||
|
||||
// Copy-paste from ajax.js
|
||||
async function get (url, headers, options) {
|
||||
return _fetch(url, makeFetchOptions('GET', headers), options)
|
||||
}
|
||||
|
||||
async function post (url, body, headers, options) {
|
||||
return _putOrPostOrPatch('POST', url, body, headers, options)
|
||||
}
|
||||
|
||||
async function _putOrPostOrPatch (method, url, body, headers, options) {
|
||||
let fetchOptions = makeFetchOptions(method, headers)
|
||||
if (body) {
|
||||
if (body instanceof FormData) {
|
||||
fetchOptions.body = body
|
||||
} else {
|
||||
fetchOptions.body = JSON.stringify(body)
|
||||
fetchOptions.headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
}
|
||||
return _fetch(url, fetchOptions, options)
|
||||
}
|
||||
|
||||
async function _fetch (url, fetchOptions, options) {
|
||||
let response
|
||||
if (options && options.timeout) {
|
||||
response = await fetchWithTimeout(url, fetchOptions, options.timeout)
|
||||
} else {
|
||||
response = await fetch(url, fetchOptions)
|
||||
}
|
||||
return throwErrorIfInvalidResponse(response)
|
||||
}
|
||||
|
||||
async function throwErrorIfInvalidResponse (response) {
|
||||
let json = await response.json()
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return json
|
||||
}
|
||||
if (json && json.error) {
|
||||
throw new Error(response.status + ': ' + json.error)
|
||||
}
|
||||
throw new Error('Request failed: ' + response.status)
|
||||
}
|
||||
|
||||
function fetchWithTimeout (url, fetchOptions, timeout) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(url, fetchOptions).then(resolve, reject)
|
||||
setTimeout(() => reject(new Error(`Timed out after ${timeout / 1000} seconds`)), timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function makeFetchOptions (method, headers) {
|
||||
return {
|
||||
method,
|
||||
headers: Object.assign(headers || {}, {
|
||||
'Accept': 'application/json'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue