Files
team/frontend/public/frappe-push-notification.js
Nikhil KothariandGitHub 68fa26cd89 feat: use Raven Cloud for push notifications (#1654)
* wip: push notification using Raven Cloud

* wip: raven cloud configuration

* feat: send notifications to DMs

* feat: send messages silently

* fix: do not send notification in install/import/patch

* chore: order of text on push notification configuration
2025-05-02 15:03:10 +05:30

306 lines
9.5 KiB
JavaScript

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<void>}
*/
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<string>} - 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<webConfigType>}
*/
async fetchWebConfig() {
if (this.webConfig !== null && this.webConfig !== undefined) {
return this.webConfig
}
try {
if (window.frappe?.boot.push_notification_service === "Raven") {
this.webConfig = JSON.parse(window.frappe?.boot.firebase_client_config)
return this.webConfig
}
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<string>}
*/
async fetchVapidPublicKey() {
if (this.vapidPublicKey !== "") {
return this.vapidPublicKey
}
try {
if (window.frappe?.boot.push_notification_service === "Raven") {
this.vapidPublicKey = window.frappe?.boot.vapid_public_key
return this.vapidPublicKey
}
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<void>}
*/
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<boolean>}
*/
async registerTokenHandler(token) {
try {
let response = await fetch(
"/api/method/raven.api.notification.subscribe",
{
method: "POST",
body: JSON.stringify({
fcm_token: token,
environment: "Web",
device_information: navigator.userAgent,
}),
headers: {
"Content-Type": "application/json",
"X-Frappe-CSRF-Token": window.csrf_token,
},
}
)
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<boolean>}
*/
async unregisterTokenHandler(token) {
try {
let response = await fetch(
"/api/method/raven.api.notification.unsubscribe",
{
method: "POST",
body: JSON.stringify({
fcm_token: token,
}),
headers: {
"Content-Type": "application/json",
"X-Frappe-CSRF-Token": window.csrf_token,
},
}
)
return response.status === 200
} catch (e) {
console.error(e)
return false
}
}
}
export default FrappePushNotification