Merge "Timeout autoHide notifications based on visible time"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Tue, 7 Nov 2017 23:46:41 +0000 (23:46 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Tue, 7 Nov 2017 23:46:41 +0000 (23:46 +0000)
maintenance/jsduck/categories.json
resources/Resources.php
resources/src/mediawiki/mediawiki.notification.js
resources/src/mediawiki/mediawiki.visibleTimeout.js [new file with mode: 0644]
tests/qunit/QUnitTestResources.php
tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js [new file with mode: 0644]

index 3623593..66e8d01 100644 (file)
@@ -34,7 +34,8 @@
                                        "mw.cookie",
                                        "mw.experiments",
                                        "mw.viewport",
-                                       "mw.htmlform.*"
+                                       "mw.htmlform.*",
+                                       "mw.visibleTimeout"
                                ]
                        },
                        {
index a16ab0e..34b0836 100644 (file)
@@ -1134,6 +1134,7 @@ return [
                'scripts' => 'resources/src/mediawiki/mediawiki.notification.js',
                'dependencies' => [
                        'mediawiki.util',
+                       'mediawiki.visibleTimeout',
                ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
@@ -1392,6 +1393,10 @@ return [
                'styles' => 'resources/src/mediawiki/mediawiki.editfont.css',
                'targets' => [ 'desktop', 'mobile' ],
        ],
+       'mediawiki.visibleTimeout' => [
+               'scripts' => 'resources/src/mediawiki/mediawiki.visibleTimeout.js',
+               'targets' => [ 'desktop', 'mobile' ],
+       ],
 
        /* MediaWiki Action */
 
index 20f8b8d..aa86a4b 100644 (file)
@@ -80,6 +80,7 @@
                //          to stop replacement of a tagged notification with another notification using the same message.
                // options: The options passed to the notification with a little sanitization. Used by various methods.
                // $notification: jQuery object containing the notification DOM node.
+               // timeout: Holds appropriate methods to set/clear timeouts
                this.autoHideSeconds = options.autoHideSeconds &&
                        notification.autoHideSeconds[ options.autoHideSeconds ] ||
                        notification.autoHideSeconds.short;
                this.message = message;
                this.options = options;
                this.$notification = $notification;
+               if ( options.visibleTimeout ) {
+                       this.timeout = require( 'mediawiki.visibleTimeout' );
+               } else {
+                       this.timeout = {
+                               set: setTimeout,
+                               clear: clearTimeout
+                       };
+               }
        }
 
        /**
                }
                this.isPaused = true;
 
-               if ( this.timeout ) {
-                       clearTimeout( this.timeout );
-                       delete this.timeout;
+               if ( this.timeoutId ) {
+                       this.timeout.clear( this.timeoutId );
+                       delete this.timeoutId;
                }
        };
 
         */
        Notification.prototype.resume = function () {
                var notif = this;
+
                if ( !notif.isPaused ) {
                        return;
                }
                // Start any autoHide timeouts
                if ( notif.options.autoHide ) {
                        notif.isPaused = false;
-                       notif.timeout = setTimeout( function () {
+                       notif.timeoutId = notif.timeout.set( function () {
                                // Already finished, so don't try to re-clear it
-                               delete notif.timeout;
+                               delete notif.timeoutId;
                                notif.close();
                        }, this.autoHideSeconds * 1000 );
                }
                 * - type:
                 *   An optional string for the type of the message used for styling:
                 *   Examples: 'info', 'warn', 'error'.
+                *
+                * - visibleTimeout:
+                *   A boolean indicating if the autoHide timeout should be based on
+                *   time the page was visible to user. Or if it should use wall clock time.
                 */
                defaults: {
                        autoHide: true,
                        autoHideSeconds: 'short',
                        tag: null,
                        title: null,
-                       type: null
+                       type: null,
+                       visibleTimeout: true
                },
 
                /**
diff --git a/resources/src/mediawiki/mediawiki.visibleTimeout.js b/resources/src/mediawiki/mediawiki.visibleTimeout.js
new file mode 100644 (file)
index 0000000..e2bbd68
--- /dev/null
@@ -0,0 +1,114 @@
+( function ( mw, document ) {
+       var hidden, visibilityChange,
+               nextVisibleTimeoutId = 0,
+               activeTimeouts = {},
+               init = function ( overrideDoc ) {
+                       if ( overrideDoc !== undefined ) {
+                               document = overrideDoc;
+                       }
+
+                       if ( document.hidden !== undefined ) {
+                               hidden = 'hidden';
+                               visibilityChange = 'visibilitychange';
+                       } else if ( document.mozHidden !== undefined ) {
+                               hidden = 'mozHidden';
+                               visibilityChange = 'mozvisibilitychange';
+                       } else if ( document.msHidden !== undefined ) {
+                               hidden = 'msHidden';
+                               visibilityChange = 'msvisibilitychange';
+                       } else if ( document.webkitHidden !== undefined ) {
+                               hidden = 'webkitHidden';
+                               visibilityChange = 'webkitvisibilitychange';
+                       }
+               };
+
+       init();
+
+       /**
+        * @class mw.visibleTimeout
+        * @singleton
+        */
+       module.exports = {
+               /**
+                * Generally similar to setTimeout, but turns itself on/off on page
+                * visibility changes. The passed function fires after the page has been
+                * cumulatively visible for the specified number of ms.
+                *
+                * @param {Function} fn The action to execute after visible timeout has expired.
+                * @param {number} delay The number of ms the page should be visible before
+                *  calling fn.
+                * @return {number} A positive integer value which identifies the timer. This
+                *  value can be passed to clearVisibleTimeout() to cancel the timeout.
+                */
+               set: function ( fn, delay ) {
+                       var handleVisibilityChange,
+                               timeoutId = null,
+                               visibleTimeoutId = nextVisibleTimeoutId++,
+                               lastStartedAt = mw.now(),
+                               clearVisibleTimeout = function () {
+                                       if ( timeoutId !== null ) {
+                                               clearTimeout( timeoutId );
+                                               timeoutId = null;
+                                       }
+                                       delete activeTimeouts[ visibleTimeoutId ];
+                                       if ( hidden !== undefined ) {
+                                               document.removeEventListener( visibilityChange, handleVisibilityChange, false );
+                                       }
+                               },
+                               onComplete = function () {
+                                       clearVisibleTimeout();
+                                       fn();
+                               };
+
+                       handleVisibilityChange = function () {
+                               var now = mw.now();
+
+                               if ( document[ hidden ] ) {
+                                       // pause timeout if running
+                                       if ( timeoutId !== null ) {
+                                               delay = Math.max( 0, delay - Math.max( 0, now - lastStartedAt ) );
+                                               if ( delay === 0 ) {
+                                                       onComplete();
+                                               } else {
+                                                       clearTimeout( timeoutId );
+                                                       timeoutId = null;
+                                               }
+                                       }
+                               } else {
+                                       // resume timeout if not running
+                                       if ( timeoutId === null ) {
+                                               lastStartedAt = now;
+                                               timeoutId = setTimeout( onComplete, delay );
+                                       }
+                               }
+                       };
+
+                       activeTimeouts[ visibleTimeoutId ] = clearVisibleTimeout;
+                       if ( hidden !== undefined ) {
+                               document.addEventListener( visibilityChange, handleVisibilityChange, false );
+                       }
+                       handleVisibilityChange();
+
+                       return visibleTimeoutId;
+               },
+
+               /**
+                * Cancel a visible timeout previously established by calling set.
+                * Passing an invalid ID silently does nothing.
+                *
+                * @param {number} visibleTimeoutId The identifier of the visible
+                *  timeout you want to cancel. This ID was returned by the
+                *  corresponding call to set().
+                */
+               clear: function ( visibleTimeoutId ) {
+                       if ( activeTimeouts.hasOwnProperty( visibleTimeoutId ) ) {
+                               activeTimeouts[ visibleTimeoutId ]();
+                       }
+               }
+       };
+
+       if ( window.QUnit ) {
+               module.exports.setDocument = init;
+       }
+
+}( mediaWiki, document ) );
index 1f2dba4..b168754 100644 (file)
@@ -99,6 +99,7 @@ return [
                        'tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.cookie.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.experiments.test.js',
+                       'tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js',
                ],
                'dependencies' => [
                        'jquery.accessKeyLabel',
@@ -141,6 +142,7 @@ return [
                        'mediawiki.cookie',
                        'mediawiki.experiments',
                        'mediawiki.inspect',
+                       'mediawiki.visibleTimeout',
                        'test.mediawiki.qunit.testrunner',
                ],
        ]
diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js
new file mode 100644 (file)
index 0000000..7f8819d
--- /dev/null
@@ -0,0 +1,115 @@
+( function ( mw ) {
+
+       QUnit.module( 'mediawiki.visibleTimeout', QUnit.newMwEnvironment( {
+               setup: function () {
+                       // Document with just enough stuff to make the tests work.
+                       var listeners = [];
+                       this.mockDocument = {
+                               hidden: false,
+                               addEventListener: function ( type, listener ) {
+                                       if ( type === 'visibilitychange' ) {
+                                               listeners.push( listener );
+                                       }
+                               },
+                               removeEventListener: function ( type, listener ) {
+                                       var i;
+                                       if ( type === 'visibilitychange' ) {
+                                               i = listeners.indexOf( listener );
+                                               if ( i >= 0 ) {
+                                                       listeners.splice( i, 1 );
+                                               }
+                                       }
+                               },
+                               // Helper function to swap visibility and run listeners
+                               toggleVisibility: function () {
+                                       var i;
+                                       this.hidden = !this.hidden;
+                                       for ( i = 0; i < listeners.length; i++ ) {
+                                               listeners[ i ]();
+                                       }
+                               }
+                       };
+                       this.visibleTimeout = require( 'mediawiki.visibleTimeout' );
+                       this.visibleTimeout.setDocument( this.mockDocument );
+
+                       this.sandbox.useFakeTimers();
+                       // mw.now() doesn't respect the fake clock injected by useFakeTimers
+                       this.stub( mw, 'now', ( function () {
+                               return this.sandbox.clock.now;
+                       } ).bind( this ) );
+               }
+       } ) );
+
+       QUnit.test( 'basic usage', function ( assert ) {
+               var called = 0;
+
+               this.visibleTimeout.set( function () {
+                       called++;
+               }, 0 );
+               assert.strictEqual( called, 0 );
+               this.sandbox.clock.tick( 1 );
+               assert.strictEqual( called, 1 );
+
+               this.sandbox.clock.tick( 100 );
+               assert.strictEqual( called, 1 );
+
+               this.visibleTimeout.set( function () {
+                       called++;
+               }, 10 );
+               this.sandbox.clock.tick( 10 );
+               assert.strictEqual( called, 2 );
+       } );
+
+       QUnit.test( 'can cancel timeout', function ( assert ) {
+               var called = 0,
+                       timeout = this.visibleTimeout.set( function () {
+                               called++;
+                       }, 0 );
+
+               this.visibleTimeout.clear( timeout );
+               this.sandbox.clock.tick( 10 );
+               assert.strictEqual( called, 0 );
+
+               timeout = this.visibleTimeout.set( function () {
+                       called++;
+               }, 100 );
+               this.sandbox.clock.tick( 50 );
+               assert.strictEqual( called, 0 );
+               this.visibleTimeout.clear( timeout );
+               this.sandbox.clock.tick( 100 );
+               assert.strictEqual( called, 0 );
+       } );
+
+       QUnit.test( 'start hidden and become visible', function ( assert ) {
+               var called = 0;
+
+               this.mockDocument.hidden = true;
+               this.visibleTimeout.set( function () {
+                       called++;
+               }, 0 );
+               this.sandbox.clock.tick( 10 );
+               assert.strictEqual( called, 0 );
+
+               this.mockDocument.toggleVisibility();
+               this.sandbox.clock.tick( 10 );
+               assert.strictEqual( called, 1 );
+       } );
+
+       QUnit.test( 'timeout is cumulative', function ( assert ) {
+               var called = 0;
+
+               this.visibleTimeout.set( function () {
+                       called++;
+               }, 100 );
+               this.sandbox.clock.tick( 50 );
+               assert.strictEqual( called, 0 );
+
+               this.mockDocument.toggleVisibility();
+               this.sandbox.clock.tick( 1000 );
+               assert.strictEqual( called, 0 );
+
+               this.mockDocument.toggleVisibility();
+               this.sandbox.clock.tick( 50 );
+               assert.strictEqual( called, 1 );
+       } );
+}( mediaWiki ) );