Quasar - PWA with Install custom, caching, push notification, background sync
Don't forget to configure in quasar.conf for the manifest
quasar build -m pwa
Showing Install custom
<div
v-if="showAppInstallBanner"
class="banner-container bg-primary">
<div class="constrain">
<q-banner
inline-actions
dense
class="bg-primary text-white">
<template v-slot:avatar>
<q-avatar
color="white"
text-color="grey-10"
font-size="22px"
icon="eva-camera-outline" />
</template>
<strong>Install Quasargram?</strong>
<template v-slot:action>
<q-btn
flat
label="Yes"
class="q-px-sm"
dense />
<q-btn
flat
label="Later"
class="q-px-sm"
dense />
<q-btn
flat
label="Never"
class="q-px-sm"
dense />
</template>
</q-banner>
</div>
</div>
<script>
let deferredPrompt
export default {
name: 'MainLayout',
data () {
return {
showAppInstallBanner: false
}
},
mounted() {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
this.showAppInstallBanner = true
});
}
}
</script>
Firefox not supporting
beforeinstallprompt - https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent
Background Sync API - https://caniuse.com/?search=background%20sync
Button Yes to install
<q-btn
flat
@click="installApp"
label="Yes"
class="q-px-sm"
dense />
methods: {
installApp() {
this.showAppInstallBanner = false
deferredPrompt.prompt()
deferredPrompt.userChoice.then((choiceResult) => {
if (choiceResult.outcome === 'accepted') {
console.log('accepted')
} else {
console.log('rejected')
}
})
}
},
Never show again
<q-btn
flat
@click="neverShowAppInstallBanner"
label="Never"
class="q-px-sm"
dense />
<q-btn
flat
@click="showAppInstallBanner = false"
label="Later"
class="q-px-sm"
dense />
Add Plugin LocalStorage Quasar
methods: {
neverShowAppInstallBanner() {
this.showAppInstallBanner = false
this.$q.localStorage.set('neverShowAppInstallBanner', true)
}
},
mounted() {
let neverShowAppInstallBanner = this.$q.localStorage.getItem('neverShowAppInstallBanner')
if (!neverShowAppInstallBanner) {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
this.showAppInstallBanner = true
});
}
Creating Custom PWA
quasar.conf - set workboxPluginMode to InjectManifest
# custom-service-worker.js
import {precacheAndRoute} from 'workbox-precaching'
precacheAndRoute(self.__WB_MANIFEST)
I've been experiencing errors on updating other strategies, because of hot reloading, so from this point on better rerun quasar dev -m pwa after updating service worker
// disable workbox logs
self.__WB_DISABLE_DEV_LOGS = true
Caching Strategies
- Stale While Revalidate ( not critical to show the newest data )
- Cache First (ex. fonts files never change)
- Network First (ex. api)
- Network Only (ex. admin pages never cache)
- Cache Only
More detail about caching strategies
https://blog.bitsrc.io/5-service-worker-caching-strategies-for-your-next-pwa-app-58539f156f52
Register route url from google fonts
stalewhileRevalidate strategy
import {registerRoute} from 'workbox-routing';
import {StaleWhileRevalidate} from 'workbox-strategies';
// Cache Google Fonts with a stale-while-revalidate strategy, with
// a maximum number of entries.
registerRoute(
({url}) => url.origin === 'https://fonts.googleapis.com',
new StaleWhileRevalidate({
cacheName: 'google-fonts-stylesheets',
})
);
CacheFirst Strategy
import {registerRoute} from 'workbox-routing';
import {CacheFirst} from 'workbox-strategies';
import {CacheableResponsePlugin} from 'workbox-cacheable-response';
import {ExpirationPlugin} from 'workbox-expiration';
//cache first strategy
registerRoute(
({url}) => url.origin === 'https://fonts.gstatic.com',
new CacheFirst({
cacheName: 'google-fonts-webfonts',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxAgeSeconds: 60 * 60 * 24 * 365,
maxEntries: 30,
}),
],
})
)
Network first
import {registerRoute} from 'workbox-routing';
registerRoute(
({url}) => url.pathname.startsWith('/posts'),
new NetworkFirst()
);
Register route starts with http (all)
import {NetworkFirst} from 'workbox-strategies';
registerRoute(
({url}) => url.href.startsWith('http'),
new StaleWhileRevalidate()
)
After this runs there's 2 type of cache precache and runtime, precache is usually the layout and so on, runtime it will cache the posts as well.
Background sync
import {Queue} from 'workbox-background-sync';
let backgroundSyncSupported = 'sync' in self.registration ? true : false
let createPostQueue = null
if (backgroundSyncSupported) {
createPostQueue = new Queue('createPostQueue', {
onSync: async ({queue}) => {
let entry;
while (entry = await queue.shiftRequest()) {
try {
await fetch(entry.request);
console.log('Replay successful for request', entry.request)
const channel = new BroadcastChannel('sw-messages')
channel.postMessage({msg: 'offline-post-uploaded'})
} catch (error) {
console.log('Replay failed for request', entry.request, error)
// Put the entry back in the queue and re-throw the error:
await queue.unshiftRequest(entry);
throw error;
}
}
console.log('Replay complete!');
}
})
}
if (backgroundSyncSupported) {
self.addEventListener('fetch', (event) => {
if (event.request.url.endsWith('/createPost')) {
const promiseChain = fetch(event.request.clone()).catch((err) => {
return createPostQueue.pushRequest({request: event.request})
})
event.waitUntil(promiseChain)
}
})
}
Get offline posts from indexedDB
https://github.com/jakearchibald/idb
npm install idb --save
getPosts() {
this.loadingPosts = true
this.$axios.get(`${ process.env.API }/posts`).then(response => {
this.posts = response.data
this.loadingPosts = false
if (!navigator.onLine) {
this.getOfflinePosts()
}
}).catch(err => {
this.$q.dialog({
title: 'Error',
message: 'Could not find your post'
})
this.loadingPosts = false
})
},
getOfflinePosts() {
let db = openDB('workbox-background-sync').then(db => {
db.getAll('requests').then(failedRequests => {
failedRequests.forEach(failedRequest => {
if (failedRequest.queueName == 'createPostQueue') {
let request = new Request(failedRequest.requestData.url, failedRequest.requestData)
request.formData().then(formData => {
let offlinePost = {}
offlinePost.id = formData.get('id')
offlinePost.caption = formData.get('caption')
offlinePost.location = formData.get('location')
offlinePost.date = parseInt(formData.get('date'))
offlinePost.offline = true
let reader = new FileReader()
reader.readAsDataURL(formData.get('file'))
reader.onloadend = () => {
offlinePost.imageUrl = reader.result
this.posts.unshift(offlinePost)
}
})
}
})
}).catch(err => {
console.log('error ', err)
})
})
}
Using broadcast on sync hook
# service worker
const channel = new BroadcastChannel('sw-messages')
channel.postMessage({msg: 'offline-post-uploaded'})
# page receiving
const channel = new BroadcastChannel('sw-messages')
channel.addEventListener('message', event => {
console.log('Received', event.data)
})
Keep alive when changing url
<keep-alive :include=['PageName']>
<router-view />
</keep-alive>
Push Notifications
- Get Notification Permission
- Create a Push Subscription
- Store Subscriptions in Database
Unique keys and Unique Push server URL - Backend to loop through subscriptions
- Use service worker to listen for push messages
- display the notification
- bring user back to our app
- protect our push notifications with unique keys
Notifications and push notifications
Notifications
- Notifications permission required
- can be displayed anytime we like, but only when user is using the app
- can be triggered in our app's javascript code
- Minimal requirements: no need for subscriptions, backends or push servers
Push Notifications
- Notifications permission required
- sent to all of our subscribed users at once
- displayed anytime, even if the user is not using the app
- Complex requirements:
Push subscription for each user
Database to store subscriptions
Backend to send push messages
library web push - https://github.com/web-push-libs/web-push
<transition
appear
enter-active-class="animated fadeIn"
leave-active-class="animated fadeOut" >
<div
v-if="showNotificationsBanner && pushNotificationSupported"
class="banner-container bg-primary">
<div class="constrain">
<q-banner
class="bg-grey-3 q-mb-md">
<template v-slot:avatar>
<q-icon name="eva-bell-outline" color="primary"/>
</template>
Would you like to enable notifications?
<template v-slot:action>
<q-btn
flat
@click="enableNotifications"
label="Yes"
color="primary"
class="q-px-sm"
dense />
<q-btn
flat
@click="showNotificationsBanner = false"
label="Later"
color="primary"
class="q-px-sm"
dense />
<q-btn
flat
@click="neverShowNotificationsBanner"
label="Never"
color="primary"
class="q-px-sm"
dense />
</template>
</q-banner>
</div>
</div>
</transition>
<script>
let qs = require('qs');
export default {
name: 'PageHome',
data() {
return {
showNotificationsBanner: false
}
},
computed: {
pushNotificationSupported() {
if ('PushManager' in window) {
return true
}
return false
}
},
methods: {
initNotificationsBanner() {
let neverShowNotificationsBanner = this.$q.localStorage.getItem('neverShowNotificationsBanner')
if (!neverShowNotificationsBanner) {
this.showNotificationsBanner = true
}
},
enableNotifications() {
if (this.pushNotificationSupported) {
Notification.requestPermission(result => {
this.neverShowNotificationsBanner()
if (result == 'granted') {
//this.displayGrantedNotification()
this.checkForExistingPushSubscription()
}
})
}
},
checkForExistingPushSubscription() {
if (this.serviceWorkerSupported && this.pushNotificationSupported) {
let reg
navigator.serviceWorker.ready.then(swreg => {
reg = swreg
return swreg.pushManager.getSubscription()
}).then(sub => {
if (!sub) {
this.createPushSubscription(reg)
}
})
}
},
createPushSubscription(reg) {
let vapidPublicKeys = 'BMahtx---'
let vapidPublicKeyConverted = this.urlBase64ToUint8Array(vapidPublicKeys)
reg.pushManager.subscribe({
applicationServerKey: vapidPublicKeyConverted,
userVisibleOnly: true
}).then(newSub => {
let newSubData = newSub.toJSON(),
newSubDataQS = qs.stringify(newSubData)
return this.$axios.post(`${ process.env.API }/createSubscription?${newSubDataQS}`)
}).then(response => {
this.displayGrantedNotification()
}).catch(err => {
console.log('err: ', err);
})
},
displayGrantedNotification(){
new Notification("You're subsribed to notifications", {
body: "Thanks for subscribing!",
icon: "icons/icon-128x128.png",
image: "icons/icon-128x128.png",
badge: "icons/icon-128x128.png",
dir: "ltr",
lang: "en-US",
vibrate: [100,50.200],
tag: "confirm-notification",
renotify: true,
actions: [
{
action: 'hello',
title: 'Hello',
icon: "icons/icon-128x128.png"
},
{
action: 'goodbye',
title: 'Goodbye',
icon: "icons/icon-128x128.png"
}
]
})
},
neverShowNotificationsBanner() {
this.showNotificationsBanner = false
this.$q.localStorage.set('neverShowNotificationsBanner', true)
}
}
Add notification click events
# custom-service-worker.js
//events - notification
self.addEventListener('notificationclick', event => {
let notification = event.notification
let action = event.action
if (action == 'hello') {
console.log('hello clicked')
} else if (action == 'goodbye') {
console.log('goodbye clicked')
} else {
event.waitUntil(
clients.matchAll().then(clis => {
let clientUsingApp = clis.find(cli => {
return cli.visibilityState === 'visible'
})
if (clientUsingApp) {
clientUsingApp.navigate('notification.data.openUrl')
clientUsingApp.focus()
} else {
clients.openWindow('notification.data.openUrl')
}
})
)
}
})
Add Notification Push Events
//events - push
self.addEventListener('push', event => {
if (event.data) {
let data = JSON.parse(event.data.text())
event.waitUntil(
self.registration.showNotification(
data.title,
{
body: data.body,
icon: "icons/icon-128x128.png",
badge: "icons/icon-128x128.png",
}
)
)
}
})
# backend
let webpush = require('web-push')
/*
config - webpush
*/
webpush.setVapidDetails(
'mailto:test@test.com',
'BMahtx---',
'Je2Dk5Vd---'
);
function sendPushNotification() {
let subscriptions = []
db.collection('subscriptions').get().then(snapshot => {
snapshot.forEach((doc) => {
subscriptions.push(doc.data())
})
return subscriptions
}).then(subscriptions => {
subscriptions.forEach(subscription => {
const pushSubscription = {
endpoint:subscription.endpoint,
keys: {
auth: subscription.keys.auth,
p256dh: subscription.keys.p256dh
}
};
let pushContent = {
title: 'New Quasargram Post!',
body: 'New Post Added! Check it out!',
openUrl: '/#/'
}
let pushContentStringify = JSON.stringify(pushContent)
webpush.sendNotification(pushSubscription, pushContentStringify);
})
})
}