Timeout autoHide notifications based on visible time
authorErik Bernhardson <ebernhardson@wikimedia.org>
Thu, 21 Sep 2017 16:13:01 +0000 (09:13 -0700)
committerBartosz Dziewoński <matma.rex@gmail.com>
Tue, 7 Nov 2017 23:12:30 +0000 (00:12 +0100)
On supported browsers handle the auto hide timeout with a
count of cumulative time the page has been visible to the
user. Old functionality can still be accessed, if desired,
by setting the visibleTimeout notification option to false.
On browsers without support for this visibilitychange event
wall clock time (the old behaviour) is used.

Adds a library function functionally similar to setTimeout that
only considers time when the page is visible. This is useful
both for analytics purposes, and when you want to temporarily
put something on screen and be reasonably certain it doesn't
go away until a user has seen it.

Bug: T42322
Change-Id: I7d8ea85602cae9cfc72e0155bc3092049ecafd43

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 ) );