Vue PWA + Firebase Push Notifications

接續上次講嘅 Vue PWA + Docker,今次係要加多少少功能,加個 Firebase Push Notifications,中文係咪叫「推送短訊」? 雖然今時今日嚟講係小功能,不過由於有 Windows、macOS、Android、同埋 iOS,仲有 Chrome、Edge Chromium 等等不同嘅 browser,都算幾麻煩㗎,我為咗減少工作量,淨係針對 Chrome 做測試,間唔中用埋 Edge Chromium 嚟証實有冇搞錯。

Notifications 有分 foreground 同 background,foreground 即係當個 web app 喺度 run 緊,你睇到個畫面嘅,background 就即係個 web app 喺分頁 run 緊,不過你個 browser 睇緊其他網站,或者完全冇 run 到(關咗)。

開波,先嚟睇個 project 多咗啲咩 filo?

 

加咗啲咩嘢 plugins?去睇 package.json:

{
  "name": "x5-easyrip",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "axios": "^0.19.2",
    "core-js": "^3.6.4",
    "fingerprintjs2": "^2.1.0",
    "firebase": "^7.14.2",
    "firebase-admin": "^8.11.0",
    "firebase-functions": "^3.6.1",
    "register-service-worker": "^1.6.2",
    "ua-parser-js": "^0.7.21",
    "velocity-animate": "^1.5.2",
    "vue": "^2.6.11",
    "vue-file-agent": "^1.6.1-beta.2",
    "vue-i18n": "^8.15.5",
    "vue-js-cookie": "^2.1.0",
    "vue-notification": "^1.3.20",
    "vue-router": "^3.1.5",
    "vuetify": "^2.2.11",
    "vuex": "^3.1.2",
    "vuex-persist": "^2.2.0"
  },
  "devDependencies": {
    "@intlify/vue-i18n-loader": "^0.6.1",
    "@vue/cli-plugin-babel": "^4.2.0",
    "@vue/cli-plugin-eslint": "^4.2.0",
    "@vue/cli-plugin-pwa": "^4.2.0",
    "@vue/cli-service": "^4.2.0",
    "babel-eslint": "^10.0.3",
    "eslint": "^6.7.2",
    "eslint-plugin-vue": "^6.1.2",
    "fibers": "^4.0.2",
    "node-sass": "^4.13.1",
    "sass": "^1.26.3",
    "sass-loader": "^8.0.0",
    "vue-cli-plugin-vuetify": "^2.0.5",
    "vue-template-compiler": "^2.6.11",
    "vuetify-loader": "^1.3.0"
  }
}

 

#14~16:係 Firebase 相關嘅 libraries

#17:用嚟登記個 service-worker JS,service-worker 係用嚟喺 background 接收 notifications

#13+18:先用 #18 嚟分析個 browser 嘅 User Agent 資料,然後根據 User Agent 嘅資料經 #13 gen 一個 unique 嘅 ID 用嚟記住個 user,我個 backend 要知邊個 user 然後會叫 Firebase 獨獨送出 notification 俾佢,咁就唔使送錯 notifications 俾唔相關嘅 users

#24+19:係用嚟顯示 customized 嘅 notifications,個 popup notifications 可以靚仔啲咁啫,#24 係主,#19 係美化

Okay?由上至下咁睇啲 filo 內容,firebase-messaging-sw.js

// Give the service worker access to Firebase Messaging.
// Note that you can only use Firebase Messaging here, other Firebase libraries
// are not available in the service worker.
importScripts('https://www.gstatic.com/firebasejs/7.14.2/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/7.14.2/firebase-messaging.js');

