diff --git a/frontend/index.html b/frontend/index.html index 30fa865ee7..9682e3b7b0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -184,7 +184,10 @@ + diff --git a/frontend/package.json b/frontend/package.json index 46e1842693..709465ceed 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,13 +19,18 @@ "frappe-ui": "^0.1.18", "vue": "^3.2.25", "vue-router": "^4.0.12", - "vite": "^4.5.0", - "vite-plugin-pwa": "^0.16.6", "autoprefixer": "^10.4.2", "postcss": "^8.4.5", + "tailwindcss": "^3.0.15", + "vite": "^5.1.4", + "vite-plugin-pwa": "^0.19.0", + "workbox-precaching": "^7.0.0", + "workbox-core": "^7.0.0", + "firebase": "^10.8.0" + }, + "devDependencies": { "eslint": "^8.39.0", "eslint-plugin-vue": "^9.11.0", - "prettier": "^2.8.8", - "tailwindcss": "^3.0.15" + "prettier": "^2.8.8" } } diff --git a/frontend/public/frappe-push-notification.js b/frontend/public/frappe-push-notification.js new file mode 100644 index 0000000000..4b42f06f59 --- /dev/null +++ b/frontend/public/frappe-push-notification.js @@ -0,0 +1,294 @@ +import { initializeApp } from "firebase/app" +import { + getMessaging, + getToken, + isSupported, + deleteToken, + onMessage as onFCMMessage, +} from "firebase/messaging" + +class FrappePushNotification { + static get relayServerBaseURL() { + return window.frappe?.boot.push_relay_server_url + } + + // Type definitions + /** + * Web Config + * FCM web config to initialize firebase app + * + * @typedef {object} webConfigType + * @property {string} projectId + * @property {string} appId + * @property {string} apiKey + * @property {string} authDomain + * @property {string} messagingSenderId + */ + + /** + * Constructor + * + * @param {string} projectName + */ + constructor(projectName) { + // client info + this.projectName = projectName + /** @type {webConfigType | null} */ + this.webConfig = null + this.vapidPublicKey = "" + this.token = null + + // state + this.initialized = false + this.messaging = null + /** @type {ServiceWorkerRegistration | null} */ + this.serviceWorkerRegistration = null + + // event handlers + this.onMessageHandler = null + } + + /** + * Initialize notification service client + * + * @param {ServiceWorkerRegistration} serviceWorkerRegistration - Service worker registration object + * @returns {Promise} + */ + async initialize(serviceWorkerRegistration) { + if (this.initialized) { + return + } + this.serviceWorkerRegistration = serviceWorkerRegistration + const config = await this.fetchWebConfig() + this.messaging = getMessaging(initializeApp(config)) + this.onMessage(this.onMessageHandler) + this.initialized = true + } + + /** + * Append config to service worker URL + * + * @param {string} url - Service worker URL + * @param {string} parameter_name - Parameter name to add config + * @returns {Promise} - Service worker URL with config + */ + async appendConfigToServiceWorkerURL(url, parameter_name = "config") { + let config = await this.fetchWebConfig() + const encode_config = encodeURIComponent(JSON.stringify(config)) + return `${url}?${parameter_name}=${encode_config}` + } + + /** + * Fetch web config of the project + * + * @returns {Promise} + */ + async fetchWebConfig() { + if (this.webConfig !== null && this.webConfig !== undefined) { + return this.webConfig + } + try { + let url = `${FrappePushNotification.relayServerBaseURL}/api/method/notification_relay.api.get_config?project_name=${this.projectName}` + let response = await fetch(url) + let response_json = await response.json() + this.webConfig = response_json.config + return this.webConfig + } catch (e) { + throw new Error( + "Push Notification Relay is not configured properly on your site." + ) + } + } + + /** + * Fetch VAPID public key + * + * @returns {Promise} + */ + async fetchVapidPublicKey() { + if (this.vapidPublicKey !== "") { + return this.vapidPublicKey + } + try { + let url = `${FrappePushNotification.relayServerBaseURL}/api/method/notification_relay.api.get_config?project_name=${this.projectName}` + let response = await fetch(url) + let response_json = await response.json() + this.vapidPublicKey = response_json.vapid_public_key + return this.vapidPublicKey + } catch (e) { + throw new Error( + "Push Notification Relay is not configured properly on your site." + ) + } + } + + /** + * Register on message handler + * + * @param {function( + * { + * data:{ + * title: string, + * body: string, + * click_action: string|null, + * } + * } + * )} callback - Callback function to handle message + */ + onMessage(callback) { + if (callback == null) return + this.onMessageHandler = callback + if (this.messaging == null) return + onFCMMessage(this.messaging, this.onMessageHandler) + } + + /** + * Check if notification is enabled + * + * @returns {boolean} + */ + isNotificationEnabled() { + return localStorage.getItem(`firebase_token_${this.projectName}`) !== null + } + + /** + * Enable notification + * This will return notification permission status and token + * + * @returns {Promise<{permission_granted: boolean, token: string}>} + */ + async enableNotification() { + if (!(await isSupported())) { + throw new Error("Push notifications are not supported on your device") + } + // Return if token already presence in the instance + if (this.token != null) { + return { + permission_granted: true, + token: this.token, + } + } + // ask for permission + const permission = await Notification.requestPermission() + if (permission !== "granted") { + return { + permission_granted: false, + token: "", + } + } + // check in local storage for old token + let oldToken = localStorage.getItem(`firebase_token_${this.projectName}`) + const vapidKey = await this.fetchVapidPublicKey() + let newToken = await getToken(this.messaging, { + vapidKey: vapidKey, + serviceWorkerRegistration: this.serviceWorkerRegistration, + }) + // register new token if token is changed + if (oldToken !== newToken) { + // unsubscribe old token + if (oldToken) { + await this.unregisterTokenHandler(oldToken) + } + // subscribe push notification and register token + let isSubscriptionSuccessful = await this.registerTokenHandler(newToken) + if (isSubscriptionSuccessful === false) { + throw new Error("Failed to subscribe to push notification") + } + // save token to local storage + localStorage.setItem(`firebase_token_${this.projectName}`, newToken) + } + this.token = newToken + return { + permission_granted: true, + token: newToken, + } + } + + /** + * Disable notification + * This will delete token from firebase and unsubscribe from push notification + * + * @returns {Promise} + */ + async disableNotification() { + if (this.token == null) { + // try to fetch token from local storage + this.token = localStorage.getItem(`firebase_token_${this.projectName}`) + if (this.token == null || this.token === "") { + return + } + } + // delete old token from firebase + try { + await deleteToken(this.messaging) + } catch (e) { + console.error("Failed to delete token from firebase") + console.error(e) + } + try { + await this.unregisterTokenHandler(this.token) + } catch { + console.error("Failed to unsubscribe from push notification") + console.error(e) + } + // remove token + localStorage.removeItem(`firebase_token_${this.projectName}`) + this.token = null + } + + /** + * Register Token Handler + * + * @param {string} token - FCM token returned by {@link enableNotification} method + * @returns {promise} + */ + async registerTokenHandler(token) { + try { + let response = await fetch( + "/api/method/frappe.push_notification.subscribe?fcm_token=" + + token + + "&project_name=" + + this.projectName, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + } + ) + return response.status === 200 + } catch (e) { + console.error(e) + return false + } + } + + /** + * Unregister Token Handler + * + * @param {string} token - FCM token returned by `enableNotification` method + * @returns {promise} + */ + async unregisterTokenHandler(token) { + try { + let response = await fetch( + "/api/method/frappe.push_notification.unsubscribe?fcm_token=" + + token + + "&project_name=" + + this.projectName, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + } + ) + return response.status === 200 + } catch (e) { + console.error(e) + return false + } + } +} + +export default FrappePushNotification diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000000..cb110afeb7 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,64 @@ +import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching" +import { clientsClaim } from "workbox-core" + +import { initializeApp } from "firebase/app" +import { getMessaging, onBackgroundMessage } from "firebase/messaging/sw" + +// Use the precache manifest generated by Vite +precacheAndRoute(self.__WB_MANIFEST) + +// Clean up old caches +cleanupOutdatedCaches() + +const jsonConfig = new URL(location).searchParams.get("config") + +// Firebase config initialization +try { + const firebaseApp = initializeApp(JSON.parse(jsonConfig)) + const messaging = getMessaging(firebaseApp) + + function isChrome() { + return navigator.userAgent.toLowerCase().includes("chrome") + } + + onBackgroundMessage(messaging, (payload) => { + const notificationTitle = payload.data.title + let notificationOptions = { + body: payload.data.body || "", + } + if (payload.data.notification_icon) { + notificationOptions["icon"] = payload.data.notification_icon + } + if (isChrome()) { + notificationOptions["data"] = { + url: payload.data.click_action, + } + } else { + if (payload.data.click_action) { + notificationOptions["actions"] = [ + { + action: payload.data.click_action, + title: "View Details", + }, + ] + } + } + self.registration.showNotification(notificationTitle, notificationOptions) + }) + + if (isChrome()) { + self.addEventListener("notificationclick", (event) => { + event.stopImmediatePropagation() + event.notification.close() + if (event.notification.data && event.notification.data.url) { + clients.openWindow(event.notification.data.url) + } + }) + } +} catch (error) { + console.log("Failed to initialize Firebase", error) +} + +self.skipWaiting() +clientsClaim() +console.log("Service Worker Initialized") diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 929fd19d01..37dc2b6efb 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,9 +8,17 @@ diff --git a/frontend/src/components/ListView.vue b/frontend/src/components/ListView.vue index 4e3cf1ee35..f6bf3c38d6 100644 --- a/frontend/src/components/ListView.vue +++ b/frontend/src/components/ListView.vue @@ -109,15 +109,7 @@ diff --git a/frontend/src/views/Notifications.vue b/frontend/src/views/Notifications.vue index 57ec2d0f5f..f670509e8c 100644 --- a/frontend/src/views/Notifications.vue +++ b/frontend/src/views/Notifications.vue @@ -19,24 +19,36 @@
-
-
+
+
{{ unreadNotificationsCount.data }} Unread
- +
+ + +
+ window.frappe?.boot.push_relay_server_url && + arePushNotificationsEnabled.data +) + const markAllAsRead = createResource({ url: "hrms.api.mark_all_notifications_as_read", onSuccess() { diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue index a8ef5cf1b9..1ceff543f4 100644 --- a/frontend/src/views/Profile.vue +++ b/frontend/src/views/Profile.vue @@ -42,6 +42,7 @@ }}
+
+ +
+
+ +
+ +
+ Settings +
+
+ +
+
+
+