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:
Sorin Davidoi 2018-10-06 20:06:10 +00:00 committed by Nolan Lawson
parent 50f2cadf50
commit e45af16bf9
11 changed files with 439 additions and 4 deletions

View file

@ -138,7 +138,8 @@
"btoa", "btoa",
"Blob", "Blob",
"Element", "Element",
"Image" "Image",
"NotificationEvent"
], ],
"ignore": [ "ignore": [
"dist", "dist",

View 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()
}
}
}

View file

@ -2,7 +2,7 @@ import { post, paramsString, WRITE_TIMEOUT } from '../_utils/ajax'
import { basename } from './utils' import { basename } from './utils'
const WEBSITE = 'https://pinafore.social' const WEBSITE = 'https://pinafore.social'
const SCOPES = 'read write follow' const SCOPES = 'read write follow push'
const CLIENT_NAME = 'Pinafore' const CLIENT_NAME = 'Pinafore'
export function registerApplication (instanceName, redirectUri) { export function registerApplication (instanceName, redirectUri) {

View 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))
}

View file

@ -15,6 +15,27 @@
<AccountDisplayName account={verifyCredentials} /> <AccountDisplayName account={verifyCredentials} />
</span> </span>
</div> </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> <h2>Theme:</h2>
<form class="theme-chooser" aria-label="Choose a theme"> <form class="theme-chooser" aria-label="Choose a theme">
<div class="theme-groups"> <div class="theme-groups">
@ -74,6 +95,20 @@
.acct-display-name { .acct-display-name {
grid-area: 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 { .theme-chooser {
background: var(--form-bg); background: var(--form-bg);
border: 1px solid var(--main-border); border: 1px solid var(--main-border);
@ -148,8 +183,10 @@
logOutOfInstance, logOutOfInstance,
updateVerifyCredentialsForInstance updateVerifyCredentialsForInstance
} from '../../../_actions/instances' } from '../../../_actions/instances'
import { updatePushSubscriptionForInstance, updateAlerts } from '../../../_actions/pushSubscription'
import { themes } from '../../../_static/themes' import { themes } from '../../../_static/themes'
import AccountDisplayName from '../../../_components/profile/AccountDisplayName.html' import AccountDisplayName from '../../../_components/profile/AccountDisplayName.html'
import { toast } from '../../../_utils/toast'
export default { export default {
async oncreate () { async oncreate () {
@ -159,6 +196,15 @@
selectedTheme: instanceThemes[instanceName] || 'default' selectedTheme: instanceThemes[instanceName] || 'default'
}) })
await updateVerifyCredentialsForInstance(instanceName) 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, store: () => store,
data: () => ({ data: () => ({
@ -168,6 +214,7 @@
computed: { computed: {
instanceName: ({ params }) => params.instanceName, instanceName: ({ params }) => params.instanceName,
verifyCredentials: ({ $verifyCredentials, instanceName }) => $verifyCredentials && $verifyCredentials[instanceName], verifyCredentials: ({ $verifyCredentials, instanceName }) => $verifyCredentials && $verifyCredentials[instanceName],
pushNotificationsSupport: ({ $pushNotificationsSupport }) => $pushNotificationsSupport,
themeGroups: ({ themes }) => ([ themeGroups: ({ themes }) => ([
{ {
dark: false, dark: false,
@ -189,6 +236,35 @@
let { instanceName } = this.get() let { instanceName } = this.get()
switchToInstance(instanceName) 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) { async onLogOut (e) {
e.preventDefault() e.preventDefault()
let { instanceName } = this.get() let { instanceName } = this.get()

View file

@ -1,6 +1,7 @@
import { updateInstanceInfo, updateVerifyCredentialsForInstance } from '../../_actions/instances' import { updateInstanceInfo, updateVerifyCredentialsForInstance } from '../../_actions/instances'
import { updateLists } from '../../_actions/lists' import { updateLists } from '../../_actions/lists'
import { createStream } from '../../_actions/streaming' import { createStream } from '../../_actions/streaming'
import { updatePushSubscriptionForInstance } from '../../_actions/pushSubscription'
import { updateCustomEmojiForInstance } from '../../_actions/emoji' import { updateCustomEmojiForInstance } from '../../_actions/emoji'
import { addStatusesOrNotifications } from '../../_actions/addStatusOrNotification' import { addStatusesOrNotifications } from '../../_actions/addStatusOrNotification'
import { getTimeline } from '../../_api/timelines' import { getTimeline } from '../../_api/timelines'
@ -28,6 +29,7 @@ export function instanceObservers (store) {
updateInstanceInfo(currentInstance) updateInstanceInfo(currentInstance)
updateCustomEmojiForInstance(currentInstance) updateCustomEmojiForInstance(currentInstance)
updateLists() updateLists()
updatePushSubscriptionForInstance(currentInstance)
await updateInstanceInfo(currentInstance) await updateInstanceInfo(currentInstance)

View 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 })
}
})
}

View file

@ -6,6 +6,7 @@ import { navObservers } from './navObservers'
import { autosuggestObservers } from './autosuggestObservers' import { autosuggestObservers } from './autosuggestObservers'
import { pageVisibilityObservers } from './pageVisibilityObservers' import { pageVisibilityObservers } from './pageVisibilityObservers'
import { resizeObservers } from './resizeObservers' import { resizeObservers } from './resizeObservers'
import { notificationPermissionObservers } from './notificationPermissionObservers'
export function observers (store) { export function observers (store) {
instanceObservers(store) instanceObservers(store)
@ -16,4 +17,5 @@ export function observers (store) {
autosuggestObservers(store) autosuggestObservers(store)
pageVisibilityObservers(store) pageVisibilityObservers(store)
resizeObservers(store) resizeObservers(store)
notificationPermissionObservers(store)
} }

View file

@ -17,7 +17,8 @@ const KEYS_TO_STORE_IN_LOCAL_STORAGE = new Set([
'reduceMotion', 'reduceMotion',
'omitEmojiInDisplayNames', 'omitEmojiInDisplayNames',
'pinnedPages', 'pinnedPages',
'composeData' 'composeData',
'pushSubscription'
]) ])
class PinaforeStore extends LocalStorageStore { class PinaforeStore extends LocalStorageStore {
@ -49,7 +50,9 @@ export const store = new PinaforeStore({
customEmoji: {}, customEmoji: {},
composeData: {}, composeData: {},
verifyCredentials: {}, 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) mixins(PinaforeStore)

20
routes/_utils/base64.js Normal file
View 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)
}

View file

@ -94,3 +94,206 @@ self.addEventListener('fetch', event => {
return fetch(req) 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'
})
}
}