jenkins-bot has submitted this change and it was merged. ( 
https://gerrit.wikimedia.org/r/379568 )

Change subject: Timeout autoHide notifications based on visible time
......................................................................


Timeout autoHide notifications based on visible time

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
---
M maintenance/jsduck/categories.json
M resources/Resources.php
M resources/src/mediawiki/mediawiki.notification.js
A resources/src/mediawiki/mediawiki.visibleTimeout.js
M tests/qunit/QUnitTestResources.php
A tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js
6 files changed, 259 insertions(+), 7 deletions(-)

Approvals:
  Bartosz Dziewoński: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/maintenance/jsduck/categories.json 
b/maintenance/jsduck/categories.json
index 3623593..66e8d01 100644
--- a/maintenance/jsduck/categories.json
+++ b/maintenance/jsduck/categories.json
@@ -34,7 +34,8 @@
                                        "mw.cookie",
                                        "mw.experiments",
                                        "mw.viewport",
-                                       "mw.htmlform.*"
+                                       "mw.htmlform.*",
+                                       "mw.visibleTimeout"
                                ]
                        },
                        {
diff --git a/resources/Resources.php b/resources/Resources.php
index a16ab0e..34b0836 100644
--- a/resources/Resources.php
+++ b/resources/Resources.php
@@ -1134,6 +1134,7 @@
                'scripts' => 
'resources/src/mediawiki/mediawiki.notification.js',
                'dependencies' => [
                        'mediawiki.util',
+                       'mediawiki.visibleTimeout',
                ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
@@ -1392,6 +1393,10 @@
                'styles' => 'resources/src/mediawiki/mediawiki.editfont.css',
                'targets' => [ 'desktop', 'mobile' ],
        ],
+       'mediawiki.visibleTimeout' => [
+               'scripts' => 
'resources/src/mediawiki/mediawiki.visibleTimeout.js',
+               'targets' => [ 'desktop', 'mobile' ],
+       ],
 
        /* MediaWiki Action */
 
diff --git a/resources/src/mediawiki/mediawiki.notification.js 
b/resources/src/mediawiki/mediawiki.notification.js
index 20f8b8d..aa86a4b 100644
--- a/resources/src/mediawiki/mediawiki.notification.js
+++ b/resources/src/mediawiki/mediawiki.notification.js
@@ -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;
@@ -88,6 +89,14 @@
                this.message = message;
                this.options = options;
                this.$notification = $notification;
+               if ( options.visibleTimeout ) {
+                       this.timeout = require( 'mediawiki.visibleTimeout' );
+               } else {
+                       this.timeout = {
+                               set: setTimeout,
+                               clear: clearTimeout
+                       };
+               }
        }
 
        /**
@@ -171,9 +180,9 @@
                }
                this.isPaused = true;
 
-               if ( this.timeout ) {
-                       clearTimeout( this.timeout );
-                       delete this.timeout;
+               if ( this.timeoutId ) {
+                       this.timeout.clear( this.timeoutId );
+                       delete this.timeoutId;
                }
        };
 
@@ -184,15 +193,16 @@
         */
        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 );
                }
@@ -409,13 +419,18 @@
                 * - 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
index 0000000..e2bbd68
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.visibleTimeout.js
@@ -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 ) );
diff --git a/tests/qunit/QUnitTestResources.php 
b/tests/qunit/QUnitTestResources.php
index 1f2dba4..b168754 100644
--- a/tests/qunit/QUnitTestResources.php
+++ b/tests/qunit/QUnitTestResources.php
@@ -99,6 +99,7 @@
                        
'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 @@
                        '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
index 0000000..7f8819d
--- /dev/null
+++ b/tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js
@@ -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 ) );

-- 
To view, visit https://gerrit.wikimedia.org/r/379568
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I7d8ea85602cae9cfc72e0155bc3092049ecafd43
Gerrit-PatchSet: 15
Gerrit-Project: mediawiki/core
Gerrit-Branch: master
Gerrit-Owner: EBernhardson <ebernhard...@wikimedia.org>
Gerrit-Reviewer: Bartosz Dziewoński <matma....@gmail.com>
Gerrit-Reviewer: EBernhardson <ebernhard...@wikimedia.org>
Gerrit-Reviewer: Fomafix <foma...@googlemail.com>
Gerrit-Reviewer: Jack Phoenix <j...@countervandalism.net>
Gerrit-Reviewer: Jdrewniak <jdrewn...@wikimedia.org>
Gerrit-Reviewer: Krinkle <krinklem...@gmail.com>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to