/**
* This module enables measuring user engagement with webpages. See the `onPageData`
* event for specifics.
*
* @module pageNavigation
*/
import * as events from "./events.js";
import * as messaging from "./messaging.js";
import * as pageManager from "./pageManager.js";
import * as matching from "./matching.js";
import pageNavigationContentScript from "include:./content-scripts/pageNavigation.content.js";
/**
* A listener for the `onPageData` event.
* @callback pageDataListener
* @memberof module:pageNavigation.onPageData
* @param {Object} details - Additional information about the page data event.
* @param {string} details.pageId - The ID for the page, unique across browsing sessions.
* @param {string} details.url - The URL of the page, without any hash.
* @param {string} details.referrer - The referrer URL for the page, or `""` if there is no referrer.
* @param {number} details.pageVisitStartTime - The time when the page visit started, in ms since
* the epoch.
* @param {number} details.pageVisitStopTime - The time when the page visit ended, in ms since the
* epoch.
* @param {number} details.attentionDuration - The amount of time in ms that the page had user attention.
* @param {number} details.audioDuration - The amount of time in ms that the page was playing audio.
* @param {number} details.attentionAndAudioDuration - The amount of time in ms that the page both had
* user attention and was playing audio.
* @param {number} details.maxRelativeScrollDepth - The maximum relative scroll depth on the page.
* @param {boolean} details.privateWindow - Whether the page loaded in a private window.
*/
/**
* @typedef {Object} PageDataListenerRecord
* @property {matching.MatchPatternSet} matchPatternSet - The match patterns for the listener.
* @property {boolean} privateWindows - Whether to notify the listener about pages in private windows.
* @property {browser.scripting.RegisteredContentScript} contentScript - The content
* script associated with the listener.
* @private
*/
/**
* A map where each key is a listener and each value is a record for that listener.
* @constant {Map<pageDataListener, PageDataListenerRecord>}
* @private
*/
const pageDataListeners = new Map();
/**
* Add a listener for the `onPageData` event.
* @function addListener
* @memberof module:pageNavigation.onPageData
* @param {pageDataListener} listener - The listener to add.
* @param {Object} options - Options for the listener.
* @param {string[]} options.matchPatterns - The webpages that the listener should be notified about, specified with WebExtensions match patterns.
* @param {boolean} [options.privateWindows=false] - Whether to measure pages in private windows.
*/
/**
* Remove a listener for the `onPageData` event.
* @function removeListener
* @memberof module:pageNavigation.onPageData
* @param {pageDataListener} listener - The listener to remove.
*/
/**
* Whether a specified listener has been added for the `onPageData` event.
* @function hasListener
* @memberof module:pageNavigation.onPageData
* @param {pageDataListener} listener - The listener to check.
* @returns {boolean} Whether the listener has been added for the event.
*/
/**
* Whether the `onPageData` event has any listeners.
* @function hasAnyListeners
* @memberof module:pageNavigation.onPageData
* @returns {boolean} Whether the event has any listeners.
*/
/**
* An event that fires when a page visit has ended and data about the
* visit is available.
* @namespace
*/
export const onPageData = events.createEvent({
name: "webScience.pageNavigation.onPageData",
addListenerCallback: addListener,
removeListenerCallback: removeListener,
notifyListenersCallback: () => { return false; }
});
/**
* Whether the module has completed initialization.
* @type {boolean}
* @private
*/
let initialized = false;
/**
* A callback function for adding a page data listener.
* @param {pageDataCallback} listener - The listener being added.
* @param {Object} options - Options for the listener.
* @param {string[]} options.matchPatterns - The match patterns for pages where the listener should
* be notified.
* @param {boolean} [options.privateWindows=false] - Whether the listener should be notified for
* pages in private windows.
* @private
*/
async function addListener(listener, {
matchPatterns,
privateWindows = false
}) {
// Initialization
if(!initialized) {
initialized = true;
await pageManager.initialize();
messaging.onMessage.addListener(pageData => {
// Remove the type string from the content script message
delete pageData.type;
// Notify listeners when the private window and match pattern requirements are met
for(const [listener, listenerRecord] of pageDataListeners) {
if((!pageData.privateWindow || listenerRecord.privateWindows)
&& (listenerRecord.matchPatternSet.matches(pageData.url))) {
listener(pageData);
}
}
},
{
type: "webScience.pageNavigation.pageData",
schema: {
pageId: "string",
url: "string",
referrer: "string",
pageVisitStartTime: "number",
pageVisitStopTime: "number",
attentionDuration: "number",
audioDuration: "number",
attentionAndAudioDuration: "number",
maxRelativeScrollDepth: "number",
privateWindow: "boolean"
}
});
}
// Compile the match patterns for the listener
const matchPatternSet = matching.createMatchPatternSet(matchPatterns);
// Firefox only supports this as of version 105, remove this check when that version of Firefox ships.
let persistAcrossSessions = true;
const browserInfo = browser.runtime && browser.runtime.getBrowserInfo && await browser.runtime.getBrowserInfo();
if (browserInfo && browserInfo.name === "Firefox") {
persistAcrossSessions = false;
}
const contentScriptId = "pageNavigation";
let scripts = await browser.scripting.getRegisteredContentScripts({
ids: [contentScriptId],
});
if (scripts.length === 0) {
await browser.scripting.registerContentScripts([{
id: contentScriptId,
js: ["dist/browser-polyfill.min.js", pageNavigationContentScript],
matches: matchPatterns,
persistAcrossSessions,
runAt: "document_start"
}]);
}
// Store a record for the listener
pageDataListeners.set(listener, {
matchPatternSet,
contentScriptId,
privateWindows
});
}
/**
* A callback function for removing a page data listener.
* @param {pageDataCallback} listener - The listener that is being removed.
* @private
*/
async function removeListener(listener) {
// If there is a record of the listener, unregister its content script
// and delete the record
const listenerRecord = pageDataListeners.get(listener);
if(listenerRecord === undefined) {
return;
}
if (listenerRecord.contentScriptId) {
await browser.scripting.unregisterContentScripts({
ids: [listenerRecord.contentScriptId]
});
}
pageDataListeners.delete(listener);
}