Add reading depth

Use schema revision 16163887.

Add the 'checkin' action, which is accompanied by the 'checkin'
property. The action is logged at the following seconds
(Fibonacci numbers) after the page loads: 1, 2, 3, 5, 8, 13, 21,
34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765.
The `checkin` property contains the values listed above.

Bug: T147314
Change-Id: Ib9ec7bd0e60aa34a04e32222b025347f6ee31794
This commit is contained in:
Baha 2016-12-13 17:52:20 -05:00
parent 397d3e8bc2
commit 4afa1958a0
8 changed files with 451 additions and 3 deletions

View file

@ -38,7 +38,7 @@
] ]
}, },
"EventLoggingSchemas": { "EventLoggingSchemas": {
"Popups": 16112163 "Popups": 16163887
}, },
"callback": "PopupsHooks::onExtensionRegistration", "callback": "PopupsHooks::onExtensionRegistration",
"config": { "config": {
@ -85,9 +85,9 @@
"resources/ext.popups/changeListeners/eventLogging.js", "resources/ext.popups/changeListeners/eventLogging.js",
"resources/ext.popups/changeListeners/syncUserSettings.js", "resources/ext.popups/changeListeners/syncUserSettings.js",
"resources/ext.popups/changeListeners/settings.js", "resources/ext.popups/changeListeners/settings.js",
"resources/ext.popups/settingsDialog.js", "resources/ext.popups/settingsDialog.js",
"resources/ext.popups/pageVisibility.js",
"resources/ext.popups/checkin.js",
"resources/ext.popups/boot.js" "resources/ext.popups/boot.js"
], ],
"templates": { "templates": {

View file

@ -3,6 +3,7 @@
var actions = {}, var actions = {},
types = { types = {
BOOT: 'BOOT', BOOT: 'BOOT',
CHECKIN: 'CHECKIN',
LINK_DWELL: 'LINK_DWELL', LINK_DWELL: 'LINK_DWELL',
LINK_ABANDON_START: 'LINK_ABANDON_START', LINK_ABANDON_START: 'LINK_ABANDON_START',
LINK_ABANDON_END: 'LINK_ABANDON_END', LINK_ABANDON_END: 'LINK_ABANDON_END',
@ -88,6 +89,18 @@
}; };
}; };
/**
* How long has the user been actively reading the page?
* @param {number} time The number of seconds the user has seen the page
* @returns {{type: string, time: number}}
*/
actions.checkin = function ( time ) {
return {
type: types.CHECKIN,
time: time
};
};
/** /**
* Represents Page Previews fetching data via the [gateway](./gateway.js). * Represents Page Previews fetching data via the [gateway](./gateway.js).
* *

View file

@ -73,6 +73,7 @@
* 2. Binding the actions to such store * 2. Binding the actions to such store
* 3. Trigger the boot action to bootstrap the system * 3. Trigger the boot action to bootstrap the system
* 4. When the page content is ready: * 4. When the page content is ready:
* - Setup `checkin` actions
* - Process the eligible links for page previews * - Process the eligible links for page previews
* - Initialize the renderer * - Initialize the renderer
* - Bind hover and click events to the eligible links to trigger actions * - Bind hover and click events to the eligible links to trigger actions
@ -127,6 +128,8 @@
mw.config mw.config
); );
mw.popups.checkin.setupActions( actions.checkin );
mw.popups.renderer.init(); mw.popups.renderer.init();
previewLinks previewLinks

View file

@ -0,0 +1,130 @@
( function ( mw, $ ) {
var pageVisibility = mw.popups.pageVisibility,
checkin = {
/**
* Checkin times - Fibonacci numbers
*
* Exposed for testing only.
*
* @type {number[]}
* @private
*/
CHECKIN_TIMES: [ 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233,
377, 610, 987, 1597, 2584, 4181, 6765 ],
/**
* Have checkin actions been setup already?
*
* Exposed for testing only.
*
* @private
* @type {boolean}
*/
haveCheckinActionsBeenSetup: false
};
/**
* A customized `setTimeout` function that takes page visibility into account
*
* If the document is not visible to the user, e.g. browser window is minimized,
* then pause the time. Otherwise execute `callback` after `delay` milliseconds.
* The callback won't be executed if the browser does not suppor the page visibility
* API.
*
* Exposed for testing only.
*
* @see https://www.w3.org/TR/page-visibility/#dom-document-hidden
* @private
* @param {Function} callback Function to call when the time is up
* @param {number} delay The number of milliseconds to wait before executing the callback
*/
checkin.setVisibleTimeout = function ( callback, delay ) {
var hiddenPropertyName = pageVisibility.getDocumentHiddenPropertyName( document ),
visibilityChangeEventName = pageVisibility.getDocumentVisibilitychangeEventName( document ),
timeoutId,
lastStartedAt;
if ( !hiddenPropertyName || !visibilityChangeEventName ) {
return;
}
/**
* Execute the callback and turn off listening to the visibilitychange event
*/
function done() {
callback();
$( document ).off( visibilityChangeEventName, visibilityChangeHandler );
}
/**
* Pause or resume the timer depending on the page visibility state
*/
function visibilityChangeHandler() {
var millisecondsPassed;
// Pause the timer if the page is hidden ...
if ( pageVisibility.isDocumentHidden( document ) ) {
// ... and only if the timer has started.
// Timer may not have been started if the document opened in a
// hidden tab for example. The timer will be started when the
// document is visible to the user.
if ( lastStartedAt ) {
millisecondsPassed = new Date().getTime() - lastStartedAt;
delay = Math.max( 0, delay - millisecondsPassed );
clearTimeout( timeoutId );
}
} else {
lastStartedAt = new Date().getTime();
timeoutId = setTimeout( done, delay );
}
}
visibilityChangeHandler();
$( document ).on( visibilityChangeEventName, visibilityChangeHandler );
};
/**
* Perform the passed `checkin` action at the predefined times
*
* Actions are setup only once no matter how many times this function is
* called. Ideally this function should be called once.
*
* @see checkin.CHECKIN_TIMES
* @param {Function} checkinAction
*/
checkin.setupActions = function( checkinAction ) {
var timeIndex = 0,
timesLength = checkin.CHECKIN_TIMES.length,
time, // current checkin time
nextTime; // the checkin time that will be logged next
if ( checkin.haveCheckinActionsBeenSetup ) {
return;
}
/**
* Execute the checkin action with the current checkin time
*
* If more checkin times are left, then setup a timer to log the next one.
*/
function setup() {
time = checkin.CHECKIN_TIMES[ timeIndex ];
checkinAction( time );
timeIndex += 1;
if ( timeIndex < timesLength ) {
nextTime = checkin.CHECKIN_TIMES[ timeIndex ];
// Execute the callback after the number of seconds left till the
// next checkin time.
checkin.setVisibleTimeout( setup, ( nextTime - time ) * 1000 );
}
}
checkin.setVisibleTimeout( setup, checkin.CHECKIN_TIMES[ timeIndex ] * 1000 );
checkin.haveCheckinActionsBeenSetup = true;
};
mw.popups.checkin = checkin;
}( mediaWiki, jQuery ) );

