diff --git a/package.json b/package.json index 2a22cbb1..cd0fe95c 100644 --- a/package.json +++ b/package.json @@ -138,7 +138,8 @@ "btoa", "Blob", "Element", - "Image" + "Image", + "NotificationEvent" ], "ignore": [ "dist", diff --git a/routes/_actions/pushSubscription.js b/routes/_actions/pushSubscription.js new file mode 100644 index 00000000..6d45a1d8 --- /dev/null +++ b/routes/_actions/pushSubscription.js @@ -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() + } + } +} diff --git a/routes/_api/oauth.js b/routes/_api/oauth.js index 4d294bc2..8ab5ecc3 100644 --- a/routes/_api/oauth.js +++ b/routes/_api/oauth.js @@ -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) { diff --git a/routes/_api/pushSubscription.js b/routes/_api/pushSubscription.js new file mode 100644 index 00000000..a17a1455 --- /dev/null +++ b/routes/_api/pushSubscription.js @@ -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)) +} diff --git a/routes/_pages/settings/instances/[instanceName].html b/routes/_pages/settings/instances/[instanceName].html index 61b3a1f6..e0d98c41 100644 --- a/routes/_pages/settings/instances/[instanceName].html +++ b/routes/_pages/settings/instances/[instanceName].html @@ -15,6 +15,27 @@ +

Push notifications:

+
+ {#if pushNotificationsSupport === false} +

Your browser doesn't support push notifications.

+ {:elseif $notificationPermission === "denied"} +

You have denied permission to show notifications.

+ {/if} +
+ + +
+ + +
+ + +
+ + +
+

Theme:

@@ -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() diff --git a/routes/_store/observers/instanceObservers.js b/routes/_store/observers/instanceObservers.js index b5e4477c..b088d8a0 100644 --- a/routes/_store/observers/instanceObservers.js +++ b/routes/_store/observers/instanceObservers.js @@ -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) diff --git a/routes/_store/observers/notificationPermissionObservers.js b/routes/_store/observers/notificationPermissionObservers.js new file mode 100644 index 00000000..ad1275d6 --- /dev/null +++ b/routes/_store/observers/notificationPermissionObservers.js @@ -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 }) + } + }) +} diff --git a/routes/_store/observers/observers.js b/routes/_store/observers/observers.js index 3f0f5158..a66aa949 100644 --- a/routes/_store/observers/observers.js +++ b/routes/_store/observers/observers.js @@ -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) } diff --git a/routes/_store/store.js b/routes/_store/store.js index 0122b249..b772507a 100644 --- a/routes/_store/store.js +++ b/routes/_store/store.js @@ -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) diff --git a/routes/_utils/base64.js b/routes/_utils/base64.js new file mode 100644 index 00000000..d2d4c37a --- /dev/null +++ b/routes/_utils/base64.js @@ -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) +} diff --git a/templates/service-worker.js b/templates/service-worker.js index f14a97a3..efced8bc 100644 --- a/templates/service-worker.js +++ b/templates/service-worker.js @@ -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' + }) + } +}