Skip to content

Commit

Permalink
feat(notification): add service worker support for `@ng-web-apis/noti…
Browse files Browse the repository at this point in the history
…fication`
  • Loading branch information
nsbarsukov committed Nov 7, 2023
1 parent c115836 commit c79b71d
Show file tree
Hide file tree
Showing 14 changed files with 242 additions and 18 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<button
tuiButton
[disabled]="(denied$ | async)!"
[showLoader]="(showLoader$ | async)!"
(click)="sendNotification()"
>
Send notification
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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!');
},
});
}
}
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,6 +16,7 @@ import {NotificationPageComponent} from './notification-page.component';
TuiAddonDocModule,
TuiBadgeModule,
TuiButtonModule,
TuiLinkModule,
TuiNotificationModule,
RouterModule.forChild([{path: '', component: NotificationPageComponent}]),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,9 @@ <h2 class="header">
instance after its successful creation.

<p>
Use rxjs function
Use
<code>fromEvent</code>
to listen events that can be triggered on the
method to listen events that can be triggered on the
<code>Notification</code>
instance.
<br />
Expand All @@ -160,6 +160,26 @@ <h2 class="header">
</a>
.
</p>

<tui-notification status="warning">
Notifications spawned by Service Worker support only
<a
tuiLink
href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/notificationclick_event"
target="_blank"
>
<code>click</code>
</a>
and
<a
tuiLink
href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/notificationclose_event"
target="_blank"
>
<code>close</code>
</a>
events!
</tui-notification>
</ng-template>

<tui-notification
Expand Down
1 change: 1 addition & 0 deletions libs/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './tokens/network-information';
export * from './tokens/page-visibility';
export * from './tokens/performance';
export * from './tokens/screen';
export * from './tokens/service-worker';
export * from './tokens/session-storage';
export * from './tokens/speech-recognition';
export * from './tokens/speech-synthesis';
Expand Down
9 changes: 9 additions & 0 deletions libs/common/src/tokens/service-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {inject, InjectionToken} from '@angular/core';
import {NAVIGATOR} from './navigator';

export const SERVICE_WORKER = new InjectionToken(
`An abstraction over window.navigator.serviceWorker object`,
{
factory: () => inject(NAVIGATOR).serviceWorker,
},
);
1 change: 1 addition & 0 deletions libs/notification/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
*/

export * from './tokens/support';
export * from './tokens/notification-factory';
export * from './services/notification.service';
87 changes: 77 additions & 10 deletions libs/notification/src/services/notification.service.ts
Original file line number Diff line number Diff line change
@@ -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'),
Expand All @@ -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<NotificationPermission> {
if (!this.support) {
Expand All @@ -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<Notification>(subscriber => {
subscriber.next(notification);
return new Observable<Notification>(subscriber => {
subscriber.next(notification);

return () => notification.close();
}).pipe(takeUntil(close$));
});
return () => notification.close();
}).pipe(takeUntil(close$));
}),
);
}

fromEvent<E extends keyof NotificationEventMap>(
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;
}
}),
);
}
}
14 changes: 14 additions & 0 deletions libs/notification/src/tokens/notification-clicks.ts
Original file line number Diff line number Diff line change
@@ -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;
},
},
);
44 changes: 44 additions & 0 deletions libs/notification/src/tokens/notification-closes.ts
Original file line number Diff line number Diff line change
@@ -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<Notification> => {
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(),
);
},
},
);
27 changes: 27 additions & 0 deletions libs/notification/src/tokens/notification-factory.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Notification>
): Promise<Notification> => {
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);
}
};
},
},
);
6 changes: 6 additions & 0 deletions libs/notification/src/types/injection-token-type.ts
Original file line number Diff line number Diff line change
@@ -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> = Token extends InjectionToken<infer T> ? T : never;
27 changes: 27 additions & 0 deletions libs/notification/src/utils/zone.ts
Original file line number Diff line number Diff line change
@@ -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<T>(zone: NgZone): MonoTypeOperatorFunction<T> {
return source =>
new Observable(subscriber =>
zone.runOutsideAngular(() => source.subscribe(subscriber)),
);
}

export function zonefull<T>(zone: NgZone): MonoTypeOperatorFunction<T> {
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<T>(zone: NgZone): MonoTypeOperatorFunction<T> {
return pipe(zonefree(zone), zonefull(zone));
}

0 comments on commit c79b71d

Please sign in to comment.