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 Dec 15, 2023
1 parent 83fd5c7 commit b16edfa
Show file tree
Hide file tree
Showing 19 changed files with 262 additions and 33 deletions.
4 changes: 3 additions & 1 deletion apps/demo/src/app/app.browser.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,6 +36,7 @@ import {AppRoutingModule} from './app.routes';
],
declarations: [AppComponent],
providers: [
provideSwPush(SwPush),
{
provide: HIGHLIGHT_OPTIONS,
useValue: {fullLibraryLoader: async () => import(`highlight.js`)},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
})
Expand Down
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,29 +1,32 @@
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 {timer} from 'rxjs';
import {filter, map, switchMap, takeUntil} from 'rxjs/operators';
import {TuiButtonModule} from '@taiga-ui/core';
import {BehaviorSubject, timer} from 'rxjs';
import {filter, map, switchMap, takeUntil, tap} from 'rxjs/operators';

@Component({
standalone: true,
selector: 'notification-page-example-3',
imports: [CommonModule],
imports: [AsyncPipe, TuiButtonModule],
templateUrl: './index.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NotificationPageExample3 {
private readonly notifications: NotificationService = inject(NotificationService);

readonly denied$ = inject(PermissionsService)
.state('notifications')
.pipe(map(isDenied));

readonly showLoader$ = new BehaviorSubject(false);

sendNotification(): void {
this.notifications
.requestPermission()
.pipe(
filter(isGranted),
tap(() => this.showLoader$.next(true)),
switchMap(() =>
this.notifications.open('Close me, please!', {
requireInteraction: true,
Expand All @@ -32,7 +35,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,14 +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 {fromEvent} from 'rxjs';
import {TuiButtonModule} from '@taiga-ui/core';
import {filter, map, switchMap} from 'rxjs/operators';

@Component({
standalone: true,
selector: 'notification-page-example-4',
imports: [CommonModule],
imports: [AsyncPipe, TuiButtonModule],
templateUrl: './index.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
Expand All @@ -31,7 +31,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,8 +2,7 @@ import {CommonModule} from '@angular/common';
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
import {PermissionsService} from '@ng-web-apis/permissions';
import {TuiAddonDocModule, TuiDocExample} from '@taiga-ui/addon-doc';
import {TuiButtonModule, TuiLinkModule, TuiNotificationModule} from '@taiga-ui/core';
import {TuiBadgeModule} from '@taiga-ui/kit';
import {TuiLinkModule, TuiNotificationModule} from '@taiga-ui/core';

import {NotificationPageExample1} from './examples/01-getting-permission';
import {NotificationPageExample2} from './examples/02-create-notification';
Expand All @@ -16,8 +15,6 @@ import {NotificationPageExample4} from './examples/04-listen-notification-events
imports: [
CommonModule,
TuiAddonDocModule,
TuiBadgeModule,
TuiButtonModule,
TuiNotificationModule,
TuiLinkModule,
NotificationPageExample1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,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 @@ -163,6 +163,26 @@ <h2 class="header">
</a>
.
</p>

<tui-notification status="warning">
Notifications spawned by Service Worker support only
<a
href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/notificationclick_event"
target="_blank"

Check failure on line 171 in apps/demo/src/app/pages/notification/notification-page.template.html

View workflow job for this annotation

GitHub Actions / Lint

Missing `rel="noreferrer"` attribute in a tag
tuiLink
>
<code>click</code>
</a>
and
<a
href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/notificationclose_event"
target="_blank"

Check failure on line 179 in apps/demo/src/app/pages/notification/notification-page.template.html

View workflow job for this annotation

GitHub Actions / Lint

Missing `rel="noreferrer"` attribute in a tag
tuiLink
>
<code>close</code>
</a>
events!
</tui-notification>
</ng-template>

<tui-notification
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {NgForOf} from '@angular/common';
import {NgForOf, NgIf} from '@angular/common';
import {ChangeDetectionStrategy, Component} from '@angular/core';
import {PaymentRequestModule} from '@ng-web-apis/payment-request';

Expand All @@ -20,7 +20,7 @@ class ShopItem implements PaymentItem {
@Component({
standalone: true,
selector: 'app-shop',
imports: [NgForOf, PaymentRequestModule],
imports: [NgForOf, NgIf, PaymentRequestModule],
templateUrl: './shop.component.html',
styleUrls: ['./shop.component.less'],
changeDetection: ChangeDetectionStrategy.OnPush,
Expand Down
3 changes: 3 additions & 0 deletions libs/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ 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';
export * from './tokens/user-agent';
export * from './tokens/window';
export * from './types/injection-token-type';
export * from './utils/zone';
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';

Check failure on line 1 in libs/common/src/tokens/service-worker.ts

View workflow job for this annotation

GitHub Actions / Lint

Run autofix to sort these imports!
import {NAVIGATOR} from './navigator';

export const SERVICE_WORKER = new InjectionToken(
`An abstraction over window.navigator.serviceWorker object`,

Check failure on line 5 in libs/common/src/tokens/service-worker.ts

View workflow job for this annotation

GitHub Actions / Lint

InjectionToken's description should contain token's name
{
factory: () => inject(NAVIGATOR).serviceWorker,
},
);
3 changes: 3 additions & 0 deletions libs/common/src/types/injection-token-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type {InjectionToken} from '@angular/core';

export type InjectionTokenType<Token> = Token extends InjectionToken<infer T> ? T : never;
24 changes: 24 additions & 0 deletions libs/common/src/utils/zone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {NgZone} from '@angular/core';
import {MonoTypeOperatorFunction, Observable, pipe} from 'rxjs';

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));
}
2 changes: 2 additions & 0 deletions libs/notification/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './services/notification.service';
export * from './tokens/notification-factory';
export * from './tokens/support';
export * from './utils/provide-sw-push';
87 changes: 76 additions & 11 deletions libs/notification/src/services/notification.service.ts
Original file line number Diff line number Diff line change
@@ -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<NotificationPermission> {
if (!this.support) {
Expand All @@ -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<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 & {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;
}
}),
);
}
}
10 changes: 10 additions & 0 deletions libs/notification/src/tokens/notification-clicks.ts
Original file line number Diff line number Diff line change
@@ -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<SwPush['notificationClicks']>(
`Global listener for events when ANY system notification spawned by Notification API (and only inside Service Worker!) has been clicked`,

Check failure on line 6 in libs/notification/src/tokens/notification-clicks.ts

View workflow job for this annotation

GitHub Actions / Lint

InjectionToken's description should contain token's name
{
factory: () => NEVER,
},
);
Loading

0 comments on commit b16edfa

Please sign in to comment.