swimlane/ngx-ui

View on GitHub
projects/swimlane/ngx-ui/src/lib/components/notification/notification.service.ts

Summary

Maintainability
A
1 hr
Test Coverage
import { Injectable, ComponentRef, Inject, Type } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { Subscription } from 'rxjs';

import { InjectionService } from '../../services/injection/injection.service';
import { InjectionRegistryService } from '../../services/injection-registry/injection-registry.service';
import { PartialBindings } from '../../services/injection-registry/partial-bindings.interface';

import { NotificationType } from './notification-type.enum';
import { NotificationStyleType } from './notification-style-type.enum';
import { NotificationPermissionType } from './notification-permission-type.enum';
import { NotificationComponent } from './notification.component';
import { NotificationContainerComponent } from './notification-container.component';
import { NotificationOptions } from './notification-options.interface';

/** adding dynamic to suppress `Document` type metadata error  */
/** @dynamic */
@Injectable({
  providedIn: 'root'
})
export class NotificationService extends InjectionRegistryService<NotificationComponent> {
  static readonly limit: number | boolean = 10;
  readonly defaults: NotificationOptions = {
    inputs: {
      timeout: 3000,
      rateLimit: true,
      pauseOnHover: true,
      type: NotificationType.html,
      styleType: NotificationStyleType.none,
      showClose: true,
      sound: false
    }
  };

  permission: NotificationPermission;
  container?: ComponentRef<NotificationContainerComponent>;
  type = NotificationComponent;

  get isNativeSupported(): boolean {
    return 'Notification' in window;
  }

  constructor(readonly injectionService: InjectionService, @Inject(DOCUMENT) private readonly document: Document) {
    super(injectionService);
  }

  create(bindings: Partial<NotificationOptions>): ComponentRef<NotificationComponent> {
    // verify flood not happening
    if (bindings.rateLimit && this.isFlooded(bindings)) {
      return;
    }

    // if limit reached, remove the first one
    const compsByType = this.getByType();

    if (compsByType && (compsByType.length as any) >= NotificationService.limit) {
      this.destroy(compsByType[0]);
    }

    // native notifications need to be invoked
    let component: ComponentRef<NotificationComponent> | Notification;

    if (bindings.type === NotificationType.native) {
      component = this.showNative(bindings);
    } else {
      component = super.create(bindings);
      this.createSubscriptions(component);
      this.startTimer(component);
    }

    return component as any;
  }

  startTimer(component: ComponentRef<NotificationComponent>): void {
    if (component.instance && component.instance.timeout !== false) {
      clearTimeout(component.instance.timer);

      component.instance.timer = setTimeout(() => {
        this.destroy(component);
      }, component.instance.timeout as number);
    }
  }

  pauseTimer(component: ComponentRef<NotificationComponent>): void {
    clearTimeout(component.instance.timer);
  }

  requestPermissions(): void {
    if (this.isNativeSupported) {
      Notification.requestPermission(/* istanbul ignore next */ status => (this.permission = status));
    }
  }

  assignDefaults(options: Partial<NotificationOptions>): PartialBindings {
    const bindings = super.assignDefaults(options as any);

    if (bindings.inputs && bindings.inputs.timeout === true) {
      bindings.inputs.timeout = this.defaults.inputs.timeout;
    }

    // add a timestamp for flood checks
    bindings.inputs.timestamp = +new Date();
    return bindings;
  }

  injectComponent(type: Type<NotificationContainerComponent>, options: PartialBindings): ComponentRef<any> {
    if (!this.container || !this.document.contains(this.container.location.nativeElement)) {
      this.container = this.injectionService.appendComponent(NotificationContainerComponent);
    }

    return this.injectionService.appendComponent(type, options, this.container);
  }

  createSubscriptions(component: ComponentRef<NotificationComponent>): any {
    const pauseSub: Subscription = component.instance.pause.subscribe(() => {
      this.pauseTimer(component);
    });

    const resumeSub: Subscription = component.instance.resume.subscribe(() => {
      this.startTimer(component);
    });

    const closeSub: Subscription = component.instance.close.subscribe(() => {
      closeSub.unsubscribe();
      resumeSub.unsubscribe();
      pauseSub.unsubscribe();

      this.destroy(component);
    });
  }

  isFlooded(options: Partial<NotificationOptions>): boolean {
    const compsByType = this.getByType();

    for (const notification of compsByType) {
      const instance = notification.instance;

      if (
        instance.title === options.title &&
        instance.body === options.body &&
        instance.timestamp + 1000 > options.timestamp
      ) {
        return true;
      }
    }

    return false;
  }

  showNative(options: Partial<NotificationOptions>): any {
    if (!this.isNativeSupported) return;
    if (!this.permission) this.requestPermissions();
    if (this.permission === NotificationPermissionType.denied) return;

    const note = new Notification(options.title, options);

    note.onerror = () => {
      // eslint-disable-next-line no-console
      console.error('Notification failed!', options);
    };

    // manually do this
    if (options && typeof options.timeout === 'number') {
      setTimeout(note.close.bind(note), options.timeout);
    }

    return note;
  }
}