From b2bdc30d5d171552b78db68314039a9c2d935ed5 Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Tue, 10 Oct 2023 16:19:32 +0300 Subject: [PATCH] feat: new `@ng-web-apis/notification` package (Notification API) (#123) --- .github/workflows/test.yml | 6 + .gitignore | 1 + apps/demo/project.json | 20 ++ apps/demo/src/app/app.routes.ts | 6 + apps/demo/src/app/constants/demo-path.ts | 1 + .../app/pages/home/home-page.component.html | 20 ++ .../src/app/pages/home/home-page.component.ts | 4 +- .../examples/01-getting-permission/index.html | 17 ++ .../examples/01-getting-permission/index.ts | 30 +++ .../02-create-notification/index.html | 3 + .../examples/02-create-notification/index.ts | 33 +++ .../examples/03-close-notification/index.html | 3 + .../examples/03-close-notification/index.ts | 36 ++++ .../04-listen-notification-events/index.html | 3 + .../04-listen-notification-events/index.ts | 36 ++++ .../notification-page.component.ts | 38 ++++ .../notification/notification-page.module.ts | 30 +++ .../notification/notification-page.style.less | 21 ++ .../notification-page.template.html | 180 +++++++++++++++++ libs/notification/CHANGELOG.md | 0 libs/notification/LICENSE | 190 ++++++++++++++++++ libs/notification/README.md | 58 ++++++ libs/notification/karma.conf.js | 43 ++++ libs/notification/logo.svg | 29 +++ libs/notification/ng-package.json | 11 + libs/notification/package.json | 26 +++ libs/notification/project.json | 41 ++++ libs/notification/src/index.ts | 6 + .../src/services/notification.service.ts | 50 +++++ libs/notification/src/tokens/support.ts | 9 + libs/notification/test.ts | 23 +++ libs/notification/tests/notification.spec.ts | 59 ++++++ libs/notification/tsconfig.spec.json | 5 + libs/permissions/src/index.ts | 1 + .../src/utils/permissions-predicates.ts | 19 ++ package-lock.json | 13 ++ tsconfig.build.json | 3 +- tsconfig.json | 3 +- 38 files changed, 1074 insertions(+), 3 deletions(-) create mode 100644 apps/demo/src/app/pages/notification/examples/01-getting-permission/index.html create mode 100644 apps/demo/src/app/pages/notification/examples/01-getting-permission/index.ts create mode 100644 apps/demo/src/app/pages/notification/examples/02-create-notification/index.html create mode 100644 apps/demo/src/app/pages/notification/examples/02-create-notification/index.ts create mode 100644 apps/demo/src/app/pages/notification/examples/03-close-notification/index.html create mode 100644 apps/demo/src/app/pages/notification/examples/03-close-notification/index.ts create mode 100644 apps/demo/src/app/pages/notification/examples/04-listen-notification-events/index.html create mode 100644 apps/demo/src/app/pages/notification/examples/04-listen-notification-events/index.ts create mode 100644 apps/demo/src/app/pages/notification/notification-page.component.ts create mode 100644 apps/demo/src/app/pages/notification/notification-page.module.ts create mode 100644 apps/demo/src/app/pages/notification/notification-page.style.less create mode 100644 apps/demo/src/app/pages/notification/notification-page.template.html create mode 100644 libs/notification/CHANGELOG.md create mode 100644 libs/notification/LICENSE create mode 100644 libs/notification/README.md create mode 100644 libs/notification/karma.conf.js create mode 100644 libs/notification/logo.svg create mode 100644 libs/notification/ng-package.json create mode 100644 libs/notification/package.json create mode 100644 libs/notification/project.json create mode 100644 libs/notification/src/index.ts create mode 100644 libs/notification/src/services/notification.service.ts create mode 100644 libs/notification/src/tokens/support.ts create mode 100644 libs/notification/test.ts create mode 100644 libs/notification/tests/notification.spec.ts create mode 100644 libs/notification/tsconfig.spec.json create mode 100644 libs/permissions/src/utils/permissions-predicates.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a5d88ecd3..d1a979a16 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,7 @@ jobs: storage, workers, view-transition, + notification, ] name: ${{ matrix.project }} steps: @@ -128,6 +129,11 @@ jobs: directory: ./coverage/view-transition/ flags: summary,view-transition name: view-transition + - uses: codecov/codecov-action@v3.1.3 + with: + directory: ./coverage/notification/ + flags: summary,notification + name: notification concurrency: group: test-${{ github.workflow }}-${{ github.ref }} diff --git a/.gitignore b/.gitignore index dbd1d2387..1409bab58 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ testem.log Thumbs.db apps/demo/routesFile.txt +.ssl diff --git a/apps/demo/project.json b/apps/demo/project.json index 3222c9cad..e2206e0dd 100644 --- a/apps/demo/project.json +++ b/apps/demo/project.json @@ -136,6 +136,12 @@ }, "defaultConfiguration": "development" }, + "serve-ssl": { + "executor": "nx:run-commands", + "options": { + "command": "nx mkcert demo && nx serve demo --ssl --open --host 0.0.0.0 --disable-host-check" + } + }, "generate-routes-file": { "executor": "nx:run-commands", "options": { @@ -190,6 +196,20 @@ "params": "ignore" } ] + }, + "mkcert": { + "executor": "nx:run-commands", + "options": { + "parallel": false, + "commands": [ + "echo \"mkcert is a simple tool for making locally-trusted development certificates\"", + "echo \"Read about installation and more: https://github.com/FiloSottile/mkcert\"", + "echo ------", + "mkcert -install", + "mkdir -p .ssl", + "mkcert -key-file .ssl/localhost-key.pem -cert-file .ssl/localhost.pem localhost 127.0.0.1 $(ifconfig | grep \"inet \" | grep -Fv 127.0.0.1 | awk '{print $2}' | tr '\\n' ' ') ::1" + ] + } } } } diff --git a/apps/demo/src/app/app.routes.ts b/apps/demo/src/app/app.routes.ts index 72ca75ae9..6c2463bc3 100644 --- a/apps/demo/src/app/app.routes.ts +++ b/apps/demo/src/app/app.routes.ts @@ -93,6 +93,12 @@ export const appRoutes: Routes = [ (await import(`./pages/view-transition/view-transition-page.module`)) .ViewTransitionPageModule, }, + { + path: DemoPath.Notification, + loadChildren: async () => + (await import(`./pages/notification/notification-page.module`)) + .NotificationPageModule, + }, { path: '', redirectTo: DemoPath.HomePage, diff --git a/apps/demo/src/app/constants/demo-path.ts b/apps/demo/src/app/constants/demo-path.ts index 69b22db46..214607172 100644 --- a/apps/demo/src/app/constants/demo-path.ts +++ b/apps/demo/src/app/constants/demo-path.ts @@ -15,4 +15,5 @@ export enum DemoPath { StoragePage = `storage`, WorkersPage = `workers`, ViewTransitionPage = `view-transition`, + Notification = `notification`, } diff --git a/apps/demo/src/app/pages/home/home-page.component.html b/apps/demo/src/app/pages/home/home-page.component.html index a0f0947d9..70db0c4b3 100644 --- a/apps/demo/src/app/pages/home/home-page.component.html +++ b/apps/demo/src/app/pages/home/home-page.component.html @@ -250,3 +250,23 @@

View Transition

class="icon" /> + + +
+

Notification

+ A library for declarative use of + Notification API + with Angular +
+ Notification API logo +
diff --git a/apps/demo/src/app/pages/home/home-page.component.ts b/apps/demo/src/app/pages/home/home-page.component.ts index 6ddd41dd4..3e4eae3b1 100644 --- a/apps/demo/src/app/pages/home/home-page.component.ts +++ b/apps/demo/src/app/pages/home/home-page.component.ts @@ -1,12 +1,13 @@ import {ChangeDetectionStrategy, Component, Inject} from '@angular/core'; +import {DemoPath} from '@demo/constants'; import {WEB_AUDIO_SUPPORT} from '@ng-web-apis/audio'; import {GEOLOCATION_SUPPORT} from '@ng-web-apis/geolocation'; import {INTERSECTION_OBSERVER_SUPPORT} from '@ng-web-apis/intersection-observer'; import {MIDI_SUPPORT} from '@ng-web-apis/midi'; +import {NOTIFICATION_SUPPORT} from '@ng-web-apis/notification'; import {PAYMENT_REQUEST_SUPPORT} from '@ng-web-apis/payment-request'; import {PERMISSIONS_SUPPORT} from '@ng-web-apis/permissions'; import {RESIZE_OBSERVER_SUPPORT} from '@ng-web-apis/resize-observer'; -import {DemoPath} from '@demo/constants'; @Component({ selector: `home-page`, @@ -25,5 +26,6 @@ export class HomePageComponent { @Inject(MIDI_SUPPORT) readonly midiSupport: boolean, @Inject(WEB_AUDIO_SUPPORT) readonly audioSupport: boolean, @Inject(PERMISSIONS_SUPPORT) readonly permissionsSupport: boolean, + @Inject(NOTIFICATION_SUPPORT) readonly notificationSupport: boolean, ) {} } diff --git a/apps/demo/src/app/pages/notification/examples/01-getting-permission/index.html b/apps/demo/src/app/pages/notification/examples/01-getting-permission/index.html new file mode 100644 index 000000000..03121eb78 --- /dev/null +++ b/apps/demo/src/app/pages/notification/examples/01-getting-permission/index.html @@ -0,0 +1,17 @@ + + + + + + + diff --git a/apps/demo/src/app/pages/notification/examples/01-getting-permission/index.ts b/apps/demo/src/app/pages/notification/examples/01-getting-permission/index.ts new file mode 100644 index 000000000..b8e708587 --- /dev/null +++ b/apps/demo/src/app/pages/notification/examples/01-getting-permission/index.ts @@ -0,0 +1,30 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {NotificationService} from '@ng-web-apis/notification'; +import {PermissionsService} from '@ng-web-apis/permissions'; + +@Component({ + selector: 'notification-page-example-1', + templateUrl: './index.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NotificationPageExample1 { + readonly notificationPermissionState$ = this.permissions.state('notifications'); + + constructor( + private readonly notifications: NotificationService, + private readonly permissions: PermissionsService, + ) {} + + requestPermission(): void { + this.notifications.requestPermission().subscribe({ + next: permission => + console.info( + 'Permission status:', + permission, // 'denied' | 'granted' + ), + error: err => + // e.g. 'Notification API is not supported in your browser' + console.error(err), + }); + } +} diff --git a/apps/demo/src/app/pages/notification/examples/02-create-notification/index.html b/apps/demo/src/app/pages/notification/examples/02-create-notification/index.html new file mode 100644 index 000000000..1a13bb9ba --- /dev/null +++ b/apps/demo/src/app/pages/notification/examples/02-create-notification/index.html @@ -0,0 +1,3 @@ + diff --git a/apps/demo/src/app/pages/notification/examples/02-create-notification/index.ts b/apps/demo/src/app/pages/notification/examples/02-create-notification/index.ts new file mode 100644 index 000000000..b24db66e0 --- /dev/null +++ b/apps/demo/src/app/pages/notification/examples/02-create-notification/index.ts @@ -0,0 +1,33 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {NotificationService} from '@ng-web-apis/notification'; +import {isDenied, isGranted, PermissionsService} from '@ng-web-apis/permissions'; +import {filter, map, switchMap} from 'rxjs/operators'; + +@Component({ + selector: 'notification-page-example-2', + templateUrl: './index.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NotificationPageExample2 { + readonly denied$ = this.permissions.state('notifications').pipe(map(isDenied)); + + constructor( + private readonly notifications: NotificationService, + private readonly permissions: PermissionsService, + ) {} + + sendNotification(): void { + this.notifications + .requestPermission() + .pipe( + filter(isGranted), + switchMap(() => + this.notifications.open('Web APIs for Angular', { + body: 'High quality lightweight wrappers for native Web APIs for idiomatic use with Angular', + icon: 'assets/images/web-api.svg', + }), + ), + ) + .subscribe(); + } +} 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 new file mode 100644 index 000000000..1a13bb9ba --- /dev/null +++ b/apps/demo/src/app/pages/notification/examples/03-close-notification/index.html @@ -0,0 +1,3 @@ + 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 new file mode 100644 index 000000000..cc4e15ee1 --- /dev/null +++ b/apps/demo/src/app/pages/notification/examples/03-close-notification/index.ts @@ -0,0 +1,36 @@ +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'; + +@Component({ + selector: 'notification-page-example-3', + templateUrl: './index.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NotificationPageExample3 { + readonly denied$ = this.permissions.state('notifications').pipe(map(isDenied)); + + constructor( + private readonly notifications: NotificationService, + private readonly permissions: PermissionsService, + ) {} + + sendNotification(): void { + this.notifications + .requestPermission() + .pipe( + filter(isGranted), + switchMap(() => + this.notifications.open('Close me, please!', { + requireInteraction: true, + }), + ), + takeUntil(timer(5_000)), // close stream after 5 seconds + ) + .subscribe({ + complete: () => console.info('Notification closed!'), + }); + } +} diff --git a/apps/demo/src/app/pages/notification/examples/04-listen-notification-events/index.html b/apps/demo/src/app/pages/notification/examples/04-listen-notification-events/index.html new file mode 100644 index 000000000..1a13bb9ba --- /dev/null +++ b/apps/demo/src/app/pages/notification/examples/04-listen-notification-events/index.html @@ -0,0 +1,3 @@ + 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 new file mode 100644 index 000000000..7395d8df7 --- /dev/null +++ b/apps/demo/src/app/pages/notification/examples/04-listen-notification-events/index.ts @@ -0,0 +1,36 @@ +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({ + selector: 'notification-page-example-4', + templateUrl: './index.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NotificationPageExample4 { + readonly denied$ = this.permissions.state('notifications').pipe(map(isDenied)); + + constructor( + private readonly notifications: NotificationService, + private readonly permissions: PermissionsService, + ) {} + + sendNotification(): void { + this.notifications + .requestPermission() + .pipe( + filter(isGranted), + switchMap(() => + this.notifications.open(`Click me, please`, { + body: `Then open console and investigate property "target"`, + requireInteraction: true, + data: `Randomly generated number: ${Math.random().toFixed(2)}`, + }), + ), + switchMap(notification => fromEvent(notification, 'click')), + ) + .subscribe(console.info); + } +} diff --git a/apps/demo/src/app/pages/notification/notification-page.component.ts b/apps/demo/src/app/pages/notification/notification-page.component.ts new file mode 100644 index 000000000..aa1623837 --- /dev/null +++ b/apps/demo/src/app/pages/notification/notification-page.component.ts @@ -0,0 +1,38 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {PermissionsService} from '@ng-web-apis/permissions'; +import {TuiDocExample} from '@taiga-ui/addon-doc'; + +@Component({ + selector: 'notification-page', + templateUrl: './notification-page.template.html', + styleUrls: ['./notification-page.style.less'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NotificationPageComponent { + readonly notificationPermissionState$ = this.permissions.state('notifications'); + + readonly deniedPermissionNotification = + 'You have denied notification permission. Please, change it in browser settings.'; + + readonly gettingPermissionExample: TuiDocExample = { + 'index.ts': import('./examples/01-getting-permission/index.ts?raw'), + 'index.html': import('./examples/01-getting-permission/index.html?raw'), + }; + + readonly createNotificationExample: TuiDocExample = { + 'index.ts': import('./examples/02-create-notification/index.ts?raw'), + 'index.html': import('./examples/02-create-notification/index.html?raw'), + }; + + readonly closeNotificationExample: TuiDocExample = { + 'index.ts': import('./examples/03-close-notification/index.ts?raw'), + 'index.html': import('./examples/03-close-notification/index.html?raw'), + }; + + readonly listenNotificationEventsExample: TuiDocExample = { + 'index.ts': import('./examples/04-listen-notification-events/index.ts?raw'), + 'index.html': import('./examples/04-listen-notification-events/index.html?raw'), + }; + + constructor(private readonly permissions: PermissionsService) {} +} diff --git a/apps/demo/src/app/pages/notification/notification-page.module.ts b/apps/demo/src/app/pages/notification/notification-page.module.ts new file mode 100644 index 000000000..2643d98aa --- /dev/null +++ b/apps/demo/src/app/pages/notification/notification-page.module.ts @@ -0,0 +1,30 @@ +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 {TuiBadgeModule} from '@taiga-ui/kit'; +import {NotificationPageExample1} from './examples/01-getting-permission'; +import {NotificationPageExample2} from './examples/02-create-notification'; +import {NotificationPageExample3} from './examples/03-close-notification'; +import {NotificationPageExample4} from './examples/04-listen-notification-events'; +import {NotificationPageComponent} from './notification-page.component'; + +@NgModule({ + imports: [ + CommonModule, + TuiAddonDocModule, + TuiBadgeModule, + TuiButtonModule, + TuiNotificationModule, + RouterModule.forChild([{path: '', component: NotificationPageComponent}]), + ], + declarations: [ + NotificationPageComponent, + NotificationPageExample1, + NotificationPageExample2, + NotificationPageExample3, + NotificationPageExample4, + ], +}) +export class NotificationPageModule {} diff --git a/apps/demo/src/app/pages/notification/notification-page.style.less b/apps/demo/src/app/pages/notification/notification-page.style.less new file mode 100644 index 000000000..046439dff --- /dev/null +++ b/apps/demo/src/app/pages/notification/notification-page.style.less @@ -0,0 +1,21 @@ +:host { + display: block; + max-width: 900px; + margin: 0 auto; + font: var(--tui-font-text-m); +} + +tui-notification { + margin-bottom: 1rem; +} + +.header { + font: var(--tui-font-heading-4); + display: flex; + align-items: center; + gap: 1rem; +} + +.description { + margin-bottom: 2rem; +} diff --git a/apps/demo/src/app/pages/notification/notification-page.template.html b/apps/demo/src/app/pages/notification/notification-page.template.html new file mode 100644 index 000000000..63091e069 --- /dev/null +++ b/apps/demo/src/app/pages/notification/notification-page.template.html @@ -0,0 +1,180 @@ +

+ Notification API logo + Notification API +

+ +

+ A library for declarative use of + + Notification API + + with Angular. + + +

+ +

+ The main entity of the library is + NotificationService + (provided in root). +
+ Inject it into your component and investigate all example above. +

+ +
+ + + Before an app can send a notification, the user must grant the + application the right to do so. + +

+ Use + requestPermission + method to request consent to display notifications. +
+ It returns Observable which emits value after user select option + inside system prompt. +

+
+ + + Notification prompting can only be done from a user gesture (e.g. + clicking a button)! +
+ Otherwise, some browsers will silently disallow notification + permission requests. +
+ + +
+ + + + Use + open + method to create a notification. +

+ The first argument is a + title + to display within the notification. +

+

+ The second argument contains many experimental + options + to enhance the notification behavior and appearance. +
+ See the full + + list of available options + + . +

+
+ + + {{deniedPermissionNotification}} + + + +
+ + + +

+ The observable (returned by + open + method) automatically + completes + the stream when the notification is closed (e.g. user clicks the + close button). +

+ +

+ You can also close the notification manually by closing the + stream by + takeUntil + operator. +

+
+ + + {{deniedPermissionNotification}} + + + +
+ + + + The observable (returned by + open + method) emits + Notification + instance after its successful creation. + +

+ Use rxjs function + fromEvent + to listen events that can be triggered on the + Notification + instance. +
+ See the full + + list of available events + + . +

+
+ + + {{deniedPermissionNotification}} + + + +
+
diff --git a/libs/notification/CHANGELOG.md b/libs/notification/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/libs/notification/LICENSE b/libs/notification/LICENSE new file mode 100644 index 000000000..b84e23a28 --- /dev/null +++ b/libs/notification/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2023 Tinkoff Bank + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/libs/notification/README.md b/libs/notification/README.md new file mode 100644 index 000000000..6cc4694d2 --- /dev/null +++ b/libs/notification/README.md @@ -0,0 +1,58 @@ +# ![ng-web-apis logo](https://raw.githubusercontent.com/taiga-family/ng-web-apis/main/libs/notification/logo.svg) Notification API for Angular + +[![npm version](https://img.shields.io/npm/v/@ng-web-apis/notification.svg)](https://npmjs.com/package/@ng-web-apis/notification) +[![npm bundle size](https://img.shields.io/bundlephobia/minzip/@ng-web-apis/notification)](https://bundlephobia.com/result?p=@ng-web-apis/notification) +[![codecov](https://codecov.io/github/taiga-family/ng-web-apis/graph/badge.svg?flag=notification)](https://codecov.io/github/taiga-family/ng-web-apis/tree/main/libs/notification) + +This is a library for declarative use of +[Notification API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) with Angular. + +## Install + +```bash +npm i @ng-web-apis/notification +``` + +## Usage + +1. Import the `NotificationService` into your Angular component or service where you want to use it. + +```ts +import {NotificationService} from '@ng-web-apis/notification'; +``` + +2. Inject the `NotificationService` into your component's constructor or with `inject` (Angular 14+). + +```ts +// in constructor +constructor(private notificationAPIService: NotificationService) {} + +// via inject +notificationAPIService = inject(NotificationService); +``` + +3. Use the `requestPermission` and `open` methods to request permission and open a notification. + +```ts +this.notificationAPIService + .requestPermission() + .pipe( + filter(permission => permission === 'granted'), + switchMap(() => + this.notificationAPIService.open('Hello world!', { + body: 'This is a notification', + requireInteraction: true, + }), + ), + ) + .subscribe(); +``` + +## Demo + +You can [try online demo here](https://taiga-family.github.io/ng-web-apis/notification) + +## See also + +Other [Web APIs for Angular](https://taiga-family.github.io/ng-web-apis/) by +[@ng-web-apis](https://github.com/taiga-family/ng-web-apis) diff --git a/libs/notification/karma.conf.js b/libs/notification/karma.conf.js new file mode 100644 index 000000000..e149b2de0 --- /dev/null +++ b/libs/notification/karma.conf.js @@ -0,0 +1,43 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage-istanbul-reporter'), + require('@angular-devkit/build-angular/plugins/karma'), + ], + client: { + clearContext: false, // leave Jasmine Spec Runner output visible in browser + }, + coverageIstanbulReporter: { + dir: require('path').join(__dirname, '../../coverage/notification'), + reports: ['html', 'lcovonly'], + fixWebpackSourcePaths: true, + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['ChromeHeadless'], + singleRun: true, + customLaunchers: { + ChromeHeadless: { + base: 'Chrome', + flags: [ + '--no-sandbox', + '--headless', + '--disable-gpu', + '--disable-web-security', + '--remote-debugging-port=9222', + ], + }, + }, + }); +}; diff --git a/libs/notification/logo.svg b/libs/notification/logo.svg new file mode 100644 index 000000000..68024465b --- /dev/null +++ b/libs/notification/logo.svg @@ -0,0 +1,29 @@ + + + + + + + diff --git a/libs/notification/ng-package.json b/libs/notification/ng-package.json new file mode 100644 index 000000000..09a2de672 --- /dev/null +++ b/libs/notification/ng-package.json @@ -0,0 +1,11 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "assets": [ + "logo.svg", + "README.md" + ], + "dest": "../../dist/notification", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/notification/package.json b/libs/notification/package.json new file mode 100644 index 000000000..d2d7302ef --- /dev/null +++ b/libs/notification/package.json @@ -0,0 +1,26 @@ +{ + "name": "@ng-web-apis/notification", + "version": "3.0.0", + "description": "A library for declarative use of Notification API with Angular", + "keywords": [ + "angular", + "ng", + "notification" + ], + "homepage": "https://github.com/taiga-family/ng-web-apis/blob/main/libs/notification/README.md", + "bugs": "https://github.com/taiga-family/ng-web-apis/issues", + "repository": "https://github.com/taiga-family/ng-web-apis", + "license": "Apache-2.0", + "author": { + "name": "Nikita Barsukov", + "email": "nikita.s.barsukov@gmail.com" + }, + "contributors": [ + "Nikita Barsukov " + ], + "peerDependencies": { + "@angular/core": ">=12.0.0", + "@ng-web-apis/common": ">=3.0.0", + "rxjs": ">=6.0.0" + } +} diff --git a/libs/notification/project.json b/libs/notification/project.json new file mode 100644 index 000000000..212269196 --- /dev/null +++ b/libs/notification/project.json @@ -0,0 +1,41 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "notification", + "root": "libs/notification", + "sourceRoot": "libs/notification", + "projectType": "library", + "targets": { + "test": { + "executor": "@angular-devkit/build-angular:karma", + "outputs": ["coverage/notification"], + "options": { + "main": "libs/notification/test.ts", + "tsConfig": "tsconfig.spec.json", + "karmaConfig": "libs/notification/karma.conf.js", + "codeCoverage": true, + "browsers": "ChromeHeadless" + } + }, + "build": { + "executor": "@angular-devkit/build-angular:ng-packagr", + "outputs": ["dist/notification"], + "options": { + "tsConfig": "tsconfig.build.json", + "project": "libs/notification/ng-package.json" + }, + "dependsOn": [ + { + "target": "build", + "projects": "dependencies", + "params": "forward" + } + ] + }, + "publish": { + "executor": "@nrwl/workspace:run-commands", + "options": { + "command": "npm publish ./dist/notification --ignore-scripts || echo \"already published\"" + } + } + } +} diff --git a/libs/notification/src/index.ts b/libs/notification/src/index.ts new file mode 100644 index 000000000..704a29e93 --- /dev/null +++ b/libs/notification/src/index.ts @@ -0,0 +1,6 @@ +/** + * Public API of @ng-web-apis/notification + */ + +export * from './tokens/support'; +export * from './services/notification.service'; diff --git a/libs/notification/src/services/notification.service.ts b/libs/notification/src/services/notification.service.ts new file mode 100644 index 000000000..a7ef701cd --- /dev/null +++ b/libs/notification/src/services/notification.service.ts @@ -0,0 +1,50 @@ +import {Inject, Injectable} from '@angular/core'; +import {defer, fromEvent, Observable, throwError} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; + +import {NOTIFICATION_SUPPORT} from '../tokens/support'; + +const NOT_SUPPORTED_ERROR$ = throwError( + () => new Error('Notification API is not supported in your browser'), +); + +@Injectable({ + providedIn: 'root', +}) +export class NotificationService { + constructor(@Inject(NOTIFICATION_SUPPORT) private readonly support: boolean) {} + + requestPermission(): Observable { + if (!this.support) { + return NOT_SUPPORTED_ERROR$; + } + + /** + * TODO: replace deprecated callback with promise after Safari 15+ support + * return from(Notification.requestPermission()); + */ + return new Observable(subscriber => { + void Notification.requestPermission(permission => { + subscriber.next(permission); + subscriber.complete(); + })?.catch(err => subscriber.error(err)); + }); + } + + open(title: string, options?: NotificationOptions): Observable { + if (!this.support) { + return NOT_SUPPORTED_ERROR$; + } + + return defer(() => { + const notification = new Notification(title, options); + const close$ = fromEvent(notification, 'close'); + + return new Observable(subscriber => { + subscriber.next(notification); + + return () => notification.close(); + }).pipe(takeUntil(close$)); + }); + } +} diff --git a/libs/notification/src/tokens/support.ts b/libs/notification/src/tokens/support.ts new file mode 100644 index 000000000..a6bfefeff --- /dev/null +++ b/libs/notification/src/tokens/support.ts @@ -0,0 +1,9 @@ +import {inject, InjectionToken} from '@angular/core'; +import {WINDOW} from '@ng-web-apis/common'; + +export const NOTIFICATION_SUPPORT = new InjectionToken( + 'Is Notification API supported?', + { + factory: () => 'Notification' in inject(WINDOW), + }, +); diff --git a/libs/notification/test.ts b/libs/notification/test.ts new file mode 100644 index 000000000..bb10cb1ff --- /dev/null +++ b/libs/notification/test.ts @@ -0,0 +1,23 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files +import 'zone.js'; +import 'zone.js/testing'; + +import {getTestBed} from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; + +declare const require: any; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting(), +); + +// Then we find all the tests. +const context = require.context('./', true, /\.spec\.ts$/); + +// And load the modules. +context.keys().map(context); diff --git a/libs/notification/tests/notification.spec.ts b/libs/notification/tests/notification.spec.ts new file mode 100644 index 000000000..fdc045123 --- /dev/null +++ b/libs/notification/tests/notification.spec.ts @@ -0,0 +1,59 @@ +import {TestBed} from '@angular/core/testing'; +import {NOTIFICATION_SUPPORT, NotificationService} from '@ng-web-apis/notification'; +import {firstValueFrom} from 'rxjs'; + +describe('Notification API', () => { + describe('if Notification API is not supported', () => { + let service: NotificationService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: NOTIFICATION_SUPPORT, + useValue: false, + }, + NotificationService, + ], + }); + + service = TestBed.inject(NotificationService); + }); + + it('method `requestPermission` (from `NotificationService`) returns Observable which fails with error', async () => { + await expectAsync( + firstValueFrom(service.requestPermission()), + ).toBeRejectedWithError('Notification API is not supported in your browser'); + }); + + it('method `open` (from `NotificationService`) returns Observable which fails with error', async () => { + await expectAsync( + firstValueFrom(service.open('Hello world')), + ).toBeRejectedWithError('Notification API is not supported in your browser'); + }); + }); + + describe('if Notification API is supported', () => { + let service: NotificationService; + let notificationSupportToken: boolean; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [NotificationService], + }); + + service = TestBed.inject(NotificationService); + notificationSupportToken = TestBed.inject(NOTIFICATION_SUPPORT); + }); + + it('token `NOTIFICATION_SUPPORT` returns true', () => { + expect(notificationSupportToken).toBe(true); + }); + + it('method `requestPermission` (from `NotificationService`) returns `default` permission for the first time', async () => { + const permission = await firstValueFrom(service.requestPermission()); + + expect(permission).toBe('default'); + }); + }); +}); diff --git a/libs/notification/tsconfig.spec.json b/libs/notification/tsconfig.spec.json new file mode 100644 index 000000000..8e7067ed2 --- /dev/null +++ b/libs/notification/tsconfig.spec.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.spec.json", + "include": ["**/*.spec.ts", "./test.ts", "**/*.d.ts"], + "files": ["./test.ts"] +} diff --git a/libs/permissions/src/index.ts b/libs/permissions/src/index.ts index 5140d50db..44b48f4e5 100644 --- a/libs/permissions/src/index.ts +++ b/libs/permissions/src/index.ts @@ -4,3 +4,4 @@ export * from './services/permissions.service'; export * from './tokens/permissions'; export * from './tokens/permissions-support'; +export * from './utils/permissions-predicates'; diff --git a/libs/permissions/src/utils/permissions-predicates.ts b/libs/permissions/src/utils/permissions-predicates.ts new file mode 100644 index 000000000..a57e43901 --- /dev/null +++ b/libs/permissions/src/utils/permissions-predicates.ts @@ -0,0 +1,19 @@ +export function isGranted( + state: PermissionState | NotificationPermission | PushPermissionState, +): state is 'granted' { + return state === 'granted'; +} + +export function isDenied( + state: PermissionState | NotificationPermission | PushPermissionState, +): state is 'denied' { + return state === 'denied'; +} + +export function isPrompt(s: NotificationPermission): s is 'default'; +export function isPrompt(s: PermissionState | PushPermissionState): s is 'prompt'; +export function isPrompt( + state: PermissionState | NotificationPermission | PushPermissionState, +): state is 'prompt' | 'default' { + return state === 'prompt' || state === 'default'; +} diff --git a/package-lock.json b/package-lock.json index f0d3ada66..01cfb0e4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -170,6 +170,15 @@ "@ng-web-apis/common": ">=2.0.0" } }, + "libs/notification": { + "version": "3.0.0", + "license": "Apache-2.0", + "peerDependencies": { + "@angular/core": ">=12.0.0", + "@ng-web-apis/common": ">=3.0.0", + "rxjs": ">=6.0.0" + } + }, "libs/payment-request": { "name": "@ng-web-apis/payment-request", "version": "3.0.3", @@ -5945,6 +5954,10 @@ "resolved": "libs/mutation-observer", "link": true }, + "node_modules/@ng-web-apis/notification": { + "resolved": "libs/notification", + "link": true + }, "node_modules/@ng-web-apis/payment-request": { "resolved": "libs/payment-request", "link": true diff --git a/tsconfig.build.json b/tsconfig.build.json index f8d857b0b..15b2bb5fb 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -24,7 +24,8 @@ "@ng-web-apis/midi": ["./dist/midi"], "@ng-web-apis/workers": ["./dist/workers"], "@ng-web-apis/resize-observer": ["./dist/resize-observer"], - "@ng-web-apis/view-transition": ["./dist/view-transition"] + "@ng-web-apis/view-transition": ["./dist/view-transition"], + "@ng-web-apis/notification": ["./dist/notification"] } } } diff --git a/tsconfig.json b/tsconfig.json index b448f86f8..83465abe2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -76,7 +76,8 @@ "@ng-web-apis/storage": ["./libs/storage/src/index.ts"], "@ng-web-apis/workers": ["./libs/workers/src/index.ts"], "@ng-web-apis/resize-observer": ["./libs/resize-observer/src/index.ts"], - "@ng-web-apis/view-transition": ["./libs/view-transition/src/index.ts"] + "@ng-web-apis/view-transition": ["./libs/view-transition/src/index.ts"], + "@ng-web-apis/notification": ["./libs/notification/src/index.ts"] } }, "ts-node": {