From 0e69f27c2fde98aa3c5597964e21d26934987c05 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 29 May 2017 11:43:59 +0200 Subject: [PATCH] Basic desktop notification implementation See #2279 --- .../templates/headIncludeJavaScript.tpl | 8 + .../install/files/acp/templates/header.tpl | 1 + .../files/js/WoltLabSuite/Core/Core.js | 16 +- .../WoltLabSuite/Core/Notification/Handler.js | 145 ++++++++++++++++++ .../WoltLabSuite/Core/Ui/Redactor/Autosave.js | 9 +- .../lib/data/session/SessionAction.class.php | 52 ++++++- .../UserNotificationHandler.class.php | 27 ++++ 7 files changed, 249 insertions(+), 9 deletions(-) create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Notification/Handler.js diff --git a/com.woltlab.wcf/templates/headIncludeJavaScript.tpl b/com.woltlab.wcf/templates/headIncludeJavaScript.tpl index b082288e21..774b8cc6da 100644 --- a/com.woltlab.wcf/templates/headIncludeJavaScript.tpl +++ b/com.woltlab.wcf/templates/headIncludeJavaScript.tpl @@ -12,6 +12,7 @@ var TIME_NOW = {@TIME_NOW}; var LAST_UPDATE_TIME = {@LAST_UPDATE_TIME}; var URL_LEGACY_MODE = false; + var ENABLE_DEBUG_MODE = {if ENABLE_DEBUG_MODE}true{else}false{/if}; {if ENABLE_DEBUG_MODE} {* This constant is a compiler option, it does not exist in production. *} @@ -200,6 +201,13 @@ requirejs.config({ {if $__sessionKeepAlive|isset} new WCF.System.KeepAlive({@$__sessionKeepAlive}); + + require(['WoltLabSuite/Core/Notification/Handler'], function(NotificationHandler) { + NotificationHandler.setup({ + icon: '{@$__wcf->getPath()}images/apple-touch-icon.png', + sessionKeepAlive: {@$__sessionKeepAlive} + }); + }); {/if} }); diff --git a/wcfsetup/install/files/acp/templates/header.tpl b/wcfsetup/install/files/acp/templates/header.tpl index 8fd4eff58e..adf37670ab 100644 --- a/wcfsetup/install/files/acp/templates/header.tpl +++ b/wcfsetup/install/files/acp/templates/header.tpl @@ -25,6 +25,7 @@ var TIME_NOW = {@TIME_NOW}; var LAST_UPDATE_TIME = {@LAST_UPDATE_TIME}; var URL_LEGACY_MODE = false; + var ENABLE_DEBUG_MODE = {if ENABLE_DEBUG_MODE}true{else}false{/if}; {if ENABLE_DEBUG_MODE} {* This constant is a compiler option, it does not exist in production. *} diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Core.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Core.js index 59ff46a739..85bb5274d6 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Core.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Core.js @@ -28,7 +28,7 @@ define([], function() { var newObj = {}; for (var key in obj) { - if (objOwns(obj, key) && typeof obj[key] !== 'undefined') { + if (obj.hasOwnProperty(key) && typeof obj[key] !== 'undefined') { newObj[key] = _clone(obj[key]); } } @@ -36,6 +36,9 @@ define([], function() { return newObj; }; + //noinspection JSUnresolvedVariable + var _prefix = 'wsc' + window.WCF_PATH.hashCode() + '-'; + /** * @exports WoltLabSuite/Core/Core */ @@ -196,7 +199,7 @@ define([], function() { * * @param {object} obj target object * @param {string=} prefix parameter prefix - * @return encoded parameter string + * @return {string} encoded parameter string */ serialize: function(obj, prefix) { var parameters = []; @@ -239,6 +242,15 @@ define([], function() { } element.dispatchEvent(event); + }, + + /** + * Returns the unique prefix for the localStorage. + * + * @return {string} prefix for the localStorage + */ + getStoragePrefix: function() { + return _prefix; } }; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Notification/Handler.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Notification/Handler.js new file mode 100644 index 0000000000..2b40c038b1 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Notification/Handler.js @@ -0,0 +1,145 @@ +define(['Ajax', 'Core', 'EventHandler'], function(Ajax, Core, EventHandler) { + "use strict"; + + if (!('Promise' in window) || !('Notification' in window)) { + // fake object exposed to ancient browsers (*cough* IE11 *cough*) + return { + setup: function () {} + } + } + + var _allowNotification = false; + var _icon = ''; + var _inactiveSince = 0; + var _lastRequestTimestamp = window.TIME_NOW; + var _requestTimer = null; + var _sessionKeepAlive = 0; + + return { + setup: function (options) { + options = Core.extend({ + icon: '', + sessionKeepAlive: 0 + }, options); + + _icon = options.icon; + _sessionKeepAlive = options.sessionKeepAlive * 60; + + console.log("DEBUG ONLY"); + var x = this._dispatchRequest.bind(this); + //this._prepareNextRequest(); + + document.addEventListener('visibilitychange', this._onVisibilityChange.bind(this)); + window.addEventListener('storage', this._onStorage.bind(this)); + + this._onVisibilityChange(); + + Notification.requestPermission().then(function (result) { + if (result === 'granted') { + _allowNotification = true; + console.log("DEBUG ONLY"); + x(); + } + }); + }, + + _onVisibilityChange: function() { + _inactiveSince = (document.hidden) ? Date.now() : 0; + }, + + _getNextDelay: function() { + if (_inactiveSince === 0) return 5; + + // milliseconds -> minutes + var inactiveMins = ~~((Date.now() - _inactiveSince) / 60000); + if (inactiveMins < 15) { + return 5; + } + else if (inactiveMins < 30) { + return 10; + } + + return 15; + }, + + _prepareNextRequest: function() { + var delay = Math.min(this._getNextDelay(), _sessionKeepAlive); + + _requestTimer = window.setTimeout(this._dispatchRequest.bind(this), delay * 60000); + }, + + _dispatchRequest: function() { + var parameters = {}; + EventHandler.fire('com.woltlab.wcf.notification', 'beforePoll', parameters); + + // this timestamp is used to determine new notifications and to avoid + // notifications being displayed multiple times due to different origins + // (=subdomains) used, because we cannot synchronize them in the client + parameters.lastRequestTimestamp = _lastRequestTimestamp; + + Ajax.api(this, { + parameters: parameters + }); + }, + + _onStorage: function() { + window.clearTimeout(_requestTimer); + this._prepareNextRequest(); + + // TODO: update counters and stuff, this is not the requesting tab! + }, + + _ajaxSuccess: function(data) { + // forward keep alive data + window.WCF.System.PushNotification.executeCallbacks(data.returnValues.keepAliveData); + + var abort = false; + var pollData = data.returnValues.pollData; + + // store response data in session storage + try { + window.localStorage.setItem(Core.getStoragePrefix() + 'notification', JSON.stringify(pollData)); + } + catch (e) { + // storage is unavailable, e.g. in private mode, log error and disable polling + abort = true; + + window.console.log(e); + } + + if (!abort) { + this._prepareNextRequest(); + } + + _lastRequestTimestamp = data.returnValues.lastRequestTimestamp; + + EventHandler.fire('com.woltlab.wcf.notification', 'afterPoll', pollData); + + this._showNotification(pollData); + }, + + _showNotification: function(pollData) { + if (!_allowNotification) { + return; + } + + if (typeof pollData.notification === 'object' && typeof pollData.notification.message === 'string') { + new Notification(pollData.notification.title, { + body: pollData.notification.message, + icon: _icon + }) + } + }, + + _ajaxSetup: function() { + return { + data: { + actionName: 'poll', + className: 'wcf\\data\\session\\SessionAction' + }, + ignoreError: !window.ENABLE_DEBUG_MODE, + silent: !window.ENABLE_DEBUG_MODE + }; + } + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Redactor/Autosave.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Redactor/Autosave.js index 45a69389ee..8ddd4c4026 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Redactor/Autosave.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Redactor/Autosave.js @@ -7,7 +7,7 @@ * @license GNU Lesser General Public License * @module WoltLabSuite/Core/Ui/Redactor/Autosave */ -define(['EventHandler', 'Language', 'Dom/Traverse', './Metacode'], function(EventHandler, Language, DomTraverse, UiRedactorMetacode) { +define(['Core', 'EventHandler', 'Language', 'Dom/Traverse', './Metacode'], function(Core, EventHandler, Language, DomTraverse, UiRedactorMetacode) { "use strict"; if (!COMPILER_TARGET_DEFAULT) { @@ -29,9 +29,6 @@ define(['EventHandler', 'Language', 'Dom/Traverse', './Metacode'], function(Even // time between save requests in seconds var _frequency = 15; - //noinspection JSUnresolvedVariable - var _prefix = 'wsc' + window.WCF_PATH.hashCode() + '-'; - /** * @param {Element} element textarea element * @constructor @@ -47,7 +44,7 @@ define(['EventHandler', 'Language', 'Dom/Traverse', './Metacode'], function(Even this._container = null; this._editor = null; this._element = element; - this._key = _prefix + elData(this._element, 'autosave'); + this._key = Core.getStoragePrefix() + elData(this._element, 'autosave'); this._lastMessage = ''; this._originalMessage = ''; this._overlay = null; @@ -273,7 +270,7 @@ define(['EventHandler', 'Language', 'Dom/Traverse', './Metacode'], function(Even key = window.localStorage.key(i); // check if key matches our prefix - if (key.indexOf(_prefix) !== 0) { + if (key.indexOf(Core.getStoragePrefix()) !== 0) { continue; } diff --git a/wcfsetup/install/files/lib/data/session/SessionAction.class.php b/wcfsetup/install/files/lib/data/session/SessionAction.class.php index e0d15616e0..719b6f6980 100644 --- a/wcfsetup/install/files/lib/data/session/SessionAction.class.php +++ b/wcfsetup/install/files/lib/data/session/SessionAction.class.php @@ -4,6 +4,7 @@ use wcf\data\AbstractDatabaseObjectAction; use wcf\system\event\EventHandler; use wcf\system\session\SessionHandler; use wcf\system\user\notification\UserNotificationHandler; +use wcf\system\user\storage\UserStorageHandler; use wcf\system\WCF; /** @@ -51,7 +52,8 @@ class SessionAction extends AbstractDatabaseObjectAction { public function keepAlive() { // ignore sessions created by this request if (WCF::getSession()->lastActivityTime == TIME_NOW) { - return []; + // TODO: DEBUG ONLY + //return []; } // update last activity time @@ -67,4 +69,52 @@ class SessionAction extends AbstractDatabaseObjectAction { return $this->keepAliveData; } + + /** + * Validates parameters to poll notification data. + */ + public function validatePoll() { + $this->readInteger('lastRequestTimestamp'); + } + + /** + * Polls notification data, including values provided by `keepAlive()`. + * + * @return array[] + */ + public function poll() { + $pollData = []; + + // trigger session keep alive + $keepAliveData = (new SessionAction([], 'keepAlive'))->executeAction()['returnValues']; + + // get notifications + if (!empty($keepAliveData['userNotificationCount'])) { + // We can synchronize notification polling between tabs of the same domain, but + // this doesn't work for different origins, that is different sub-domains that + // belong to the same instance. + // + // Storing the time of the last request on the server has the benefit of avoiding + // the same notification being presented to the client by different tabs. + $lastRequestTime = UserStorageHandler::getInstance()->getField('__notification_lastRequestTime'); + if ($lastRequestTime === null || $lastRequestTime < $this->parameters['lastRequestTimestamp']) { + $lastRequestTime = $this->parameters['lastRequestTimestamp']; + } + + $pollData['notification'] = UserNotificationHandler::getInstance()->getLatestNotification($lastRequestTime); + + if (!empty($pollData['notification'])) { + UserStorageHandler::getInstance()->update(WCF::getUser()->userID, '__notification_lastRequestTime', TIME_NOW); + } + } + + // notify 3rd party components + EventHandler::getInstance()->fireAction($this, 'poll', $pollData); + + return [ + 'keepAliveData' => $keepAliveData, + 'lastRequestTimestamp' => TIME_NOW, + 'pollData' => $pollData + ]; + } } diff --git a/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php b/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php index f72f808967..c259c75593 100644 --- a/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php +++ b/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php @@ -899,4 +899,31 @@ class UserNotificationHandler extends SingletonFactory { if ($row === false) return false; return $row['mailNotificationType']; } + + /** + * Returns the title and text-only message body for the latest notification, + * that is both unread and newer than `$lastRequestTimestamp`. May return an + * empty array if there is no new notification. + * + * @param integer $lastRequestTimestamp + * @return string[] + */ + public function getLatestNotification($lastRequestTimestamp) { + $notifications = $this->fetchNotifications(1, 0, 0); + if (!empty($notifications) && reset($notifications)->time > $lastRequestTimestamp) { + $notifications = $this->processNotifications($notifications); + + if (isset($notifications['notifications'][0])) { + /** @var IUserNotificationEvent $event */ + $event = $notifications['notifications'][0]['event']; + + return [ + 'title' => strip_tags($event->getTitle()), + 'message' => strip_tags($event->getMessage()) + ]; + } + } + + return []; + } } -- 2.20.1