Skip to content

Commit

Permalink
feat: add infrastructure for push notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
czabaj authored Dec 18, 2024
1 parent 1968eb7 commit d8faccf
Show file tree
Hide file tree
Showing 14 changed files with 202 additions and 43 deletions.
18 changes: 15 additions & 3 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<UpdateDeviceTokenMessage>(
{ cors: CORS, region: REGION },
{ cors: CORS, enforceAppCheck: true, region: REGION },
async (request) => {
const uid = request.auth?.uid;
if (!uid) {
Expand Down Expand Up @@ -249,7 +253,7 @@ const getUserFamiliarName = async (
*
*/
export const dispatchNotification = onCall<NotificationEventMessages>(
{ cors: CORS, region: REGION },
{ cors: CORS, enforceAppCheck: true, region: REGION },
async (request) => {
const uid = request.auth?.uid;
if (!uid) {
Expand Down Expand Up @@ -278,6 +282,10 @@ export const dispatchNotification = onCall<NotificationEventMessages>(
subscribedUsers
);
if (subscribedNotificationTokens.length === 0) {
logger.log(
`No registration tokens stored for notification event`,
request.data
);
return;
}
const currentUserFamiliarName = await getUserFamiliarName(
Expand Down Expand Up @@ -318,6 +326,10 @@ export const dispatchNotification = onCall<NotificationEventMessages>(
subscribedUsers
);
if (subscribedNotificationTokens.length === 0) {
logger.log(
`No registration tokens stored for notification event`,
request.data
);
return;
}
const kegData = keg.data()!;
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Empty file removed public/firebase-messaging-sw.js
Empty file.
2 changes: 2 additions & 0 deletions src/App.res
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ external polyfillAnchorPositioning: anchorPositioningPolyfillFn = "default"

polyfillAnchorPositioning()->ignore

ServiceWorker.registerSW({})

@react.component
let make = () => {
let url = RescriptReactRouter.useUrl()
Expand Down
9 changes: 9 additions & 0 deletions src/Rxjs.res
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> etc as their source type
// Custom observables and observables derived from multiple sources will have void source type
Expand Down Expand Up @@ -67,6 +68,7 @@ module Subject = {
type t<'a> = t<subject, source<'a>, '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"
Expand All @@ -87,6 +89,13 @@ module Subject = {
module BehaviorSubject = {
type t<'a> = t<behaviorsubject, source<'a>, 'a>
@module("rxjs") @new external make: 'a => t<'a> = "BehaviorSubject"
@send external next: (t<'a>, 'a) => unit = "next"
}

module ReplaySubject = {
type t<'a> = t<replaysubject, source<'a>, '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<foreign, void, 'a> = "%identity"
Expand Down
31 changes: 28 additions & 3 deletions src/backend/Firebase.res
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -449,8 +452,30 @@ module Messaging = {
@module("firebase/messaging")
external _getToken: (t, getTokenOptions) => promise<string> = "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<string>,
fcmOptions: fcmOptions,
from: string,
messageId: string,
notification: Js.Dict.t<string>,
}

@module("firebase/messaging")
external onMessage: (t, messagePayload => unit) => unit => unit = "onMessage"
}

module Timestamp = {
Expand Down
24 changes: 17 additions & 7 deletions src/backend/NotificationHooks.res
Original file line number Diff line number Diff line change
Expand Up @@ -35,35 +35,45 @@ 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
}
}

let useDispatchFreshKegNotification = (~currentUserUid: string, ~place: FirestoreModels.place) => {
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")
Expand Down
1 change: 1 addition & 0 deletions src/backend/Reactfire.res
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ external useFunctions: unit => Firebase.Functions.t = "useFunctions"
external useObservable: (
~observableId: string,
~source: Rxjs.t<Rxjs.foreign, Rxjs.void, 'a>,
~config: reactfireOptions<'a>=?,
) => observableStatus<'a> = "useObservable"

type signInCheckResult = {user: Null.t<Firebase.User.t>}
Expand Down
66 changes: 38 additions & 28 deletions src/components/FcmTokenSync/FcmTokenSync.res
Original file line number Diff line number Diff line change
@@ -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))
Expand All @@ -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
}
})
23 changes: 23 additions & 0 deletions src/serviceWorker/ServiceWorker.res
Original file line number Diff line number Diff line change
@@ -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<serviceWorkerRegistration>) => unit,
onRegisterError?: Exn.t => unit,
}

@module("virtual:pwa-register")
external registerSW: registerSWOptions => unit = "registerSW"

let serviceWorkerRegistration: promise<
serviceWorkerRegistration,
> = %raw(`navigator.serviceWorker.ready`)
30 changes: 30 additions & 0 deletions src/serviceWorker/sw.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export default defineConfig(({ mode }) => ({
},
})),
VitePWA({
devOptions: {
enabled: true,
type: "module",
},
filename: "sw.ts",
manifest: {
name: "Check Beer",
short_name: "CheckBeer",
Expand All @@ -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`
Expand Down
Loading

0 comments on commit d8faccf

Please sign in to comment.