Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vg/web push #2

Merged
merged 2 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading