Prog blog

How to add push notifications to Angular website

How to add push notifications to Angular website

What is Push Notification?

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.

What is 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.

Implementation of Push Notification in Angular.

To receive notifications, you must ask for permission. For this purpose, a subscribe-button component was created.

Subscribe button component

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 variable stored in the local storage. When it is not available, Notifications permissions are checked and if there is permission for notifications, it automatically registers the user in Firebase Cloud Messaging. If there is no notifications_grated variable then the subscribe button is displayed. When you press it, you will receive a request for Notifications permission. After permission is granted, the button will disappear as soon as it receives a response from pushNotificationsService.subscribe() with the status granted.

Demo of working push notification functionality on the phone.

The logic responsible for integrating Notifications with Firebase Cloud Messaging is contained in the PushNotificationsService.

Push Notifications Service

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:

  1. Checking if all API's are available on the device.

  2. Registration for Firebase Cloud Messaging

  3. 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$ will return true.

The subscribe method invokes a request permission for Notifications and if the status of the granted is received, the getToken method is invoked which retrieves the token from the firebase and sends a http query to the server asking for token registration to topic all.

Additional environment properties.

src/environments/environment.ts

export const environment = {
  publicVapidKey:
    'BP4HNtKjB1OT54fs5sojoqzPj4IS4vmleEmcdqjNdnK0UMBXHRKzLKTSs_ns47Cc4050i5liPmRjG-QARrmbz9o',
  functions: {
    notificationsHttp:
      'https://us-central1-project-12332.cloudfunctions.net/notifications',
  },
};

Angular Service Worker Push Notifications patch.

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 How to fix video safari issue in Angular where I described how to use the patch-package step by step.

./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:

  1. Loads the necessary firebase messaging libraries.

  2. Waits for the INITIALIZE_FCM action to run the firebase library.

  3. Adds support for opening the url after the notificationclick event.

/subscribe/topic/all handler.

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);

How to send message to topic all

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.