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",
|
"btoa",
|
||||||
"Blob",
|
"Blob",
|
||||||
"Element",
|
"Element",
|
||||||
"Image"
|
"Image",
|
||||||
|
"NotificationEvent"
|
||||||
],
|
],
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"dist",
|
"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'
|
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) {
|
||||||
|
|
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} />
|
<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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
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 { 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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
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)
|
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