Skip to content

Commit c79b71d

Browse files
committed
feat(notification): add service worker support for @ng-web-apis/notification
1 parent c115836 commit c79b71d

File tree

14 files changed

+242
-18
lines changed

14 files changed

+242
-18
lines changed

apps/demo/src/app/pages/notification/examples/03-close-notification/index.html

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<button
22
tuiButton
33
[disabled]="(denied$ | async)!"
4+
[showLoader]="(showLoader$ | async)!"
45
(click)="sendNotification()"
56
>
67
Send notification

apps/demo/src/app/pages/notification/examples/03-close-notification/index.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {ChangeDetectionStrategy, Component} from '@angular/core';
22
import {NotificationService} from '@ng-web-apis/notification';
33
import {isDenied, isGranted, PermissionsService} from '@ng-web-apis/permissions';
4-
import {timer} from 'rxjs';
5-
import {filter, map, switchMap, takeUntil} from 'rxjs/operators';
4+
import {BehaviorSubject, timer} from 'rxjs';
5+
import {filter, map, switchMap, takeUntil, tap} from 'rxjs/operators';
66

77
@Component({
88
selector: 'notification-page-example-3',
@@ -11,6 +11,7 @@ import {filter, map, switchMap, takeUntil} from 'rxjs/operators';
1111
})
1212
export class NotificationPageExample3 {
1313
readonly denied$ = this.permissions.state('notifications').pipe(map(isDenied));
14+
readonly showLoader$ = new BehaviorSubject(false);
1415

1516
constructor(
1617
private readonly notifications: NotificationService,
@@ -22,6 +23,7 @@ export class NotificationPageExample3 {
2223
.requestPermission()
2324
.pipe(
2425
filter(isGranted),
26+
tap(() => this.showLoader$.next(true)),
2527
switchMap(() =>
2628
this.notifications.open('Close me, please!', {
2729
requireInteraction: true,
@@ -30,7 +32,10 @@ export class NotificationPageExample3 {
3032
takeUntil(timer(5_000)), // close stream after 5 seconds
3133
)
3234
.subscribe({
33-
complete: () => console.info('Notification closed!'),
35+
complete: () => {
36+
this.showLoader$.next(false);
37+
console.info('Notification closed!');
38+
},
3439
});
3540
}
3641
}

apps/demo/src/app/pages/notification/examples/04-listen-notification-events/index.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {ChangeDetectionStrategy, Component} from '@angular/core';
22
import {NotificationService} from '@ng-web-apis/notification';
33
import {isDenied, isGranted, PermissionsService} from '@ng-web-apis/permissions';
4-
import {fromEvent} from 'rxjs';
54
import {filter, map, switchMap} from 'rxjs/operators';
65

76
@Component({
@@ -29,7 +28,9 @@ export class NotificationPageExample4 {
2928
data: `Randomly generated number: ${Math.random().toFixed(2)}`,
3029
}),
3130
),
32-
switchMap(notification => fromEvent(notification, 'click')),
31+
switchMap(notification =>
32+
this.notifications.fromEvent(notification, 'click'),
33+
),
3334
)
3435
.subscribe(console.info);
3536
}

apps/demo/src/app/pages/notification/notification-page.module.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {CommonModule} from '@angular/common';
22
import {NgModule} from '@angular/core';
33
import {RouterModule} from '@angular/router';
44
import {TuiAddonDocModule} from '@taiga-ui/addon-doc';
5-
import {TuiButtonModule, TuiNotificationModule} from '@taiga-ui/core';
5+
import {TuiButtonModule, TuiLinkModule, TuiNotificationModule} from '@taiga-ui/core';
66
import {TuiBadgeModule} from '@taiga-ui/kit';
77
import {NotificationPageExample1} from './examples/01-getting-permission';
88
import {NotificationPageExample2} from './examples/02-create-notification';
@@ -16,6 +16,7 @@ import {NotificationPageComponent} from './notification-page.component';
1616
TuiAddonDocModule,
1717
TuiBadgeModule,
1818
TuiButtonModule,
19+
TuiLinkModule,
1920
TuiNotificationModule,
2021
RouterModule.forChild([{path: '', component: NotificationPageComponent}]),
2122
],

apps/demo/src/app/pages/notification/notification-page.template.html

+22-2
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,9 @@ <h2 class="header">
144144
instance after its successful creation.
145145

146146
<p>
147-
Use rxjs function
147+
Use
148148
<code>fromEvent</code>
149-
to listen events that can be triggered on the
149+
method to listen events that can be triggered on the
150150
<code>Notification</code>
151151
instance.
152152
<br />
@@ -160,6 +160,26 @@ <h2 class="header">
160160
</a>
161161
.
162162
</p>
163+
164+
<tui-notification status="warning">
165+
Notifications spawned by Service Worker support only
166+
<a
167+
tuiLink
168+
href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/notificationclick_event"
169+
target="_blank"
170+
>
171+
<code>click</code>
172+
</a>
173+
and
174+
<a
175+
tuiLink
176+
href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/notificationclose_event"
177+
target="_blank"
178+
>
179+
<code>close</code>
180+
</a>
181+
events!
182+
</tui-notification>
163183
</ng-template>
164184

165185
<tui-notification

libs/common/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from './tokens/network-information';
1111
export * from './tokens/page-visibility';
1212
export * from './tokens/performance';
1313
export * from './tokens/screen';
14+
export * from './tokens/service-worker';
1415
export * from './tokens/session-storage';
1516
export * from './tokens/speech-recognition';
1617
export * from './tokens/speech-synthesis';
+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {inject, InjectionToken} from '@angular/core';
2+
import {NAVIGATOR} from './navigator';
3+
4+
export const SERVICE_WORKER = new InjectionToken(
5+
`An abstraction over window.navigator.serviceWorker object`,
6+
{
7+
factory: () => inject(NAVIGATOR).serviceWorker,
8+
},
9+
);

libs/notification/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
*/
44

55
export * from './tokens/support';
6+
export * from './tokens/notification-factory';
67
export * from './services/notification.service';

libs/notification/src/services/notification.service.ts

+77-10
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
11
import {Inject, Injectable} from '@angular/core';
2-
import {defer, fromEvent, Observable, throwError} from 'rxjs';
2+
import {SERVICE_WORKER} from '@ng-web-apis/common';
3+
import {
4+
filter,
5+
from,
6+
fromEvent,
7+
map,
8+
NEVER,
9+
Observable,
10+
shareReplay,
11+
switchMap,
12+
throwError,
13+
} from 'rxjs';
314
import {takeUntil} from 'rxjs/operators';
415

16+
import {NOTIFICATION_SW_CLICKS} from '../tokens/notification-clicks';
17+
import {NOTIFICATION_SW_CLOSES} from '../tokens/notification-closes';
18+
import {NOTIFICATION_FACTORY} from '../tokens/notification-factory';
519
import {NOTIFICATION_SUPPORT} from '../tokens/support';
20+
import {InjectionTokenType} from '../types/injection-token-type';
621

722
const NOT_SUPPORTED_ERROR$ = throwError(
823
() => new Error('Notification API is not supported in your browser'),
@@ -12,7 +27,26 @@ const NOT_SUPPORTED_ERROR$ = throwError(
1227
providedIn: 'root',
1328
})
1429
export class NotificationService {
15-
constructor(@Inject(NOTIFICATION_SUPPORT) private readonly support: boolean) {}
30+
private readonly swRegistration$ = from(this.sw.getRegistration()).pipe(
31+
shareReplay(1),
32+
);
33+
34+
constructor(
35+
@Inject(NOTIFICATION_SUPPORT) private readonly support: boolean,
36+
@Inject(NOTIFICATION_FACTORY)
37+
private readonly createNotification: InjectionTokenType<
38+
typeof NOTIFICATION_FACTORY
39+
>,
40+
@Inject(NOTIFICATION_SW_CLICKS)
41+
private readonly notificationSwClicks$: InjectionTokenType<
42+
typeof NOTIFICATION_SW_CLICKS
43+
>,
44+
@Inject(NOTIFICATION_SW_CLOSES)
45+
private readonly notificationSwCloses$: InjectionTokenType<
46+
typeof NOTIFICATION_SW_CLOSES
47+
>,
48+
@Inject(SERVICE_WORKER) private readonly sw: ServiceWorkerContainer,
49+
) {}
1650

1751
requestPermission(): Observable<NotificationPermission> {
1852
if (!this.support) {
@@ -36,15 +70,48 @@ export class NotificationService {
3670
return NOT_SUPPORTED_ERROR$;
3771
}
3872

39-
return defer(() => {
40-
const notification = new Notification(title, options);
41-
const close$ = fromEvent(notification, 'close');
73+
return from(this.createNotification(title, options)).pipe(
74+
switchMap(notification => {
75+
const close$ = this.fromEvent(notification, 'close');
4276

43-
return new Observable<Notification>(subscriber => {
44-
subscriber.next(notification);
77+
return new Observable<Notification>(subscriber => {
78+
subscriber.next(notification);
4579

46-
return () => notification.close();
47-
}).pipe(takeUntil(close$));
48-
});
80+
return () => notification.close();
81+
}).pipe(takeUntil(close$));
82+
}),
83+
);
84+
}
85+
86+
fromEvent<E extends keyof NotificationEventMap>(
87+
targetNotification: Notification,
88+
eventName: E,
89+
): Observable<{action: string} | void> {
90+
const mapToVoid = map(() => undefined);
91+
92+
return this.swRegistration$.pipe(
93+
switchMap(swRegistration => {
94+
if (!swRegistration) {
95+
return fromEvent(targetNotification, eventName).pipe(mapToVoid);
96+
}
97+
98+
const isTargetNotification = (notification: {timestamp?: number}) =>
99+
notification.timestamp === targetNotification.timestamp;
100+
101+
switch (eventName) {
102+
case 'click':
103+
return this.notificationSwClicks$.pipe(
104+
filter(x => isTargetNotification(x.notification)),
105+
);
106+
case 'close':
107+
return this.notificationSwCloses$.pipe(
108+
filter(isTargetNotification),
109+
mapToVoid,
110+
);
111+
default:
112+
return NEVER;
113+
}
114+
}),
115+
);
49116
}
50117
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {inject, InjectFlags, InjectionToken} from '@angular/core';
2+
import {SwPush} from '@angular/service-worker';
3+
import {NEVER} from 'rxjs';
4+
5+
export const NOTIFICATION_SW_CLICKS = new InjectionToken(
6+
`Global listener for events when ANY system notification spawned by Notification API (and only inside Service Worker!) has been clicked`,
7+
{
8+
factory: () => {
9+
const swPush = inject(SwPush, InjectFlags.Optional);
10+
11+
return swPush && swPush.isEnabled ? swPush.notificationClicks : NEVER;
12+
},
13+
},
14+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {inject, InjectionToken, NgZone} from '@angular/core';
2+
import {ANIMATION_FRAME, SERVICE_WORKER} from '@ng-web-apis/common';
3+
import {
4+
combineLatest,
5+
filter,
6+
from,
7+
map,
8+
NEVER,
9+
Observable,
10+
pairwise,
11+
share,
12+
switchMap,
13+
} from 'rxjs';
14+
import {zoneOptimized} from '../utils/zone';
15+
16+
export const NOTIFICATION_SW_CLOSES = new InjectionToken(
17+
`Global listener for events when ANY system notification spawned by Notification API (and only inside Service Worker!) has been closed`,
18+
{
19+
/**
20+
* TODO: refactor the token's factory after this issue will be solved:
21+
* https://github.com/angular/angular/issues/52244
22+
* ```
23+
* const swPush = inject(SwPush, InjectFlags.Optional);
24+
* return swPush && swPush.isEnabled ? swPush.notificationCloses : NEVER;
25+
* ```
26+
*/
27+
factory: (): Observable<Notification> => {
28+
return combineLatest([
29+
from(inject(SERVICE_WORKER).getRegistration()),
30+
inject(ANIMATION_FRAME),
31+
]).pipe(
32+
switchMap(([reg]) => (reg ? from(reg.getNotifications()) : NEVER)),
33+
pairwise(),
34+
filter(([prev, cur]) => prev.length > cur.length),
35+
map(
36+
([prev, cur]) =>
37+
prev.find((notification, i) => notification !== cur[i])!,
38+
),
39+
zoneOptimized(inject(NgZone)),
40+
share(),
41+
);
42+
},
43+
},
44+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {inject, InjectionToken} from '@angular/core';
2+
import {SERVICE_WORKER} from '@ng-web-apis/common';
3+
4+
export const NOTIFICATION_FACTORY = new InjectionToken(
5+
'An async function to create Notification using Notification API (with and without service worker)',
6+
{
7+
factory: () => {
8+
const sw = inject(SERVICE_WORKER);
9+
10+
return async (
11+
...args: ConstructorParameters<typeof Notification>
12+
): Promise<Notification> => {
13+
const registration = await sw.getRegistration();
14+
15+
if (registration) {
16+
await registration.showNotification(...args);
17+
18+
const notifications = await registration.getNotifications();
19+
20+
return notifications[notifications.length - 1];
21+
} else {
22+
return new Notification(...args);
23+
}
24+
};
25+
},
26+
},
27+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type {InjectionToken} from '@angular/core';
2+
3+
// TODO: discuss with team what should we do with this code duplication
4+
// Could we use `@taiga-ui/cdk` inside `@ng-web-apis/notification` ?
5+
6+
export type InjectionTokenType<Token> = Token extends InjectionToken<infer T> ? T : never;

libs/notification/src/utils/zone.ts

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {NgZone} from '@angular/core';
2+
import {MonoTypeOperatorFunction, Observable, pipe} from 'rxjs';
3+
4+
// TODO: discuss with team what should we do with this code duplication
5+
// Could we use `@taiga-ui/cdk` inside `@ng-web-apis/notification` ?
6+
7+
export function zonefree<T>(zone: NgZone): MonoTypeOperatorFunction<T> {
8+
return source =>
9+
new Observable(subscriber =>
10+
zone.runOutsideAngular(() => source.subscribe(subscriber)),
11+
);
12+
}
13+
14+
export function zonefull<T>(zone: NgZone): MonoTypeOperatorFunction<T> {
15+
return source =>
16+
new Observable(subscriber =>
17+
source.subscribe({
18+
next: value => zone.run(() => subscriber.next(value)),
19+
error: (error: unknown) => zone.run(() => subscriber.error(error)),
20+
complete: () => zone.run(() => subscriber.complete()),
21+
}),
22+
);
23+
}
24+
25+
export function zoneOptimized<T>(zone: NgZone): MonoTypeOperatorFunction<T> {
26+
return pipe(zonefree(zone), zonefull(zone));
27+
}

0 commit comments

Comments
 (0)