Basic desktop notification implementation
authorAlexander Ebert <ebert@woltlab.com>
Mon, 29 May 2017 09:43:59 +0000 (11:43 +0200)
committerAlexander Ebert <ebert@woltlab.com>
Mon, 29 May 2017 10:34:20 +0000 (12:34 +0200)
See #2279

com.woltlab.wcf/templates/headIncludeJavaScript.tpl
wcfsetup/install/files/acp/templates/header.tpl
wcfsetup/install/files/js/WoltLabSuite/Core/Core.js
wcfsetup/install/files/js/WoltLabSuite/Core/Notification/Handler.js [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Redactor/Autosave.js
wcfsetup/install/files/lib/data/session/SessionAction.class.php
wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php

index b082288e2104cb6664ea6393ed6710dc16a1f2b6..774b8cc6daa528c38dc4d0e0b7192e7dff85ada2 100644 (file)
@@ -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}
        });
 </script>
index 8fd4eff58eb013c27937fb3478b18397190c28a3..adf37670abb559cdf6c57c1b8b226a531caa3dab 100644 (file)
@@ -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. *}
index 59ff46a739986e9711b211001dd57d68470025ce..85bb5274d67796ff24395c298910286f3cab09d4 100644 (file)
@@ -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 (file)
index 0000000..2b40c03
--- /dev/null
@@ -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
+                       };
+               }
+       }
+});
index 45a69389ee2439963cf3625b4b4cc47018eaa6a3..8ddd4c40263ca91f1a8ca757c536f638ee4ba06f 100644 (file)
@@ -7,7 +7,7 @@
  * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
  * @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;
                                }
                                
index e0d15616e0ebfef1a998a4614804c94c16040fef..719b6f69805266b6ca33e6068956b9b6d0acf824 100644 (file)
@@ -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
+               ];
+       }
 }
index f72f8089672919465ed065df15d9843659fcf1dd..c259c75593ae5a317408ea8218b13f1a8ace8f2d 100644 (file)
@@ -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 [];
+       }
 }