| /** |
| * @license |
| * Copyright 2022 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import {ParsedChangeInfo} from '../types/types'; |
| import {getReason} from '../utils/attention-set-util'; |
| import {readJSONResponsePayload} from '../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper'; |
| import {filterAttentionChangesAfter} from '../utils/service-worker-util'; |
| import {AccountDetailInfo} from '../api/rest-api'; |
| import { |
| ServiceWorkerMessageType, |
| TRIGGER_NOTIFICATION_UPDATES_MS, |
| } from '../services/service-worker-installer'; |
| import { |
| getServiceWorkerState, |
| putServiceWorkerState, |
| } from './service-worker-indexdb'; |
| import {DashboardType, createDashboardUrl} from '../models/views/dashboard'; |
| import {createChangeUrl} from '../models/views/change'; |
| import {noAwait} from '../utils/async-util'; |
| |
| export class ServiceWorker { |
| constructor( |
| /* private but used in test */ public ctx: ServiceWorkerGlobalScope |
| ) {} |
| |
| // private but used in test |
| latestUpdateTimestampMs = Date.now(); |
| |
| allowBrowserNotificationsPreference = false; |
| |
| /** |
| * We cannot rely on a state in a service worker, because every time |
| * service worker starts or stops, new instance is created. So every time |
| * there is new instance we load state from indexdb. |
| */ |
| async init() { |
| await this.loadState(); |
| this.ctx.addEventListener('message', e => this.onMessage(e)); |
| this.ctx.addEventListener('notificationclick', e => |
| this.onNotificationClick(e) |
| ); |
| } |
| |
| // private but used in test |
| saveState() { |
| return putServiceWorkerState({ |
| latestUpdateTimestampMs: this.latestUpdateTimestampMs, |
| allowBrowserNotificationsPreference: |
| this.allowBrowserNotificationsPreference, |
| }); |
| } |
| |
| private async loadState() { |
| const state = await getServiceWorkerState(); |
| if (state) { |
| this.latestUpdateTimestampMs = state.latestUpdateTimestampMs; |
| this.allowBrowserNotificationsPreference = |
| state.allowBrowserNotificationsPreference; |
| } |
| } |
| |
| private onMessage(e: ExtendableMessageEvent) { |
| if (e.data?.type === ServiceWorkerMessageType.TRIGGER_NOTIFICATIONS) { |
| e.waitUntil( |
| this.showLatestAttentionChangeNotification( |
| e.data?.account as AccountDetailInfo | undefined |
| ) |
| ); |
| } else if ( |
| e.data?.type === ServiceWorkerMessageType.USER_PREFERENCE_CHANGE |
| ) { |
| e.waitUntil( |
| this.allowBrowserNotificationsPreferenceChanged( |
| e.data?.allowBrowserNotificationsPreference as boolean |
| ) |
| ); |
| } |
| } |
| |
| private onNotificationClick(e: NotificationEvent) { |
| e.notification.close(); |
| e.waitUntil(this.openWindow(e.notification.data.url)); |
| } |
| |
| async allowBrowserNotificationsPreferenceChanged(preference: boolean) { |
| this.allowBrowserNotificationsPreference = preference; |
| await this.saveState(); |
| } |
| |
| // private but used in test |
| async showLatestAttentionChangeNotification(account?: AccountDetailInfo) { |
| // Message always contains account, but we do not throw error. |
| if (!account?._account_id) return; |
| if (!this.allowBrowserNotificationsPreference) return; |
| const latestAttentionChanges = await this.getChangesToNotify(account); |
| const numOfChangesToNotifyAbout = latestAttentionChanges.length; |
| if (numOfChangesToNotifyAbout === 1) { |
| this.showNotificationForChange(latestAttentionChanges[0], account); |
| } else if (numOfChangesToNotifyAbout > 1) { |
| this.showNotificationForDashboard(numOfChangesToNotifyAbout); |
| } |
| } |
| |
| // Code based on code sample from |
| // https://developer.mozilla.org/en-US/docs/Web/API/Clients/openWindow |
| private async openWindow(url?: string) { |
| if (!url) return; |
| const clientsArr = await this.ctx.clients.matchAll({type: 'window'}); |
| try { |
| let client = clientsArr.find(c => c.url === url); |
| if (!client) |
| client = (await this.ctx.clients.openWindow(url)) ?? undefined; |
| await client?.focus(); |
| } catch (e) { |
| console.error(`Cannot open window about notified change - ${e}`); |
| } |
| } |
| |
| private showNotificationForChange( |
| change: ParsedChangeInfo, |
| account: AccountDetailInfo |
| ) { |
| const body = getReason(undefined, account, change); |
| const changeUrl = createChangeUrl({ |
| change, |
| usp: 'service-worker-notification', |
| }); |
| // We are adding origin because each notification can have different origin |
| // User can have different service workers for different origins/hosts. |
| // TODO(milutin): Check if this works properly with getBaseUrl() |
| const data = {url: `${self.location.origin}${changeUrl}`}; |
| const icon = `${self.location.origin}/favicon.ico`; |
| this.ctx.registration.showNotification(change.subject, { |
| body, |
| data, |
| icon, |
| }); |
| this.sendReport('notify about 1 change'); |
| } |
| |
| private showNotificationForDashboard(numOfChangesToNotifyAbout: number) { |
| const title = `You are in the attention set for ${numOfChangesToNotifyAbout} new changes.`; |
| const dashboardUrl = createDashboardUrl({type: DashboardType.USER}); |
| const data = {url: `${self.location.origin}${dashboardUrl}`}; |
| const icon = `${self.location.origin}/favicon.ico`; |
| this.ctx.registration.showNotification(title, {data, icon}); |
| this.sendReport(`notify about ${numOfChangesToNotifyAbout} changes`); |
| } |
| |
| // private but used in test |
| async getChangesToNotify(account: AccountDetailInfo) { |
| // We throttle polling, since there can be many clients triggerring |
| // always only one service worker. |
| const durationFromLatestUpdateMS = |
| Date.now() - this.latestUpdateTimestampMs; |
| if (durationFromLatestUpdateMS < TRIGGER_NOTIFICATION_UPDATES_MS) { |
| return []; |
| } |
| const prevLatestUpdateTimestampMs = this.latestUpdateTimestampMs; |
| this.latestUpdateTimestampMs = Date.now(); |
| await this.saveState(); |
| noAwait(this.sendReport('polling')); |
| const changes = await this.getLatestAttentionSetChanges(); |
| const latestAttentionChanges = filterAttentionChangesAfter( |
| changes, |
| account, |
| prevLatestUpdateTimestampMs |
| ); |
| return latestAttentionChanges; |
| } |
| |
| // private but used in test |
| async getLatestAttentionSetChanges(): Promise<ParsedChangeInfo[]> { |
| // TODO(milutin): Implement more generic query builder |
| const response = await fetch( |
| '/changes/?O=1000081&S=0&n=25&q=attention%3Aself' |
| ); |
| |
| try { |
| // Throws an error if response payload is not prefixed JSON. |
| return (await readJSONResponsePayload(response)) |
| .parsed as unknown as ParsedChangeInfo[]; |
| } catch (err) { |
| if (err instanceof Error) { |
| console.warn( |
| `Request for latest attention set changes failed. Error: ${err.message}` |
| ); |
| } |
| return []; |
| } |
| } |
| |
| /** |
| * Send report event to 1 client (last focused one). The client will use |
| * gr-reporting service to send event to metric event collectors. |
| */ |
| async sendReport(eventName: string) { |
| const clientsArr = await this.ctx.clients.matchAll({type: 'window'}); |
| const lastFocusedClient = clientsArr?.[0]; |
| if (!lastFocusedClient) return; |
| |
| lastFocusedClient.postMessage({ |
| type: ServiceWorkerMessageType.REPORTING, |
| eventName, |
| }); |
| } |
| } |