forked from mirrors/gecko-dev
498 lines
16 KiB
JavaScript
498 lines
16 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/. */
|
|
|
|
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)
|
|
);
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"cfrFeaturesUserPref",
|
|
"browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
|
|
true,
|
|
_handlePrefChange
|
|
);
|
|
|
|
/* Work around the pref callback being run after the document has been unlinked.
|
|
See bug 1543537. */
|
|
const docWeak = Cu.getWeakReference(document);
|
|
async function _handlePrefChange() {
|
|
const doc = docWeak.get();
|
|
if (!doc || doc.visibilityState === "hidden") {
|
|
return;
|
|
}
|
|
let prefVal = lazy.featureTourProgress;
|
|
// End the tour according to the tour progress pref or if the user disabled
|
|
// contextual feature recommendations.
|
|
if (prefVal.complete || !lazy.cfrFeaturesUserPref) {
|
|
_endTour();
|
|
CURRENT_SCREEN = null;
|
|
} else if (prefVal.screen !== CURRENT_SCREEN?.id) {
|
|
READY = false;
|
|
const container = doc.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;
|
|
let SAVED_ACTIVE_ELEMENT;
|
|
|
|
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.setAttribute("aria-owns", `${CONTAINER_ID}`);
|
|
document.body.appendChild(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;
|
|
let overlap = 17;
|
|
// If we have no overlap, we send the callout the same number of pixels
|
|
// in the opposite direction
|
|
overlap = CURRENT_SCREEN?.content?.noCalloutOverlap ? overlap * -1 : overlap;
|
|
overlap -= 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 = {
|
|
// availableSpace should be the space between the edge of the page in the assumed direction
|
|
// and the edge of the parent (with the callout being intended to fit between those two edges)
|
|
// while needed space should be the space necessary to fit the callout container
|
|
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)) {
|
|
// position here is referencing the direction that the callout container
|
|
// is pointing to, and therefore should be the _opposite_ side of the arrow
|
|
// eg. if arrow is at the "end" in LTR layouts, the container is pointing
|
|
// at an element to the right of itself, while in RTL layouts it is pointing to the left of itself
|
|
position = RTL ^ (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();
|
|
addEventListener("unload", () => {
|
|
AWParent.didDestroy();
|
|
});
|
|
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() {
|
|
// We don't want focus events that happen during teardown to effect
|
|
// SAVED_ACTIVE_ELEMENT
|
|
window.removeEventListener("focus", focusHandler, { capture: true });
|
|
|
|
// wait for fade out transition
|
|
let container = document.getElementById(CONTAINER_ID);
|
|
container?.classList.add("hidden");
|
|
setTimeout(() => {
|
|
container?.remove();
|
|
_removePositionListeners();
|
|
RENDER_OBSERVER?.disconnect();
|
|
|
|
// Put the focus back to the last place the user focused outside of the
|
|
// featureCallout windows.
|
|
if (SAVED_ACTIVE_ELEMENT) {
|
|
SAVED_ACTIVE_ELEMENT.focus({ focusVisible: true });
|
|
}
|
|
}, 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({
|
|
browser: window.docShell.chromeEventHandler,
|
|
// triggerId and triggerContext
|
|
id: "featureCalloutCheck",
|
|
context: { source: document.location.pathname.toLowerCase() },
|
|
});
|
|
CONFIG = result.message.content;
|
|
|
|
// Only add an impression if we actually have a message to impress
|
|
if (Object.keys(result.message).length) {
|
|
lazy.ASRouter.addImpression(result.message);
|
|
}
|
|
|
|
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) {
|
|
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();
|
|
window.addEventListener("focus", focusHandler, {
|
|
capture: true, // get the event before retargeting
|
|
});
|
|
// Alert screen readers to the presence of the callout
|
|
container.setAttribute("role", "alert");
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
_addCalloutLinkElements();
|
|
// Add handlers for repositioning callout
|
|
_addPositionListeners();
|
|
_setupWindowFunctions();
|
|
|
|
// If user has disabled CFR, don't show any callouts. But make sure we load
|
|
// the necessary stylesheets first, since re-enabling CFR should allow
|
|
// callouts to be shown without needing to reload. In the future this could
|
|
// allow adding a CTA to disable recommendations with a label like "Don't show
|
|
// these again" (or potentially a toggle to re-enable them).
|
|
if (!lazy.cfrFeaturesUserPref) {
|
|
CURRENT_SCREEN = null;
|
|
return;
|
|
}
|
|
|
|
await _renderCallout();
|
|
}
|
|
|
|
function focusHandler(e) {
|
|
let container = document.getElementById(CONTAINER_ID);
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
// If focus has fired on the feature callout window itself, or on something
|
|
// contained in that window, ignore it, as we can't possibly place the focus
|
|
// on it after the callout is closd.
|
|
if (
|
|
e.target.id === CONTAINER_ID ||
|
|
(Node.isInstance(e.target) && container.contains(e.target))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Save this so that if the next focus event is re-entering the popup,
|
|
// then we'll put the focus back here where the user left it once we exit
|
|
// the feature callout series.
|
|
SAVED_ACTIVE_ELEMENT = document.activeElement;
|
|
}
|
|
|
|
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", () => {
|
|
// If we have more than one screen, it means that we're
|
|
// displaying a feature tour, in which transitions are handled
|
|
// by the pref change observer.
|
|
if (CONFIG?.screens.length > 1) {
|
|
_handlePrefChange();
|
|
} else {
|
|
showFeatureCallout();
|
|
}
|
|
});
|