/* * https://github.com/morethanwords/tweb * Copyright (C) 2019-2021 Eduard Kuzmenko * https://github.com/morethanwords/tweb/blob/master/LICENSE * * Originally from: * https://github.com/zhukov/webogram * Copyright (C) 2014 Igor Zhukov * https://github.com/zhukov/webogram/blob/master/LICENSE */ import {Database} from '../../config/databases'; import DATABASE_STATE from '../../config/databases/state'; import {IS_FIREFOX, IS_MOBILE} from '../../environment/userAgent'; import deepEqual from '../../helpers/object/deepEqual'; import IDBStorage from '../files/idb'; import {log, serviceMessagePort} from './index.service'; import {ServicePushPingTaskPayload} from './serviceMessagePort'; const ctx = self as any as ServiceWorkerGlobalScope; const defaultBaseUrl = location.protocol + '//' + location.hostname + location.pathname.split('/').slice(0, -1).join('/') + '/'; // as in webPushApiManager.ts const PING_PUSH_TIMEOUT = 10000 + 1500; let lastPingTime = 0; let localNotificationsAvailable = !IS_MOBILE; export type PushNotificationObject = { loc_key: string, loc_args: string[], // user_id: number, // should be number custom: { channel_id?: string, // should be number chat_id?: string, // should be number from_id?: string, // should be number msg_id: string, peerId?: string, // should be number silent?: string // can be '1' }, sound?: string, random_id: number, badge?: string, // should be number description: string, mute: string, // should be number title: string, message?: string, action?: 'mute1d' | 'push_settings', // will be set before postMessage to main thread }; class SomethingGetter, Storage extends Record> { private cache: Partial = {}; private storage: IDBStorage; constructor( db: T, storeName: typeof db['stores'][number]['name'], private defaults: { [Property in keyof Storage]: ((value: Storage[Property]) => Storage[Property]) | Storage[Property] } ) { this.storage = new IDBStorage(db, storeName); } private getDefault(key: T) { const callback = this.defaults[key]; return typeof(callback) === 'function' ? callback() : callback; } public get(key: T) { if(this.cache.hasOwnProperty(key)) { return this.cache[key]; } const promise = this.storage.get(key as string) as Promise; return promise.then((value) => value, () => undefined as Storage[T]).then((value) => { if(this.cache.hasOwnProperty(key)) { return this.cache[key]; } value ??= this.getDefault(key); return this.cache[key] = value; }); } public getCached(key: T) { const value = this.get(key); if(value instanceof Promise) { throw 'no property'; } return value; } public async set(key: T, value: Storage[T]) { const cached = this.cache[key] ?? this.defaults[key]; if(deepEqual(cached, value)) { return; } this.cache[key] = value; try { this.storage.save(key as string, value); } catch(err) { } } } type PushStorage = { push_mute_until: number, push_lang: Partial push_settings: Partial }; const defaults: PushStorage = { push_mute_until: 0, push_lang: { push_message_nopreview: 'You have a new message', push_action_mute1d: 'Mute for 24H', push_action_settings: 'Settings' }, push_settings: {} }; const getter = new SomethingGetter(DATABASE_STATE, 'session', defaults); // fill cache for(const i in defaults) { getter.get(i as keyof PushStorage); } ctx.addEventListener('push', (event) => { const obj: PushNotificationObject = event.data.json(); log('push', {...obj}); try { if(!obj.badge) { throw 'no badge'; } const [muteUntil, settings, lang] = [ getter.getCached('push_mute_until'), getter.getCached('push_settings'), getter.getCached('push_lang') ]; const nowTime = Date.now(); if( userInvisibleIsSupported() && muteUntil && nowTime < muteUntil ) { throw `Supress notification because mute for ${Math.ceil((muteUntil - nowTime) / 60000)} min`; } const hasActiveWindows = (Date.now() - lastPingTime) <= PING_PUSH_TIMEOUT && localNotificationsAvailable; if(hasActiveWindows) { throw 'Supress notification because some instance is alive'; } const notificationPromise = fireNotification(obj, settings, lang); event.waitUntil(notificationPromise); } catch(err) { log(err); } }); ctx.addEventListener('notificationclick', (event) => { const notification = event.notification; log('On notification click: ', notification.tag); notification.close(); const action = event.action as PushNotificationObject['action']; if(action === 'mute1d' && userInvisibleIsSupported()) { log('[SW] mute for 1d'); getter.set('push_mute_until', Date.now() + 86400e3); return; } const data: PushNotificationObject = notification.data; if(!data) { return; } const promise = ctx.clients.matchAll({ type: 'window' }).then((clientList) => { data.action = action; pendingNotification = data; for(let i = 0; i < clientList.length; i++) { const client = clientList[i]; if('focus' in client) { client.focus(); serviceMessagePort.invokeVoid('pushClick', pendingNotification, client); pendingNotification = undefined; return; } } if(ctx.clients.openWindow) { return Promise.resolve(getter.get('push_settings')).then((settings) => { return ctx.clients.openWindow(settings.baseUrl || defaultBaseUrl); }); } }).catch((error) => { log.error('Clients.matchAll error', error); }) event.waitUntil(promise); }); ctx.addEventListener('notificationclose', onCloseNotification); const notifications: Set = new Set(); let pendingNotification: PushNotificationObject; function pushToNotifications(notification: Notification) { if(!notifications.has(notification)) { notifications.add(notification); // @ts-ignore notification.onclose = onCloseNotification; } } function onCloseNotification(event: NotificationEvent) { removeFromNotifications(event.notification) } function removeFromNotifications(notification: Notification) { notifications.delete(notification); } export function closeAllNotifications(tag?: string) { for(const notification of notifications) { try { notification.close(); } catch(e) {} } let promise: Promise; if('getNotifications' in ctx.registration) { promise = ctx.registration.getNotifications({tag}).then((notifications) => { for(let i = 0, len = notifications.length; i < len; ++i) { try { notifications[i].close(); } catch(e) {} } }).catch((error) => { log.error('Offline register SW error', error); }); } else { promise = Promise.resolve(); } notifications.clear(); return promise; } function userInvisibleIsSupported() { return IS_FIREFOX; } function fireNotification(obj: PushNotificationObject, settings: PushStorage['push_settings'], lang: PushStorage['push_lang']) { const icon = 'assets/img/logo_filled_rounded.png'; const badge = 'assets/img/masked.svg'; let title = obj.title || 'Telegram'; let body = obj.description || ''; let peerId: string; if(obj.custom) { if(obj.custom.channel_id) { peerId = '' + -obj.custom.channel_id; } else if(obj.custom.chat_id) { peerId = '' + -obj.custom.chat_id; } else { peerId = obj.custom.from_id || ''; } } obj.custom.peerId = '' + peerId; let tag = 'peer' + peerId; if(settings?.nopreview) { title = 'Telegram'; body = lang.push_message_nopreview; tag = 'unknown_peer'; } log('show notify', title, body, icon, obj); const actions: (Omit & {action: PushNotificationObject['action']})[] = [{ action: 'mute1d', title: lang.push_action_mute1d }/* , { action: 'push_settings', title: lang.push_action_settings || 'Settings' } */]; const notificationPromise = ctx.registration.showNotification(title, { body, icon, tag, data: obj, actions, badge, silent: obj.custom.silent === '1' }); return notificationPromise.catch((error) => { log.error('Show notification promise', error); }); } export function onPing(payload: ServicePushPingTaskPayload, source?: MessageEventSource) { lastPingTime = Date.now(); localNotificationsAvailable = payload.localNotifications; if(pendingNotification && source) { serviceMessagePort.invokeVoid('pushClick', pendingNotification, source); pendingNotification = undefined; } if(payload.lang) { getter.set('push_lang', payload.lang); } if(payload.settings) { getter.set('push_settings', payload.settings); } }