/* 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/. */
import React, { useContext, useEffect, useRef } from "react";
import { useDispatch, useSelector, batch } from "react-redux";
import { BaseContext } from "content-src/lib/BaseContext";
// Bug 2034542: these per-widget imports can be removed once the non-Nova render
// path (@nova-cleanup) is gone and all widgets render via WIDGET_ROW_COMPONENTS.
import { Lists } from "./Lists/Lists";
import { FocusTimer } from "./FocusTimer/FocusTimer";
import { WeatherForecast } from "./WeatherForecast/WeatherForecast";
import { Weather as WeatherWidget } from "./Weather/Weather";
import { MessageWrapper } from "content-src/components/MessageWrapper/MessageWrapper";
import { WidgetsFeatureHighlight } from "../DiscoveryStreamComponents/FeatureHighlight/WidgetsFeatureHighlight";
import { WidgetsRowFeatureHighlight } from "../DiscoveryStreamComponents/FeatureHighlight/WidgetsRowFeatureHighlight";
import { OMCHighlightSlot } from "../DiscoveryStreamComponents/FeatureHighlight/OMCHighlightSlot";
import { SLOTS } from "../DiscoveryStreamComponents/FeatureHighlight/OMCHighlightSlots.mjs";
import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
import {
WIDGET_REGISTRY,
isWidgetAddable,
isWidgetEnabled,
resolveWidgetSize,
resolveWidgetOrder,
resolveWidgetHasSidebar,
getHideAllTargets,
} from "common/WidgetsRegistry.mjs";
import { WIDGET_ROW_COMPONENTS } from "./WidgetsComponentRegistry.jsx";
import { WidgetWrapper } from "./WidgetWrapper";
import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary";
import { useWidgetDnD } from "./useWidgetDnD.jsx";
const CONTAINER_ACTION_TYPES = {
HIDE_ALL: "hide_all",
CHANGE_SIZE_ALL: "change_size_all",
CHANGE_ROW_VISIBILITY: "change_row_visibility",
FEEDBACK: "feedback",
};
const PREF_WIDGETS_ENABLED = "widgets.enabled";
const PREF_NOVA_ENABLED = "nova.enabled";
const PREF_WIDGETS_SYSTEM_WEATHER_FORECAST_ENABLED =
"widgets.system.weatherForecast.enabled";
const PREF_WIDGETS_MAXIMIZED = "widgets.maximized";
const PREF_WIDGETS_SYSTEM_MAXIMIZED = "widgets.system.maximized";
const PREF_WIDGETS_ROW_EXPANDED = "widgets.row.expanded";
const PREF_WIDGETS_FEEDBACK_ENABLED = "widgets.feedback.enabled";
const PREF_WIDGETS_HIDE_ALL_TOAST_ENABLED = "widgets.hideAllToast.enabled";
const WIDGETS_FEEDBACK_URL =
"https://support.mozilla.org/kb/firefox-new-tab-widgets";
// resets timer to default values (exported for testing)
// In practice, this logic runs inside a useEffect when
// the timer widget is disabled (after the pref flips from true to false).
// Because Enzyme tests cannot reliably simulate that pref update or trigger
// the related useEffect, we expose this helper to at least just test the reset behavior instead
export function resetTimerToDefaults(dispatch, timerType) {
const originalTime = timerType === "focus" ? 1500 : 300;
// Reset both focus and break timers to their initial durations
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_TIMER_RESET,
data: {
timerType,
duration: originalTime,
initialDuration: originalTime,
},
})
);
// Set the timer type back to "focus"
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_TIMER_SET_TYPE,
data: {
timerType: "focus",
},
})
);
}
function renderWeather({
novaEnabled,
weatherEnabled,
weatherForecastEnabled,
weatherSize,
dispatch,
handleUserInteraction,
isMaximized,
widgetsMayBeMaximized,
}) {
if (novaEnabled) {
return (
weatherEnabled &&
weatherSize !== "small" && (
)
);
}
return (
weatherForecastEnabled && (
)
);
}
// eslint-disable-next-line complexity, max-statements
function Widgets() {
const prefs = useSelector(state => state.Prefs.values);
const weatherData = useSelector(state => state.Weather);
const { messageData } = useSelector(state => state.Messages);
const timerType = useSelector(state => state.TimerWidget.timerType);
const timerData = useSelector(state => state.TimerWidget);
const sportsWidgetState = useSelector(
state => state.SportsWidget?.widgetState
);
const dispatch = useDispatch();
const { openWidgetsPanel } = useContext(BaseContext);
const novaEnabled = prefs[PREF_NOVA_ENABLED];
const isMaximized = prefs[PREF_WIDGETS_MAXIMIZED];
const rowExpanded = !!prefs[PREF_WIDGETS_ROW_EXPANDED];
const nimbusMaximizedTrainhopEnabled =
prefs.trainhopConfig?.widgets?.maximized;
const feedbackEnabled =
prefs.trainhopConfig?.widgets?.feedbackEnabled ||
prefs[PREF_WIDGETS_FEEDBACK_ENABLED];
const hideAllToastEnabled =
prefs.trainhopConfig?.widgets?.hideAllToastEnabled ||
prefs[PREF_WIDGETS_HIDE_ALL_TOAST_ENABLED];
const feedbackUrl =
prefs.trainhopConfig?.widgets?.feedbackUrl ?? WIDGETS_FEEDBACK_URL;
const showWidgetsSizeToggle =
nimbusMaximizedTrainhopEnabled || prefs[PREF_WIDGETS_SYSTEM_MAXIMIZED];
const widgetsMayBeMaximized = showWidgetsSizeToggle;
const widgetsEnabled = prefs[PREF_WIDGETS_ENABLED];
// Bug 2034542: these per-widget lookups and all the derived consts below
// (listsEnabled, timerEnabled, weatherBase, weatherEnabled, weatherSize,
// weatherGoesToSidebar, widgetEnabledMap) can be replaced with a single
// registry-driven loop once weather's extra enabled conditions
// (weatherData.initialized, isWeatherEnabled) are either folded into the
// registry or handled inside the Weather component itself.
const listsWidget = WIDGET_REGISTRY.find(w => w.id === "lists");
const timerWidget = WIDGET_REGISTRY.find(w => w.id === "focusTimer");
const weatherWidget = WIDGET_REGISTRY.find(w => w.id === "weather");
const listsEnabled = isWidgetEnabled(listsWidget, prefs, widgetsEnabled);
const timerEnabled = isWidgetEnabled(timerWidget, prefs, widgetsEnabled);
// This weather forecast widget will only show when the following are true:
// - The weather view is set to "detailed" (can be checked with the weather.display pref)
// - Weather is displayed on New Tab (system.showWeather)
// - The weather forecast widget is enabled (system.weatherForecast.enabled)
// Note that if the view is set to "detailed" but the weather forecast widget is not enabled,
// then the mini weather widget will display with the "detailed" view
const weatherForecastSystemEnabled =
prefs.trainhopConfig?.widgets?.weatherForecastEnabled ||
prefs[PREF_WIDGETS_SYSTEM_WEATHER_FORECAST_ENABLED];
const showDetailedView = prefs["weather.display"] === "detailed";
// Check if weather is enabled (browser.newtabpage.activity-stream.showWeather)
const { showWeather } = prefs;
const systemShowWeather = prefs["system.showWeather"];
const weatherExperimentEnabled = prefs.trainhopConfig?.weather?.enabled;
const isWeatherEnabled =
showWeather && (systemShowWeather || weatherExperimentEnabled);
const weatherForecastEnabled =
widgetsEnabled &&
weatherForecastSystemEnabled &&
showDetailedView &&
weatherData?.initialized &&
isWeatherEnabled;
const weatherBase = isWidgetEnabled(weatherWidget, prefs, widgetsEnabled);
const weatherEnabled =
weatherBase && weatherData?.initialized && isWeatherEnabled;
const weatherSize = resolveWidgetSize(weatherWidget, prefs);
// Weather renders in the sidebar when its effective size is "small" AND the
// sidebar placement is active. If a trainhopSidebar override sets hasSidebar
// to false, weatherGoesToSidebar is false and the widget falls through to the
// row here instead of disappearing.
const weatherGoesToSidebar =
resolveWidgetHasSidebar(weatherWidget, prefs) && weatherSize === "small";
const widgetEnabledMap = {
lists: listsEnabled,
focusTimer: timerEnabled,
weather: weatherEnabled && !weatherGoesToSidebar,
sportsWidget: isWidgetEnabled(
WIDGET_REGISTRY.find(w => w.id === "sportsWidget"),
prefs,
widgetsEnabled
),
clocks: isWidgetEnabled(
WIDGET_REGISTRY.find(w => w.id === "clocks"),
prefs,
widgetsEnabled
),
privacy: isWidgetEnabled(
WIDGET_REGISTRY.find(w => w.id === "privacy"),
prefs,
widgetsEnabled
),
crossword: isWidgetEnabled(
WIDGET_REGISTRY.find(w => w.id === "crossword"),
prefs,
widgetsEnabled
),
stocks: isWidgetEnabled(
WIDGET_REGISTRY.find(w => w.id === "stocks"),
prefs,
widgetsEnabled
),
pictureOfTheDay: isWidgetEnabled(
WIDGET_REGISTRY.find(w => w.id === "pictureOfTheDay"),
prefs,
widgetsEnabled
),
};
const widgetOrder = resolveWidgetOrder(prefs);
const {
effectiveOrder,
draggedId,
previewOrderMap,
handleDragStart,
handleDragOver,
handleDrop,
handleDragEnd,
handleMouseDown,
} = useWidgetDnD({
widgetOrder,
prefs,
dispatch,
});
const anyWidgetInRow =
WIDGET_REGISTRY.some(w => widgetEnabledMap[w.id]) ||
(!novaEnabled && weatherForecastEnabled);
const allWidgetsAdded = WIDGET_REGISTRY.filter(w =>
isWidgetAddable(w, prefs)
).every(w => prefs[w.enabledPref]);
const renderedWidgetSizes = WIDGET_REGISTRY.filter(
w => widgetEnabledMap[w.id]
).map(w => resolveWidgetSize(w, prefs));
const addButtonSize = renderedWidgetSizes.includes("large")
? "large"
: "medium";
// Widget size is "medium" only when maximize feature is enabled and widgets
// are currently minimized. Otherwise defaults to "large".
//
// This is a row-level approximation, not a per-widget truth. Users can resize
// widgets individually, so this single value will not reflect the real size of
// every widget in the row. For accurate per-widget sizing, rely on each
// widget's own change-size event (WIDGETS_USER_EVENT with user_action
// "change_size", which carries the widget's real widget_size) as the source of
// truth rather than this value.
const widgetSize = widgetsMayBeMaximized && !isMaximized ? "medium" : "large";
// track previous timerEnabled state to detect when it becomes disabled
const prevTimerEnabledRef = useRef(timerEnabled);
// Reset timer when it becomes disabled
useEffect(() => {
const wasTimerEnabled = prevTimerEnabledRef.current;
const isTimerEnabled = timerEnabled;
// Only reset if timer was enabled and is now disabled
if (wasTimerEnabled && !isTimerEnabled && timerData) {
resetTimerToDefaults(dispatch, timerType);
}
// Update the ref to track current state
prevTimerEnabledRef.current = isTimerEnabled;
}, [timerEnabled, timerData, dispatch, timerType]);
function hideAllWidgets() {
batch(() => {
const targets = getHideAllTargets(prefs, widgetEnabledMap);
for (const target of targets) {
dispatch(ac.SetPref(target.enabledPref, false));
}
// @nova-cleanup(remove-conditional): Remove the !novaEnabled guard and this branch
if (!novaEnabled && weatherForecastEnabled) {
dispatch(ac.SetPref("showWeather", false));
}
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_HIDE_ALL,
data: { targets, widget_size: widgetSize },
})
);
// @nova-cleanup(remove-conditional): Remove once weatherForecastEnabled path is removed
if (!novaEnabled && weatherForecastEnabled) {
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_ENABLED,
data: {
widget_name: "weather",
widget_source: "widget",
enabled: false,
widget_size: widgetSize,
},
})
);
}
if (hideAllToastEnabled) {
dispatch(
ac.OnlyToOneContent(
{
type: at.SHOW_TOAST_MESSAGE,
data: { toastId: "hideWidgetsToast", showNotifications: true },
},
"ActivityStream:Content"
)
);
}
});
}
function handleHideAllWidgetsClick(e) {
e.preventDefault();
hideAllWidgets();
}
function handleHideAllWidgetsKeyDown(e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
hideAllWidgets();
}
}
function toggleMaximize() {
const newMaximizedState = !isMaximized;
const newWidgetSize =
widgetsMayBeMaximized && !newMaximizedState ? "medium" : "large";
// Batch into one SET_MULTIPLE_PREFS so the sizes return in a single broadcast,
// not one staggered round-trip per widget.
const prefUpdates = { [PREF_WIDGETS_MAXIMIZED]: newMaximizedState };
// When Nova is enabled, treat the shared header control as a toggle
// between the default/full widget presentation and the compact one.
// Only widgets actually rendered in the sidebar are skipped — that is the
// same hasSidebar + size === "small" test used in WidgetsSidebar and by
// weatherGoesToSidebar above, not "any small widget". A "small" widget
// sitting in the row is resized along with the others so it isn't left
// behind by the row toggle. On minimize such a widget lands at "medium"
// rather than returning to "small", since the size pref holds the display
// state directly and the pre-maximize size isn't retained.
if (novaEnabled) {
const targetSize = newMaximizedState ? "large" : "medium";
for (const widget of WIDGET_REGISTRY) {
const inSidebar =
resolveWidgetHasSidebar(widget, prefs) &&
resolveWidgetSize(widget, prefs) === "small";
if (!inSidebar) {
prefUpdates[widget.sizePref] = targetSize;
}
}
}
const telemetryData = {
action_type: CONTAINER_ACTION_TYPES.CHANGE_SIZE_ALL,
action_value: newMaximizedState ? "maximize_widgets" : "minimize_widgets",
widget_size: newWidgetSize,
};
batch(() => {
dispatch(ac.SetMultiplePrefs(prefUpdates));
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_CONTAINER_ACTION,
data: telemetryData,
})
);
});
}
function handleToggleMaximizeClick(e) {
e.preventDefault();
toggleMaximize();
}
function handleToggleMaximizeKeyDown(e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleMaximize();
}
}
function handleManageWidgetsClick(e) {
e.preventDefault();
openWidgetsPanel();
dispatch(ac.UserEvent({ event: "SHOW_PERSONALIZE" }));
}
function toggleRowExpanded() {
const next = !rowExpanded;
batch(() => {
dispatch(ac.SetPref(PREF_WIDGETS_ROW_EXPANDED, next));
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_CONTAINER_ACTION,
data: {
action_type: CONTAINER_ACTION_TYPES.CHANGE_ROW_VISIBILITY,
action_value: next ? "expand_row" : "collapse_row",
widget_size: widgetSize,
},
})
);
});
}
function handleToggleRowExpandedClick(e) {
e.preventDefault();
toggleRowExpanded();
}
function handleFeedbackClick(e) {
e.preventDefault();
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.OPEN_LINK,
data: {
url: feedbackUrl,
...(novaEnabled ? { where: "tab" } : {}),
},
})
);
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_CONTAINER_ACTION,
data: {
action_type: CONTAINER_ACTION_TYPES.FEEDBACK,
widget_size: widgetSize,
},
})
);
});
}
function handleUserInteraction(widgetName) {
const prefName = `widgets.${widgetName}.interaction`;
const hasInteracted = prefs[prefName];
// we want to make sure that the value is a strict false (and that the property exists)
if (hasInteracted === false) {
dispatch(ac.SetPref(prefName, true));
}
}
function renderWidgetsTitle() {
if (!novaEnabled) {
return
;
}
return (
{showWidgetsSizeToggle ? (
) : null}
);
}
function renderWidgetsActions() {
if (novaEnabled) {
return (
);
}
return (
<>
{showWidgetsSizeToggle ? (
) : null}
>
);
}
if (!anyWidgetInRow) {
return null;
}
// CSS container queries on the widgets section decide whether the toggle
// button is shown — see _Widgets.scss. The collapsed row holds one widget
// per card-column slot regardless of size, so for each card-column count
// (1–4) anything past the first N positions overflows. This keeps mediums
// to a single (shorter) row rather than stacking them two-deep to fill a
// large-height band. The matching `data-overflow-N` attribute is read by
// the @container rules in CSS.
const enabledWidgetIds = [];
// Use effectiveOrder (matches the render loop) so optimistic reorders aren't briefly mis-hidden.
for (const id of effectiveOrder) {
if (!WIDGET_ROW_COMPONENTS[id] || !widgetEnabledMap[id]) {
continue;
}
enabledWidgetIds.push(id);
}
const widgetCount = enabledWidgetIds.length;
const overflowsAt = cols => widgetCount > cols;
// For each viewport (cols 1–4), returns the set of widget render indices
// that would be clipped when the row is collapsed: everything past the
// first `cols` slots. CSS keys off the matching `data-hidden-N` to make
// them tab-out and a11y-hide at that viewport.
const hiddenIndicesAt = cols => {
const set = new Set();
for (let i = cols; i < widgetCount; i++) {
set.add(i);
}
return set;
};
const hiddenAtCols = {
1: hiddenIndicesAt(1),
2: hiddenIndicesAt(2),
3: hiddenIndicesAt(3),
4: hiddenIndicesAt(4),
};
const overflowAttrs = {
"data-overflow-1": overflowsAt(1) ? "" : undefined,
"data-overflow-2": overflowsAt(2) ? "" : undefined,
"data-overflow-3": overflowsAt(3) ? "" : undefined,
"data-overflow-4": overflowsAt(4) ? "" : undefined,
};
const isCollapsed = novaEnabled && !rowExpanded;
return (