feat(service-worker): add `openWindow`, `focusLastFocusedOrOpen` and `navigateLastFocusedOrOpen` (#42520)

Add `openWindow`, `focusLastFocusedOrOpen` and `navigateLastFocusedOrOpen` abilty to the notificationclick handler such that when either a notification or notification action is clicked the service-worker can act accordinly without the need for the app to be open

PR Close #26907

PR Close #42520
This commit is contained in:
codingnuclei 2021-06-08 16:59:33 +01:00 committed by Dylan Hunn
parent 9de65dbdce
commit d546501ab5
10 changed files with 547 additions and 31 deletions

View File

@ -617,6 +617,7 @@ groups:
'aio/content/guide/service-worker-config.md',
'aio/content/guide/service-worker-devops.md',
'aio/content/guide/service-worker-intro.md',
'aio/content/guide/service-worker-notifications.md',
'aio/content/images/guide/service-worker/**'
])
reviewers:

View File

@ -95,4 +95,4 @@ You can subscribe to `SwUpdate#unrecoverable` to be notified and handle these er
## More on Angular service workers
You may also be interested in the following:
* [Service Worker in Production](guide/service-worker-devops).
* [Service Worker Notifications](guide/service-worker-notifications).

View File

@ -66,6 +66,7 @@ The rest of the articles in this section specifically address the Angular implem
* [App Shell](guide/app-shell)
* [Service Worker Communication](guide/service-worker-communications)
* [Service Worker Notifications](guide/service-worker-notifications)
* [Service Worker in Production](guide/service-worker-devops)
* [Service Worker Configuration](guide/service-worker-config)

View File

@ -0,0 +1,106 @@
# Service worker notifications
Push notifications are a compelling way to engage users. Through the power of service workers, notifications can be delivered to a device even when your application is not in focus.
The Angular service worker enables the display of push notifications and the handling of notification click events.
<div class="alert is-helpful">
When using the Angular service worker, push notification interactions are handled using the `SwPush` service.
To learn more about the native APIs involved see [Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) and [Using the Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API).
</div>
#### Prerequisites
We recommend you have a basic understanding of the following:
- [Getting Started with Service Workers](guide/service-worker-getting-started).
## Notification payload
Invoke push notifications by pushing a message with a valid payload. See `SwPush` for guidance.
<div class="alert is-helpful">
In Chrome, you can test push notifications without a backend.
Open Devtools -> Application -> Service Workers and use the `Push` input to send a JSON notification payload.
</div>
## Notification click handling
The default behavior for the `notificationclick` event is to close the notification and notify `SwPush.notificationClicks`.
You can specify an additional operation to be executed on `notificationclick` by adding an `onActionClick` property to the `data` object, and providing a `default` entry. This is especially useful for when there are no open clients when a notification is clicked.
```json
{
"notification": {
"title": "New Notification!",
"data": {
"onActionClick": {
"default": {"operation": "openWindow", "url": "foo"}
}
}
}
}
```
### Operations
The Angular service worker supports the following operations:
- `openWindow`: Opens a new tab at the specified URL, which is resolved relative to the service worker scope.
- `focusLastFocusedOrOpen`: Focuses the last focused client. If there is no client open, then it opens a new tab at the specified URL, which is resolved relative to the service worker scope.
- `navigateLastFocusedOrOpen`: Focuses the last focused client and navigates it to the specified URL, which is resolved relative to the service worker scope. If there is no client open, then it opens a new tab at the specified URL.
<div class="alert is-important">
If an `onActionClick` item does not define a `url`, then the service worker's registration scope is used.
</div>
### Actions
Actions offer a way to customize how the user can interact with a notification.
Using the `actions` property, you can define a set of available actions. Each action is represented as an action button that the user can click to interact with the notification.
In addition, using the `onActionClick` property on the `data` object, you can tie each action to an operation to be performed when the corresponding action button is clicked:
```ts
{
"notification": {
"title": "New Notification!",
"actions": [
{"action": "foo", "title": "Open new tab"},
{"action": "bar", "title": "Focus last"},
{"action": "baz", "title": "Navigate last"},
{"action": "qux", "title": "Just notify existing clients"}
],
"data": {
"onActionClick": {
"default": {"operation": "openWindow"},
"foo": {"operation": "openWindow", "url": "/absolute/path"},
"bar": {"operation": "focusLastFocusedOrOpen", "url": "relative/path"},
"baz": {"operation": "navigateLastFocusedOrOpen", "url": "https://other.domain.com/"}
}
}
}
}
```
<div class="alert is-important">
If an action does not have a corresponding `onActionClick` entry, then the notification is closed and `SwPush.notificationClicks` is notified on existing clients.
</div>
## More on Angular service workers
You may also be interested in the following:
- [Service Worker in Production](guide/service-worker-devops).

