接續上次講嘅 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,我冇理佢,留低啲手尾?
#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!
短片: