Skip to content

Commit

Permalink
wip: add Web Push via FCM
Browse files Browse the repository at this point in the history
  • Loading branch information
th0rgall committed Aug 14, 2023
1 parent ed7e4df commit 97ac771
Show file tree
Hide file tree
Showing 19 changed files with 489 additions and 16 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ VITE_FIREBASE_MEASUREMENT_ID=null
# https://firebase.google.com/docs/app-check/web/debug-provider#localhost
# Go to the App Check section in the console, and generate a token
# VITE_FIREBASE_APPCHECK_DEBUG_TOKEN=
#
# For Web Push with Firebase Cloud Messaging
# Does not work with local demo emulators, but it does work with a fully emulated staging environment.
# VITE_FIREBASE_VAPID_PUBLIC_KEY=

# Used for public landing page images etc.
VITE_STATIC_ASSETS_BUCKET=https://storage.googleapis.com/wtmg-static
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/firebase-hosting-merge-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:
VITE_FIREBASE_MESSAGING_SENDER_ID: ${{secrets.FIREBASE_MESSAGING_SENDER_ID}}
VITE_FIREBASE_APP_ID: ${{secrets.FIREBASE_APP_ID}}
VITE_FIREBASE_MEASUREMENT_ID: ${{secrets.FIREBASE_MEASUREMENT_ID}}
VITE_FIREBASE_VAPID_PUBLIC_KEY: ${{vars.FIREBASE_VAPID_PUBLIC_KEY}}
VITE_MAPBOX_ACCESS_TOKEN: ${{secrets.MAPBOX_ACCESS_TOKEN}}
VITE_THUNDERFOREST_API_KEY: ${{secrets.THUNDERFOREST_API_KEY}}
VITE_DIRECT_TRAIN_API_URLS: ${{secrets.DIRECT_TRAIN_API_URLS}}
Expand Down
34 changes: 30 additions & 4 deletions api/src/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const removeDiacritics = require('./util/removeDiacritics');
const { sendMessageReceivedEmail } = require('./mail');
const removeEndingSlash = require('./util/removeEndingSlash');
const { auth, db } = require('./firebase');
const { sendNotification } = require('./push');
const fail = require('./util/fail');

/**
* @typedef {import("../../src/lib/models/User").UserPrivate} UserPrivate
Expand Down Expand Up @@ -85,15 +87,39 @@ exports.onMessageCreate = async (snap, context) => {
const nameParts = sender.displayName.split(/[^A-Za-z-]/);
const messageUrl = `${baseUrl}/chat/${normalizeName(nameParts[0])}/${chatId}`;

await sendMessageReceivedEmail({
email: recipient.email,
firstName: recipient.displayName,
senderName: sender.displayName,
const commonPayload = {
senderName: sender.displayName ?? '',
message: normalizeMessage(message.content),
messageUrl,
superfan: recipientUserPublicDocData.superfan ?? false,
language: recipientUserPrivateDocData.communicationLanguage ?? 'en'
};

if (!recipient.email || !recipient.displayName) {
console.error(`Email or display name of ${recipientId} are not valid`);
fail('internal');
}

// Send email
await sendMessageReceivedEmail({
...commonPayload,
email: recipient.email,
firstName: recipient.displayName
});

// Send a notification to all registered devices via FCM
const pushRegistrations = (
await recipientUserPrivateDocRef.collection('push-registrations').get()
).docs.map((d) => d.data());

await Promise.all(
pushRegistrations.map((pR) =>
sendNotification({
...commonPayload,
fcmToken: pR.fcmToken
})
)
);
} catch (ex) {
console.log(ex);
}
Expand Down
45 changes: 45 additions & 0 deletions api/src/push.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// eslint-disable-next-line import/no-unresolved
const { getMessaging } = require('firebase-admin/messaging');
/**
* @typedef {Object} PushConfig
* @property {string} fcmToken
* @property {string} senderName
* @property {string} message
* @property {string} messageUrl
* @property {boolean} superfan
* @property {string} language
*/

/**
* @param {PushConfig} config
* @returns
*/
exports.sendNotification = async (config) => {
const { fcmToken, senderName, message, messageUrl } = config;

/**
* @type {import('firebase-admin/messaging').Message}
*/
const fcmPayload = {
notification: {
title: `Message from ${senderName}`,
body: message
},
webpush: {
fcmOptions: { link: messageUrl }
},
token: fcmToken
};

// Send a message to the device corresponding to the provided
// registration token.
return getMessaging()
.send(fcmPayload)
.then((response) => {
// Response is a message ID string.
console.log('Successfully sent message notification:', response);
})
.catch((error) => {
console.log('Error sending message notification:', error);
});
};
4 changes: 4 additions & 0 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ service cloud.firestore {
&& incomingData().visible is bool;
allow delete: if isValidTrailAccess(userId);
}
match /push-registrations/{registrationId} {
allow read: if isSignedIn() && isOwner(userId);
allow write: if isSignedIn() && isOwner(userId);
}
}

