Skip to content

Commit 14dfa62

Browse files
authored
Merge pull request #1499 from frappe/mergify/bp/version-15-hotfix/pr-1384
feat(PWA): Push Notifications (backport #1384)
2 parents a13efad + 64ecc65 commit 14dfa62

21 files changed

+1725
-304
lines changed

frontend/index.html

+3
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,10 @@
184184

185185
<script>
186186
window.csrf_token = "{{ csrf_token }}"
187+
if (!window.frappe) window.frappe = {}
188+
frappe.boot = {{ boot }}
187189
</script>
190+
188191
<script type="module" src="/src/main.js"></script>
189192
</body>
190193
</html>

frontend/package.json

+9-4
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,18 @@
1919
"frappe-ui": "^0.1.18",
2020
"vue": "^3.2.25",
2121
"vue-router": "^4.0.12",
22-
"vite": "^4.5.0",
23-
"vite-plugin-pwa": "^0.16.6",
2422
"autoprefixer": "^10.4.2",
2523
"postcss": "^8.4.5",
24+
"tailwindcss": "^3.0.15",
25+
"vite": "^5.1.4",
26+
"vite-plugin-pwa": "^0.19.0",
27+
"workbox-precaching": "^7.0.0",
28+
"workbox-core": "^7.0.0",
29+
"firebase": "^10.8.0"
30+
},
31+
"devDependencies": {
2632
"eslint": "^8.39.0",
2733
"eslint-plugin-vue": "^9.11.0",
28-
"prettier": "^2.8.8",
29-
"tailwindcss": "^3.0.15"
34+
"prettier": "^2.8.8"
3035
}
3136
}
+282
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import { initializeApp } from "firebase/app"
2+
import {
3+
getMessaging,
4+
getToken,
5+
isSupported,
6+
deleteToken,
7+
onMessage as onFCMMessage,
8+
} from "firebase/messaging"
9+
10+
class FrappePushNotification {
11+
static get relayServerBaseURL() {
12+
return window.frappe?.boot.push_relay_server_url
13+
}
14+
15+
// Type definitions
16+
/**
17+
* Web Config
18+
* FCM web config to initialize firebase app
19+
*
20+
* @typedef {object} webConfigType
21+
* @property {string} projectId
22+
* @property {string} appId
23+
* @property {string} apiKey
24+
* @property {string} authDomain
25+
* @property {string} messagingSenderId
26+
*/
27+
28+
/**
29+
* Constructor
30+
*
31+
* @param {string} projectName
32+
*/
33+
constructor(projectName) {
34+
// client info
35+
this.projectName = projectName
36+
/** @type {webConfigType | null} */
37+
this.webConfig = null
38+
this.vapidPublicKey = ""
39+
this.token = null
40+
41+
// state
42+
this.initialized = false
43+
this.messaging = null
44+
/** @type {ServiceWorkerRegistration | null} */
45+
this.serviceWorkerRegistration = null
46+
47+
// event handlers
48+
this.onMessageHandler = null
49+
}
50+
51+
/**
52+
* Initialize notification service client
53+
*
54+
* @param {ServiceWorkerRegistration} serviceWorkerRegistration - Service worker registration object
55+
* @returns {Promise<void>}
56+
*/
57+
async initialize(serviceWorkerRegistration) {
58+
if (this.initialized) {
59+
return
60+
}
61+
this.serviceWorkerRegistration = serviceWorkerRegistration
62+
const config = await this.fetchWebConfig()
63+
this.messaging = getMessaging(initializeApp(config))
64+
this.onMessage(this.onMessageHandler)
65+
this.initialized = true
66+
}
67+
68+
/**
69+
* Append config to service worker URL
70+
*
71+
* @param {string} url - Service worker URL
72+
* @param {string} parameter_name - Parameter name to add config
73+
* @returns {Promise<string>} - Service worker URL with config
74+
*/
75+
async appendConfigToServiceWorkerURL(url, parameter_name = "config") {
76+
let config = await this.fetchWebConfig()
77+
const encode_config = encodeURIComponent(JSON.stringify(config))
78+
return `${url}?${parameter_name}=${encode_config}`
79+
}
80+
81+
/**
82+
* Fetch web config of the project
83+
*
84+
* @returns {Promise<webConfigType>}
85+
*/
86+
async fetchWebConfig() {
87+
if (this.webConfig !== null && this.webConfig !== undefined) {
88+
return this.webConfig
89+
}
90+
let url = `${FrappePushNotification.relayServerBaseURL}/api/method/notification_relay.api.get_config?project_name=${this.projectName}`
91+
let response = await fetch(url)
92+
let response_json = await response.json()
93+
this.webConfig = response_json.config
94+
return this.webConfig
95+
}
96+
97+
/**
98+
* Fetch VAPID public key
99+
*
100+
* @returns {Promise<string>}
101+
*/
102+
async fetchVapidPublicKey() {
103+
if (this.vapidPublicKey !== "") {
104+
return this.vapidPublicKey
105+
}
106+
let url = `${FrappePushNotification.relayServerBaseURL}/api/method/notification_relay.api.get_config?project_name=${this.projectName}`
107+
let response = await fetch(url)
108+
let response_json = await response.json()
109+
this.vapidPublicKey = response_json.vapid_public_key
110+
return this.vapidPublicKey
111+
}
112+
113+
/**
114+
* Register on message handler
115+
*
116+
* @param {function(
117+
* {
118+
* data:{
119+
* title: string,
120+
* body: string,
121+
* click_action: string|null,
122+
* }
123+
* }
124+
* )} callback - Callback function to handle message
125+
*/
126+
onMessage(callback) {
127+
if (callback == null) return
128+
this.onMessageHandler = callback
129+
if (this.messaging == null) return
130+
onFCMMessage(this.messaging, this.onMessageHandler)
131+
}
132+
133+
/**
134+
* Check if notification is enabled
135+
*
136+
* @returns {boolean}
137+
*/
138+
isNotificationEnabled() {
139+
return localStorage.getItem(`firebase_token_${this.projectName}`) !== null
140+
}
141+
142+
/**
143+
* Enable notification
144+
* This will return notification permission status and token
145+
*
146+
* @returns {Promise<{permission_granted: boolean, token: string}>}
147+
*/
148+
async enableNotification() {
149+
if (!(await isSupported())) {
150+
throw new Error("Push notifications are not supported on your device")
151+
}
152+
// Return if token already presence in the instance
153+
if (this.token != null) {
154+
return {
155+
permission_granted: true,
156+
token: this.token,
157+
}
158+
}
159+
// ask for permission
160+
const permission = await Notification.requestPermission()
161+
if (permission !== "granted") {
162+
return {
163+
permission_granted: false,
164+
token: "",
165+
}
166+
}
167+
// check in local storage for old token
168+
let oldToken = localStorage.getItem(`firebase_token_${this.projectName}`)
169+
const vapidKey = await this.fetchVapidPublicKey()
170+
let newToken = await getToken(this.messaging, {
171+
vapidKey: vapidKey,
172+
serviceWorkerRegistration: this.serviceWorkerRegistration,
173+
})
174+
// register new token if token is changed
175+
if (oldToken !== newToken) {
176+
// unsubscribe old token
177+
if (oldToken) {
178+
await this.unregisterTokenHandler(oldToken)
179+
}
180+
// subscribe push notification and register token
181+
let isSubscriptionSuccessful = await this.registerTokenHandler(newToken)
182+
if (isSubscriptionSuccessful === false) {
183+
throw new Error("Failed to subscribe to push notification")
184+
}
185+
// save token to local storage
186+
localStorage.setItem(`firebase_token_${this.projectName}`, newToken)
187+
}
188+
this.token = newToken
189+
return {
190+
permission_granted: true,
191+
token: newToken,
192+
}
193+
}
194+
195+
/**
196+
* Disable notification
197+
* This will delete token from firebase and unsubscribe from push notification
198+
*
199+
* @returns {Promise<void>}
200+
*/
201+
async disableNotification() {
202+
if (this.token == null) {
203+
// try to fetch token from local storage
204+
this.token = localStorage.getItem(`firebase_token_${this.projectName}`)
205+
if (this.token == null || this.token === "") {
206+
return
207+
}
208+
}
209+
// delete old token from firebase
210+
try {
211+
await deleteToken(this.messaging)
212+
} catch (e) {
213+
console.error("Failed to delete token from firebase")
214+
console.error(e)
215+
}
216+
try {
217+
await this.unregisterTokenHandler(this.token)
218+
} catch {
219+
console.error("Failed to unsubscribe from push notification")
220+
console.error(e)
221+
}
222+
// remove token
223+
localStorage.removeItem(`firebase_token_${this.projectName}`)
224+
this.token = null
225+
}
226+
227+
/**
228+
* Register Token Handler
229+
*
230+
* @param {string} token - FCM token returned by {@link enableNotification} method
231+
* @returns {promise<boolean>}
232+
*/
233+
async registerTokenHandler(token) {
234+
try {
235+
let response = await fetch(
236+
"/api/method/frappe.push_notification.subscribe?fcm_token=" +
237+
token +
238+
"&project_name=" +
239+
this.projectName,
240+
{
241+
method: "GET",
242+
headers: {
243+
"Content-Type": "application/json",
244+
},
245+
}
246+
)
247+
return response.status === 200
248+
} catch (e) {
249+
console.error(e)
250+
return false
251+
}
252+
}
253+
254+
/**
255+
* Unregister Token Handler
256+
*
257+
* @param {string} token - FCM token returned by `enableNotification` method
258+
* @returns {promise<boolean>}
259+
*/
260+
async unregisterTokenHandler(token) {
261+
try {
262+
let response = await fetch(
263+
"/api/method/frappe.push_notification.unsubscribe?fcm_token=" +
264+
token +
265+
"&project_name=" +
266+
this.projectName,
267+
{
268+
method: "GET",
269+
headers: {
270+
"Content-Type": "application/json",
271+
},
272+
}
273+
)
274+
return response.status === 200
275+
} catch (e) {
276+
console.error(e)
277+
return false
278+
}
279+
}
280+
}
281+
282+
export default FrappePushNotification

