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

  1. Stale While Revalidate ( not critical to show the newest data )
  2. Cache First (ex. fonts files never change)
  3. Network First (ex. api)
  4. Network Only (ex. admin pages never cache)
  5. 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);
        })
      })
    }