blob: b83713c94041fb96121edf335f3c16028c69603e [file] [log] [blame]
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {FlagsService, KnownExperimentId} from './flags/flags';
import {
areNotificationsEnabled,
registerServiceWorker,
} from '../utils/worker-util';
import {UserModel} from '../models/user/user-model';
import {AccountDetailInfo} from '../api/rest-api';
import {until} from '../utils/async-util';
import {LifeCycle} from '../constants/reporting';
import {ReportingService} from './gr-reporting/gr-reporting';
import {define} from '../models/dependency';
import {Model} from '../models/model';
import {Observable} from 'rxjs';
import {select} from '../utils/observable-util';
/** Type of incoming messages for ServiceWorker. */
export enum ServiceWorkerMessageType {
TRIGGER_NOTIFICATIONS = 'TRIGGER_NOTIFICATIONS',
USER_PREFERENCE_CHANGE = 'USER_PREFERENCE_CHANGE',
REPORTING = 'REPORTING',
}
export const TRIGGER_NOTIFICATION_UPDATES_MS = 5 * 60 * 1000;
export const serviceWorkerInstallerToken = define<ServiceWorkerInstaller>(
'service-worker-installer'
);
/**
* Service worker state:
* initialized - True when service worker registered and event listeners added.
* - False otherwise
* shouldShowPrompt - True when user didn't make decision about notifications
* - False otherwise
*/
export interface ServiceWorkerInstallerState {
initialized: boolean;
shouldShowPrompt: boolean;
}
export class ServiceWorkerInstaller extends Model<ServiceWorkerInstallerState> {
readonly initialized$: Observable<Boolean | undefined> = select(
this.state$,
state => state.initialized
);
readonly shouldShowPrompt$: Observable<Boolean | undefined> = select(
this.initialized$,
_ => this.shouldShowPrompt()
);
// Internal state, it's exposed in initialized$
private initialized = false;
account?: AccountDetailInfo;
allowBrowserNotificationsPreference?: boolean;
constructor(
private readonly flagsService: FlagsService,
private readonly reportingService: ReportingService,
private readonly userModel: UserModel
) {
super({initialized: false, shouldShowPrompt: false});
if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
return;
}
this.userModel.account$.subscribe(acc => (this.account = acc));
this.userModel.preferences$.subscribe(prefs => {
if (
this.allowBrowserNotificationsPreference !==
prefs.allow_browser_notifications
) {
this.allowBrowserNotificationsPreference =
prefs.allow_browser_notifications;
// flag can disable notifications similar to user setting
navigator.serviceWorker.controller?.postMessage({
type: ServiceWorkerMessageType.USER_PREFERENCE_CHANGE,
allowBrowserNotificationsPreference:
this.allowBrowserNotificationsPreference &&
this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS),
});
}
});
Promise.all([
until(this.userModel.account$, account => !!account),
until(
this.userModel.preferences$,
prefs => !!prefs.allow_browser_notifications
),
]).then(() => {
this.init();
});
}
private async init() {
if (this.initialized) return;
if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
return;
}
if (!this.areNotificationsEnabled()) return;
if (!('serviceWorker' in navigator)) {
console.error('Service worker API not available');
return;
}
await registerServiceWorker('/service-worker.js');
const permission = Notification.permission;
this.reportingService.reportLifeCycle(LifeCycle.NOTIFICATION_PERMISSION, {
permission,
});
if (this.isPermitted(permission)) this.startTriggerTimer();
this.initialized = true;
this.updateState({initialized: true});
// Assumption: service worker will send event only to 1 client.
navigator.serviceWorker.onmessage = event => {
if (event.data?.type === ServiceWorkerMessageType.REPORTING) {
this.reportingService.reportLifeCycle(LifeCycle.SERVICE_WORKER_UPDATE, {
eventName: event.data.eventName as string | undefined,
});
}
};
}
// private, used in test
shouldShowPrompt(): boolean {
if (!this.initialized) return false;
if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
return false;
}
if (!this.areNotificationsEnabled()) return false;
return Notification.permission === 'default';
}
public async requestPermission() {
const permission = await Notification.requestPermission();
this.reportingService.reportLifeCycle(LifeCycle.NOTIFICATION_PERMISSION, {
requested: true,
permission,
});
if (this.isPermitted(permission)) this.startTriggerTimer();
}
areNotificationsEnabled() {
// Push Notification developer can have notification enabled even if they
// are disabled for this.account.
if (
!this.flagsService.isEnabled(
KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
) &&
!areNotificationsEnabled(this.account)
) {
return false;
}
return this.allowBrowserNotificationsPreference;
}
/**
* Every 5 minutes, we trigger service-worker to get
* latest updates in attention set and service-worker will create
* notifications.
*/
startTriggerTimer() {
setTimeout(() => {
this.startTriggerTimer();
navigator.serviceWorker.controller?.postMessage({
type: ServiceWorkerMessageType.TRIGGER_NOTIFICATIONS,
account: this.account,
});
}, TRIGGER_NOTIFICATION_UPDATES_MS);
}
isPermitted(permission: NotificationPermission) {
return permission === 'granted';
}
}