frontend/public/sw.js

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching"
2+
import { clientsClaim } from "workbox-core"
3+
4+
import { initializeApp } from "firebase/app"
5+
import { getMessaging, onBackgroundMessage } from "firebase/messaging/sw"
6+
7+
// Use the precache manifest generated by Vite
8+
precacheAndRoute(self.__WB_MANIFEST)
9+
10+
// Clean up old caches
11+
cleanupOutdatedCaches()
12+
13+
const jsonConfig = new URL(location).searchParams.get("config")
14+
const firebaseApp = initializeApp(JSON.parse(jsonConfig))
15+
const messaging = getMessaging(firebaseApp)
16+
17+
function isChrome() {
18+
return navigator.userAgent.toLowerCase().includes("chrome")
19+
}
20+
21+
onBackgroundMessage(messaging, (payload) => {
22+
const notificationTitle = payload.data.title
23+
let notificationOptions = {
24+
body: payload.data.body || "",
25+
}
26+
if (payload.data.notification_icon) {
27+
notificationOptions["icon"] = payload.data.notification_icon
28+
}
29+
if (isChrome()) {
30+
notificationOptions["data"] = {
31+
url: payload.data.click_action,
32+
}
33+
} else {
34+
if (payload.data.click_action) {
35+
notificationOptions["actions"] = [
36+
{
37+
action: payload.data.click_action,
38+
title: "View Details",
39+
},
40+
]
41+
}
42+
}
43+
self.registration.showNotification(notificationTitle, notificationOptions)
44+
})
45+
46+
if (isChrome()) {
47+
self.addEventListener("notificationclick", (event) => {
48+
event.stopImmediatePropagation()
49+
event.notification.close()
50+
if (event.notification.data && event.notification.data.url) {
51+
clients.openWindow(event.notification.data.url)
52+
}
53+
})
54+
}
55+
56+
self.skipWaiting()
57+
clientsClaim()

frontend/src/App.vue

+8
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,17 @@
88
</template>
99

1010
<script setup>
11+
import { onMounted } from "vue"
1112
import { IonApp, IonRouterOutlet } from "@ionic/vue"
1213
1314
import { Toasts } from "frappe-ui"
1415
1516
import InstallPrompt from "@/components/InstallPrompt.vue"
17+
import { showNotification } from "@/utils/pushNotifications"
18+
19+
onMounted(() => {
20+
window?.frappePushNotification?.onMessage((payload) => {
21+
showNotification(payload)
22+
})
23+
})
1624
</script>

0 commit comments

Comments
 (0)