Skip to main content

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 getTokenmethod 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 functionsservice.

./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://rayros.github.io/assets/icons/192x192.png'
  const imageUrl = 'https://rayros.github.io/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://rayros.github.io/assets/posts/how-to-fix-video-safari-issue-in-angular/title-image.png';
const url = 'https://rayros.github.io/';
const icon = 'https://rayros.github.io/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

Comments

Popular posts from this blog

How to use Ngx-Charts in Angular ?

Charts helps us to visualize large amount of data in an easy to understand and interactive way. This helps businesses to grow more by taking important decisions from the data. For example, e-commerce can have charts or reports for product sales, with various categories like product type, year, etc. In angular, we have various charting libraries to create charts.  Ngx-charts  is one of them. Check out the list of  best angular chart libraries .  In this article, we will see data visualization with ngx-charts and how to use ngx-charts in angular application ? We will see, How to install ngx-charts in angular ? Create a vertical bar chart Create a pie chart, advanced pie chart and pie chart grid Introduction ngx-charts  is an open-source and declarative charting framework for angular2+. It is maintained by  Swimlane . It is using Angular to render and animate the SVG elements with all of its binding and speed goodness and uses d3 for the excellent math functio...

Understand Angular’s forRoot and forChild

  forRoot   /   forChild   is a pattern for singleton services that most of us know from routing. Routing is actually the main use case for it and as it is not commonly used outside of it, I wouldn’t be surprised if most Angular developers haven’t given it a second thought. However, as the official Angular documentation puts it: “Understanding how  forRoot()  works to make sure a service is a singleton will inform your development at a deeper level.” So let’s go. Providers & Injectors Angular comes with a dependency injection (DI) mechanism. When a component depends on a service, you don’t manually create an instance of the service. You  inject  the service and the dependency injection system takes care of providing an instance. import { Component, OnInit } from '@angular/core'; import { TestService } from 'src/app/services/test.service'; @Component({ selector: 'app-test', templateUrl: './test.component.html', styleUrls: ['./test.compon...

How to solve Puppeteer TimeoutError: Navigation timeout of 30000 ms exceeded

During the automation of multiple tasks on my job and personal projects, i decided to move on  Puppeteer  instead of the old school PhantomJS. One of the most usual problems with pages that contain a lot of content, because of the ads, images etc. is the load time, an exception is thrown (specifically the TimeoutError) after a page takes more than 30000ms (30 seconds) to load totally. To solve this problem, you will have 2 options, either to increase this timeout in the configuration or remove it at all. Personally, i prefer to remove the limit as i know that the pages that i work with will end up loading someday. In this article, i'll explain you briefly 2 ways to bypass this limitation. A. Globally on the tab The option that i prefer, as i browse multiple pages in the same tab, is to remove the timeout limit on the tab that i use to browse. For example, to remove the limit you should add: await page . setDefaultNavigationTimeout ( 0 ) ;  COPY SNIPPET The setDefaultNav...