Source code
Revision control
Copy as Markdown
Other Tools
/* 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,
import {
hasContentAreaWidgets,
isWidgetsContainerVisible,
} from "./WidgetsRegistry.mjs";
/**
* Newtab page layout variants. "Page layout" is the arrangement of the whole
* newtab page, not a DiscoveryStream layout (see SectionsLayoutFeed and
* selectLayoutRender, which are unrelated).
*
* The pref holds the layout's name rather than a set of feature booleans, so a
* new layout is a new value here instead of a new pref, class prefix and metric.
* The value is what newtab.page_layout_variant reports.
*/
export const PAGE_LAYOUT_VARIANTS = {
NOVA_FULL_WIDTH: "nova-full-width",
SIDE_BY_SIDE_CONTENT_LEAD: "side-by-side-content-lead",
SIDE_BY_SIDE_WIDGETS_LEAD: "side-by-side-widgets-lead",
SIDE_BY_SIDE_CONTENT_LEAD_FIVE: "side-by-side-content-lead-five",
SIDE_BY_SIDE_WIDGETS_LEAD_FIVE: "side-by-side-widgets-lead-five",
SPACES_BUTTONS_TOP: "spaces-buttons-top",
SPACES_BUTTONS_BOTTOM: "spaces-buttons-bottom",
};
export const DEFAULT_PAGE_LAYOUT_VARIANT = PAGE_LAYOUT_VARIANTS.NOVA_FULL_WIDTH;
/**
* Band classes per side-by-side variant, keyed by variant name.
*
* Two orthogonal classes rather than the variant name itself, because CSS
* matches classes per token: `.side-by-side-content-lead` would not match an
* element classed `side-by-side-content-lead-five`, so every rule would need a
* duplicate selector. The lead class carries the column order and every
* side-by-side rule keys off it, so a variant and its -five counterpart share
* one; side-by-side-five is what unlocks the fourth content card.
*
* "lead" is inline-start, so these stay correct in RTL.
*/
const SIDE_BY_SIDE_CLASSES = {
[PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_CONTENT_LEAD]: [
"side-by-side-content-lead",
],
[PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_WIDGETS_LEAD]: [
"side-by-side-widgets-lead",
],
[PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_CONTENT_LEAD_FIVE]: [
"side-by-side-content-lead",
"side-by-side-five",
],
[PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_WIDGETS_LEAD_FIVE]: [
"side-by-side-widgets-lead",
"side-by-side-five",
],
};
export const SIDE_BY_SIDE_PAGE_LAYOUTS = Object.keys(SIDE_BY_SIDE_CLASSES);
export const PREF_PAGE_LAYOUT_VARIANT = "pageLayouts.variant";
/**
* Returns the assigned page layout variant, whether or not it can currently
* render. This is the value telemetry reports.
*
* @param {object} prefs - current pref values from the Redux store
* @returns {string}
*/
export function resolvePageLayoutVariant(prefs) {
const trainhop = prefs?.trainhopConfig?.pageLayouts?.variant;
if (typeof trainhop === "string" && trainhop) {
return trainhop;
}
return prefs?.[PREF_PAGE_LAYOUT_VARIANT] || DEFAULT_PAGE_LAYOUT_VARIANT;
}
/**
* Returns the classes the content band needs for the assigned variant, or an
* empty array outside the experiment.
*
* @param {object} prefs - current pref values from the Redux store
* @returns {string[]}
*/
export function sideBySideBandClasses(prefs) {
return SIDE_BY_SIDE_CLASSES[resolvePageLayoutVariant(prefs)] ?? [];
}
/**
* Returns true if a side-by-side variant is assigned, whether or not the page
* can lay it out. The section panels key off this rather than isSideBySideActive,
* so a lone section still gets its panel while in the experiment.
*
* @param {object} prefs - current pref values from the Redux store
* @returns {boolean}
*/
export function isSideBySideAssigned(prefs) {
return SIDE_BY_SIDE_PAGE_LAYOUTS.includes(resolvePageLayoutVariant(prefs));
}
/**
* Returns true if a side-by-side variant is assigned and the page has both
* things to put side by side. Without stories, or without a content-area widget,
* the band falls back to its full-width single column.
*
* Use this rather than testing the variant directly, so the widgets gate stays
* consistent with the rest of the page.
*
* @param {object} prefs - current pref values from the Redux store
* @returns {boolean}
*/
export function isSideBySideActive(prefs) {
return Boolean(
SIDE_BY_SIDE_PAGE_LAYOUTS.includes(resolvePageLayoutVariant(prefs)) &&
prefs?.["feeds.section.topstories"] &&
prefs?.["feeds.system.topstories"] &&
isWidgetsContainerVisible(prefs) &&
hasContentAreaWidgets(prefs)
);
}
// Orthogonal classes, for the same reason side-by-side uses them.
const SPACES_CLASSES = {
[PAGE_LAYOUT_VARIANTS.SPACES_BUTTONS_TOP]: ["spaces", "spaces-buttons-top"],
[PAGE_LAYOUT_VARIANTS.SPACES_BUTTONS_BOTTOM]: [
"spaces",
"spaces-buttons-bottom",
],
};
export const SPACES_PAGE_LAYOUTS = Object.keys(SPACES_CLASSES);
// Tablist order. Stories leads so an unaware user lands where they expect.
export const SPACE_IDS = {
STORIES: "stories",
WIDGETS: "widgets",
// Backed by feeds.section.highlights, despite the name.
ACTIVITY: "activity",
};
// Every space, in tablist order. The experiment turns a space on unless
// optOutPref says the user switched it off while enrolled; PrefsFeed mirrors
// !userPref into it on change, so enrollment itself leaves it alone. userPref
// is never written. feedGated means userPref also starts a feed, so PrefsFeed
// has to write its default too.
export const SPACE_CONFIG = {
[SPACE_IDS.STORIES]: {
trainhopKey: "stories",
userPref: "feeds.section.topstories",
optOutPref: "spaces.storiesOptOut",
},
[SPACE_IDS.WIDGETS]: {
trainhopKey: "widgets",
userPref: "widgets.enabled",
optOutPref: "spaces.widgetsOptOut",
},
[SPACE_IDS.ACTIVITY]: {
trainhopKey: "highlights",
userPref: "feeds.section.highlights",
optOutPref: "spaces.activityOptOut",
feedGated: true,
},
};
/**
* Band classes for the assigned variant, empty outside the experiment.
*
* @param {object} prefs - current pref values from the Redux store
* @returns {string[]}
*/
export function spacesBandClasses(prefs) {
return SPACES_CLASSES[resolvePageLayoutVariant(prefs)] ?? [];
}
/**
* Whether a spaces variant is assigned, populated or not.
*
* @param {object} prefs - current pref values from the Redux store
* @returns {boolean}
*/
export function isSpacesAssigned(prefs) {
return SPACES_PAGE_LAYOUTS.includes(resolvePageLayoutVariant(prefs));
}
/**
* Whether the experiment is currently forcing this space on, which it does for
* a profile that had it switched off. Only ever true while enrolled, and never
* a reason to ignore a choice made since.
*
* OR this with the pref a caller already reads, rather than replacing it:
* `prefs[PREF_X] || isSpaceOverridden(...)` keeps the user's value visible at
* the call site. Callers that read one of these prefs need it, or the space
* renders empty and its customize-menu toggle contradicts the page.
*
* @param {string} id - a SPACE_IDS value
* @param {object} prefs - current pref values from the Redux store
* @returns {boolean}
*/
export function isSpaceOverridden(id, prefs) {
const { trainhopKey, optOutPref } = SPACE_CONFIG[id];
return Boolean(
isSpacesAssigned(prefs) &&
prefs?.trainhopConfig?.[trainhopKey]?.enabled &&
!prefs?.[optOutPref]
);
}
function isSpaceEnabled(id, prefs) {
return (
Boolean(prefs?.[SPACE_CONFIG[id].userPref]) || isSpaceOverridden(id, prefs)
);
}
/**
* Ids of the spaces that have content, in tablist order. Being enabled is not
* enough for two of them, and no override crosses that floor -- an empty space
* is worse than a missing one.
*
* @param {object} prefs - current pref values from the Redux store
* @returns {string[]}
*/
export function resolvePopulatedSpaces(prefs = {}) {
return Object.values(SPACE_IDS).filter(id => {
if (!isSpaceEnabled(id, prefs)) {
return false;
}
if (id === SPACE_IDS.STORIES) {
// Region and locale decide whether stories exist at all.
return Boolean(prefs["feeds.system.topstories"]);
}
if (id === SPACE_IDS.WIDGETS) {
// Ignoring the master toggle, which isSpaceEnabled already covered: is
// any widget on that renders in the content area? Weather moves to the
// sidebar at its small size, so a weather-only profile has nothing here.
return hasContentAreaWidgets(prefs, true);
}
return true;
});
}
/**
* Whether spaces is assigned and has somewhere to navigate to. Below two spaces
* the band falls back to stacking its sections.
*
* @param {object} prefs - current pref values from the Redux store
* @returns {boolean}
*/
export function isSpacesActive(prefs) {
return Boolean(
// Every spaces style is scoped under .nova-enabled.
prefs?.["nova.enabled"] &&
isSpacesAssigned(prefs) &&
resolvePopulatedSpaces(prefs).length > 1
);
}