From d8faccf81a1b4f64ff1fc912b4b157732b8ecda8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20R=2E=20Gr=C3=B6hling?= <47554112+czabaj@users.noreply.github.com> Date: Wed, 18 Dec 2024 17:46:38 +0100 Subject: [PATCH] feat: add infrastructure for push notifications --- functions/src/index.ts | 18 +++++- package.json | 4 +- public/firebase-messaging-sw.js | 0 src/App.res | 2 + src/Rxjs.res | 9 +++ src/backend/Firebase.res | 31 ++++++++- src/backend/NotificationHooks.res | 24 ++++--- src/backend/Reactfire.res | 1 + src/components/FcmTokenSync/FcmTokenSync.res | 66 +++++++++++--------- src/serviceWorker/ServiceWorker.res | 23 +++++++ src/serviceWorker/sw.ts | 30 +++++++++ tsconfig.json | 2 +- vite.config.mts | 7 +++ yarn.lock | 28 +++++++++ 14 files changed, 202 insertions(+), 43 deletions(-) delete mode 100644 public/firebase-messaging-sw.js create mode 100644 src/serviceWorker/ServiceWorker.res create mode 100644 src/serviceWorker/sw.ts diff --git a/functions/src/index.ts b/functions/src/index.ts index af6dc53..6304e97 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -33,7 +33,11 @@ import { getPrivateCollection, } from "./helpers"; -const CORS = [`https://check.beer`, /localhost:\d+$/]; +const CORS = [ + `https://check.beer`, + /localhost:\d+$/, + /^https:\/\/beerbook2-da255--pr2-vg-web-push-\w+\.web\.app$/, +]; const REGION = `europe-west3`; initializeApp(); @@ -163,7 +167,7 @@ export const truncateUserInDb = auth.user().onDelete(async (user) => { * This function has access to a private collection and stores there the notification registration token of the user. */ export const updateNotificationToken = onCall( - { cors: CORS, region: REGION }, + { cors: CORS, enforceAppCheck: true, region: REGION }, async (request) => { const uid = request.auth?.uid; if (!uid) { @@ -249,7 +253,7 @@ const getUserFamiliarName = async ( * */ export const dispatchNotification = onCall( - { cors: CORS, region: REGION }, + { cors: CORS, enforceAppCheck: true, region: REGION }, async (request) => { const uid = request.auth?.uid; if (!uid) { @@ -278,6 +282,10 @@ export const dispatchNotification = onCall( subscribedUsers ); if (subscribedNotificationTokens.length === 0) { + logger.log( + `No registration tokens stored for notification event`, + request.data + ); return; } const currentUserFamiliarName = await getUserFamiliarName( @@ -318,6 +326,10 @@ export const dispatchNotification = onCall( subscribedUsers ); if (subscribedNotificationTokens.length === 0) { + logger.log( + `No registration tokens stored for notification event`, + request.data + ); return; } const kegData = keg.data()!; diff --git a/package.json b/package.json index aaa1100..1aec79f 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,9 @@ "vite": "^5.4.5", "vite-plugin-ejs": "^1.7.0", "vite-plugin-pwa": "^0.20.5", - "vitest": "^2.1.0" + "vitest": "^2.1.0", + "workbox-core": "^7.3.0", + "workbox-precaching": "^7.3.0" }, "private": true, "scripts": { diff --git a/public/firebase-messaging-sw.js b/public/firebase-messaging-sw.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/App.res b/src/App.res index d69e314..4e077d7 100644 --- a/src/App.res +++ b/src/App.res @@ -8,6 +8,8 @@ external polyfillAnchorPositioning: anchorPositioningPolyfillFn = "default" polyfillAnchorPositioning()->ignore +ServiceWorker.registerSW({}) + @react.component let make = () => { let url = RescriptReactRouter.useUrl() diff --git a/src/Rxjs.res b/src/Rxjs.res index aa97d69..a9a1a0e 100644 --- a/src/Rxjs.res +++ b/src/Rxjs.res @@ -10,6 +10,7 @@ type foreign // custom or otherwise type subject type behaviorsubject +type replaysubject // Subject based observables can be emitted to will have use source etc as their source type // Custom observables and observables derived from multiple sources will have void source type @@ -67,6 +68,7 @@ module Subject = { type t<'a> = t, 'a> @module("rxjs") @new external make: 'a => t<'a> = "Subject" @module("rxjs") @new external makeEmpty: unit => t<'a> = "Subject" + @send external next: (t<'a>, 'a) => unit = "next" } @send external next: (t<'class, source<'a>, 'b>, 'a) => unit = "next" @@ -87,6 +89,13 @@ module Subject = { module BehaviorSubject = { type t<'a> = t, 'a> @module("rxjs") @new external make: 'a => t<'a> = "BehaviorSubject" + @send external next: (t<'a>, 'a) => unit = "next" +} + +module ReplaySubject = { + type t<'a> = t, 'a> + @module("rxjs") @new external make: (~bufferSize: int=?, ~windowTime: int=?) => t<'a> = "ReplaySubject" + @send external next: (t<'a>, 'a) => unit = "next" } external toObservable: t<'c, 's, 'a> => t = "%identity" diff --git a/src/backend/Firebase.res b/src/backend/Firebase.res index a7e4458..c5e9cf9 100644 --- a/src/backend/Firebase.res +++ b/src/backend/Firebase.res @@ -440,7 +440,10 @@ module Messaging = { @module("firebase/messaging") external getMessaging: FirebaseApp.t => t = "getMessaging" - type getTokenOptions = {vapidKey: string} + type getTokenOptions = { + serviceWorkerRegistration?: ServiceWorker.serviceWorkerRegistration, + vapidKey: string, + } // Subscribes the Messaging instance to push notifications. Returns a Firebase Cloud Messaging registration token that // can be used to send push messages to that Messaging instance. If notification permission isn't already granted, @@ -449,8 +452,30 @@ module Messaging = { @module("firebase/messaging") external _getToken: (t, getTokenOptions) => promise = "getToken" - let getToken = (messaging: t) => - _getToken(messaging, {vapidKey: %raw(`import.meta.env.VITE_FIREBASE_VAPID_KEY`)}) + let getToken = async (messaging: t) => { + let serviceWorkerRegistration = await ServiceWorker.serviceWorkerRegistration + await _getToken( + messaging, + {serviceWorkerRegistration, vapidKey: %raw(`import.meta.env.VITE_FIREBASE_VAPID_KEY`)}, + ) + } + + type fcmOptions = { + analyticsLabel?: string, + link?: string, + } + + type messagePayload = { + collapseKey: string, + data: Js.Dict.t, + fcmOptions: fcmOptions, + from: string, + messageId: string, + notification: Js.Dict.t, + } + + @module("firebase/messaging") + external onMessage: (t, messagePayload => unit) => unit => unit = "onMessage" } module Timestamp = { diff --git a/src/backend/NotificationHooks.res b/src/backend/NotificationHooks.res index e01c342..61503d3 100644 --- a/src/backend/NotificationHooks.res +++ b/src/backend/NotificationHooks.res @@ -35,18 +35,18 @@ let useDispatchFreeTableNotification = ( let firestore = Reactfire.useFirestore() let functions = Reactfire.useFunctions() let dispatchNotification = Firebase.Functions.httpsCallable(functions, "dispatchNotification") - let subsciredUsers = useGetSubscibedUsers(~currentUserUid, ~event=FreeTable, ~place) + let subscribedUsers = useGetSubscibedUsers(~currentUserUid, ~event=FreeTable, ~place) let freeTableSituation = React.useMemo2(() => { - subsciredUsers->Array.length > 0 && + subscribedUsers->Array.length > 0 && recentConsumptionsByUser ->Map.values ->Array.fromIterator ->Array.every(consumptions => consumptions->Array.length === 0) - }, (recentConsumptionsByUser, subsciredUsers)) + }, (recentConsumptionsByUser, subscribedUsers)) () => if freeTableSituation { let placeRef = Db.placeDocument(firestore, Db.getUid(place)) - dispatchNotification(FreeTableMessage({place: placeRef.path, users: subsciredUsers}))->ignore + dispatchNotification(FreeTableMessage({place: placeRef.path, users: subscribedUsers}))->ignore } } @@ -54,16 +54,26 @@ let useDispatchFreshKegNotification = (~currentUserUid: string, ~place: Firestor let firestore = Reactfire.useFirestore() let functions = Reactfire.useFunctions() let dispatchNotification = Firebase.Functions.httpsCallable(functions, "dispatchNotification") - let subsciredUsers = useGetSubscibedUsers(~currentUserUid, ~event=FreshKeg, ~place) + let subscribedUsers = useGetSubscibedUsers(~currentUserUid, ~event=FreshKeg, ~place) (keg: Db.kegConverted) => { - let freshKegSituation = subsciredUsers->Array.length > 0 && keg.consumptionsSum === 0 + let freshKegSituation = subscribedUsers->Array.length > 0 && keg.consumptionsSum === 0 if freshKegSituation { let kegRef = Db.kegDoc(firestore, Db.getUid(place), Db.getUid(keg)) - dispatchNotification(FreshKegMessage({keg: kegRef.path, users: subsciredUsers}))->ignore + dispatchNotification(FreshKegMessage({keg: kegRef.path, users: subscribedUsers}))->ignore } } } +// let useDispatchTestNotification = (~currentUserUid: string, ~place: FirestoreModels.place) => { +// let firestore = Reactfire.useFirestore() +// let functions = Reactfire.useFunctions() +// let dispatchNotification = Firebase.Functions.httpsCallable(functions, "dispatchNotification") +// () => { +// let placeRef = Db.placeDocument(firestore, Db.getUid(place)) +// dispatchNotification(FreeTableMessage({place: placeRef.path, users: [currentUserUid]}))->ignore +// } +// } + let useUpdateNotificationToken = () => { let functions = Reactfire.useFunctions() let updateDeviceToken = Firebase.Functions.httpsCallable(functions, "updateNotificationToken") diff --git a/src/backend/Reactfire.res b/src/backend/Reactfire.res index 77a7c48..888fc24 100644 --- a/src/backend/Reactfire.res +++ b/src/backend/Reactfire.res @@ -118,6 +118,7 @@ external useFunctions: unit => Firebase.Functions.t = "useFunctions" external useObservable: ( ~observableId: string, ~source: Rxjs.t, + ~config: reactfireOptions<'a>=?, ) => observableStatus<'a> = "useObservable" type signInCheckResult = {user: Null.t} diff --git a/src/components/FcmTokenSync/FcmTokenSync.res b/src/components/FcmTokenSync/FcmTokenSync.res index d208c9a..8e6c433 100644 --- a/src/components/FcmTokenSync/FcmTokenSync.res +++ b/src/components/FcmTokenSync/FcmTokenSync.res @@ -1,3 +1,10 @@ +let isMobileIOs: bool = %raw(`navigator.userAgent.match(/(iPhone|iPad)/)`) +let canSubscribe = + // avoid excessive call of the cloud function in development + %raw(`import.meta.env.PROD`) && + (// on iOS the notifications are only allowed in standalone mode + !isMobileIOs || %raw(`window.navigator.standalone === true`)) + let isSubscribedToNotificationsRx = (auth, firestore, placeId) => { open Rxjs let currentUserRx = Rxfire.user(auth)->op(keepMap(Null.toOption)) @@ -18,33 +25,36 @@ let isSubscribedToNotificationsRx = (auth, firestore, placeId) => { } @react.component -let make = (~placeId) => { - let auth = Reactfire.useAuth() - let firestore = Reactfire.useFirestore() - let messaging = Reactfire.useMessaging() - let updateNotificationToken = NotificationHooks.useUpdateNotificationToken() - let isStandaloneModeStatus = DomUtils.useIsStandaloneMode() - let isSubscribedToNotifications = Reactfire.useObservable( - ~observableId="isSubscribedToNotifications", - ~source=isSubscribedToNotificationsRx(auth, firestore, placeId), - ) - React.useEffect2(() => { - switch (isSubscribedToNotifications.data, isStandaloneModeStatus.data) { - | (Some(true), Some(true)) => - messaging - ->Firebase.Messaging.getToken - ->Promise.then(updateNotificationToken) - ->Promise.then(_ => Promise.resolve()) - ->Promise.catch(error => { - let exn = Js.Exn.asJsExn(error)->Option.getExn - LogUtils.captureException(exn) - Promise.resolve() - }) - ->ignore - | _ => () - } - None - }, (isSubscribedToNotifications.data, isStandaloneModeStatus.data)) +let make = React.memo((~placeId) => { + if canSubscribe { + let auth = Reactfire.useAuth() + let firestore = Reactfire.useFirestore() + let messaging = Reactfire.useMessaging() + let updateNotificationToken = NotificationHooks.useUpdateNotificationToken() + let isSubscribedToNotifications = Reactfire.useObservable( + ~observableId="isSubscribedToNotifications", + ~source=isSubscribedToNotificationsRx(auth, firestore, placeId), + ) + React.useEffect(() => { + switch isSubscribedToNotifications.data { + | Some(true) => + messaging + ->Firebase.Messaging.getToken + ->Promise.then(updateNotificationToken) + ->Promise.then(_ => Promise.resolve()) + ->Promise.catch( + error => { + let exn = Js.Exn.asJsExn(error)->Option.getExn + LogUtils.captureException(exn) + Promise.resolve() + }, + ) + ->ignore + | _ => () + } + None + }, [isSubscribedToNotifications.data]) + } React.null -} +}) diff --git a/src/serviceWorker/ServiceWorker.res b/src/serviceWorker/ServiceWorker.res new file mode 100644 index 0000000..03421bf --- /dev/null +++ b/src/serviceWorker/ServiceWorker.res @@ -0,0 +1,23 @@ +// only partially defined, see https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration +type serviceWorkerRegistration = {active: bool} + +type registerSWOptions = { + immediate?: bool, + onNeedRefresh?: unit => unit, + onOfflineReady?: unit => unit, + /** + * Called once the service worker is registered (requires version `0.12.8+`). + * + * @param swScriptUrl The service worker script url. + * @param registration The service worker registration if available. + */ + onRegisteredSW?: (string, option) => unit, + onRegisterError?: Exn.t => unit, +} + +@module("virtual:pwa-register") +external registerSW: registerSWOptions => unit = "registerSW" + +let serviceWorkerRegistration: promise< + serviceWorkerRegistration, +> = %raw(`navigator.serviceWorker.ready`) diff --git a/src/serviceWorker/sw.ts b/src/serviceWorker/sw.ts new file mode 100644 index 0000000..b4c8951 --- /dev/null +++ b/src/serviceWorker/sw.ts @@ -0,0 +1,30 @@ +import { getMessaging, onBackgroundMessage } from "firebase/messaging/sw"; +import { initializeApp } from "firebase/app"; +import { clientsClaim } from "workbox-core"; +import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching"; + +import { firebaseConfig } from "../backend/firebaseConfig"; + +declare const self: ServiceWorkerGlobalScope; + +self.skipWaiting(); +clientsClaim(); + +cleanupOutdatedCaches(); + +precacheAndRoute(self.__WB_MANIFEST); + +self.addEventListener("message", (event) => { + if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting(); +}); + +const firebaseApp = initializeApp(firebaseConfig); +const messaging = getMessaging(firebaseApp); +onBackgroundMessage(messaging, (payload) => { + const notificationTitle = payload.notification?.title; + if (!notificationTitle) return; + self.registration.showNotification(notificationTitle, { + body: payload.notification!.body, + icon: "/pwa-192.png", + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 54939d1..a5f67e7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "forceConsistentCasingInFileNames": true, "isolatedModules": true, "jsx": "react-jsxdev", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": ["dom", "dom.iterable", "esnext", "WebWorker"], "module": "esnext", "moduleResolution": "node", "noEmit": true, diff --git a/vite.config.mts b/vite.config.mts index 6b76e67..eb87898 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -30,6 +30,11 @@ export default defineConfig(({ mode }) => ({ }, })), VitePWA({ + devOptions: { + enabled: true, + type: "module", + }, + filename: "sw.ts", manifest: { name: "Check Beer", short_name: "CheckBeer", @@ -49,6 +54,8 @@ export default defineConfig(({ mode }) => ({ ], }, registerType: `autoUpdate`, + strategies: `injectManifest`, + srcDir: "src/serviceWorker", workbox: { globPatterns: [`**/*.{js,css,html,png,svg}`], // avoid handling the Firebase Auth `__/auth/handler` diff --git a/yarn.lock b/yarn.lock index 723dc63..1be775a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8737,6 +8737,11 @@ workbox-core@7.1.0: resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-7.1.0.tgz#1867576f994f20d9991b71a7d0b2581af22db170" integrity sha512-5KB4KOY8rtL31nEF7BfvU7FMzKT4B5TkbYa2tzkS+Peqj0gayMT9SytSFtNzlrvMaWgv6y/yvP9C0IbpFjV30Q== +workbox-core@7.3.0, workbox-core@^7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-7.3.0.tgz#f24fb92041a0b7482fe2dd856544aaa9fa105248" + integrity sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw== + workbox-expiration@7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-7.1.0.tgz#c9d348ffc8c3d1ffdddaf6c37bf5be830a69073e" @@ -8771,6 +8776,15 @@ workbox-precaching@7.1.0: workbox-routing "7.1.0" workbox-strategies "7.1.0" +workbox-precaching@^7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-7.3.0.tgz#a84663d69efdb334f25c04dba0a72ed3391c4da8" + integrity sha512-ckp/3t0msgXclVAYaNndAGeAoWQUv7Rwc4fdhWL69CCAb2UHo3Cef0KIUctqfQj1p8h6aGyz3w8Cy3Ihq9OmIw== + dependencies: + workbox-core "7.3.0" + workbox-routing "7.3.0" + workbox-strategies "7.3.0" + workbox-range-requests@7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-7.1.0.tgz#8d4344cd85b87d8077289a64dda59fb614628783" @@ -8797,6 +8811,13 @@ workbox-routing@7.1.0: dependencies: workbox-core "7.1.0" +workbox-routing@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-7.3.0.tgz#fc86296bc1155c112ee2c16b3180853586c30208" + integrity sha512-ZUlysUVn5ZUzMOmQN3bqu+gK98vNfgX/gSTZ127izJg/pMMy4LryAthnYtjuqcjkN4HEAx1mdgxNiKJMZQM76A== + dependencies: + workbox-core "7.3.0" + workbox-strategies@7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-7.1.0.tgz#a589f2adc0df8f33049c7f4d4cdf4c9556715918" @@ -8804,6 +8825,13 @@ workbox-strategies@7.1.0: dependencies: workbox-core "7.1.0" +workbox-strategies@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-7.3.0.tgz#bb1530f205806895aacdea3639e6cf6bfb3a6cb0" + integrity sha512-tmZydug+qzDFATwX7QiEL5Hdf7FrkhjaF9db1CbB39sDmEZJg3l9ayDvPxy8Y18C3Y66Nrr9kkN1f/RlkDgllg== + dependencies: + workbox-core "7.3.0" + workbox-streams@7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-7.1.0.tgz#8e080e56b5dee7aa0f956fdd3a10506821d2e786"