View File

@ -435,6 +435,11 @@
"title": "Service Worker Communication",
"tooltip": "Services that enable you to interact with an Angular service worker."
},
{
"url": "guide/service-worker-notifications",
"title": "Service Worker Notifications",
"tooltip": "Configuring service worker notification behavior."
},
{
"url": "guide/service-worker-devops",
"title": "Service Worker in Production",

View File

@ -81,6 +81,9 @@ import {ERR_SW_NOT_SUPPORTED, NgswCommChannel, PushEvent} from './low_level';
* <code-example path="service-worker/push/module.ts" region="subscribe-to-notification-clicks"
* header="app.component.ts"></code-example>
*
* You can read more on handling notification clicks in the [Service worker notifications
* guide](guide/service-worker-notifications).
*
* @see [Push Notifications](https://developers.google.com/web/fundamentals/codelabs/push-notifications/)
* @see [Angular Push Notifications](https://blog.angular-university.io/angular-push-notifications/)
* @see [MDN: Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)

View File

@ -348,12 +348,52 @@ export class Driver implements Debuggable, UpdateSource {
NOTIFICATION_OPTION_NAMES.filter(name => name in notification)
.forEach(name => options[name] = notification[name]);
const notificationAction = action === '' || action === undefined ? 'default' : action;
const onActionClick = notification?.data?.onActionClick[notificationAction];
const urlToOpen = new URL(onActionClick?.url ?? '', this.scope.registration.scope).href;
switch (onActionClick?.operation) {
case 'openWindow':
await this.scope.clients.openWindow(urlToOpen);
break;
case 'focusLastFocusedOrOpen': {
let matchingClient = await this.getLastFocusedMatchingClient(this.scope);
if (matchingClient) {
await matchingClient?.focus();
} else {
await this.scope.clients.openWindow(urlToOpen);
}
break;
}
case 'navigateLastFocusedOrOpen': {
let matchingClient = await this.getLastFocusedMatchingClient(this.scope);
if (matchingClient) {
matchingClient = await matchingClient.navigate(urlToOpen);
await matchingClient?.focus();
} else {
await this.scope.clients.openWindow(urlToOpen);
}
break;
}
default:
break;
}
await this.broadcast({
type: 'NOTIFICATION_CLICK',
data: {action, notification: options},
});
}
private async getLastFocusedMatchingClient(scope: ServiceWorkerGlobalScope):
Promise<WindowClient|null> {
const windowClients = await scope.clients.matchAll({type: 'window'});
// As per the spec windowClients are `sorted in the most recently focused order`
return windowClients[0];
}
private async reportStatus(client: Client, promise: Promise<void>, nonce: number): Promise<void> {
const response = {type: 'STATUS', nonce, status: true};
try {

View File

@ -36,9 +36,12 @@ declare class Client {
}
interface Clients {
claim(): Promise<any>;
claim(): Promise<void>;
get(id: string): Promise<Client>;
matchAll(options?: ClientMatchOptions): Promise<Array<Client>>;
matchAll<T extends ClientMatchOptions>(
options?: T
): Promise<ReadonlyArray<T['type'] extends 'window' ? WindowClient : Client>>;
openWindow(url: string): Promise<WindowClient | null>;
}
interface ClientMatchOptions {
@ -46,11 +49,12 @@ interface ClientMatchOptions {
type?: ClientMatchTypes;
}
interface WindowClient {
focused: boolean;
visibilityState: WindowClientState;
interface WindowClient extends Client {
readonly ancestorOrigins: ReadonlyArray<string>;
readonly focused: boolean;
readonly visibilityState: VisibilityState;
focus(): Promise<WindowClient>;
navigate(url: string): Promise<WindowClient>;
navigate(url: string): Promise<WindowClient | null>;
}
type ClientFrameType = 'auxiliary'|'top-level'|'nested'|'none';

View File

@ -14,7 +14,7 @@ import {sha1} from '../src/sha1';
import {clearAllCaches, MockCache} from '../testing/cache';
import {MockRequest, MockResponse} from '../testing/fetch';
import {MockFileSystem, MockFileSystemBuilder, MockServerState, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock';
import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope';
import {MockClient, SwTestHarness, SwTestHarnessBuilder, WindowClientImpl} from '../testing/scope';
(function() {
// Skip environments that don't support the minimum APIs needed to run the SW tests.
@ -721,6 +721,7 @@ describe('Driver', () => {
}]);
});
describe('notification click events', () => {
it('broadcasts notification click events with action', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
@ -737,8 +738,10 @@ describe('Driver', () => {
it('broadcasts notification click events without action', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
await scope.handleClick(
{title: 'This is a test without action', body: 'Test body without action'});
await scope.handleClick({
title: 'This is a test without action',
body: 'Test body without action',
});
const message: any = scope.clients.getMock('default')!.messages[0];
expect(message.type).toEqual('NOTIFICATION_CLICK');
@ -747,6 +750,337 @@ describe('Driver', () => {
expect(message.data.notification.body).toEqual('Test body without action');
});
describe('Client interactions', () => {
describe('`openWindow` operation', () => {
it('opens a new client window at url', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
spyOn(scope.clients, 'openWindow');
const url = 'foo';
await driver.initialized;
await scope.handleClick(
{
title: 'This is a test with url',
body: 'Test body with url',
data: {
onActionClick: {
foo: {operation: 'openWindow', url},
},
},
},
'foo');
expect(scope.clients.openWindow)
.toHaveBeenCalledWith(`${scope.registration.scope}${url}`);
});
it('opens a new client window with `/` when no `url`', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
spyOn(scope.clients, 'openWindow');
await driver.initialized;
await scope.handleClick(
{
title: 'This is a test without url',
body: 'Test body without url',
data: {
onActionClick: {
foo: {operation: 'openWindow'},
},
},
},
'foo');
expect(scope.clients.openWindow).toHaveBeenCalledWith(`${scope.registration.scope}`);
});
});
describe('`focusLastFocusedOrOpen` operation', () => {
it('focuses last client keeping previous url', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
const mockClient = new WindowClientImpl('fooBar');
spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([mockClient]));
spyOn(mockClient, 'focus');
spyOn(mockClient, 'navigate');
const url = 'foo';
await driver.initialized;
await scope.handleClick(
{
title: 'This is a test with operation focusLastFocusedOrOpen',
body: 'Test body with operation focusLastFocusedOrOpen',
data: {
onActionClick: {
foo: {operation: 'focusLastFocusedOrOpen', url},
},
},
},
'foo');
expect(mockClient.navigate).not.toHaveBeenCalled();
expect(mockClient.url).toEqual('http://localhost/unique');
expect(mockClient.focus).toHaveBeenCalled();
});
it('falls back to openWindow at url when no last client to focus', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
spyOn(scope.clients, 'openWindow');
spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([]));
const url = 'foo';
await driver.initialized;
await scope.handleClick(
{
title: 'This is a test with operation focusLastFocusedOrOpen',
body: 'Test body with operation focusLastFocusedOrOpen',
data: {
onActionClick: {
foo: {operation: 'focusLastFocusedOrOpen', url},
},
},
},
'foo');
expect(scope.clients.openWindow)
.toHaveBeenCalledWith(`${scope.registration.scope}${url}`);
});
it('falls back to openWindow at `/` when no last client and no `url`', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
spyOn(scope.clients, 'openWindow');
spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([]));
await driver.initialized;
await scope.handleClick(
{
title: 'This is a test with operation focusLastFocusedOrOpen',
body: 'Test body with operation focusLastFocusedOrOpen',
data: {
onActionClick: {
foo: {operation: 'focusLastFocusedOrOpen'},
},
},
},
'foo');
expect(scope.clients.openWindow).toHaveBeenCalledWith(`${scope.registration.scope}`);
});
});
describe('`navigateLastFocusedOrOpen` operation', () => {
it('navigates last client to `url`', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
const mockClient = new WindowClientImpl('fooBar');
spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([mockClient]));
spyOn(mockClient, 'focus');
spyOn(mockClient, 'navigate').and.returnValue(Promise.resolve(mockClient));
const url = 'foo';
await driver.initialized;
await scope.handleClick(
{
title: 'This is a test with operation navigateLastFocusedOrOpen',
body: 'Test body with operation navigateLastFocusedOrOpen',
data: {
onActionClick: {
foo: {operation: 'navigateLastFocusedOrOpen', url},
},
},
},
'foo');
expect(mockClient.navigate).toHaveBeenCalledWith(`${scope.registration.scope}${url}`);
expect(mockClient.focus).toHaveBeenCalled();
});
it('navigates last client to `/` if no `url', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
const mockClient = new WindowClientImpl('fooBar');
spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([mockClient]));
spyOn(mockClient, 'focus');
spyOn(mockClient, 'navigate').and.returnValue(Promise.resolve(mockClient));
await driver.initialized;
await scope.handleClick(
{
title: 'This is a test with operation navigateLastFocusedOrOpen',
body: 'Test body with operation navigateLastFocusedOrOpen',
data: {
onActionClick: {
foo: {operation: 'navigateLastFocusedOrOpen'},
},
},
},
'foo');
expect(mockClient.navigate).toHaveBeenCalledWith(`${scope.registration.scope}`);
expect(mockClient.focus).toHaveBeenCalled();
});
it('falls back to openWindow at url when no last client to focus', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
spyOn(scope.clients, 'openWindow');
spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([]));
const url = 'foo';
await driver.initialized;
await scope.handleClick(
{
title: 'This is a test with operation navigateLastFocusedOrOpen',
body: 'Test body with operation navigateLastFocusedOrOpen',
data: {
onActionClick: {
foo: {operation: 'navigateLastFocusedOrOpen', url},
},
},
},
'foo');
expect(scope.clients.openWindow)
.toHaveBeenCalledWith(`${scope.registration.scope}${url}`);
});
it('falls back to openWindow at `/` when no last client and no `url`', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
spyOn(scope.clients, 'openWindow');
spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([]));
await driver.initialized;
await scope.handleClick(
{
title: 'This is a test with operation navigateLastFocusedOrOpen',
body: 'Test body with operation navigateLastFocusedOrOpen',
data: {
onActionClick: {
foo: {operation: 'navigateLastFocusedOrOpen'},
},
},
},
'foo');
expect(scope.clients.openWindow).toHaveBeenCalledWith(`${scope.registration.scope}`);
});
});
describe('No matching onActionClick field', () => {
it('no client interaction', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
spyOn(scope.clients, 'openWindow');
await driver.initialized;
await scope.handleClick(
{
title: 'This is a test without onActionClick field',
body: 'Test body without onActionClick field',
data: {
onActionClick: {
fooz: {operation: 'focusLastFocusedOrOpen', url: 'fooz'},
},
},
},
'foo');
expect(scope.clients.openWindow).not.toHaveBeenCalled();
});
});
describe('no action', () => {
it('uses onActionClick default when no specific action is clicked', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
spyOn(scope.clients, 'openWindow');
const url = 'fooz';
await driver.initialized;
await scope.handleClick(
{
title: 'This is a test without action',
body: 'Test body without action',
data: {
onActionClick: {
default: {operation: 'openWindow', url},
},
},
},
'');
expect(scope.clients.openWindow)
.toHaveBeenCalledWith(`${scope.registration.scope}${url}`);
});
describe('no onActionClick default', () => {
it('has no client interaction', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
spyOn(scope.clients, 'openWindow');
await driver.initialized;
await scope.handleClick(
{title: 'This is a test without action', body: 'Test body without action'});
expect(scope.clients.openWindow).not.toHaveBeenCalled();
});
});
});
describe('URL resolution', () => {
it('should resolve relative to service worker scope', async () => {
(scope.registration.scope as string) = 'http://localhost/foo/bar/';
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
spyOn(scope.clients, 'openWindow');
await driver.initialized;
await scope.handleClick(
{
title: 'This is a test with a relative url',
body: 'Test body with a relative url',
data: {
onActionClick: {
foo: {operation: 'openWindow', url: 'baz/qux'},
},
},
},
'foo');
expect(scope.clients.openWindow).toHaveBeenCalledWith('http://localhost/foo/bar/baz/qux');
});
it('should resolve with an absolute path', async () => {
(scope.registration.scope as string) = 'http://localhost/foo/bar/';
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
spyOn(scope.clients, 'openWindow');
await driver.initialized;
await scope.handleClick(
{
title: 'This is a test with an absolute path url',
body: 'Test body with an absolute path url',
data: {
onActionClick: {
foo: {operation: 'openWindow', url: '/baz/qux'},
},
},
},
'foo');
expect(scope.clients.openWindow).toHaveBeenCalledWith('http://localhost/baz/qux');
});
it('should resolve other origins', async () => {
(scope.registration.scope as string) = 'http://localhost/foo/bar/';
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
spyOn(scope.clients, 'openWindow');
await driver.initialized;
await scope.handleClick(
{
title: 'This is a test with external origin',
body: 'Test body with external origin',
data: {
onActionClick: {
foo: {operation: 'openWindow', url: 'http://other.host/baz/qux'},
},
},
},
'foo');
expect(scope.clients.openWindow).toHaveBeenCalledWith('http://other.host/baz/qux');
});
});
});
});
it('prefetches updates to lazy cache when set', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;

View File

@ -31,6 +31,24 @@ export class MockClient {
this.queue.next(message);
}
}
export class WindowClientImpl extends MockClient implements WindowClient {
readonly ancestorOrigins: ReadonlyArray<string> = [];
readonly focused: boolean = false;
readonly visibilityState: VisibilityState = 'hidden';
frameType: ClientFrameType = 'top-level';
url = 'http://localhost/unique';
constructor(readonly id: string) {
super(id);
}
async focus(): Promise<WindowClient> {
return this;
}
async navigate(url: string): Promise<WindowClient|null> {
return this;
}
}
export class SwTestHarnessBuilder {
private origin = parseUrl(this.scopeUrl).origin;
@ -76,8 +94,12 @@ export class MockClients implements Clients {
return this.clients.get(id);
}
async matchAll(): Promise<Client[]> {
return Array.from(this.clients.values()) as any[] as Client[];
async matchAll<T extends ClientQueryOptions>(options?: T):
Promise<ReadonlyArray<T['type'] extends 'window'? WindowClient : Client>> {
return Array.from(this.clients.values()) as any[];
}
async openWindow(url: string): Promise<WindowClient|null> {
return null;
}
async claim(): Promise<any> {}