From 11ccf1aa16531ad286ea6e447e7ce74895e2ef51 Mon Sep 17 00:00:00 2001 From: Igor Zhukov Date: Thu, 15 Dec 2016 22:08:16 +0300 Subject: [PATCH] Web Push notifications supported Closes #981 --- app/js/controllers.js | 18 +- app/js/lib/mtproto.js | 7 +- app/js/lib/ng_utils.js | 73 ++++-- app/js/lib/push_worker.js | 312 +++++++++++++++++++++-- app/js/locales/en-us.json | 2 + app/js/messages_manager.js | 2 + app/js/services.js | 70 ++++- app/partials/desktop/settings_modal.html | 7 + app/partials/mobile/settings_modal.html | 7 + app/service_worker.js | 4 +- 10 files changed, 457 insertions(+), 45 deletions(-) diff --git a/app/js/controllers.js b/app/js/controllers.js index 81504e86..c2bacb00 100644 --- a/app/js/controllers.js +++ b/app/js/controllers.js @@ -3989,7 +3989,7 @@ angular.module('myApp.controllers', ['myApp.i18n']) } }) - .controller('SettingsModalController', function ($rootScope, $scope, $timeout, $modal, AppUsersManager, AppChatsManager, AppPhotosManager, MtpApiManager, Storage, NotificationsManager, MtpApiFileManager, PasswordManager, ApiUpdatesManager, ChangelogNotifyService, LayoutSwitchService, AppRuntimeManager, ErrorService, _) { + .controller('SettingsModalController', function ($rootScope, $scope, $timeout, $modal, AppUsersManager, AppChatsManager, AppPhotosManager, MtpApiManager, Storage, NotificationsManager, MtpApiFileManager, PasswordManager, ApiUpdatesManager, ChangelogNotifyService, LayoutSwitchService, WebPushApiManager, AppRuntimeManager, ErrorService, _) { $scope.profile = {} $scope.photo = {} $scope.version = Config.App.version @@ -4142,10 +4142,13 @@ angular.module('myApp.controllers', ['myApp.i18n']) }) } - Storage.get('notify_nodesktop', 'send_ctrlenter', 'notify_volume', 'notify_novibrate', 'notify_nopreview').then(function (settings) { + Storage.get('notify_nodesktop', 'send_ctrlenter', 'notify_volume', 'notify_novibrate', 'notify_nopreview', 'notify_nopush').then(function (settings) { $scope.notify.desktop = !settings[0] $scope.send.enter = settings[1] ? '' : '1' + $scope.notify.pushAvailable = WebPushApiManager.isAvailable + $scope.notify.push = !settings[5] + if (settings[2] !== false) { $scope.notify.volume = settings[2] > 0 && settings[2] <= 1.0 ? settings[2] : 0 } else { @@ -4196,6 +4199,17 @@ angular.module('myApp.controllers', ['myApp.i18n']) $rootScope.$broadcast('settings_changed') } + $scope.togglePush = function () { + $scope.notify.push = !$scope.notify.push + + if ($scope.notify.push) { + Storage.remove('notify_nopush') + } else { + Storage.set({notify_nopush: true}) + } + $rootScope.$broadcast('settings_changed') + } + $scope.togglePreview = function () { $scope.notify.preview = !$scope.notify.preview diff --git a/app/js/lib/mtproto.js b/app/js/lib/mtproto.js index f25bf518..e6835bf3 100644 --- a/app/js/lib/mtproto.js +++ b/app/js/lib/mtproto.js @@ -1468,6 +1468,11 @@ angular.module('izhukov.mtproto', ['izhukov.utils']) } MtpNetworker.prototype.processMessage = function (message, messageID, sessionID) { + var msgidInt = parseInt(messageID.toString(10).substr(0, -10), 10) + if (msgidInt % 2) { + console.warn('[MT] Server even message id: ', messageID, message) + return + } // console.log('process message', message, messageID, sessionID) switch (message._) { case 'msg_container': @@ -1513,7 +1518,7 @@ angular.module('izhukov.mtproto', ['izhukov.utils']) case 'message': if (this.lastServerMessages.indexOf(messageID) != -1) { - console.warn('[MT] Server same messageID: ', messageID) + // console.warn('[MT] Server same messageID: ', messageID) this.ackMessage(messageID) return } diff --git a/app/js/lib/ng_utils.js b/app/js/lib/ng_utils.js index 73d4eb19..6787831f 100755 --- a/app/js/lib/ng_utils.js +++ b/app/js/lib/ng_utils.js @@ -394,7 +394,7 @@ angular.module('izhukov.utils', []) request.onsuccess = function (event) { finished = true - db = request.result + var db = request.result db.onerror = function (error) { storageIsAvailable = false @@ -1981,7 +1981,7 @@ angular.module('izhukov.utils', []) return timeParams }) - .service('WebPushApiManager', function ($timeout, $q, $rootScope) { + .service('WebPushApiManager', function ($window, $timeout, $q, $rootScope, AppRuntimeManager) { var isAvailable = true var isPushEnabled = false @@ -2002,6 +2002,7 @@ angular.module('izhukov.utils', []) if (!started) { started = true getSubscription() + setUpServiceWorkerChannel() } } @@ -2011,13 +2012,7 @@ angular.module('izhukov.utils', []) } navigator.serviceWorker.ready.then(function(reg) { reg.pushManager.getSubscription().then(function(subscription) { - if (!subscription) { - console.log('Not yet subscribed to Push') - subscribe() - return - } - - isPushEnabled = true + isPushEnabled = subscription ? true : false pushSubscriptionNotify('init', subscription) }) .catch(function(err) { @@ -2052,11 +2047,11 @@ angular.module('izhukov.utils', []) } navigator.serviceWorker.ready.then(function(reg) { reg.pushManager.getSubscription().then(function (subscription) { - pushSubscriptionNotify('unsubscribe', subscription) - isPushEnabled = false if (subscription) { + pushSubscriptionNotify('unsubscribe', subscription) + setTimeout(function() { subscription.unsubscribe().then(function(successful) { isPushEnabled = false @@ -2073,12 +2068,55 @@ angular.module('izhukov.utils', []) }) } - function pushSubscriptionNotify(event, subscription) { - console.warn(dT(), 'Push', event, subscription.toJSON()) - $rootScope.$emit('push_' + event, { - tokenType: 10, - tokenValue: JSON.stringify(subscription.toJSON()) + function isAliveNotify() { + if (!isAvailable || + $rootScope.idle && $rootScope.idle.deactivated) { + return + } + var baseUrl = (location.href || '').replace(/#.*$/, '') + '#/im' + var eventData = {type: 'alive', baseUrl: baseUrl} + if (navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage(eventData) + } + setTimeout(isAliveNotify, 10000) + } + + function hidePushNotifications() { + if (!isAvailable) { + return + } + if (navigator.serviceWorker.controller) { + var eventData = {type: 'notifications_clear'} + navigator.serviceWorker.controller.postMessage(eventData) + } + } + + function setUpServiceWorkerChannel() { + navigator.serviceWorker.addEventListener('message', function(event) { + if (event.data && + event.data.type == 'push_click') { + if ($rootScope.idle && $rootScope.idle.deactivated) { + AppRuntimeManager.reload() + return + } + $rootScope.$emit('push_notification_click', event.data.data) + } }) + navigator.serviceWorker.ready.then(isAliveNotify) + } + + + function pushSubscriptionNotify(event, subscription) { + if (subscription) { + console.warn(dT(), 'Push', event, subscription.toJSON()) + $rootScope.$emit('push_' + event, { + tokenType: 10, + tokenValue: JSON.stringify(subscription.toJSON()) + }) + } else { + console.warn(dT(), 'Push', event, false) + $rootScope.$emit('push_' + event, false) + } } return { @@ -2086,7 +2124,8 @@ angular.module('izhukov.utils', []) start: start, isPushEnabled: isPushEnabled, subscribe: subscribe, - unsubscribe: unsubscribe + unsubscribe: unsubscribe, + hidePushNotifications: hidePushNotifications } }) \ No newline at end of file diff --git a/app/js/lib/push_worker.js b/app/js/lib/push_worker.js index b87c8561..0848b354 100644 --- a/app/js/lib/push_worker.js +++ b/app/js/lib/push_worker.js @@ -1,47 +1,323 @@ -console.log('Push worker placeholder') +'use strict'; + +console.log('[SW] Push worker started') var port +var lastAliveTime = false +var pendingNotification = false +var muteUntil = false +var baseUrl +switch (location.hostname) { + case 'localhost': + baseUrl = 'http://localhost:8000/app/index.html#/im' + break + case 'zhukov.github.io': + baseUrl = 'https://zhukov.github.io/webogram/#/im' + break + default: + case 'web.telegram.org': + baseUrl = 'https://' + location.hostname + '/#/im' +} self.addEventListener('push', function(event) { var obj = event.data.json() - console.log('push obj', obj) - fireNotification(obj, event) + console.log('[SW] push', obj) + if (!obj.badge) { + closeAllNotifications(obj, event) + } else { + fireNotification(obj, event) + } }) -self.onmessage = function(e) { - console.log(e) - port = e.ports[0] -} +self.addEventListener('activate', function(event) { + event.waitUntil(clients.claim()) +}) + + +self.addEventListener('message', function(event) { + console.log('[SW] on message', event.data) + port = event.ports[0] || event.source + if (event.data.type == 'alive') { + lastAliveTime = +(new Date()) + + if (pendingNotification && + port && + 'postMessage' in port) { + port.postMessage(pendingNotification) + pendingNotification = false + } + } + if (event.data.type == 'notifications_clear') { + closeAllNotifications(event.data, event) + } + if (event.data.baseUrl) { + baseUrl = event.data.baseUrl + } +}) function fireNotification(obj, event) { + var nowTime = +(new Date()) + if (nowTime - lastAliveTime < 60000) { + console.log('Supress notification because some instance is alive') + return false + } + if (muteUntil && nowTime < muteUntil) { + console.log('Supress notification because mute for ', (muteUntil - nowTime) / 60000, 'min') + return false + } + var title = obj.title || 'Telegram' var body = obj.description || '' - var icon = 'img/Telegram72.png' - - event.waitUntil(self.registration.showNotification(title, { + var icon = 'img/logo_share.png' + var peerID + + if (obj.custom && obj.custom.channel_id) { + peerID = -obj.custom.channel_id + } + else if (obj.custom && obj.custom.chat_id) { + peerID = -obj.custom.chat_id + } + else { + peerID = obj.custom && obj.custom.from_id || 0 + } + obj.custom.peerID = peerID + + var notificationPromise = self.registration.showNotification(title, { body: body, - icon: icon + icon: icon, + tag: 'peer' + peerID, + data: obj, + actions: [ + { + action: 'mute1d', + title: 'Mute background alerts for 1 day' + }, + { + action: 'push_settings', + title: 'Background alerts settings' + } + ] + }) + + var finalPromise = notificationPromise.then(function (event) { + if (event && event.notification) { + pushToNotifications(event.notification) + } + }) + + event.waitUntil(finalPromise) + + return true +} + + +var notifications = [] +function pushToNotifications(notification) { + if (notifications.indexOf(notification) == -1) { + notifications.push(notification) + notification.onclose = onCloseNotification + } +} + +function onCloseNotification(event) { + muteUntil = Math.max(muteUntil || 0, +(new Date()) + 600000) // 10 min + removeFromNotifications(event.notification) +} + +function removeFromNotifications(notification) { + console.warn('on close', notification) + var pos = notifications.indexOf(notification) + if (pos != -1) { + notifications.splice(pos, 1) + } +} + +function closeAllNotifications(obj, event) { + for (var i = 0, len = notifications.length; i < len; i++) { + try { + notifications[i].close() + } catch (e) {} + } + + event.waitUntil(self.registration.getNotifications({}).then(function(notifications) { + for (var i = 0, len = notifications.length; i < len; i++) { + try { + notifications[i].close() + } catch (e) {} + } })) + + notifications = [] } self.addEventListener('notificationclick', function(event) { - console.log('On notification click: ', event.notification.tag) - event.notification.close() + var notification = event.notification + console.log('On notification click: ', notification.tag) + notification.close() + + var action = event.action + if (action == 'mute1d') { + console.log('[SW] mute for 1d') + muteUntil = +(new Date()) + 86400000 + IDBManager.setItem('mute_until', muteUntil.toString()) + return + } - // This looks to see if the current is already open and - // focuses if it is event.waitUntil(clients.matchAll({ type: 'window' }).then(function(clientList) { + notification.data.action = action + pendingNotification = {type: 'push_click', data: notification.data} for (var i = 0; i < clientList.length; i++) { var client = clientList[i] if ('focus' in client) { - return client.focus() + client.focus() + ;(port || client).postMessage(pendingNotification) + pendingNotification = false + return } } - if (clients.openWindow) - return clients.openWindow('') + if (clients.openWindow) { + return clients.openWindow(baseUrl) + } })) +}) + +self.addEventListener('notificationclose', onCloseNotification) + + + + +;(function () { + var dbName = 'keyvalue' + var dbStoreName = 'kvItems' + var dbVersion = 2 + var openDbPromise + var idbIsAvailable = self.indexedDB !== undefined && + self.IDBTransaction !== undefined + + function isAvailable () { + return idbIsAvailable + } + + function openDatabase () { + if (openDbPromise) { + return openDbPromise + } + + return openDbPromise = new Promise(function (resolve, reject) { + try { + var request = indexedDB.open(dbName, dbVersion) + var createObjectStore = function (db) { + db.createObjectStore(dbStoreName) + } + if (!request) { + throw new Exception() + } + } catch (error) { + console.error('error opening db', error.message) + idbIsAvailable = false + return $q.reject(error) + } + + var finished = false + setTimeout(function () { + if (!finished) { + request.onerror({type: 'IDB_CREATE_TIMEOUT'}) + } + }, 3000) + + request.onsuccess = function (event) { + finished = true + var db = request.result + + db.onerror = function (error) { + idbIsAvailable = false + console.error('Error creating/accessing IndexedDB database', error) + reject(error) + } + + resolve(db) + } + + request.onerror = function (event) { + finished = true + idbIsAvailable = false + console.error('Error creating/accessing IndexedDB database', event) + reject(event) + } + + request.onupgradeneeded = function (event) { + finished = true + console.warn('performing idb upgrade from', event.oldVersion, 'to', event.newVersion) + var db = event.target.result + if (event.oldVersion == 1) { + db.deleteObjectStore(dbStoreName) + } + createObjectStore(db) + } + }) + } + + function setItem (key, value) { + return openDatabase().then(function (db) { + try { + var objectStore = db.transaction([dbStoreName], IDBTransaction.READ_WRITE || 'readwrite').objectStore(dbStoreName) + var request = objectStore.put(value, key) + } catch (error) { + idbIsAvailable = false + return Promise.reject(error) + } + + return new Promise(function(resolve, reject) { + request.onsuccess = function (event) { + resolve(value) + } + + request.onerror = function (error) { + reject(error) + } + }) + }) + } + + function getItem (key) { + return openDatabase().then(function (db) { + return new Promise(function(resolve, reject) { + var objectStore = db.transaction([dbStoreName], IDBTransaction.READ || 'readonly').objectStore(dbStoreName) + var request = objectStore.get(key) + + request.onsuccess = function (event) { + var result = event.target.result + if (result === undefined) { + reject() + } else { + resolve(result) + } + } + + request.onerror = function (error) { + reject(error) + } + }) + + }) + } + + openDatabase() + + self.IDBManager = { + name: 'IndexedDB', + isAvailable: isAvailable, + setItem: setItem, + getItem: getItem + } +})() + + + +IDBManager.getItem('mute_until').then(function (newMuteUntil) { + muteUntil = Math.max(muteUntil || 0, newMuteUntil || 0) || false }) \ No newline at end of file diff --git a/app/js/locales/en-us.json b/app/js/locales/en-us.json index 014891e9..2faec498 100644 --- a/app/js/locales/en-us.json +++ b/app/js/locales/en-us.json @@ -59,10 +59,12 @@ "settings_modal_active_sessions": "Active sessions", "settings_modal_settings": "Settings", "settings_modal_notification_alert": "Notification alerts", + "settings_modal_notification_push": "PUSH notifications", "settings_modal_vibrate": "Vibrate", "settings_modal_sounds": "Sounds", "settings_modal_language": "Language", "settings_modal_notifications": "Desktop notifications", + "settings_modal_pushes": "Background notifications", "settings_modal_message_preview": "Message preview", "settings_modal_sound": "Sound", "settings_modal_enter_send_description_md": "**Enter** - send message, **Shift + Enter** - new line", diff --git a/app/js/messages_manager.js b/app/js/messages_manager.js index b3794cda..49742a61 100644 --- a/app/js/messages_manager.js +++ b/app/js/messages_manager.js @@ -260,6 +260,8 @@ angular.module('myApp.services') if (hasPrepend && !newDialogsHandlePromise) { newDialogsHandlePromise = $timeout(handleNewDialogs, 0) + } else { + $rootScope.$broadcast('dialogs_multiupdate', {}) } }) } diff --git a/app/js/services.js b/app/js/services.js index e8f92af8..629689f9 100755 --- a/app/js/services.js +++ b/app/js/services.js @@ -2512,7 +2512,7 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) } }) - .service('AppInlineBotsManager', function (qSync, $q, $rootScope, toaster, Storage, ErrorService, MtpApiManager, AppMessagesManager, AppMessagesIDsManager, AppDocsManager, AppPhotosManager, AppGamesManager, RichTextProcessor, AppUsersManager, AppPeersManager, PeersSelectService, GeoLocationManager) { + .service('AppInlineBotsManager', function (qSync, $q, $rootScope, toaster, Storage, ErrorService, MtpApiManager, AppMessagesManager, AppMessagesIDsManager, AppDocsManager, AppPhotosManager, AppGamesManager, RichTextProcessor, AppUsersManager, AppPeersManager, LocationParamsService, PeersSelectService, GeoLocationManager) { var inlineResults = {} return { @@ -3516,7 +3516,7 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) } }) - .service('NotificationsManager', function ($rootScope, $window, $interval, $q, _, MtpApiManager, AppPeersManager, IdleManager, Storage, AppRuntimeManager, FileManager, WebPushApiManager) { + .service('NotificationsManager', function ($rootScope, $window, $interval, $q, $modal, _, MtpApiManager, AppPeersManager, AppChatsManager, AppUsersManager, IdleManager, Storage, AppRuntimeManager, FileManager, WebPushApiManager) { navigator.vibrate = navigator.vibrate || navigator.mozVibrate || navigator.webkitVibrate var notificationsMsSiteMode = false @@ -3598,9 +3598,17 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) }) var registeredDevice = false + var pushInited = false $rootScope.$on('push_init', function (e, tokenData) { - if (tokenData) { - registerDevice(tokenData) + pushInited = true + if (!settings.nodesktop && !settings.nopush) { + if (tokenData) { + registerDevice(tokenData) + } else { + WebPushApiManager.subscribe() + } + } else { + unregisterDevice(tokenData) } }) $rootScope.$on('push_subscribe', function (e, tokenData) { @@ -3610,6 +3618,42 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) unregisterDevice(tokenData) }) + var topMessagesDeferred = $q.defer() + var unregisterTopMsgs = $rootScope.$on('dialogs_multiupdate', function () { + unregisterTopMsgs() + topMessagesDeferred.resolve() + }) + var topMessagesPromise = topMessagesDeferred.promise + + $rootScope.$on('push_notification_click', function (e, notificationData) { + if (notificationData.action == 'push_settings') { + $modal.open({ + templateUrl: templateUrl('settings_modal'), + controller: 'SettingsModalController', + windowClass: 'settings_modal_window mobile_modal', + backdrop: 'single' + }) + return + } + var peerID = notificationData.custom && notificationData.custom.peerID + console.log('click', notificationData, peerID) + if (peerID) { + topMessagesPromise.then(function () { + if (notificationData.custom.channel_id && + !AppChatsManager.hasChat(notificationData.custom.channel_id)) { + return + } + if (peerID > 0 && !AppUsersManager.hasUser(peerID)) { + return + } + $rootScope.$broadcast('history_focus', { + peerString: AppPeersManager.getPeerString(peerID) + }) + }) + } + }) + + return { start: start, notify: notify, @@ -3627,7 +3671,7 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) } function updateNotifySettings () { - Storage.get('notify_nodesktop', 'notify_volume', 'notify_novibrate', 'notify_nopreview').then(function (updSettings) { + Storage.get('notify_nodesktop', 'notify_volume', 'notify_novibrate', 'notify_nopreview', 'notify_nopush').then(function (updSettings) { settings.nodesktop = updSettings[0] settings.volume = updSettings[1] === false ? 0.5 @@ -3635,6 +3679,20 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) settings.novibrate = updSettings[2] settings.nopreview = updSettings[3] + settings.nopush = updSettings[4] + + + if (pushInited) { + var needPush = !settings.nopush && !settings.nodesktop && WebPushApiManager.isAvailable || false + var hasPush = registeredDevice !== false + if (needPush != hasPush) { + if (needPush) { + WebPushApiManager.subscribe() + } else { + WebPushApiManager.unsubscribe() + } + } + } }) } @@ -3901,6 +3959,8 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) } notificationsShown = {} notificationsCount = 0 + + WebPushApiManager.hidePushNotifications() } function registerDevice (tokenData) { diff --git a/app/partials/desktop/settings_modal.html b/app/partials/desktop/settings_modal.html index 4781b9f4..9cba2ff7 100644 --- a/app/partials/desktop/settings_modal.html +++ b/app/partials/desktop/settings_modal.html @@ -61,6 +61,13 @@ + + + + + + + diff --git a/app/partials/mobile/settings_modal.html b/app/partials/mobile/settings_modal.html index 0e375a5e..7dbf2554 100644 --- a/app/partials/mobile/settings_modal.html +++ b/app/partials/mobile/settings_modal.html @@ -86,6 +86,13 @@ +
+ + + + +
+
diff --git a/app/service_worker.js b/app/service_worker.js index dfdecfa0..d35780c5 100644 --- a/app/service_worker.js +++ b/app/service_worker.js @@ -1,3 +1,3 @@ -// Version 7 -importScripts('js/lib/push_worker.js?3') +// Version 44 +importScripts('js/lib/push_worker.js?44')