diff --git a/apps/demo/src/app/app.browser.module.ts b/apps/demo/src/app/app.browser.module.ts
index 021899e64..56a807b29 100644
--- a/apps/demo/src/app/app.browser.module.ts
+++ b/apps/demo/src/app/app.browser.module.ts
@@ -2,8 +2,9 @@ import {LocationStrategy, PathLocationStrategy} from '@angular/common';
import {NgModule, SecurityContext} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
-import {ServiceWorkerModule} from '@angular/service-worker';
+import {ServiceWorkerModule, SwPush} from '@angular/service-worker';
import {POSITION_OPTIONS} from '@ng-web-apis/geolocation';
+import {provideSwPush} from '@ng-web-apis/notification';
import {TuiLinkModule, TuiRootModule, TuiSvgModule} from '@taiga-ui/core';
import {HIGHLIGHT_OPTIONS, HighlightModule} from 'ngx-highlightjs';
import {MarkdownModule} from 'ngx-markdown';
@@ -35,6 +36,7 @@ import {AppRoutingModule} from './app.routes';
],
declarations: [AppComponent],
providers: [
+ provideSwPush(SwPush),
{
provide: HIGHLIGHT_OPTIONS,
useValue: {fullLibraryLoader: async () => import(`highlight.js`)},
diff --git a/apps/demo/src/app/pages/notification/examples/01-getting-permission/index.ts b/apps/demo/src/app/pages/notification/examples/01-getting-permission/index.ts
index 6195be330..726663e44 100644
--- a/apps/demo/src/app/pages/notification/examples/01-getting-permission/index.ts
+++ b/apps/demo/src/app/pages/notification/examples/01-getting-permission/index.ts
@@ -2,12 +2,13 @@ import {CommonModule} from '@angular/common';
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
import {NotificationService} from '@ng-web-apis/notification';
import {PermissionsService} from '@ng-web-apis/permissions';
+import {TuiButtonModule} from '@taiga-ui/core';
import {TuiBadgeModule} from '@taiga-ui/kit';
@Component({
standalone: true,
selector: 'notification-page-example-1',
- imports: [CommonModule, TuiBadgeModule],
+ imports: [CommonModule, TuiBadgeModule, TuiButtonModule],
templateUrl: './index.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
diff --git a/apps/demo/src/app/pages/notification/examples/02-create-notification/index.ts b/apps/demo/src/app/pages/notification/examples/02-create-notification/index.ts
index 116c93dca..1f79d06c9 100644
--- a/apps/demo/src/app/pages/notification/examples/02-create-notification/index.ts
+++ b/apps/demo/src/app/pages/notification/examples/02-create-notification/index.ts
@@ -1,13 +1,14 @@
-import {CommonModule} from '@angular/common';
+import {AsyncPipe} from '@angular/common';
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
import {NotificationService} from '@ng-web-apis/notification';
import {isDenied, isGranted, PermissionsService} from '@ng-web-apis/permissions';
+import {TuiButtonModule} from '@taiga-ui/core';
import {filter, map, switchMap} from 'rxjs/operators';
@Component({
standalone: true,
selector: 'notification-page-example-2',
- imports: [CommonModule],
+ imports: [AsyncPipe, TuiButtonModule],
templateUrl: './index.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
diff --git a/apps/demo/src/app/pages/notification/examples/03-close-notification/index.html b/apps/demo/src/app/pages/notification/examples/03-close-notification/index.html
index e46c535c9..e993760a3 100644
--- a/apps/demo/src/app/pages/notification/examples/03-close-notification/index.html
+++ b/apps/demo/src/app/pages/notification/examples/03-close-notification/index.html
@@ -1,6 +1,7 @@
+
+
+ Notifications spawned by Service Worker support only
+
+ click
+
+ and
+
+ close
+
+ events!
+
inject(NAVIGATOR).serviceWorker,
+ },
+);
diff --git a/libs/common/src/types/injection-token-type.ts b/libs/common/src/types/injection-token-type.ts
new file mode 100644
index 000000000..d56dd55c8
--- /dev/null
+++ b/libs/common/src/types/injection-token-type.ts
@@ -0,0 +1,3 @@
+import type {InjectionToken} from '@angular/core';
+
+export type InjectionTokenType = Token extends InjectionToken ? T : never;
diff --git a/libs/common/src/utils/zone.ts b/libs/common/src/utils/zone.ts
new file mode 100644
index 000000000..c85c8e7a5
--- /dev/null
+++ b/libs/common/src/utils/zone.ts
@@ -0,0 +1,24 @@
+import {NgZone} from '@angular/core';
+import {MonoTypeOperatorFunction, Observable, pipe} from 'rxjs';
+
+export function zonefree(zone: NgZone): MonoTypeOperatorFunction {
+ return source =>
+ new Observable(subscriber =>
+ zone.runOutsideAngular(() => source.subscribe(subscriber)),
+ );
+}
+
+export function zonefull(zone: NgZone): MonoTypeOperatorFunction {
+ return source =>
+ new Observable(subscriber =>
+ source.subscribe({
+ next: value => zone.run(() => subscriber.next(value)),
+ error: (error: unknown) => zone.run(() => subscriber.error(error)),
+ complete: () => zone.run(() => subscriber.complete()),
+ }),
+ );
+}
+
+export function zoneOptimized(zone: NgZone): MonoTypeOperatorFunction {
+ return pipe(zonefree(zone), zonefull(zone));
+}
diff --git a/libs/notification/src/index.ts b/libs/notification/src/index.ts
index 99039d3a9..fc060b120 100644
--- a/libs/notification/src/index.ts
+++ b/libs/notification/src/index.ts
@@ -1,2 +1,4 @@
export * from './services/notification.service';
+export * from './tokens/notification-factory';
export * from './tokens/support';
+export * from './utils/provide-sw-push';
diff --git a/libs/notification/src/services/notification.service.ts b/libs/notification/src/services/notification.service.ts
index 9966c14bb..ce4b6b316 100644
--- a/libs/notification/src/services/notification.service.ts
+++ b/libs/notification/src/services/notification.service.ts
@@ -1,18 +1,52 @@
-import {Inject, Injectable} from '@angular/core';
-import {defer, fromEvent, Observable, throwError} from 'rxjs';
+import {Inject, inject, Injectable} from '@angular/core';
+import {InjectionTokenType, SERVICE_WORKER} from '@ng-web-apis/common';
+import {
+ filter,
+ from,
+ fromEvent,
+ map,
+ NEVER,
+ Observable,
+ shareReplay,
+ switchMap,
+ throwError,
+} from 'rxjs';
import {takeUntil} from 'rxjs/operators';
+import {NOTIFICATION_SW_CLICKS} from '../tokens/notification-clicks';
+import {NOTIFICATION_SW_CLOSES} from '../tokens/notification-closes';
+import {NOTIFICATION_FACTORY} from '../tokens/notification-factory';
import {NOTIFICATION_SUPPORT} from '../tokens/support';
const NOT_SUPPORTED_ERROR$ = throwError(
() => new Error(`Notification API is not supported in your browser`),
);
+const mapToVoid = map(() => undefined);
+
@Injectable({
providedIn: `root`,
})
export class NotificationService {
- constructor(@Inject(NOTIFICATION_SUPPORT) private readonly support: boolean) {}
+ private readonly swRegistration$ = from(
+ inject(SERVICE_WORKER).getRegistration(),
+ ).pipe(shareReplay({bufferSize: 1, refCount: true}));
+
+ constructor(
+ @Inject(NOTIFICATION_SUPPORT) private readonly support: boolean,
+ @Inject(NOTIFICATION_FACTORY)
+ private readonly createNotification: InjectionTokenType<
+ typeof NOTIFICATION_FACTORY
+ >,
+ @Inject(NOTIFICATION_SW_CLICKS)
+ private readonly notificationSwClicks$: InjectionTokenType<
+ typeof NOTIFICATION_SW_CLICKS
+ >,
+ @Inject(NOTIFICATION_SW_CLOSES)
+ private readonly notificationSwCloses$: InjectionTokenType<
+ typeof NOTIFICATION_SW_CLOSES
+ >,
+ ) {}
requestPermission(): Observable {
if (!this.support) {
@@ -36,15 +70,46 @@ export class NotificationService {
return NOT_SUPPORTED_ERROR$;
}
- return defer(() => {
- const notification = new Notification(title, options);
- const close$ = fromEvent(notification, `close`);
+ return from(this.createNotification(title, options)).pipe(
+ switchMap(notification => {
+ const close$ = this.fromEvent(notification, `close`);
- return new Observable(subscriber => {
- subscriber.next(notification);
+ return new Observable(subscriber => {
+ subscriber.next(notification);
- return () => notification.close();
- }).pipe(takeUntil(close$));
- });
+ return () => notification.close();
+ }).pipe(takeUntil(close$));
+ }),
+ );
+ }
+
+ fromEvent(
+ targetNotification: Notification & {timestamp?: number},
+ eventName: E,
+ ): Observable<{action: string} | void> {
+ const isTargetNotification = ({timestamp}: {timestamp?: number}): boolean =>
+ timestamp === targetNotification.timestamp;
+
+ return this.swRegistration$.pipe(
+ switchMap(swRegistration => {
+ if (!swRegistration) {
+ return fromEvent(targetNotification, eventName).pipe(mapToVoid);
+ }
+
+ switch (eventName) {
+ case `click`:
+ return this.notificationSwClicks$.pipe(
+ filter(x => isTargetNotification(x.notification)),
+ );
+ case `close`:
+ return this.notificationSwCloses$.pipe(
+ filter(isTargetNotification),
+ mapToVoid,
+ );
+ default:
+ return NEVER;
+ }
+ }),
+ );
}
}
diff --git a/libs/notification/src/tokens/notification-clicks.ts b/libs/notification/src/tokens/notification-clicks.ts
new file mode 100644
index 000000000..d977204bf
--- /dev/null
+++ b/libs/notification/src/tokens/notification-clicks.ts
@@ -0,0 +1,10 @@
+import {InjectionToken} from '@angular/core';
+import type {SwPush} from '@angular/service-worker';
+import {NEVER} from 'rxjs';
+
+export const NOTIFICATION_SW_CLICKS = new InjectionToken(
+ `Global listener for events when ANY system notification spawned by Notification API (and only inside Service Worker!) has been clicked`,
+ {
+ factory: () => NEVER,
+ },
+);
diff --git a/libs/notification/src/tokens/notification-closes.ts b/libs/notification/src/tokens/notification-closes.ts
new file mode 100644
index 000000000..e6222643b
--- /dev/null
+++ b/libs/notification/src/tokens/notification-closes.ts
@@ -0,0 +1,11 @@
+import {InjectionToken} from '@angular/core';
+import {NEVER, Observable} from 'rxjs';
+
+export const NOTIFICATION_SW_CLOSES = new InjectionToken<
+ Observable
+>(
+ `[NOTIFICATION_SW_CLOSES]: Global listener for events when ANY system notification spawned by Notification API (and only inside Service Worker!) has been closed`,
+ {
+ factory: () => NEVER,
+ },
+);
diff --git a/libs/notification/src/tokens/notification-factory.ts b/libs/notification/src/tokens/notification-factory.ts
new file mode 100644
index 000000000..d0ed5887c
--- /dev/null
+++ b/libs/notification/src/tokens/notification-factory.ts
@@ -0,0 +1,27 @@
+import {inject, InjectionToken} from '@angular/core';
+import {SERVICE_WORKER} from '@ng-web-apis/common';
+
+export const NOTIFICATION_FACTORY = new InjectionToken(
+ 'An async function to create Notification using Notification API (with and without service worker)',
+ {
+ factory: () => {
+ const sw = inject(SERVICE_WORKER);
+
+ return async (
+ ...args: ConstructorParameters
+ ): Promise => {
+ const registration = await sw.getRegistration();
+
+ if (registration) {
+ await registration.showNotification(...args);
+
+ const notifications = await registration.getNotifications();
+
+ return notifications[notifications.length - 1];
+ } else {
+ return new Notification(...args);
+ }
+ };
+ },
+ },
+);
diff --git a/libs/notification/src/utils/provide-sw-push.ts b/libs/notification/src/utils/provide-sw-push.ts
new file mode 100644
index 000000000..9765eff3c
--- /dev/null
+++ b/libs/notification/src/utils/provide-sw-push.ts
@@ -0,0 +1,45 @@
+import {inject, NgZone, Provider, Type} from '@angular/core';
+import type {SwPush} from '@angular/service-worker';
+import {ANIMATION_FRAME, SERVICE_WORKER, zoneOptimized} from '@ng-web-apis/common';
+import {combineLatest, filter, from, map, NEVER, pairwise, share, switchMap} from 'rxjs';
+import {NOTIFICATION_SW_CLICKS} from '../tokens/notification-clicks';
+import {NOTIFICATION_SW_CLOSES} from '../tokens/notification-closes';
+
+export function provideSwPush(swPush: Type): Provider[] {
+ return [
+ {
+ provide: NOTIFICATION_SW_CLICKS,
+ deps: [swPush],
+ useFactory: ({isEnabled, notificationClicks}: SwPush) =>
+ isEnabled ? notificationClicks : NEVER,
+ },
+ {
+ provide: NOTIFICATION_SW_CLOSES,
+ /**
+ * TODO: refactor the token's factory after this issue will be solved:
+ * https://github.com/angular/angular/issues/52244
+ * ```
+ * {
+ * provide: NOTIFICATION_SW_CLOSES,
+ * useValue: swPush.isEnabled ? swPush.notificationCloses : NEVER,
+ * },
+ * ```
+ */
+ useFactory: () =>
+ combineLatest([
+ from(inject(SERVICE_WORKER).getRegistration()),
+ inject(ANIMATION_FRAME),
+ ]).pipe(
+ switchMap(([reg]) => (reg ? from(reg.getNotifications()) : NEVER)),
+ pairwise(),
+ filter(([prev, cur]) => prev.length > cur.length),
+ map(
+ ([prev, cur]) =>
+ prev.find((notification, i) => notification !== cur[i])!,
+ ),
+ zoneOptimized(inject(NgZone)),
+ share(),
+ ),
+ },
+ ];
+}