View file

@ -0,0 +1,111 @@
( function ( mw ) {
var pageVisibility = {
/**
* Cached value of the browser specific name of the `document.hidden` property
*
* Exposed for testing only.
*
* @type {null|undefined|string}
* @private
*/
documentHiddenPropertyName: null,
/**
* Cached value of the browser specific name of the `document.visibilitychange` event
*
* Exposed for testing only.
*
* @type {null|undefined|string}
* @private
*/
documentVisibilityChangeEventName: null
};
/**
* Return the browser specific name of the `document.hidden` property
*
* Exposed for testing only.
*
* @see https://www.w3.org/TR/page-visibility/#dom-document-hidden
* @see https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
* @private
* @param {Object} doc window.document object
* @return {string|undefined}
*/
pageVisibility.getDocumentHiddenPropertyName = function ( doc ) {
var property;
if ( pageVisibility.documentHiddenPropertyName === null ) {
if ( doc.hidden !== undefined ) {
property = 'hidden';
} else if ( doc.mozHidden !== undefined ) {
property = 'mozHidden';
} else if ( doc.msHidden !== undefined ) {
property = 'msHidden';
} else if ( doc.webkitHidden !== undefined ) {
property = 'webkitHidden';
} else {
// let's be explicit about returning `undefined`
property = undefined;
}
// cache
pageVisibility.documentHiddenPropertyName = property;
}
return pageVisibility.documentHiddenPropertyName;
};
/**
* Return the browser specific name of the `document.visibilitychange` event
*
* Exposed for testing only.
*
* @see https://www.w3.org/TR/page-visibility/#sec-visibilitychange-event
* @see https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
* @private
* @param {Object} doc window.document object
* @return {string|undefined}
*/
pageVisibility.getDocumentVisibilitychangeEventName = function ( doc ) {
var eventName;
if ( pageVisibility.documentVisibilityChangeEventName === null ) {
if ( doc.hidden !== undefined ) {
eventName = 'visibilitychange';
} else if ( doc.mozHidden !== undefined ) {
eventName = 'mozvisibilitychange';
} else if ( doc.msHidden !== undefined ) {
eventName = 'msvisibilitychange';
} else if ( doc.webkitHidden !== undefined ) {
eventName = 'webkitvisibilitychange';
} else {
// let's be explicit about returning `undefined`
eventName = undefined;
}
// cache
pageVisibility.documentVisibilityChangeEventName = eventName;
}
return pageVisibility.documentVisibilityChangeEventName;
};
/**
* Whether `window.document` is visible
*
* `undefined` is returned if the browser does not support the Visibility API.
*
* Exposed for testing only.
*
* @see https://www.w3.org/TR/page-visibility/#dom-document-hidden
* @see https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
* @private
* @param {Object} doc window.document object
* @returns {boolean|undefined}
*/
pageVisibility.isDocumentHidden = function ( doc ) {
var property = pageVisibility.getDocumentHiddenPropertyName( doc );
return property !== undefined ? doc[ property ] : undefined;
};
mw.popups.pageVisibility = pageVisibility;
}( mediaWiki ) );

