Skip to content

Commit b16edfa

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

File tree

19 files changed

+262
-33
lines changed

19 files changed

+262
-33
lines changed

apps/demo/src/app/app.browser.module.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import {LocationStrategy, PathLocationStrategy} from '@angular/common';
22
import {NgModule, SecurityContext} from '@angular/core';
33
import {BrowserModule} from '@angular/platform-browser';
44
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
5-
import {ServiceWorkerModule} from '@angular/service-worker';
5+
import {ServiceWorkerModule, SwPush} from '@angular/service-worker';
66
import {POSITION_OPTIONS} from '@ng-web-apis/geolocation';
7+
import {provideSwPush} from '@ng-web-apis/notification';
78
import {TuiLinkModule, TuiRootModule, TuiSvgModule} from '@taiga-ui/core';
89
import {HIGHLIGHT_OPTIONS, HighlightModule} from 'ngx-highlightjs';
910
import {MarkdownModule} from 'ngx-markdown';
@@ -35,6 +36,7 @@ import {AppRoutingModule} from './app.routes';
3536
],
3637
declarations: [AppComponent],
3738
providers: [
39+
provideSwPush(SwPush),
3840
{
3941
provide: HIGHLIGHT_OPTIONS,
4042
useValue: {fullLibraryLoader: async () => import(`highlight.js`)},

apps/demo/src/app/pages/notification/examples/01-getting-permission/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import {CommonModule} from '@angular/common';
22
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
33
import {NotificationService} from '@ng-web-apis/notification';
44
import {PermissionsService} from '@ng-web-apis/permissions';
5+
import {TuiButtonModule} from '@taiga-ui/core';
56
import {TuiBadgeModule} from '@taiga-ui/kit';
67

78
@Component({
89
standalone: true,
910
selector: 'notification-page-example-1',
10-
imports: [CommonModule, TuiBadgeModule],
11+
imports: [CommonModule, TuiBadgeModule, TuiButtonModule],
1112
templateUrl: './index.html',
1213
changeDetection: ChangeDetectionStrategy.OnPush,
1314
})

apps/demo/src/app/pages/notification/examples/02-create-notification/index.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
import {CommonModule} from '@angular/common';
1+
import {AsyncPipe} from '@angular/common';
22
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
33
import {NotificationService} from '@ng-web-apis/notification';
44
import {isDenied, isGranted, PermissionsService} from '@ng-web-apis/permissions';
5+
import {TuiButtonModule} from '@taiga-ui/core';
56
import {filter, map, switchMap} from 'rxjs/operators';
67

78
@Component({
89
standalone: true,
910
selector: 'notification-page-example-2',
10-
imports: [CommonModule],
11+
imports: [AsyncPipe, TuiButtonModule],
1112
templateUrl: './index.html',
1213
changeDetection: ChangeDetectionStrategy.OnPush,
1314
})

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
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,32 @@
1-
import {CommonModule} from '@angular/common';
1+
import {AsyncPipe} from '@angular/common';
22
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
33
import {NotificationService} from '@ng-web-apis/notification';
44
import {isDenied, isGranted, PermissionsService} from '@ng-web-apis/permissions';
5-
import {timer} from 'rxjs';
6-
import {filter, map, switchMap, takeUntil} from 'rxjs/operators';
5+
import {TuiButtonModule} from '@taiga-ui/core';
6+
import {BehaviorSubject, timer} from 'rxjs';
7+
import {filter, map, switchMap, takeUntil, tap} from 'rxjs/operators';
78

89
@Component({
910
standalone: true,
1011
selector: 'notification-page-example-3',
11-
imports: [CommonModule],
12+
imports: [AsyncPipe, TuiButtonModule],
1213
templateUrl: './index.html',
1314
changeDetection: ChangeDetectionStrategy.OnPush,
1415
})
1516
export class NotificationPageExample3 {
1617
private readonly notifications: NotificationService = inject(NotificationService);
17-
1818
readonly denied$ = inject(PermissionsService)
1919
.state('notifications')
2020
.pipe(map(isDenied));
2121

22+
readonly showLoader$ = new BehaviorSubject(false);
23+
2224
sendNotification(): void {
2325
this.notifications
2426
.requestPermission()
2527
.pipe(
2628
filter(isGranted),
29+
tap(() => this.showLoader$.next(true)),
2730
switchMap(() =>
2831
this.notifications.open('Close me, please!', {
2932
requireInteraction: true,
@@ -32,7 +35,10 @@ export class NotificationPageExample3 {
3235
takeUntil(timer(5_000)), // close stream after 5 seconds
3336
)
3437
.subscribe({
35-
complete: () => console.info('Notification closed!'),
38+
complete: () => {
39+
this.showLoader$.next(false);
40+
console.info('Notification closed!');
41+
},
3642
});
3743
}
3844
}

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

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import {CommonModule} from '@angular/common';
1+
import {AsyncPipe} from '@angular/common';
22
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
33
import {NotificationService} from '@ng-web-apis/notification';
44
import {isDenied, isGranted, PermissionsService} from '@ng-web-apis/permissions';
5-
import {fromEvent} from 'rxjs';
5+
import {TuiButtonModule} from '@taiga-ui/core';
66
import {filter, map, switchMap} from 'rxjs/operators';
77

88
@Component({
99
standalone: true,
1010
selector: 'notification-page-example-4',
11-
imports: [CommonModule],
11+
imports: [AsyncPipe, TuiButtonModule],
1212
templateUrl: './index.html',
1313
changeDetection: ChangeDetectionStrategy.OnPush,
1414
})
@@ -31,7 +31,9 @@ export class NotificationPageExample4 {
3131
data: `Randomly generated number: ${Math.random().toFixed(2)}`,
3232
}),
3333
),
34-
switchMap(notification => fromEvent(notification, 'click')),
34+
switchMap(notification =>
35+
this.notifications.fromEvent(notification, 'click'),
36+
),
3537
)
3638
.subscribe(console.info);
3739
}

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

+1-4
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import {CommonModule} from '@angular/common';
22
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
33
import {PermissionsService} from '@ng-web-apis/permissions';
44
import {TuiAddonDocModule, TuiDocExample} from '@taiga-ui/addon-doc';
5-
import {TuiButtonModule, TuiLinkModule, TuiNotificationModule} from '@taiga-ui/core';
6-
import {TuiBadgeModule} from '@taiga-ui/kit';
5+
import {TuiLinkModule, TuiNotificationModule} from '@taiga-ui/core';
76

87
import {NotificationPageExample1} from './examples/01-getting-permission';
98
import {NotificationPageExample2} from './examples/02-create-notification';
@@ -16,8 +15,6 @@ import {NotificationPageExample4} from './examples/04-listen-notification-events
1615
imports: [
1716
CommonModule,
1817
TuiAddonDocModule,
19-
TuiBadgeModule,
20-
TuiButtonModule,
2118
TuiNotificationModule,
2219
TuiLinkModule,
2320
NotificationPageExample1,

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

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

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

168188
<tui-notification

apps/demo/src/app/pages/payment-request/shop/shop.component.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {NgForOf} from '@angular/common';
1+
import {NgForOf, NgIf} from '@angular/common';
22
import {ChangeDetectionStrategy, Component} from '@angular/core';
33
import {PaymentRequestModule} from '@ng-web-apis/payment-request';
44

@@ -20,7 +20,7 @@ class ShopItem implements PaymentItem {
2020
@Component({
2121
standalone: true,
2222
selector: 'app-shop',
23-
imports: [NgForOf, PaymentRequestModule],
23+
imports: [NgForOf, NgIf, PaymentRequestModule],
2424
templateUrl: './shop.component.html',
2525
styleUrls: ['./shop.component.less'],
2626
changeDetection: ChangeDetectionStrategy.OnPush,

libs/common/src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ 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';
1718
export * from './tokens/user-agent';
1819
export * from './tokens/window';
20+
export * from './types/injection-token-type';
21+
export * from './utils/zone';
+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+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import type {InjectionToken} from '@angular/core';
2+
3+
export type InjectionTokenType<Token> = Token extends InjectionToken<infer T> ? T : never;

libs/common/src/utils/zone.ts

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {NgZone} from '@angular/core';
2+
import {MonoTypeOperatorFunction, Observable, pipe} from 'rxjs';
3+
4+
export function zonefree<T>(zone: NgZone): MonoTypeOperatorFunction<T> {
5+
return source =>
6+
new Observable(subscriber =>
7+
zone.runOutsideAngular(() => source.subscribe(subscriber)),
8+
);
9+
}
10+
11+
export function zonefull<T>(zone: NgZone): MonoTypeOperatorFunction<T> {
12+
return source =>
13+
new Observable(subscriber =>
14+
source.subscribe({
15+
next: value => zone.run(() => subscriber.next(value)),
16+
error: (error: unknown) => zone.run(() => subscriber.error(error)),
17+
complete: () => zone.run(() => subscriber.complete()),
18+
}),
19+
);
20+
}
21+
22+
export function zoneOptimized<T>(zone: NgZone): MonoTypeOperatorFunction<T> {
23+
return pipe(zonefree(zone), zonefull(zone));
24+
}

libs/notification/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export * from './services/notification.service';
2+
export * from './tokens/notification-factory';
23
export * from './tokens/support';
4+
export * from './utils/provide-sw-push';

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

+76-11
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,52 @@
1-
import {Inject, Injectable} from '@angular/core';
2-
import {defer, fromEvent, Observable, throwError} from 'rxjs';
1+
import {Inject, inject, Injectable} from '@angular/core';
2+
import {InjectionTokenType, 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';
620

721
const NOT_SUPPORTED_ERROR$ = throwError(
822
() => new Error(`Notification API is not supported in your browser`),
923
);
1024

25+
const mapToVoid = map(() => undefined);
26+
1127
@Injectable({
1228
providedIn: `root`,
1329
})
1430
export class NotificationService {
15-
constructor(@Inject(NOTIFICATION_SUPPORT) private readonly support: boolean) {}
31+
private readonly swRegistration$ = from(
32+
inject(SERVICE_WORKER).getRegistration(),
33+
).pipe(shareReplay({bufferSize: 1, refCount: true}));
34+
35+
constructor(
36+
@Inject(NOTIFICATION_SUPPORT) private readonly support: boolean,
37+
@Inject(NOTIFICATION_FACTORY)
38+
private readonly createNotification: InjectionTokenType<
39+
typeof NOTIFICATION_FACTORY
40+
>,
41+
@Inject(NOTIFICATION_SW_CLICKS)
42+
private readonly notificationSwClicks$: InjectionTokenType<
43+
typeof NOTIFICATION_SW_CLICKS
44+
>,
45+
@Inject(NOTIFICATION_SW_CLOSES)
46+
private readonly notificationSwCloses$: InjectionTokenType<
47+
typeof NOTIFICATION_SW_CLOSES
48+
>,
49+
) {}
1650

1751
requestPermission(): Observable<NotificationPermission> {
1852
if (!this.support) {
@@ -36,15 +70,46 @@ 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 & {timestamp?: number},
88+
eventName: E,
89+
): Observable<{action: string} | void> {
90+
const isTargetNotification = ({timestamp}: {timestamp?: number}): boolean =>
91+
timestamp === targetNotification.timestamp;
92+
93+
return this.swRegistration$.pipe(
94+
switchMap(swRegistration => {
95+
if (!swRegistration) {
96+
return fromEvent(targetNotification, eventName).pipe(mapToVoid);
97+
}
98+
99+
switch (eventName) {
100+
case `click`:
101+
return this.notificationSwClicks$.pipe(
102+
filter(x => isTargetNotification(x.notification)),
103+
);
104+
case `close`:
105+
return this.notificationSwCloses$.pipe(
106+
filter(isTargetNotification),
107+
mapToVoid,
108+
);
109+
default:
110+
return NEVER;
111+
}
112+
}),
113+
);
49114
}
50115
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import {InjectionToken} from '@angular/core';
2+
import type {SwPush} from '@angular/service-worker';
3+
import {NEVER} from 'rxjs';
4+
5+
export const NOTIFICATION_SW_CLICKS = new InjectionToken<SwPush['notificationClicks']>(
6+
`Global listener for events when ANY system notification spawned by Notification API (and only inside Service Worker!) has been clicked`,
7+
{
8+
factory: () => NEVER,
9+
},
10+
);

0 commit comments

Comments
 (0)