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 1a13bb9ba..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,3 +1,8 @@ - diff --git a/apps/demo/src/app/pages/notification/examples/03-close-notification/index.ts b/apps/demo/src/app/pages/notification/examples/03-close-notification/index.ts index cc4e15ee1..13bede45f 100644 --- a/apps/demo/src/app/pages/notification/examples/03-close-notification/index.ts +++ b/apps/demo/src/app/pages/notification/examples/03-close-notification/index.ts @@ -1,8 +1,8 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {NotificationService} from '@ng-web-apis/notification'; import {isDenied, isGranted, PermissionsService} from '@ng-web-apis/permissions'; -import {timer} from 'rxjs'; -import {filter, map, switchMap, takeUntil} from 'rxjs/operators'; +import {BehaviorSubject, timer} from 'rxjs'; +import {filter, map, switchMap, takeUntil, tap} from 'rxjs/operators'; @Component({ selector: 'notification-page-example-3', @@ -11,6 +11,7 @@ import {filter, map, switchMap, takeUntil} from 'rxjs/operators'; }) export class NotificationPageExample3 { readonly denied$ = this.permissions.state('notifications').pipe(map(isDenied)); + readonly showLoader$ = new BehaviorSubject(false); constructor( private readonly notifications: NotificationService, @@ -22,6 +23,7 @@ export class NotificationPageExample3 { .requestPermission() .pipe( filter(isGranted), + tap(() => this.showLoader$.next(true)), switchMap(() => this.notifications.open('Close me, please!', { requireInteraction: true, @@ -30,7 +32,10 @@ export class NotificationPageExample3 { takeUntil(timer(5_000)), // close stream after 5 seconds ) .subscribe({ - complete: () => console.info('Notification closed!'), + complete: () => { + this.showLoader$.next(false); + console.info('Notification closed!'); + }, }); } } diff --git a/apps/demo/src/app/pages/notification/examples/04-listen-notification-events/index.ts b/apps/demo/src/app/pages/notification/examples/04-listen-notification-events/index.ts index 7395d8df7..b4acb45be 100644 --- a/apps/demo/src/app/pages/notification/examples/04-listen-notification-events/index.ts +++ b/apps/demo/src/app/pages/notification/examples/04-listen-notification-events/index.ts @@ -1,7 +1,6 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {NotificationService} from '@ng-web-apis/notification'; import {isDenied, isGranted, PermissionsService} from '@ng-web-apis/permissions'; -import {fromEvent} from 'rxjs'; import {filter, map, switchMap} from 'rxjs/operators'; @Component({ @@ -29,7 +28,9 @@ export class NotificationPageExample4 { data: `Randomly generated number: ${Math.random().toFixed(2)}`, }), ), - switchMap(notification => fromEvent(notification, 'click')), + switchMap(notification => + this.notifications.fromEvent(notification, 'click'), + ), ) .subscribe(console.info); } diff --git a/apps/demo/src/app/pages/notification/notification-page.module.ts b/apps/demo/src/app/pages/notification/notification-page.module.ts index 2643d98aa..8b01393f4 100644 --- a/apps/demo/src/app/pages/notification/notification-page.module.ts +++ b/apps/demo/src/app/pages/notification/notification-page.module.ts @@ -2,7 +2,7 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {RouterModule} from '@angular/router'; import {TuiAddonDocModule} from '@taiga-ui/addon-doc'; -import {TuiButtonModule, TuiNotificationModule} from '@taiga-ui/core'; +import {TuiButtonModule, TuiLinkModule, TuiNotificationModule} from '@taiga-ui/core'; import {TuiBadgeModule} from '@taiga-ui/kit'; import {NotificationPageExample1} from './examples/01-getting-permission'; import {NotificationPageExample2} from './examples/02-create-notification'; @@ -16,6 +16,7 @@ import {NotificationPageComponent} from './notification-page.component'; TuiAddonDocModule, TuiBadgeModule, TuiButtonModule, + TuiLinkModule, TuiNotificationModule, RouterModule.forChild([{path: '', component: NotificationPageComponent}]), ], diff --git a/apps/demo/src/app/pages/notification/notification-page.template.html b/apps/demo/src/app/pages/notification/notification-page.template.html index 63091e069..5fcdc899a 100644 --- a/apps/demo/src/app/pages/notification/notification-page.template.html +++ b/apps/demo/src/app/pages/notification/notification-page.template.html @@ -150,9 +150,9 @@

instance after its successful creation.

- Use rxjs function + Use fromEvent - to listen events that can be triggered on the + method to listen events that can be triggered on the Notification instance.
@@ -166,6 +166,26 @@

.

+ + + Notifications spawned by Service Worker support only + + click + + and + + close + + events! + inject(NAVIGATOR).serviceWorker, + }, +); diff --git a/libs/notification/src/index.ts b/libs/notification/src/index.ts index 704a29e93..19c0c8496 100644 --- a/libs/notification/src/index.ts +++ b/libs/notification/src/index.ts @@ -3,4 +3,5 @@ */ export * from './tokens/support'; +export * from './tokens/notification-factory'; export * from './services/notification.service'; diff --git a/libs/notification/src/services/notification.service.ts b/libs/notification/src/services/notification.service.ts index a7ef701cd..f49d5db45 100644 --- a/libs/notification/src/services/notification.service.ts +++ b/libs/notification/src/services/notification.service.ts @@ -1,8 +1,23 @@ import {Inject, Injectable} from '@angular/core'; -import {defer, fromEvent, Observable, throwError} from 'rxjs'; +import {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'; +import {InjectionTokenType} from '../types/injection-token-type'; const NOT_SUPPORTED_ERROR$ = throwError( () => new Error('Notification API is not supported in your browser'), @@ -12,7 +27,26 @@ const NOT_SUPPORTED_ERROR$ = throwError( providedIn: 'root', }) export class NotificationService { - constructor(@Inject(NOTIFICATION_SUPPORT) private readonly support: boolean) {} + private readonly swRegistration$ = from(this.sw.getRegistration()).pipe( + shareReplay(1), + ); + + 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 + >, + @Inject(SERVICE_WORKER) private readonly sw: ServiceWorkerContainer, + ) {} requestPermission(): Observable { if (!this.support) { @@ -36,15 +70,48 @@ 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, + eventName: E, + ): Observable<{action: string} | void> { + const mapToVoid = map(() => undefined); + + return this.swRegistration$.pipe( + switchMap(swRegistration => { + if (!swRegistration) { + return fromEvent(targetNotification, eventName).pipe(mapToVoid); + } + + const isTargetNotification = (notification: {timestamp?: number}) => + notification.timestamp === targetNotification.timestamp; + + 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..4bd4adac5 --- /dev/null +++ b/libs/notification/src/tokens/notification-clicks.ts @@ -0,0 +1,14 @@ +import {inject, InjectFlags, InjectionToken} from '@angular/core'; +import {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: () => { + const swPush = inject(SwPush, InjectFlags.Optional); + + return swPush && swPush.isEnabled ? swPush.notificationClicks : 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..7db38dbf0 --- /dev/null +++ b/libs/notification/src/tokens/notification-closes.ts @@ -0,0 +1,44 @@ +import {inject, InjectionToken, NgZone} from '@angular/core'; +import {ANIMATION_FRAME, SERVICE_WORKER} from '@ng-web-apis/common'; +import { + combineLatest, + filter, + from, + map, + NEVER, + Observable, + pairwise, + share, + switchMap, +} from 'rxjs'; +import {zoneOptimized} from '../utils/zone'; + +export const NOTIFICATION_SW_CLOSES = new InjectionToken( + `Global listener for events when ANY system notification spawned by Notification API (and only inside Service Worker!) has been closed`, + { + /** + * TODO: refactor the token's factory after this issue will be solved: + * https://github.com/angular/angular/issues/52244 + * ``` + * const swPush = inject(SwPush, InjectFlags.Optional); + * return swPush && swPush.isEnabled ? swPush.notificationCloses : NEVER; + * ``` + */ + factory: (): Observable => { + return 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(), + ); + }, + }, +); 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/types/injection-token-type.ts b/libs/notification/src/types/injection-token-type.ts new file mode 100644 index 000000000..615a48f5b --- /dev/null +++ b/libs/notification/src/types/injection-token-type.ts @@ -0,0 +1,6 @@ +import type {InjectionToken} from '@angular/core'; + +// TODO: discuss with team what should we do with this code duplication +// Could we use `@taiga-ui/cdk` inside `@ng-web-apis/notification` ? + +export type InjectionTokenType = Token extends InjectionToken ? T : never; diff --git a/libs/notification/src/utils/zone.ts b/libs/notification/src/utils/zone.ts new file mode 100644 index 000000000..c034346ac --- /dev/null +++ b/libs/notification/src/utils/zone.ts @@ -0,0 +1,27 @@ +import {NgZone} from '@angular/core'; +import {MonoTypeOperatorFunction, Observable, pipe} from 'rxjs'; + +// TODO: discuss with team what should we do with this code duplication +// Could we use `@taiga-ui/cdk` inside `@ng-web-apis/notification` ? + +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)); +}