/**
* A module to facilitate surveys of study participants. See the
* documentation for `setSurvey` for additional details about usage.
*
* ## User Experience
* * If the user has not been previously prompted for the survey,
* the survey will open in a new tab.
* * The study's browser action popup will contain either a page
* prompting the user to complete the survey (with options to open
* the survey or decline the survey), or a neutral page (if the
* user has already completed or declined the survey).
* * If the user has been previously prompted for the survey, and
* has not completed or declined the survey, the user will be
* reminded to complete the survey with a browser notification
* at a set interval.
*
* ## Limitations
* Note that this module is currently very limited: it only supports
* one survey at a time per study, with few options and a constrained design.
* We have not yet decided whether to build out this module or implement
* survey functionality in the Rally core add-on.
*
* @module userSurvey
*/
import * as id from "./id.js";
import * as timing from "./timing.js";
import * as storage from "./storage.js";
import * as messaging from "./messaging.js";
import * as permissions from "./permissions.js";
import popupPromptPage from "include:./browser-action-assets/userSurvey.popupPrompt.html";
import popupNoPromptPage from "include:./browser-action-assets/userSurvey.popupNoPrompt.html";
/**
* A persistent storage space for data about surveys.
* @type {storage.KeyValueStorage}
* @private
*/
let storageSpace = null;
/**
* The ID of the survey reminder timeout (is null if there
* is no such timeout).
* @type {number|null}
* @private
*/
let reminderTimeoutId = null;
/**
* Whether listeners for this module have already been registered.
* @type {boolean}
* @private
*/
let listenersRegistered = false;
/**
* When we last asked the user to do the survey, either with a browser
* notification or through opening a tab with the survey.
* @type {number}
* @private
*/
let lastSurveyRequest = 0;
/**
* A fully-qualified URL to an icon file to use for for reminding the
* user with a notification to complete the survey (is null if there is
* no such icon).
* @type {string|null}
* @private
*/
let reminderIconUrl = null;
/**
* How often, in seconds, to wait before reminding the user with a
* notification to participate in the survey.
* @type {number}
* @private
*/
let reminderInterval = 0;
/**
* The message to use for reminding the user with a notification to
* complete the survey.
* @type {string}
* @private
*/
let reminderMessage = "";
/**
* The title to use for reminding the user with a notification to
* complete the survey.
* @type {string}
* @private
*/
let reminderTitle = "";
/**
* The URL for the survey on an external platform
* (e.g., SurveyMonkey, Typeform, Qualtrics, etc.).
* @type {string}
* @private
*/
let surveyUrl = "";
const millisecondsPerSecond = 1000;
/**
* Opens the survey URL in a new browser tab, appending parameters
* for the participant's survey ID (surveyID) and timezone offset
* (timezone).
* @private
*/
async function openSurveyInNewTab() {
const surveyId = await getSurveyId();
const surveyUrlObj = new URL(surveyUrl);
surveyUrlObj.searchParams.append("surveyId", surveyId);
surveyUrlObj.searchParams.append("timezone", new Date().getTimezoneOffset());
browser.tabs.create({
active: true,
url: surveyUrlObj.href
});
}
/**
* Set a timeout to remind the user to complete the study.
* @private
*/
function scheduleReminderForUser() {
reminderTimeoutId = setTimeout(remindUser, Math.max((lastSurveyRequest + reminderInterval * millisecondsPerSecond) - timing.now(), 0));
}
/**
* Remind the user to complete the study, by prompting with a notification.
* @private
*/
async function remindUser() {
const surveyCompleted = await storageSpace.get("surveyCompleted");
const surveyCancelled = await storageSpace.get("surveyCancelled");
if (surveyCompleted || surveyCancelled) {
return;
}
lastSurveyRequest = timing.now();
await storageSpace.set("lastSurveyRequest", lastSurveyRequest);
browser.notifications.create({
type: "image",
message: reminderMessage,
title: reminderTitle,
iconUrl: reminderIconUrl
});
scheduleReminderForUser();
}
/**
* Set the browser action popup to the survey's no prompt page.
* @private
*/
function setPopupToNoPromptPage() {
browser.browserAction.setPopup({
popup: browser.runtime.getURL(popupNoPromptPage)
});
}
/**
* Initialize storage for the module.
* @private
*/
function initializeStorage() {
if (storageSpace === null) {
storageSpace = storage.createKeyValueStorage("webScience.userSurvey");
}
}
/**
* Listener for webRequest.onBeforeRequest when the URL is the survey
* completion URL. Sets surveyCompleted to true in storage and changes
* the browser action popup to the survey's no prompt page.
* @private
*/
function surveyCompletionUrlListener() {
storageSpace.set("surveyCompleted", true);
setPopupToNoPromptPage();
}
/**
* Prompt the user to respond to a survey. There can only be one current survey at a time.
*
* ##### Survey Behavior
* * If there is no current survey (i.e., if `setSurvey` was not previously called or
* `endSurvey` was called after `setSurvey`), this function creates a new current
* survey with the provided options, persists current survey details in storage, and
* configures survey UX.
* * If there is a current survey and `options.surveyName` matches the name of the
* current survey, this function continues the current survey with the details persisted
* in storage and configures survey UX.
* * If there is already a current survey and `options.surveyName` does not match the
* name of the current survey, throws an `Error` as there can only be one current survey
* at a time.
*
* ##### Single-Survey Studies
* If your study involves a single survey, call `setSurvey` when you first want to prompt
* the user to complete the survey, then call `setSurvey` with an identical survey name on
* subsequent extension startups to continue the survey.
*
* ##### Multi-Survey Studies
* If there is more than one survey in your study, you must call `endSurvey` for the current
* survey before calling `setSurvey` for the next survey.
*
* @param {Object} options - The options for the survey.
* @param {string} options.surveyName - A unique name for the survey within the study.
* @param {string} options.popupNoPromptMessage - A message to present to the
* user when there is no survey to prompt.
* @param {string} options.popupPromptMessage - A message to present to the user
* when there is a survey to prompt.
* @param {string} [options.popupIcon] - A path to an icon file, relative
* to the study extension's root, to use for for the browser action popup.
* This property is optional as the popup does not need to display an icon.
* @param {string} [options.reminderIcon] - A path to an icon file, relative
* to the study extension's root, to use for for reminding the user with a
* notification to complete the survey. This property is optional as the
* notification does not need to display an icon.
* @param {number} options.reminderInterval - How often, in seconds, to wait before
* reminding the user with a notification to participate in the survey.
* @param {string} options.reminderMessage - The message to use for reminding the
* user with a notification to complete the survey.
* @param {string} options.reminderTitle - The title to use for reminding the
* user with a notification to complete the survey.
* @param {string} options.surveyCompletionUrl - A URL that, when loaded,
* indicates the user has completed the survey.
* @param {string} options.surveyUrl - The URL for the survey on an external
* platform (e.g., SurveyMonkey, Typeform, Qualtrics, etc.).
*/
export async function setSurvey(options) {
permissions.check({
module: "webScience.userSurvey",
requiredPermissions: [ "notifications", "webRequest" ]
});
initializeStorage();
let surveyDetails = await storageSpace.get("surveyDetails");
// If there's no survey in storage, save the parameters in
// storage and carry out the survey based on the parameters.
// If options.surveyName differs from the survey name in storage,
// throw an error, because only one survey can be set at a time.
// Otherwise, options.surveyName is the same as the survey name in
// storage. In this case, use the survey attributes from storage.
if (!surveyDetails) {
surveyDetails = options;
await storageSpace.set("surveyDetails", options);
} else if (surveyDetails.surveyName !== options.surveyName) {
throw new Error("userSurvey only supports one survey at a time. Complete the survey that has previously been set.");
}
const currentTime = timing.now();
({surveyUrl,reminderInterval, reminderTitle, reminderMessage } = surveyDetails);
browser.storage.local.set({
"webScience.userSurvey.popupPromptMessage": surveyDetails.popupPromptMessage
});
browser.storage.local.set({
"webScience.userSurvey.popupNoPromptMessage": surveyDetails.popupNoPromptMessage
});
reminderIconUrl = surveyDetails.reminderIcon ?
browser.runtime.getURL(surveyDetails.reminderIcon) : null;
browser.storage.local.set({
"webScience.userSurvey.popupIconUrl":
surveyDetails.popupIcon ? browser.runtime.getURL(surveyDetails.popupIcon) : null
});
// Check when we last asked the user to do the survey. If it's null,
// we've never asked, which means the extension just got installed.
// Open a tab with the survey, and save this time as the most recent
// request for participation.
lastSurveyRequest = await storageSpace.get("lastSurveyRequest");
const surveyCompleted = await storageSpace.get("surveyCompleted");
const surveyCancelled = await storageSpace.get("surveyCancelled");
// Configure the browser action popup page
if (surveyCompleted || surveyCancelled) {
setPopupToNoPromptPage();
return;
}
else {
browser.browserAction.setPopup({
popup: browser.runtime.getURL(popupPromptPage)
});
}
// If this is the first survey request, open the survey in a new tab.
if (lastSurveyRequest === null) {
lastSurveyRequest = currentTime;
await storageSpace.set("lastSurveyRequest", lastSurveyRequest);
// Since this is the first survey request, initialize the stored
// completed and cancelled state to false.
await storageSpace.set("surveyCompleted", false);
await storageSpace.set("surveyCancelled", false);
openSurveyInNewTab();
}
// Schedule a reminder for the user
scheduleReminderForUser();
// Set a listener for the survey completion URL.
browser.webRequest.onBeforeRequest.addListener(
surveyCompletionUrlListener,
{ urls: [ (new URL(surveyDetails.surveyCompletionUrl)).href + "*" ] }
);
// Listeners for cancel and open survey button click only need to be added once.
// They do not need to be added again for subsequent calls to setSurvey.
// These listeners do not need to be removed in endCurrentSurvey because they will
// not receive messages when the popup is the no prompt page.
if (!listenersRegistered) {
// Set listeners for cancel and open survey button clicks in the survey request.
messaging.onMessage.addListener(() => {
storageSpace.set("surveyCancelled", true);
setPopupToNoPromptPage();
browser.webRequest.onBeforeRequest.removeListener(surveyCompletionUrlListener);
}, { type: "webScience.userSurvey.cancelSurvey" });
messaging.onMessage.addListener(() => {
openSurveyInNewTab();
}, { type: "webScience.userSurvey.openSurvey" });
}
listenersRegistered = true;
}
/**
* Each study participant has a persistent survey ID, generated with
* the `id` module. The ID is automatically added as a parameter to
* the survey URL, enabling researchers to import survey data from an
* external platform and sync it with Rally data. This method returns the
* survey ID, generating it if it does not already exist.
* @returns {Promise<string>} - The participant's survey ID.
*/
export async function getSurveyId() {
initializeStorage();
let surveyId = await storageSpace.get("surveyId");
if (surveyId === null) {
surveyId = id.generateId();
await storageSpace.set("surveyId", surveyId);
}
return surveyId;
}
/**
* Gets the status of the current survey. Can be used if a
* subsequent survey depends on the status of the previous survey.
* @returns {Promise<string>|Promise<null>} - The status of the current
* survey ("completed", "cancelled", or "active"), or null if there is no
* current survey.
*/
export async function getSurveyStatus() {
initializeStorage();
const surveyDetails = await storageSpace.get("surveyDetails");
const surveyCompleted = await storageSpace.get("surveyCompleted");
const surveyCancelled = await storageSpace.get("surveyCancelled");
if (!surveyDetails) {
return null;
} else if(surveyCompleted) {
return "completed";
} else if(surveyCancelled) {
return "cancelled";
} else {
return "active";
}
}
/**
* Gets the name of the current survey.
* @returns {Promise<string>|Promise<null>} - The name of the current survey. Returns null
* if there is no current survey.
*/
export async function getSurveyName() {
initializeStorage();
const surveyDetails = await storageSpace.get("surveyDetails");
return surveyDetails ? surveyDetails.surveyName : null;
}
/**
* End the current survey. If there is a current survey, you must call
* this function before starting a new survey.
* @returns {Promise} A Promise that resolves when the survey has been
* ended.
*/
export async function endSurvey() {
// Stop prompting for the survey.
setPopupToNoPromptPage();
// If there is an existing survey reminder timeout, clears the timeout.
clearTimeout(reminderTimeoutId);
// Remove any previously added listener for browser.webRequest.onBeforeRequest
// that checks for the survey completion URL.
browser.webRequest.onBeforeRequest.removeListener(surveyCompletionUrlListener);
initializeStorage();
// Clears the the data in storage for the current survey.
await storageSpace.set("lastSurveyRequest", null);
await storageSpace.set("surveyCompleted", false);
await storageSpace.set("surveyCancelled", false);
await storageSpace.set("surveyDetails", null);
}