if (firebase.messaging.isSupported()) {
    // Initialize the Firebase app in the service worker by passing in
    // your app's Firebase config object.
    // https://firebase.google.com/docs/web/setup#config-object
    const config = {
        apiKey: 'XXXXXXXXXXXXXXXXXXXXXXXX',
        authDomain: "xxxxxxxxxxxxxxxxxxxx",
        databaseURL: "xxxxxxxxxxxxxxxxxxxxx",
        projectId: "xxxxxxxxxxx",
        storageBucket: "xxxxxxxxxxxxxxxxxxxx",
        messagingSenderId: "9999999999999",
        appId: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    };

    firebase.initializeApp(config);

    // Retrieve an instance of Firebase Messaging so that it can handle background
    // messages.
    const messaging = firebase.messaging();

    // Get Instance ID token. Initially this makes a network call, once retrieved
    // subsequent calls to getToken will return from cache.
    messaging.getToken()
        .then(function(currentToken) {
            if (currentToken) {
                console.log("getToken", currentToken);
            } else {
                // Show permission request.
                console.log('getToken: No Instance ID token available. Request permission to generate one.');
            }
        })
        .catch(function(err) {
            console.log('getToken: An error occurred while retrieving token. ', err);
    });

    // Callback fired if Instance ID token is updated.
    // messaging.onTokenRefresh(function() {
    //     messaging.getToken()
    //     .then(function(refreshedToken) {
    //         console.log('onTokenRefresh getToken Token refreshed.');
    //         console.log('onTokenRefresh getToken', refreshedToken);
    //     })
    //     .catch(function(err) {
    //         console.log('onTokenRefresh getToken Unable to retrieve refreshed token ', err);
    //     });
    // });


    // 顯示個收到嘅 message
    // refer: https://www.cometchat.com/tutorials/vue-chat-push-notifications
    messaging.setBackgroundMessageHandler(function (payload) {
        console.log('firebase-messaging-sw.js] Received background message ', payload);
        // var sender = JSON.parse(payload.data.message);
        var notificationTitle = payload.data.title;
        var notificationOptions = {
            body: payload.data.body,
            icon: '/img/favicon-32x32.png',
        };
        return self.registration.showNotification(
            notificationTitle,
            notificationOptions
        );
    });

    self.addEventListener('notificationclick', function (event) {
        event.notification.close();
        //handle click event onClick on Web Push Notification
    });
}

用嚟處理 background 收 notifications,

#12~18 係照抄你喺 Firebase 個 console 設定俾喱個 web app 嘅資料,人人唔同,隻隻 web app 唔同。

#29~40 係問 Firebase 攞個 unique 嘅 token 嚟認住喱個 user,同一部機唔同 browser 都會唔同嘅,有時會叫 FCM Token。

#42~52 係當個 Token 有變嘅時候會 trigger,我冇理佢,留低啲手尾? :mrgreen:

#57~69 就係當有 notifications 送到,而啱啱你又冇 foreground,咁佢就會 fire,啲 codes 會喺入嚟嘅 embedded data 抽出個 Title 同 Body 嚟顯示。

講就好似好易,不過基本上係冇得 debug 嘅,我就唔識點樣去 debug,你識嘅話可以教下我。

service-worker.js

// Set this to true for production
var doCache = true;

// Name our cache
var CACHE_NAME = 'x5-easyrip';

// Delete old caches that are not our current one!
self.addEventListener("activate", event => {
    const cacheWhitelist = [CACHE_NAME];
    event.waitUntil(
      caches.keys()
        .then(keyList =>
            Promise.all(keyList.map(key => {
                if (!cacheWhitelist.includes(key)) {
                    console.log('Deleting cache: ' + key);
                    return caches.delete(key);
                }
            }))
        )
    );
});

// The first time the user starts up the PWA, 'install' is triggered.
self.addEventListener('install', function (event) {
    if (doCache) {
        event.waitUntil(
          caches.open(CACHE_NAME)
            .then(function (cache) {
                // Get the assets manifest so we can see what our js file is named
                // This is because webpack hashes it
                fetch("asset-manifest.json")
                    .then(response => {
                        response.json(); 
                    })
                    .then(assets => {
                        // Open a cache and cache our files
                        // We want to cache the page and the main.js generated by webpack
                        // We could also cache any static assets like CSS or images
                        const urlsToCache = [
                            '/'
                            // '/plate',
                            // '/film',
                            // '/login',
                            // '/reset'
                        ];
                        cache.addAll(urlsToCache);
                        console.log('cached');
                    });
            })
        );
    }
});

// When the webpage goes to fetch files, we intercept that request and serve up the matching files
// if we have them
self.addEventListener('fetch', function (event) {
    if (doCache) {
        // SOLUTION: 
        // i) detect your upload endpoint 
        // ii) return so that you can use XHR
        if (event.request.url.indexOf('BaseForm') !== -1) {
            return;
        }
        event.respondWith(
            caches.match(event.request).then(function (response) {
                return response || fetch(event.request);
            })
        );
    }
});

因為我特登用咗 firebase-messaging-sw.js 嚟分開 Firebase 同 PWA 要用嘅 codes,所以 service-worker.js 應該冇改過,同 PWA 嘅時候相同。

utils/firebase.js

import firebase from 'firebase/app';
import 'firebase/messaging';
import 'firebase/functions';

/*
 * firebase init
 */
const initializeFirebase = () => {

    //? 有網友教用 .env 裝啲 data,例如:VUE_APP_FCM_API_KEY...,未決定跟唔跟
    const firebaseConfig = {
        apiKey: "xxxxxxxxxxxxxxxxxxxxxxxx",
        authDomain: "xxxxxxxxxxxxxxxxxxxxxx",
        databaseURL: "xxxxxxxxxxxxxxxxxxxxxxxxxxx",
        projectId: "xxxxxxxxxx",
        storageBucket: "xxxxxxxxxxxxxxxxxxxxxx",
        messagingSenderId: "9999999999999",
        appId: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx6"
    }

    firebase.initializeApp(firebaseConfig);

    navigator.serviceWorker
        .register('service-worker.js')
        .then(function(registration){
            window.console.log('Service worker successfully registered.');
            return registration;
        })
        .then((registration) => {
            window.console.log('call firebase.messaging');
            firebase.messaging().useServiceWorker(registration);
        })
        .catch(function(e) {
            window.console.log('Unable to register service worker.', e);
        });
}

export {
    initializeFirebase
}

重複出現 Firebase 嘅設定資料,係俾 foreground 用嘅。

App.vue

<template>
    <div id="app-root" v-cloak>
        <keep-alive exclude="">
            <router-view :key="$route.fullPath" />
        </keep-alive>
        <notifications group="custom-template"
            :duration="5000"
            animation-name="v-fade-left"
            width= "100%"
            position="top left">
            <template slot="body" slot-scope="props">
                <div class="custom-template">
                <div class="custom-template-icon">
                    <v-icon large>mdi-cloud-upload</v-icon>
                </div>
                <div class="custom-template-content">
                    <div class="custom-template-title">
                    {{props.item.title}}
                    </div>
                    <div class="custom-template-text"
                        v-html="props.item.text"></div>
                </div>
                <div class="custom-template-close"
                    @click="props.close">
                    <v-icon medium>mdi-close</v-icon>
                </div>
                </div>
            </template>
        </notifications>
    </div>
</template>

<script>
import { initializeFirebase } from '@/utils/firebase'
import firebase from 'firebase/app';
import 'firebase/messaging';
import 'firebase/functions';
import uaHelper from '@/utils/uaHelper';

