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