View file

@ -52,6 +52,14 @@
} }
} ); } );
case popups.actionTypes.CHECKIN:
return nextState( state, {
event: {
action: 'checkin',
checkin: action.time
}
} );
case popups.actionTypes.EVENT_LOGGED: case popups.actionTypes.EVENT_LOGGED:
return nextState( state, { return nextState( state, {
event: undefined event: undefined

View file

@ -0,0 +1,108 @@
( function ( mw, $ ) {
var pageVisibility = mw.popups.pageVisibility,
checkin = mw.popups.checkin,
// remember the original chechkin times
CHECKIN_TIMES = checkin.CHECKIN_TIMES;
QUnit.module( 'ext.popups/checkin', {
setup: function () {
checkin.CHECKIN_TIMES = CHECKIN_TIMES;
checkin.haveCheckinActionsBeenSetup = false;
this.CHECKIN_TIMES = [ 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233,
377, 610, 987, 1597, 2584, 4181, 6765 ];
}
} );
QUnit.test( 'checkin times are correct', function ( assert ) {
assert.expect( 1 );
assert.deepEqual( checkin.CHECKIN_TIMES, this.CHECKIN_TIMES );
} );
QUnit.test( 'visible timeout will not fire the callback if the' +
' browser does not support the visibility API', function ( assert ) {
var delay = 1000,
spy = this.sandbox.spy(),
done = assert.async();
assert.expect( 1 );
this.sandbox.stub( pageVisibility, 'getDocumentHiddenPropertyName' ).returns( undefined );
checkin.setVisibleTimeout( spy, delay );
setTimeout( function () {
assert.ok( spy.notCalled );
done();
}, 2 * delay ); // wait a little more in case the the event loop is busy
} );
QUnit.test( 'visible timeout pause works correctly', function ( assert ) {
var delay = 5000,
pause = 2000,
// error margin in milliseconds
delta = 50,
spy = this.sandbox.spy(),
done = assert.async();
assert.expect( 2 );
this.sandbox.stub( pageVisibility, 'getDocumentHiddenPropertyName' ).returns( 'customHidden' );
this.sandbox.stub( pageVisibility, 'getDocumentVisibilitychangeEventName' ).returns( 'customvisibilitychange' );
checkin.setVisibleTimeout( spy, delay );
// pause immediately, after making sure the document is hidden
this.sandbox.stub( pageVisibility, 'isDocumentHidden' ).returns( true );
$( document ).trigger( 'customvisibilitychange' );
// resume after `pause` milliseconds
pageVisibility.isDocumentHidden.restore();
this.sandbox.stub( pageVisibility, 'isDocumentHidden' ).returns( false );
setTimeout( function () {
$( document ).trigger( 'customvisibilitychange' );
pageVisibility.isDocumentHidden.restore();
}, pause );
setTimeout( function () {
// make sure the spy is not called after `delay` as we've paused
assert.ok( spy.notCalled );
setTimeout( function () {
// make sure the spy is called after `delay` + `pause` as we've resumed
assert.ok( spy.called );
done();
}, pause + delta );
}, delay + delta );
} );
QUnit.test( 'checkin actions will not be set up if they already have been', function ( assert ) {
var spy = this.sandbox.spy( checkin, 'setVisibleTimeout' ),
actionSpy = this.sandbox.spy();
assert.expect( 1 );
checkin.haveCheckinActionsBeenSetup = true;
checkin.setupActions( actionSpy );
assert.ok( spy.notCalled, 'setVisibleTimeout has not been called.' );
} );
QUnit.test( 'checkin actions are setup correctly', function ( assert ) {
var actionSpy = this.sandbox.spy(),
done = assert.async();
checkin.CHECKIN_TIMES = [ 1, 2, 3 ];
assert.expect( checkin.CHECKIN_TIMES.length );
checkin.setupActions( actionSpy );
setTimeout( function () {
$.each( checkin.CHECKIN_TIMES, function ( i, time ) {
assert.ok( actionSpy.calledWith( time ),
'`action` has been called the correct checkin time: ' + time + '.' );
} );
done();
// give two more seconds to catch up
}, ( checkin.CHECKIN_TIMES[ checkin.CHECKIN_TIMES.length - 1 ] + 2 ) * 1000 );
} );
}( mediaWiki, jQuery ) );

View file

@ -0,0 +1,75 @@
( function ( mw, $ ) {
var pageVisibility = mw.popups.pageVisibility;
QUnit.module( 'ext.popups/pageVisibility', {
setup: function () {
pageVisibility.documentHiddenPropertyName = null;
pageVisibility.documentVisibilityChangeEventName = null;
}
} );
QUnit.test( 'browser specific `document.hidden` property name is correctly detected', function ( assert ) {
var testCases = [
[ { hidden: false }, 'hidden' ],
[ { mozHidden: false }, 'mozHidden' ],
[ { msHidden: false }, 'msHidden' ],
[ { webkitHidden: false }, 'webkitHidden' ],
[ { unsupportedHidden: false }, undefined ],
[ {}, undefined ]
];
assert.expect( testCases.length );
$.each( testCases, function ( i, testCase ) {
// clear the cache so we get a fresh result
pageVisibility.documentHiddenPropertyName = null;
assert.equal(
pageVisibility.getDocumentHiddenPropertyName( testCase[ 0 ] ), testCase[ 1 ] );
} );
} );
QUnit.test( 'browser specific `document.visibilitychange` event name is correctly detected', function ( assert ) {
var testCases = [
[ { hidden: false }, 'visibilitychange' ],
[ { mozHidden: false }, 'mozvisibilitychange' ],
[ { msHidden: false }, 'msvisibilitychange' ],
[ { webkitHidden: false }, 'webkitvisibilitychange' ],
[ { unsupportedHidden: false }, undefined ],
[ {}, undefined ]
];
assert.expect( testCases.length );
$.each( testCases, function ( i, testCase ) {
// clear the cache so we get a fresh result
pageVisibility.documentVisibilityChangeEventName = null;
assert.equal(
pageVisibility.getDocumentVisibilitychangeEventName( testCase[ 0 ] ), testCase[ 1 ] );
} );
} );
QUnit.test( 'document visibility is correctly detected', function ( assert ) {
var testCases = [
[ { hidden: false }, false ],
[ { hidden: true }, true ],
[ { mozHidden: false }, false ],
[ { mozHidden: true }, true ],
[ { msHidden: false }, false ],
[ { msHidden: true }, true ],
[ { webkitHidden: false }, false ],
[ { webkitHidden: true }, true ],
[ { unsupportedHidden: false }, undefined ],
[ {}, undefined ]
];
assert.expect( testCases.length );
$.each( testCases, function ( i, testCase ) {
// clear the cache so we get a fresh result
pageVisibility.documentHiddenPropertyName = null;
assert.equal(
pageVisibility.isDocumentHidden( testCase[ 0 ] ), testCase[ 1 ] );
} );
} );
}( mediaWiki, jQuery ) );