8000 feat(service-worker): support notification closes by arturovt · Pull Request #61442 · angular/angular · GitHub
[go: up one dir, main page]

Skip to content
8000

feat(service-worker): support notification closes #61442

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions goldens/public-api/service-worker/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export class SwPush {
title: string;
};
}>;
readonly notificationCloses: Observable<{
action: string;
notification: NotificationOptions & {
title: string;
};
}>;
requestSubscription(options: {
serverPublicKey: string;
}): Promise<PushSubscription>;
Expand Down
24 changes: 24 additions & 0 deletions packages/service-worker/src/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,25 @@ export class SwPush {
};
}>;

/**
* Emits the payloads of notifications that were closed, along with the action (if any)
* associated with the close event. If no action was used, the `action` property contains
* an empty string `''`.
*
* Note that the `notification` property does **not** contain a
* [Notification][Mozilla Notification] object but rather a
* [NotificationOptions](https://notifications.spec.whatwg.org/#dictdef-notificationoptions)
* object that also includes the `title` of the [Notification][Mozilla Notification] object.
*
* [Mozilla Notification]: https://developer.mozilla.org/en-US/docs/Web/API/Notification
*/
readonly notificationCloses: Observable<{
action: string;
notification: NotificationOptions & {
title: string;
};
}>;

/**
* Emits the currently active
* [PushSubscription](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription)
Expand All @@ -139,6 +158,7 @@ export class SwPush {
if (!sw.isEnabled) {
this.messages = NEVER;
this.notificationClicks = NEVER;
this.notificationCloses = NEVER;
this.subscription = NEVER;
return;
}
Expand All @@ -149,6 +169,10 @@ export class SwPush {
.eventsOfType('NOTIFICATION_CLICK')
.pipe(map((message: any) => message.data));

this.notificationCloses = this.sw
.eventsOfType('NOTIFICATION_CLOSE')
.pipe(map((message: any) => message.data));

this.pushManager = this.sw.registration.pipe(map((registration) => registration.pushManager));

const workerDrivenSubscriptions = this.pushManager.pipe(
Expand Down
16 changes: 16 additions & 0 deletions packages/service-worker/test/comm_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,22 @@ describe('ServiceWorker library', () => {
});
});

describe('notificationCloses', () => {
it('receives notification closes messages', () => {
const sendMessage = (type: string, action: string) =>
mock.sendMessage({type, data: {action}});

const receivedMessages: string[] = [];
push.notificationCloses.subscribe((msg: {action: string}) =>
receivedMessages.push(msg.action),
);

sendMessage('NOTIFICATION_CLOSE', 'empty_string');

expect(receivedMessages).toEqual(['empty_string']);
});
});

describe('subscription', () => {
let nextSubEmitResolve: () => void;
let nextSubEmitPromise: Promise<void>;
Expand Down
33 changes: 31 additions & 2 deletions packages/service-worker/worker/src/driver.ts
8000
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,12 @@ export class Driver implements Debuggable, UpdateSource {
}
});

// Handle the fetch, message, and push events.
// Handle the fetch, message, and push, notificationclick and notificationclose events.
this.scope.addEventListener('fetch', (event) => this.onFetch(event!));
this.scope.addEventListener('message', (event) => this.onMessage(event!));
this.scope.addEventListener('push', (event) => this.onPush(event!));
this.scope.addEventListener('notificationclick', (event) => this.onClick(event!));
this.scope.addEventListener('notificationclick', (event) => this.onClick(event));
this.scope.addEventListener('notificationclose', (event) => this.onClose(event));

// The debugger generates debug pages in response to debugging requests.
this.debugger = new DebugHandler(this, this.adapter);
Expand Down Expand Up @@ -313,6 +314,11 @@ export class Driver implements Debuggable, UpdateSource {
event.waitUntil(this.handleClick(event.notification, event.action));
}

private onClose(event: NotificationEvent): void {
// Handle the close event and keep the SW alive until it's handled.
event.waitUntil(this.handleClose(event.notification, event.action));
}

private async ensureInitialized(event: ExtendableEvent): Promise<void> {
// Since the SW may have just been started, it may or may not have been initialized already.
// `this.initialized` will be `null` if initialization has not yet been attempted, or will be a
Expand Down Expand Up @@ -418,6 +424,29 @@ export class Driver implements Debuggable, UpdateSource {
});
}

/**
* Handles the closing of a notification by extracting its options and
* broadcasting a `NOTIFICATION_CLOSE` message.
*
* This is typically called when a notification is dismissed by the user
* or closed programmatically, and it relays that information to clients
* listening for service worker events.
*
* @param notification - The original `Notification` object that was closed.
* @param action - The action string associated with the close event, if any (usually an empty string).
*/
private async handleClose(notification: Notification, action: string): Promise<void> {
const options: {-readonly [K in keyof Notification]?: Notification[K]} = {};
NOTIFICATION_OPTION_NAMES.filter((name) => name in notification).forEach(
(name) => (options[name] = notification[name]),
);

await this.broadcast({
type: 'NOTIFICATION_CLOSE',
data: {action, notification: options},
});
}

private async getLastFocusedMatchingClient(
scope: ServiceWorkerGlobalScope,
): Promise<WindowClient | null> {
Expand Down
38 changes: 38 additions & 0 deletions packages/service-worker/worker/test/happy_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,44 @@ import {envIsSupported} from '../testing/utils';
});
});

describe('notification close events', () => {
it('broadcasts notification close events', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
const notification = {title: 'This is a test with action', body: 'Test body with action'};
await driver.initialized;
await scope.handleClick(
{title: 'This is a test with action', body: 'Test body with action'},
'button',
);
await scope.handleClose(notification, '');

const {messages} = scope.clients.getMock('default')!;

expect(messages).toEqual([
{
type: 'NOTIFICATION_CLICK',
data: {
action: 'button',
notification: {
title: notification.title,
body: notification.body,
},
},
},
{
type: 'NOTIFICATION_CLOSE',
data: {
action: '',
notification: {
title: notification.title,
body: notification.body,
},
},
},
]);
});
});

it('prefetches updates to lazy cache when set', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
Expand Down
9 changes: 9 additions & 0 deletions packages/service-worker/worker/testing/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,15 @@ export class SwTestHarnessImpl
return event.ready;
}

handleClose(notification: Object, action: string): Promise<void> {
if (!this.eventHandlers.has('notificationclose')) {
throw new Error('No notificationclose handler registered');
}
const event = new MockNotificationEvent(notification, action);
this.eventHandlers.get('notificationclose')!.call(this, event);
return event.ready;
}

override timeout(ms: number): Promise<void> {
const promise = new Promise<void>((resolve) => {
this.timers.push({
Expand Down
0