forked from cybrespace/pinafore
		
	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
					
				
					 11 changed files with 439 additions and 4 deletions
				
			
		|  | @ -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…
	
	Add table
		
		Reference in a new issue