A notification is a small message that may pop up on your computer or phone. It allows the site to re-engage the user who previously allowed the message to be received. Push notifications are made up of two APIs: Notifications API and the Push API, which is only available at Worker Service level.
Below is a full example of push notification functionality in the Angular framework using Firebase Cloud Messaging.
Firebase Cloud Messaging is a new name for Google Cloud Messaging, which is a (free) cloud-based solution for handling push notifications in browser-based applications and Android and IOS applications. The service allows us to create registration support for users who have agreed to receive notifications very easily and quickly and additionally allows us to create topic groups.
To receive notifications, you must ask for permission. For this purpose, a subscribe-button
src/app/shared/subscribe-button/subscribe-button.component.html
<ng-container *ngIf="isVisible$ | async">
<button
*ngIf="permission$ | async as permission"
mat-mini-fab
color="primary"
class="subscribe-button"
aria-label="subscribe button with a notifications icon"
(click)="onSubscribe()"
[disabled]="permission === 'denied'"
>
<mat-icon *ngIf="permission === 'default'">notifications</mat-icon>
<mat-icon *ngIf="permission === 'granted'">notifications_active</mat-icon>
<mat-icon *ngIf="permission === 'denied'">notifications_off</mat-icon>
</button>
</ng-container>
src/app/shared/subscribe-button/subscribe-button.component.ts
import {
ChangeDetectionStrategy,
Component,
Inject,
PLATFORM_ID,
} from '@angular/core';
import { PushNotificationsService } from 'src/app/push-notifications.service';
import { Observable, BehaviorSubject, of } from 'rxjs';
import { isPlatformBrowser } from '@angular/common';
import { switchMap, tap } from 'rxjs/operators';
@Component({
selector: 'app-subscribe-button',
templateUrl: './subscribe-button.component.html',
styleUrls: ['./subscribe-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SubscribeButtonComponent {
private isVisibleInternal$: BehaviorSubject<boolean> = new BehaviorSubject(
isPlatformBrowser(this.platformId)
? !localStorage.getItem('notifications_granted')
: false
);
isVisible$: Observable<boolean> = this.pushNotificationsService.isAvailable$.pipe(
switchMap((isAvailable: boolean) =>
isAvailable ? this.isVisibleInternal$ : of(false)
),
tap((isVisible: boolean) => {
if (isVisible && Notification.permission === 'granted') {
this.onSubscribe();
}
})
);
permission$: Observable<NotificationPermission> = this.pushNotificationsService.permission$;
constructor(
private pushNotificationsService: PushNotificationsService,
@Inject(PLATFORM_ID) private platformId: object
) {}
async onSubscribe() {
if (Notification.permission !== 'denied') {
const status = await this.pushNotificationsService.subscribe();
if (status === 'granted') {
localStorage.setItem('notifications_granted', 'true');
setTimeout(() => {
this.isVisibleInternal$.next(false);
}, 1500);
}
}
}
}
The component performs the visual part of the functionality. It appears when the Service Worker API and Notifications API are available, then checks if the value of the notifications_granted
notifications_grated
pushNotificationsService.subscribe()
granted
The logic responsible for integrating Notifications with Firebase Cloud Messaging is contained in the PushNotificationsService
src/app/push-notifications.service.ts
import 'firebase/messaging';
import * as firebase from 'firebase/app';
import { BehaviorSubject, Observable } from 'rxjs';
import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
import { environment } from 'src/environments/environment';
import { initializeApp } from './firebase';
import { isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
const serviceWorkerControlled = () =>
new Promise<void>(resolve => {
if (navigator.serviceWorker.controller) {
return resolve();
}
navigator.serviceWorker.addEventListener('controllerchange', () =>
resolve()
);
});
@Injectable({
providedIn: 'root',
})
export class PushNotificationsService {
private isAvailableInternal$: BehaviorSubject<boolean> = new BehaviorSubject(
false
);
isAvailable$: Observable<boolean> = this.isAvailableInternal$.asObservable();
private permissionInternal$: BehaviorSubject<
NotificationPermission
> = new BehaviorSubject(
isPlatformBrowser(this.platformId) && 'Notification' in window
? Notification.permission
: 'default'
);
permission$: Observable<
NotificationPermission
> = this.permissionInternal$.asObservable();
constructor(
@Inject(PLATFORM_ID) private platformId: object,
private http: HttpClient
) {
if (isPlatformBrowser(this.platformId)) {
this.init();
}
}
async init() {
if (!('serviceWorker' in navigator)) {
return;
}
if (!('Notification' in window)) {
return;
}
initializeApp();
const swr = await navigator.serviceWorker.ready;
await serviceWorkerControlled();
navigator.serviceWorker.controller.postMessage({
action: 'INITIALIZE_FCM',
firebaseConfig: environment.firebaseConfig,
});
this.isAvailableInternal$.next(true);
const messaging = firebase.messaging();
messaging.useServiceWorker(swr);
messaging.usePublicVapidKey(environment.publicVapidKey);
messaging.onTokenRefresh(() => this.getToken());
}
async getToken() {
const messaging = firebase.messaging();
const token = await messaging.getToken();
console.log('Token is:');
console.log(token);
console.log('Sending request to /notifications/subscribe/topic/all');
await this.http
.post(`${environment.functions.notificationsHttp}/subscribe/topic/all`, {
token,
})
.toPromise();
}
async subscribe() {
console.log('Requesting permission...');
const permission: NotificationPermission = await Notification.requestPermission();
this.permissionInternal$.next(permission);
if (permission === 'granted') {
console.log('Notification permission granted.');
await this.getToken();
} else {
console.log('Unable to get permission to notify.');
}
return permission;
}
}
The service performs three tasks:
Checking if all API's are available on the device.
Registration for Firebase Cloud Messaging
Assigning a device token to topic all
Registration can be done only after the FCM service is installed in the Service Worker, so at the very beginning it is checked if there is a Service Worker API and Notifications API. If the conditions are met, a signal from the browser is expected to be received that the service worker has active control over the view. After this event, a message is sent to Service Worker with FCM installation action and firebase.messaging settings on the view side are updated. At this point, isAvailable$
true
The subscribe
granted
getToken
src/environments/environment.ts
export const environment = {
publicVapidKey:
'BP4HNtKjB1OT54fs5sojoqzPj4IS4vmleEmcdqjNdnK0UMBXHRKzLKTSs_ns47Cc4050i5liPmRjG-QARrmbz9o',
functions: {
notificationsHttp:
'https://us-central1-project-12332.cloudfunctions.net/notifications',
},
};
Unfortunately, @angular/service-worker requires some features to be added in order to work with Firebase Cloud Messaging. Below is a patch, if you want to know more I invite you to read patch-package
./patches/@angular+service-worker+8.2.14.patch
diff --git a/node_modules/@angular/service-worker/ngsw-worker.js b/node_modules/@angular/service-worker/ngsw-worker.js
index 0d185a8..df46297 100755
--- a/node_modules/@angular/service-worker/ngsw-worker.js
+++ b/node_modules/@angular/service-worker/ngsw-worker.js
@@ -1,3 +1,9 @@
+// Give the service worker access to Firebase Messaging.
+// Note that you can only use Firebase Messaging here, other Firebase libraries
+// are not available in the service worker.
+importScripts('https://www.gstatic.com/firebasejs/7.12.0/firebase-app.js');
+importScripts('https://www.gstatic.com/firebasejs/7.12.0/firebase-messaging.js');
+
(function () {
'use strict';
@@ -1788,6 +1794,9 @@ ${msgIdle}`, { headers: this.adapter.newHeaders({ 'Content-Type': 'text/plain' }
function isMsgActivateUpdate(msg) {
return msg.action === 'ACTIVATE_UPDATE';
}
+ function isMsgInitalizeFCM(msg) {
+ return msg.action === 'INITIALIZE_FCM';
+ }
/**
* @license
@@ -1925,6 +1934,10 @@ ${msgIdle}`, { headers: this.adapter.newHeaders({ 'Content-Type': 'text/plain' }
*/
onFetch(event) {
const req = event.request;
+ // PATCH to prevent caching certain kinds of requests
+ // - PUT requests which breaks files upload
+ // - requests with 'Range' header which breaks credentialed video irequests
+ if (req.method==="PUT" || !!req.headers.get('range')) return;
const scopeUrl = this.scope.registration.scope;
const requestUrlObj = this.adapter.parseUrl(req.url, scopeUrl);
if (req.headers.has('ngsw-bypass') || /[?&]ngsw-bypass(?:[=&]|$)/i.test(requestUrlObj.search)) {
@@ -2046,6 +2059,11 @@ ${msgIdle}`, { headers: this.adapter.newHeaders({ 'Content-Type': 'text/plain' }
}
handleMessage(msg, from) {
return __awaiter$5(this, void 0, void 0, function* () {
+ if (isMsgInitalizeFCM(msg)) {
+ if (firebase.apps.length === 0) {
+ firebase.initializeApp(msg.firebaseConfig);
+ }
+ }
if (isMsgCheckForUpdates(msg)) {
const action = (() => __awaiter$5(this, void 0, void 0, function* () { yield this.checkForUpdate(); }))();
yield this.reportStatus(from, action, msg.statusNonce);
@@ -2079,6 +2097,24 @@ ${msgIdle}`, { headers: this.adapter.newHeaders({ 'Content-Type': 'text/plain' }
// hasOwnProperty does not work here
NOTIFICATION_OPTION_NAMES.filter(name => name in notification)
.forEach(name => options[name] = notification[name]);
+ if (options.data && options.data.url) {
+ const url = options.data.url;
+ yield
+ clients.matchAll({type: 'window'}).then( windowClients => {
+ // Check if there is already a window/tab open with the target URL
+ for (var i = 0; i < windowClients.length; i++) {
+ var client = windowClients[i];
+ // If so, just focus it.
+ if (client.url === url && 'focus' in client) {
+ return client.focus();
+ }
+ }
+ // If not, then open the target URL in a new window/tab.
+ if (clients.openWindow) {
+ return clients.openWindow(url);
+ }
+ })
+ }
yield this.broadcast({
type: 'NOTIFICATION_CLICK',
data: { action, notification: options },
Patch does three tasks:
Loads the necessary firebase messaging libraries.
Waits for the INITIALIZE_FCM
Adds support for opening the url
notificationclick
/subscribe/topic/all
Below is an example of the handling of topics subscriptions in the firebase functions service.
./functions/src/index.ts
import * as admin from 'firebase-admin';
import * as cors from 'cors';
import * as express from 'express';
import * as functions from 'firebase-functions';
import { check, validationResult } from 'express-validator';
admin.initializeApp();
const welcomeMessage = (token: string) => {
const title = 'Prog blog';
const body = 'Welcome aboard. We\'ll be in touch. :)';
const icon = 'https://blog-81003.web.app/assets/icons/192x192.png'
const imageUrl = 'https://blog-81003.web.app/assets/home/title-image.png';
const message: admin.messaging.Message = {
notification: {
title,
body,
imageUrl,
},
webpush: {
notification: {
title,
body,
imageUrl,
icon
},
},
token,
};
return message;
}
const app = express();
app.use(cors({ origin: true }));
const path = '/subscribe/topic/all';
app.get(path, (_, res) => res.send('ok'));
app.post(
path,
[check('token').isString()],
async (req: express.Request, res: express.Response) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
try {
await admin.messaging().subscribeToTopic(req.body.token, 'all')
await admin.messaging().send(welcomeMessage(req.body.token))
console.log(req.body.token);
return res.status(200).send({ message: 'Successfully subscribed to topic: all' });
} catch (error) {
return res.status(500).json({ errors: [error] });
}
}
);
exports.notifications = functions.https.onRequest(app);
The last code is a simple example of a program to send notifications to all subscribers of a topic all
./bin/send-message.ts
import * as admin from 'firebase-admin';
admin.initializeApp();
const topic = 'all';
const title = 'How to fix video Safari issue in Angular';
const body =
'I started to add videos showing the operation of some of the solutions I describe on my blog. Something came to see how the entry about Web share button works on Safair. And it turned out that the video is not loading :/. After a long investigation, it turned out that Angular Service Worker has a bug in itself and does not send the header Range when responding to requests for video in the Safair browser. Below I describe a workaround I found on github.';
const imageUrl =
'https://blog-81003.web.app/assets/posts/how-to-fix-video-safari-issue-in-angular/title-image.png';
const url = 'https://blog-81003.web.app/';
const icon = 'https://blog-81003.web.app/assets/icons/192x192.png'
const message: admin.messaging.Message = {
notification: {
title,
body,
imageUrl,
},
data: {
url
},
webpush: {
notification: {
title,
body,
imageUrl,
icon,
data: {
url
}
},
},
topic,
};
admin
.messaging()
.send(message)
.then(response => {
// Response is a message ID string.
console.log('Successfully sent message:', response);
process.exit(0);
})
.catch(error => {
console.log('Error sending message:', error);
});
To sum up, the topic is not too complicated if we use a ready-made solution, which is offered by Firebase Cloud Messaging service. All you need is an in-depth understanding of the operation of individual APIs that are required for this functionality.