diff --git a/packages/service-worker/src/push.ts b/packages/service-worker/src/push.ts index b61b898ba4..942a96129e 100644 --- a/packages/service-worker/src/push.ts +++ b/packages/service-worker/src/push.ts @@ -25,6 +25,8 @@ export class SwPush { */ readonly messages: Observable; + readonly messagesClicked: Observable; + /** * Emits the currently active * [PushSubscription](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription) @@ -45,12 +47,16 @@ export class SwPush { constructor(private sw: NgswCommChannel) { if (!sw.isEnabled) { this.messages = NEVER; + this.messagesClicked = NEVER; this.subscription = NEVER; return; } this.messages = this.sw.eventsOfType('PUSH').pipe(map(message => message.data)); + this.messagesClicked = + this.sw.eventsOfType('NOTIFICATION_CLICK').pipe(map((message: any) => message.data)); + this.pushManager = this.sw.registration.pipe(map(registration => registration.pushManager)); const workerDrivenSubscriptions = this.pushManager.pipe(switchMap(pm => pm.getSubscription())); diff --git a/packages/service-worker/test/comm_spec.ts b/packages/service-worker/test/comm_spec.ts index 74399689ce..bada39f98c 100644 --- a/packages/service-worker/test/comm_spec.ts +++ b/packages/service-worker/test/comm_spec.ts @@ -303,6 +303,26 @@ import {async_fit, async_it} from './async'; ]); }); }); + describe('messagesClicked', () => { + it('receives notification clicked messages', () => { + const sendMessage = (type: string, message: string) => + mock.sendMessage({type, data: {message}}); + + const receivedMessages: string[] = []; + push.messagesClicked.subscribe( + (msg: {message: string}) => receivedMessages.push(msg.message)); + + sendMessage('NOTIFICATION_CLICK', 'this was a click'); + sendMessage('NOT_OTIFICATION_CLICK', 'this was not a click'); + sendMessage('NOTIFICATION_CLICK', 'this was a click too'); + sendMessage('KCILC_NOITACIFITON', 'this was a KCILC_NOITACIFITON message'); + + expect(receivedMessages).toEqual([ + 'this was a click', + 'this was a click too', + ]); + }); + }); describe('subscription', () => { let nextSubEmitResolve: () => void; @@ -367,6 +387,7 @@ import {async_fit, async_it} from './async'; it('does not crash on subscription to observables', () => { push.messages.toPromise().catch(err => fail(err)); + push.messagesClicked.toPromise().catch(err => fail(err)); push.subscription.toPromise().catch(err => fail(err)); }); diff --git a/packages/service-worker/test/integration_spec.ts b/packages/service-worker/test/integration_spec.ts index 93cf7021f7..f9e4b3300b 100644 --- a/packages/service-worker/test/integration_spec.ts +++ b/packages/service-worker/test/integration_spec.ts @@ -88,6 +88,7 @@ const serverUpdate = scope.clients.getMock('default') !.queue.subscribe(msg => { mock.sendMessage(msg); }); mock.messages.subscribe(msg => { scope.handleMessage(msg, 'default'); }); + mock.messagesClicked.subscribe(msg => { scope.handleMessage(msg, 'default'); }); mock.setupSw(); reg = mock.mockRegistration !; @@ -128,5 +129,18 @@ const serverUpdate = }); await gotPushNotice; }); + + async_it('receives push message click events', async() => { + const push = new SwPush(comm); + scope.updateServerState(serverUpdate); + + const gotNotificationClick = (async() => { + const event = await obsToSinglePromise(push.messagesClicked); + expect(event).toEqual({action: 'clicked', notification: {clicked: true}}); + })(); + + await scope.handleClick({clicked: true}, 'clicked'); + await gotNotificationClick; + }); }); })(); diff --git a/packages/service-worker/testing/mock.ts b/packages/service-worker/testing/mock.ts index 3eb1cfe588..cb39d99206 100644 --- a/packages/service-worker/testing/mock.ts +++ b/packages/service-worker/testing/mock.ts @@ -28,6 +28,7 @@ export class MockServiceWorkerContainer { mockRegistration: MockServiceWorkerRegistration|null = null; controller: MockServiceWorker|null = null; messages = new Subject(); + messagesClicked = new Subject(); addEventListener(event: 'controllerchange'|'message', handler: Function) { if (event === 'controllerchange') { diff --git a/packages/service-worker/worker/src/driver.ts b/packages/service-worker/worker/src/driver.ts index 103a303526..dd27462024 100644 --- a/packages/service-worker/worker/src/driver.ts +++ b/packages/service-worker/worker/src/driver.ts @@ -159,6 +159,7 @@ export class Driver implements Debuggable, UpdateSource { 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 !)); // The debugger generates debug pages in response to debugging requests. this.debugger = new DebugHandler(this, this.adapter); @@ -275,6 +276,11 @@ export class Driver implements Debuggable, UpdateSource { msg.waitUntil(this.handlePush(msg.data.json())); } + private onClick(event: NotificationClickEvent): void { + // Handle the click event and keep the SW alive until it's handled. + event.waitUntil(this.handleClick(event.notification, event.action)); + } + private async handleMessage(msg: MsgAny&{action: string}, from: Client): Promise { if (isMsgCheckForUpdates(msg)) { const action = (async() => { await this.checkForUpdate(); })(); @@ -299,6 +305,13 @@ export class Driver implements Debuggable, UpdateSource { await this.scope.registration.showNotification(desc['title'] !, options); } + private async handleClick(notification: Notification, action?: string): Promise { + await this.broadcast({ + type: 'NOTIFICATION_CLICK', + data: {action, notification}, + }); + } + private async reportStatus(client: Client, promise: Promise, nonce: number): Promise { const response = {type: 'STATUS', nonce, status: true}; try { diff --git a/packages/service-worker/worker/src/service-worker.d.ts b/packages/service-worker/worker/src/service-worker.d.ts index 9d01e81e58..b49bf82944 100644 --- a/packages/service-worker/worker/src/service-worker.d.ts +++ b/packages/service-worker/worker/src/service-worker.d.ts @@ -83,6 +83,8 @@ interface PushEvent extends ExtendableEvent { data: PushMessageData; } +interface NotificationClickEvent extends NotificationEvent, ExtendableEvent {} + interface PushMessageData { arrayBuffer(): ArrayBuffer; blob(): Blob; @@ -114,6 +116,7 @@ interface ServiceWorkerGlobalScope { addEventListener(event: 'fetch', fn: (event?: FetchEvent) => any): void; addEventListener(event: 'install', fn: (event?: ExtendableEvent) => any): void; addEventListener(event: 'push', fn: (event?: PushEvent) => any): void; + addEventListener(event: 'notificationclick', fn: (event?: NotificationClickEvent) => any): void; addEventListener(event: 'sync', fn: (event?: SyncEvent) => any): void; fetch(request: Request|string): Promise; diff --git a/packages/service-worker/worker/test/happy_spec.ts b/packages/service-worker/worker/test/happy_spec.ts index 36f4da0c95..24deaa42b5 100644 --- a/packages/service-worker/worker/test/happy_spec.ts +++ b/packages/service-worker/worker/test/happy_spec.ts @@ -589,6 +589,16 @@ const manifestUpdateHash = sha1(JSON.stringify(manifestUpdate)); }]); }); + async_it('broadcasts notification click events', async() => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + await driver.initialized; + await scope.handleClick({title: 'This is a test', body: 'Test body'}, 'button'); + expect(scope.clients.getMock('default') !.messages).toEqual([{ + type: 'NOTIFICATION_CLICK', + data: {action: 'button', notification: {title: 'This is a test', body: 'Test body'}} + }]); + }); + async_it('prefetches updates to lazy cache when set', async() => { expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); await driver.initialized; diff --git a/packages/service-worker/worker/testing/scope.ts b/packages/service-worker/worker/testing/scope.ts index 41f94b21f0..88cd7d1f49 100644 --- a/packages/service-worker/worker/testing/scope.ts +++ b/packages/service-worker/worker/testing/scope.ts @@ -228,6 +228,15 @@ export class SwTestHarness implements ServiceWorkerGlobalScope, Adapter, Context return event.ready; } + handleClick(notification: Object, action?: string): Promise { + if (!this.eventHandlers.has('notificationclick')) { + throw new Error('No notificationclick handler registered'); + } + const event = new MockNotificationClickEvent(notification, action); + this.eventHandlers.get('notificationclick') !.call(this, event); + return event.ready; + } + timeout(ms: number): Promise { const promise = new Promise(resolve => { this.timers.push({ @@ -340,6 +349,9 @@ class MockPushEvent extends MockExtendableEvent { json: () => this._data, }; } +class MockNotificationClickEvent extends MockExtendableEvent { + constructor(readonly notification: Object, readonly action?: string) { super(); } +} class MockInstallEvent extends MockExtendableEvent {}