From b41f6663957bcd581926dc29d1dfdad4d3ce511e Mon Sep 17 00:00:00 2001 From: Erik Bernhardson Date: Thu, 21 Sep 2017 09:13:01 -0700 Subject: [PATCH] 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 --- maintenance/jsduck/categories.json | 3 +- resources/Resources.php | 5 + .../src/mediawiki/mediawiki.notification.js | 27 +++- .../src/mediawiki/mediawiki.visibleTimeout.js | 114 +++++++++++++++++ tests/qunit/QUnitTestResources.php | 2 + .../mediawiki.visibleTimeout.test.js | 115 ++++++++++++++++++ 6 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 resources/src/mediawiki/mediawiki.visibleTimeout.js create mode 100644 tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js diff --git a/maintenance/jsduck/categories.json b/maintenance/jsduck/categories.json index 3623593762..66e8d01fcb 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 a16ab0e3a1..34b083688f 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -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 */ diff --git a/resources/src/mediawiki/mediawiki.notification.js b/resources/src/mediawiki/mediawiki.notification.js index 20f8b8d31c..aa86a4b58a 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 0000000000..e2bbd6832d --- /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 1f2dba4325..b168754bf8 100644 --- a/tests/qunit/QUnitTestResources.php +++ b/tests/qunit/QUnitTestResources.php @@ -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 index 0000000000..7f8819dec9 --- /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 ) ); -- 2.20.1