419 lines
13 KiB
JavaScript
419 lines
13 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
"use strict";
|
|
|
|
const { XPCOMUtils } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/XPCOMUtils.sys.mjs"
|
|
);
|
|
|
|
const lazy = {};
|
|
|
|
XPCOMUtils.defineLazyModuleGetters(lazy, {
|
|
AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.jsm",
|
|
ASRouter: "resource://activity-stream/lib/ASRouter.jsm",
|
|
});
|
|
|
|
// When expanding the use of Feature Callout
|
|
// to new about: pages, make `progressPref` a
|
|
// configurable field on callout messages and
|
|
// use it to determine which pref to observe
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"featureTourProgress",
|
|
"browser.firefox-view.feature-tour",
|
|
'{"message":"","screen":"","complete":true}',
|
|
_handlePrefChange,
|
|
val => JSON.parse(val)
|
|
);
|
|
|
|
async function _handlePrefChange() {
|
|
if (document.visibilityState === "hidden") {
|
|
return;
|
|
}
|
|
let prefVal = lazy.featureTourProgress;
|
|
if (prefVal.complete) {
|
|
_endTour();
|
|
} else if (prefVal.screen !== CURRENT_SCREEN?.id) {
|
|
READY = false;
|
|
let container = document.getElementById(CONTAINER_ID);
|
|
container?.classList.add("hidden");
|
|
// wait for fade out transition
|
|
setTimeout(async () => {
|
|
await _loadConfig();
|
|
container?.remove();
|
|
await _renderCallout();
|
|
}, TRANSITION_MS);
|
|
}
|
|
}
|
|
|
|
function _addCalloutLinkElements() {
|
|
function addStylesheet(href) {
|
|
const link = document.head.appendChild(document.createElement("link"));
|
|
link.rel = "stylesheet";
|
|
link.href = href;
|
|
}
|
|
function addLocalization(hrefs) {
|
|
hrefs.forEach(href => {
|
|
// eslint-disable-next-line no-undef
|
|
MozXULElement.insertFTLIfNeeded(href);
|
|
});
|
|
}
|
|
|
|
// Update styling to be compatible with about:welcome bundle
|
|
addStylesheet(
|
|
"chrome://activity-stream/content/aboutwelcome/aboutwelcome.css"
|
|
);
|
|
|
|
addLocalization([
|
|
"browser/newtab/onboarding.ftl",
|
|
"browser/spotlight.ftl",
|
|
"branding/brand.ftl",
|
|
"browser/branding/brandings.ftl",
|
|
"browser/newtab/asrouter.ftl",
|
|
"browser/featureCallout.ftl",
|
|
]);
|
|
}
|
|
|
|
let CURRENT_SCREEN;
|
|
let CONFIG;
|
|
let RENDER_OBSERVER;
|
|
let READY = false;
|
|
|
|
const TRANSITION_MS = 500;
|
|
const CONTAINER_ID = "root";
|
|
|
|
function _createContainer() {
|
|
let parent = document.querySelector(CURRENT_SCREEN?.parent_selector);
|
|
// Don't render the callout if the parent element is not present.
|
|
// This means the message was misconfigured, mistargeted, or the
|
|
// content of the parent page is not as expected.
|
|
if (!parent) {
|
|
return false;
|
|
}
|
|
|
|
let container = document.createElement("div");
|
|
container.classList.add(
|
|
"onboardingContainer",
|
|
"featureCallout",
|
|
"callout-arrow",
|
|
"hidden"
|
|
);
|
|
container.id = CONTAINER_ID;
|
|
container.setAttribute("aria-describedby", `#${CONTAINER_ID} .welcome-text`);
|
|
container.tabIndex = 0;
|
|
parent.insertAdjacentElement("afterend", container);
|
|
return container;
|
|
}
|
|
|
|
/**
|
|
* Set callout's position relative to parent element
|
|
*/
|
|
function _positionCallout() {
|
|
const container = document.getElementById(CONTAINER_ID);
|
|
const parentEl = document.querySelector(CURRENT_SCREEN?.parent_selector);
|
|
// All possible arrow positions
|
|
const arrowPositions = ["top", "bottom", "end", "start"];
|
|
const arrowPosition = CURRENT_SCREEN?.content?.arrow_position || "top";
|
|
// Callout should overlap the parent element by 17px (so the box, not
|
|
// including the arrow, will overlap by 5px)
|
|
const arrowWidth = 12;
|
|
const overlap = 17 - arrowWidth;
|
|
// Is the document layout right to left?
|
|
const RTL = document.dir === "rtl";
|
|
|
|
if (!container || !parentEl) {
|
|
return;
|
|
}
|
|
|
|
function getOffset(el) {
|
|
const rect = el.getBoundingClientRect();
|
|
return {
|
|
left: rect.left + window.scrollX,
|
|
right: rect.right + window.scrollX,
|
|
top: rect.top + window.scrollY,
|
|
bottom: rect.bottom + window.scrollY,
|
|
};
|
|
}
|
|
|
|
function clearPosition() {
|
|
Object.keys(positioners).forEach(position => {
|
|
container.style[position] = "unset";
|
|
});
|
|
arrowPositions.forEach(position => {
|
|
if (container.classList.contains(`arrow-${position}`)) {
|
|
container.classList.remove(`arrow-${position}`);
|
|
}
|
|
if (container.classList.contains(`arrow-inline-${position}`)) {
|
|
container.classList.remove(`arrow-inline-${position}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
const positioners = {
|
|
top: {
|
|
availableSpace:
|
|
document.body.offsetHeight -
|
|
getOffset(parentEl).top -
|
|
parentEl.offsetHeight +
|
|
overlap,
|
|
neededSpace: container.offsetHeight - overlap,
|
|
position() {
|
|
// Point to an element above the callout
|
|
let containerTop =
|
|
getOffset(parentEl).top + parentEl.offsetHeight - overlap;
|
|
container.style.top = `${Math.max(
|
|
container.offsetHeight - overlap,
|
|
containerTop
|
|
)}px`;
|
|
centerHorizontally(container, parentEl);
|
|
container.classList.add("arrow-top");
|
|
},
|
|
},
|
|
bottom: {
|
|
availableSpace: getOffset(parentEl).top + overlap,
|
|
neededSpace: container.offsetHeight - overlap,
|
|
position() {
|
|
// Point to an element below the callout
|
|
let containerTop =
|
|
getOffset(parentEl).top - container.offsetHeight + overlap;
|
|
container.style.top = `${Math.max(0, containerTop)}px`;
|
|
centerHorizontally(container, parentEl);
|
|
container.classList.add("arrow-bottom");
|
|
},
|
|
},
|
|
right: {
|
|
availableSpace: getOffset(parentEl).left + overlap,
|
|
neededSpace: container.offsetWidth - overlap,
|
|
position() {
|
|
// Point to an element to the right of the callout
|
|
let containerLeft =
|
|
getOffset(parentEl).left - container.offsetWidth + overlap;
|
|
if (RTL) {
|
|
// Account for cases where the document body may be narrow than the window
|
|
containerLeft -= window.innerWidth - document.body.offsetWidth;
|
|
}
|
|
container.style.left = `${Math.max(0, containerLeft)}px`;
|
|
container.style.top = `${getOffset(parentEl).top}px`;
|
|
container.classList.add("arrow-inline-end");
|
|
},
|
|
},
|
|
left: {
|
|
availableSpace:
|
|
document.body.offsetWidth - getOffset(parentEl).right + overlap,
|
|
neededSpace: container.offsetWidth - overlap,
|
|
position() {
|
|
// Point to an element to the left of the callout
|
|
let containerLeft =
|
|
getOffset(parentEl).left + parentEl.offsetWidth - overlap;
|
|
if (RTL) {
|
|
// Account for cases where the document body may be narrow than the window
|
|
containerLeft -= window.innerWidth - document.body.offsetWidth;
|
|
}
|
|
container.style.left = `${(container.offsetWidth - overlap,
|
|
containerLeft)}px`;
|
|
container.style.top = `${getOffset(parentEl).top}px`;
|
|
container.classList.add("arrow-inline-start");
|
|
},
|
|
},
|
|
};
|
|
|
|
function calloutFits(position) {
|
|
// Does callout element fit in this position relative
|
|
// to the parent element without going off screen?
|
|
return (
|
|
positioners[position].availableSpace > positioners[position].neededSpace
|
|
);
|
|
}
|
|
|
|
function choosePosition() {
|
|
let position = arrowPosition;
|
|
if (!arrowPositions.includes(position)) {
|
|
// Configured arrow position is not valid
|
|
return false;
|
|
}
|
|
if (["start", "end"].includes(position)) {
|
|
if (RTL) {
|
|
position = position === "start" ? "right" : "left";
|
|
} else {
|
|
position = position === "start" ? "left" : "right";
|
|
}
|
|
}
|
|
if (calloutFits(position)) {
|
|
return position;
|
|
}
|
|
let newPosition = Object.keys(positioners)
|
|
.filter(p => p !== position)
|
|
.find(p => calloutFits(p));
|
|
// If the callout doesn't fit in any position, use the configured one.
|
|
// The callout will be adjusted to overlap the parent element so that
|
|
// the former doesn't go off screen.
|
|
return newPosition || position;
|
|
}
|
|
|
|
function centerHorizontally() {
|
|
let sideOffset = (parentEl.offsetWidth - container.offsetWidth) / 2;
|
|
let containerSide = RTL
|
|
? window.innerWidth - getOffset(parentEl).right + sideOffset
|
|
: getOffset(parentEl).left + sideOffset;
|
|
container.style[RTL ? "right" : "left"] = `${Math.max(containerSide, 0)}px`;
|
|
}
|
|
|
|
clearPosition(container);
|
|
|
|
let finalPosition = choosePosition();
|
|
if (finalPosition) {
|
|
positioners[finalPosition].position();
|
|
}
|
|
|
|
container.classList.remove("hidden");
|
|
}
|
|
|
|
function _addPositionListeners() {
|
|
window.addEventListener("scroll", _positionCallout);
|
|
window.addEventListener("resize", _positionCallout);
|
|
}
|
|
|
|
function _removePositionListeners() {
|
|
window.removeEventListener("scroll", _positionCallout);
|
|
window.removeEventListener("resize", _positionCallout);
|
|
}
|
|
|
|
function _setupWindowFunctions() {
|
|
const AWParent = new lazy.AboutWelcomeParent();
|
|
const receive = name => data =>
|
|
AWParent.onContentMessage(`AWPage:${name}`, data, document);
|
|
// Expose top level functions expected by the bundle.
|
|
window.AWGetFeatureConfig = () => CONFIG;
|
|
window.AWGetRegion = receive("GET_REGION");
|
|
window.AWGetSelectedTheme = receive("GET_SELECTED_THEME");
|
|
// Do not send telemetry if message config sets metrics as 'block'.
|
|
if (CONFIG?.metrics !== "block") {
|
|
window.AWSendEventTelemetry = receive("TELEMETRY_EVENT");
|
|
}
|
|
window.AWSendToDeviceEmailsSupported = receive(
|
|
"SEND_TO_DEVICE_EMAILS_SUPPORTED"
|
|
);
|
|
window.AWSendToParent = (name, data) => receive(name)(data);
|
|
window.AWFinish = _endTour;
|
|
}
|
|
|
|
function _endTour() {
|
|
// wait for fade out transition
|
|
let container = document.getElementById(CONTAINER_ID);
|
|
container?.classList.add("hidden");
|
|
setTimeout(() => {
|
|
container?.remove();
|
|
_removePositionListeners();
|
|
RENDER_OBSERVER?.disconnect();
|
|
}, TRANSITION_MS);
|
|
}
|
|
|
|
async function _addScriptsAndRender(container) {
|
|
// Add React script
|
|
async function getReactReady() {
|
|
return new Promise(function(resolve) {
|
|
let reactScript = document.createElement("script");
|
|
reactScript.src = "resource://activity-stream/vendor/react.js";
|
|
container.appendChild(reactScript);
|
|
reactScript.addEventListener("load", resolve);
|
|
});
|
|
}
|
|
// Add ReactDom script
|
|
async function getDomReady() {
|
|
return new Promise(function(resolve) {
|
|
let domScript = document.createElement("script");
|
|
domScript.src = "resource://activity-stream/vendor/react-dom.js";
|
|
container.appendChild(domScript);
|
|
domScript.addEventListener("load", resolve);
|
|
});
|
|
}
|
|
// Load React, then React Dom
|
|
await getReactReady();
|
|
await getDomReady();
|
|
// Load the bundle to render the content as configured.
|
|
let bundleScript = document.createElement("script");
|
|
bundleScript.src =
|
|
"resource://activity-stream/aboutwelcome/aboutwelcome.bundle.js";
|
|
container.appendChild(bundleScript);
|
|
}
|
|
|
|
function _observeRender(container) {
|
|
RENDER_OBSERVER?.observe(container, { childList: true });
|
|
}
|
|
|
|
async function _loadConfig() {
|
|
await lazy.ASRouter.waitForInitialized;
|
|
let result = await lazy.ASRouter.sendTriggerMessage({
|
|
// triggerId and triggerContext
|
|
id: "featureCalloutCheck",
|
|
context: { source: document.location.pathname.toLowerCase() },
|
|
});
|
|
CONFIG = result.message.content;
|
|
CURRENT_SCREEN = CONFIG?.screens?.[CONFIG?.startScreen || 0];
|
|
}
|
|
|
|
async function _renderCallout() {
|
|
let container = _createContainer();
|
|
if (container) {
|
|
// This results in rendering the Feature Callout
|
|
await _addScriptsAndRender(container);
|
|
_observeRender(container);
|
|
}
|
|
}
|
|
/**
|
|
* Render content based on about:welcome multistage template.
|
|
*/
|
|
async function showFeatureCallout(messageId) {
|
|
// Don't show the feature tour if user has already completed it.
|
|
if (lazy.featureTourProgress.complete) {
|
|
return;
|
|
}
|
|
|
|
await _loadConfig();
|
|
|
|
if (!CONFIG?.screens?.length) {
|
|
return;
|
|
}
|
|
|
|
RENDER_OBSERVER = new MutationObserver(function() {
|
|
// Check if the Feature Callout screen has loaded for the first time
|
|
if (!READY && document.querySelector(`#${CONTAINER_ID} .screen`)) {
|
|
// Once the screen element is added to the DOM, wait for the
|
|
// animation frame after next to ensure that _positionCallout
|
|
// has access to the rendered screen with the correct height
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
READY = true;
|
|
_positionCallout();
|
|
let container = document.getElementById(CONTAINER_ID);
|
|
container.focus();
|
|
// Alert screen readers to the presence of the callout
|
|
container.setAttribute("role", "alert");
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
_addCalloutLinkElements();
|
|
// Add handlers for repositioning callout
|
|
_addPositionListeners();
|
|
_setupWindowFunctions();
|
|
await _renderCallout();
|
|
}
|
|
|
|
window.addEventListener("DOMContentLoaded", () => {
|
|
// Get the message id from the feature tour pref
|
|
// (If/when this surface is used with other pages,
|
|
// add logic to select the correct pref for a given
|
|
// page's tour using its location)
|
|
showFeatureCallout(lazy.featureTourProgress.message);
|
|
});
|
|
|
|
// When the window is focused, ensure tour is synced with tours in
|
|
// any other instances of the parent page
|
|
window.addEventListener("visibilitychange", () => {
|
|
_handlePrefChange();
|
|
});
|