// Garden functions
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"firebase:demo": "firebase --project demo-test emulators:start",
"firebase:demo-seed": "firebase --project demo-test emulators:exec --ui api/seeders/simple.js",
"firebase:debug": "firebase --project demo-test emulators:start --inspect-functions",
"firebase:staging": "firebase --project wtmg-dev emulators:start --only functions"
"firebase:staging": "firebase --project wtmg-dev emulators:start --only functions",
"firebase:staging-all": "firebase --project wtmg-dev emulators:start"
},
"publishConfig": {
"access": "public"
Expand All @@ -38,6 +39,7 @@
"@types/md5": "^2.3.2",
"@types/nprogress": "^0.2.0",
"@types/smoothscroll-polyfill": "^0.3.1",
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0",
"@zerodevx/svelte-img": "^1.2.10",
Expand Down Expand Up @@ -65,6 +67,7 @@
"svg-inline-loader": "^0.8.2",
"tslib": "^2.3.1",
"typescript": "^5.0.0",
"ua-parser-js": "https://github.com/faisalman/ua-parser-js.git#2.0.0-alpha.2",
"vite": "^4.4.7",
"vite-bundle-visualizer": "0.6.0",
"vitest": "^0.33.0"
Expand Down
3 changes: 3 additions & 0 deletions src/lib/api/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ export const STATS = 'stats';
export const USERS = 'users';
export const USERS_PRIVATE = 'users-private';
export const MESSAGES = 'messages';
// Subcollection of users-private
export const TRAILS = 'trails';
// Subcollection of users-private
export const PUSH_REGISTRATIONS = 'push-registrations';
51 changes: 43 additions & 8 deletions src/lib/api/firebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { type Firestore, getFirestore, connectFirestoreEmulator } from 'firebase
import { connectStorageEmulator, getStorage, type FirebaseStorage } from 'firebase/storage';
import { connectFunctionsEmulator, getFunctions, type Functions } from 'firebase/functions';
import { initializeEuropeWest1Functions, initializeUsCentral1Functions } from './functions';
import {
getMessaging,
isSupported as isWebPushSupported,
onMessage,
type Messaging
} from 'firebase/messaging';
import envIsTrue from '../util/env-is-true';

const FIREBASE_CONFIG = {
Expand All @@ -24,13 +30,15 @@ type FirestoreWarning = {
auth: string;
storage: string;
functions: string;
messaging: string;
};
export const FIREBASE_WARNING: FirestoreWarning = [
'app',
'firestore',
'auth',
'storage',
'functions'
'functions',
'messaging'
].reduce(
(warningsObj, service) => ({ ...warningsObj, [service]: messageFor(service) }),
{}
Expand Down Expand Up @@ -83,8 +91,20 @@ export const storage: () => FirebaseStorage = guardNull<FirebaseStorage>(
'storage'
);

// TODO: configure via env var?
// Note: can be changed to an internal IP
// Warnng: setting to another setting than 'localhost' has implications on the testability of services.
// - Service Workers (web push) only work on localhost OR HTTPS
// - Firebase Emulators CAN'T USE HTTPS
// https://github.com/firebase/firebase-tools/issues/1908#issuecomment-1677219899
// - Requests will fail if HTTPS hosting is configured for Sveltekit, and it tries to fetch HTTP content from Firebase Emulators.
const emulatorHostName = 'localhost';

let messagingRef: Messaging;
export const messaging: () => Messaging = guardNull<Messaging>(() => messagingRef, 'messaging');

// TODO: window may not be available on server-side SvelteKit
const isRunningLocally = window && window.location.hostname.match('localhost|127.0.0.1');
const isRunningLocally = window && window.location.hostname.match(`${emulatorHostName}|127.0.0.1`);

const shouldUseEmulator = (specificEmulatorOverride?: boolean | undefined | null) =>
// If an override is defined, only look at that value.
Expand Down Expand Up @@ -113,17 +133,18 @@ export async function initialize(): Promise<void> {

dbRef = getFirestore(appRef);
if (shouldUseEmulator(envIsTrue(import.meta.env.VITE_USE_FIRESTORE_EMULATOR))) {
connectFirestoreEmulator(dbRef, 'localhost', 8080);
connectFirestoreEmulator(dbRef, emulatorHostName, 8080);
}

authRef = getAuth(appRef);
authRef.useDeviceLanguage();
if (shouldUseEmulator(envIsTrue(import.meta.env.VITE_USE_AUTH_EMULATOR))) {
connectAuthEmulator(authRef, 'http://localhost:9099');
connectAuthEmulator(authRef, `http://${emulatorHostName}:9099`);
}

storageRef = getStorage(appRef);
if (shouldUseEmulator(envIsTrue(import.meta.env.VITE_USE_STORAGE_EMULATOR))) {
connectStorageEmulator(storageRef, 'localhost', 9199);
connectStorageEmulator(storageRef, emulatorHostName, 9199);
}

// The default functions ref is us-central1
Expand All @@ -136,8 +157,22 @@ export async function initialize(): Promise<void> {
initializeEuropeWest1Functions(europeWest1FunctionsRef);

if (shouldUseEmulator(envIsTrue(import.meta.env.VITE_USE_API_EMULATOR))) {
connectFunctionsEmulator(usCentral1FunctionsRef, 'localhost', 5001);
connectFunctionsEmulator(europeWest1FunctionsRef, 'localhost', 5001);
connectFunctionsEmulator(usCentral1FunctionsRef, emulatorHostName, 5001);
connectFunctionsEmulator(europeWest1FunctionsRef, emulatorHostName, 5001);
}

if (
// Note: Safari 16.4 *in normal mode* does not support Web Push, but there IS support in Home Screen app mode
// https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/
// Be careful: service worker support is required, and that only works on localhost and HTTPS !
(await isWebPushSupported()) &&
typeof import.meta.env.VITE_FIREBASE_VAPID_PUBLIC_KEY !== 'undefined'
) {
messagingRef = getMessaging(appRef);

onMessage(messagingRef, (payload) => {
console.log('Message received. ', payload);
// ...
});
}
authRef.useDeviceLanguage();
}
Loading

0 comments on commit 97ac771

Please sign in to comment.