export default {
    name: 'App',

    data: () => ({
        darkMode: JSON.parse(localStorage.getItem('darkMode')) === true,
    }),

    created() {
        window.getApp = this;

        /**
         * native notification 唔 work,改用 vue-notification
         */
        window.getApp.$on('showNotification', (payload) => {
            window.console.log("showNotification:", payload);

            this.$notify({
                group: 'custom-template',
                title: payload.data.title,
                text: payload.data.body,
                type: 'success',
                duration: -1,
                position: 'top left'
            });
        })


        /**
         * 轉換 light or dark theme,用 cookie 記住目前嘅選擇
         */
        window.getApp.$on('APP_DARK_THEME_TOGGLED', () => {

            this.darkMode = !this.darkMode;
            localStorage.setItem('darkMode', JSON.stringify(this.darkMode));

            window.console.log("darkMode: ", localStorage.getItem('darkMode'));

            this.$vuetify.theme.dark = this.darkMode;
        })

        /**
         * check 下有冇 notification 嘅 permission,依家冇咩用,日後睇下點
         * firebase 都係叫你用 Notification.permission 去 check
         * 參考:https://juejin.im/post/59ed37f5f265da431e15eaac
         */
        if (Notification.permission === 'granted') {
            console.log('用户允许通知');
        } else if (Notification.permission === 'denied') {
            console.log('用户拒绝通知');
        } else {
            console.log('用户还没选择,去向用户申请权限吧');
        }

        /**
         * init Firebase,問 user 准唔准收 push notifications?
         */
        initializeFirebase();

        const messaging = firebase.messaging();

        // Get Instance ID token. Initially this makes a network call, once retrieved
        // subsequent calls to getToken will return from cache.
        messaging.getToken().then((currentToken) => {
            if (currentToken) {
                // 收到 FCM token,即係有 push notifications permission
                window.console.log('Permisson already granted.');
                window.console.log('FCM Token: ', currentToken) // 3. Receiver Token to use in the notification

                localStorage.setItem('user-fcm-token', currentToken);
                uaHelper.storeUserAgent();
            } else {
                // 冇 FCM token,問 user 攞 permission,然後再去攞 FCM token
                window.console.log('No Instance ID token available. Request permission to generate one.');

                this.askUserPermission();
            }
        }).catch((err) => {
            window.console.log('An error occurred while retrieving token. ', err);
        });

        /**
         * 參考:https://www.sentinelstand.com/article/handling-firebase-notification-messages-in-your-web-app
         * Messages received. Either because the app is running in the foreground, or
         * because the notification was clicked.
         * `payload` will contain your data.
         */
        messaging.onMessage(function(payload) {
            window.console.log('Receiving foreground message', payload);

            // Customize notification here
            // const firebaseUid = '772018130355';
            // if (payload.from !== firebaseUid) {
            var notificationTitle = payload.data.title; //'CometChat Pro Notification';
            var notificationOptions = {
                body: payload.data.body, //.data.alert,
                icon: '/img/favicon-32x32.png', // sender.data.entities.sender.entity.avatar,
                requireInteraction: true
            };
            /** native notification localhost 唔 work 嘅 ,要用 showNotification 代替
            var notification = new Notification(notificationTitle, notificationOptions);
            notification.onclick = function(event) {
                notification.close();
                window.console.log(event);
            };
            */

            window.getApp.$emit('showNotification', payload);

            navigator.serviceWorker.ready.then(registration => {
                registration.showNotification(notificationTitle, notificationOptions);
            });

            // }
            // ...
        });
        /** End */
    },

    mounted() {
        //vuetify 2.x 冇再用 v-app dark prop,改用 this.$vuetify.theme.dark 嚟 toggle dark
        window.console.log("Mounted: ", this.darkMode);
        this.$vuetify.theme.dark = this.darkMode;
    },

    methods: {
        askUserPermission() {
            const messaging = firebase.messaging();
            messaging
                .requestPermission() // 1. Ask user to Allow push notifications
                .then(() => firebase.messaging().getToken()) // 2. Permission granted. Ask Firebase for FCM Token
                .then((token) => {
                    window.console.log('Permisson granted.');
                    window.console.log('FCM Token: ', token) // 3. Receiver Token to use in the notification

                    localStorage.setItem('user-fcm-token', token)
                    uaHelper.storeUserAgent();

                })
                .catch(function(err) {
                    window.console.log("Unable to get permission to notify.", err);

                    localStorage.removeItem('user-fcm-token');
                    uaHelper.removeUserAgent();
                });

        },

        showNotification() {
            this.$notify('something to tell', 'success');
        }
    }
};
</script>

<style lang="scss">
.custom-template {
  display: flex;
  flex-direction: row;
  flex-wrap: nowrap;

  text-align: left;
  font-size: 16px;
  font-family: arial;
  margin: 5px;
  margin-bottom: 0;
  align-items: center;
  justify-content: center;

  &, & > div {
    box-sizing: border-box;
  }

  background: #E8F9F0;
  border: 2px solid #D0F2E1;


  .custom-template-icon {
    flex: 0 1 auto;
    color: #15C371;
    font-size: 32px;
    padding: 0 10px;
  }

  .custom-template-close {
    flex: 0 1 auto;
    padding: 0 20px;
    font-size: 16px;
    opacity: 0.2;
    cursor: pointer;

    &:hover {
      opacity: 0.8;
    }
  }

  .custom-template-content {
    padding: 10px;
    flex: 1 0 auto;

    .custom-template-title {
      letter-spacing: 1px;
      text-transform: uppercase;
      font-size: 18px;
      font-family: arial;
      font-weight: 400;
    }
  }
}

.v-fade-left-enter-active,
.v-fade-left-leave-active,
.v-fade-left-move {
  transition: all .5s;
}

.v-fade-left-enter,
.v-fade-left-leave-to {
  opacity: 0;
  transform: translateX(-500px) scale(0.2);
}
</style>

vue 檔案分成 3 個段落,分別係 Template、Script、同 Style,Template 係個畫面見到嘅嘢,Style 係特別為咗今個 Template 而建嘅 Style codes,Script 就係個靈魂。

#6~29 係個 foreground Notificaton,我依照個 plugin vue-notification 做,不過我冇用 basic style,用咗 custom,左邊有個大啲嘅 icon,中間係個 notification,右邊加個 X icon,用嚟 close 個 notification。啲 Style 係為咗配合喱個畫面而加嘅。

#53~64 係顯示個 notification

#96 係喺 foreground 啟動個 Firebase

#102~118 係去 Firebase 攞個 FCM Token 返嚟用

#126~154 當收到 notifications 時會 fire,fire 咗就 call #53 去顯示

#148~150 應該係因為依家個 native notification 唔再用,退役咗 deprecated,要叫個 service-worker 顯示個 notification,不過我唔覺佢會叫到 service-worker 做嘢?

#165~185 係問個 user 攞 permission,如果冇 permission 就收唔到 notifiations,而且 macOS 同 Windows 又唔同處理,好麻煩嘅,多數收唔倒 notifications 都同 permission 有關,更何況我用 Windows 就長期 disabled 😀

main.js

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import './registerServiceWorker'
import vuetify from './config/vuetify';
import i18n from './locale/i18n'
import VueFileAgent from 'vue-file-agent';
import VueFileAgentStyles from 'vue-file-agent/dist/vue-file-agent.css';
import VueNotification from 'vue-notification'
import velocity from 'velocity-animate'

Vue.config.productionTip = false
Vue.use(VueFileAgent);
Vue.use(VueNotification, { velocity });

new Vue({
  VueFileAgent,
  VueFileAgentStyles,
  i18n,
  router,
  store,
  vuetify,
  created() {
    // 跳去 landing-page
    this.$router.push(localStorage.getItem('landing-page') === undefined ? 'plate' : localStorage.getItem('landing-page'));
  },
  render: h => h(App)
}).$mount('#app')

#10~11 import 個 vue-notification,喺 #15 加入。我冇特別搞 velocity 啲 animate 效果,目前嘅就已經好滿意。

好似就係咁多,thanks!

短片: