/* 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 https://mozilla.org/MPL/2.0/. */
import React, { useCallback, useEffect, useRef } from "react";
import { useSelector, batch } from "react-redux";
import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
import { useIntersectionObserver } from "../../../lib/utils";
import { LocationSearch } from "content-src/components/Weather/LocationSearch";
const USER_ACTION_TYPES = {
CHANGE_LOCATION: "change_location",
DETECT_LOCATION: "detect_location",
CHANGE_TEMP_UNIT: "change_temperature_units",
CHANGE_DISPLAY: "change_weather_display",
CHANGE_SIZE: "change_size",
LEARN_MORE: "learn_more",
PROVIDER_LINK_CLICK: "provider_link_click",
};
const PREF_NOVA_ENABLED = "nova.enabled";
const PREF_WEATHER_SIZE = "widgets.weather.size";
function WeatherForecast({ dispatch, isMaximized, widgetsMayBeMaximized }) {
const prefs = useSelector(state => state.Prefs.values);
const weatherData = useSelector(state => state.Weather);
const impressionFired = useRef(false);
const errorTelemetrySent = useRef(false);
const errorRef = useRef(null);
// @nova-cleanup(remove-pref): Remove pref check, always apply col-4 class after Nova ships
const novaEnabled = prefs[PREF_NOVA_ENABLED];
const isSmallSize = novaEnabled
? (prefs[PREF_WEATHER_SIZE] || "large") !== "large"
: !isMaximized && widgetsMayBeMaximized;
let widgetSize;
if (novaEnabled) {
widgetSize = prefs[PREF_WEATHER_SIZE] || "large";
} else {
widgetSize = isSmallSize ? "small" : "medium";
}
const handleChangeSize = useCallback(
size => {
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.SET_PREF,
data: {
name: PREF_WEATHER_SIZE,
value: size,
},
})
);
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: {
widget_name: "weather",
widget_source: "context_menu",
user_action: USER_ACTION_TYPES.CHANGE_SIZE,
action_value: size,
widget_size: size,
},
})
);
});
},
[dispatch]
);
const sizeSubmenuRef = useRef(null);
useEffect(() => {
const el = sizeSubmenuRef.current;
if (!el) {
return undefined;
}
// The size submenu panel-list is moved into the panel-item's shadow DOM by
// the panel-list custom element, so React's synthetic onClick doesn't reach
// inner items. We use composedPath() to find the clicked item across the
// shadow boundary via its data-size attribute.
const listener = e => {
const item = e.composedPath().find(node => node.dataset?.size);
if (item) {
handleChangeSize(item.dataset.size);
}
};
el.addEventListener("click", listener);
return () => el.removeEventListener("click", listener);
}, [handleChangeSize]);
const handleIntersection = useCallback(() => {
if (impressionFired.current) {
return;
}
impressionFired.current = true;
const telemetryData = {
widget_name: "weather",
widget_size: widgetSize,
};
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_IMPRESSION,
data: telemetryData,
})
);
}, [dispatch, widgetSize]);
const forecastRef = useIntersectionObserver(handleIntersection);
const WEATHER_SUGGESTION = weatherData.suggestions?.[0];
const HOURLY_FORECASTS = weatherData.hourlyForecasts ?? [];
const hasError =
!WEATHER_SUGGESTION?.current_conditions ||
!WEATHER_SUGGESTION?.forecast ||
!HOURLY_FORECASTS[0];
const handleErrorIntersection = useCallback(
entries => {
const entry = entries.find(e => e.isIntersecting);
if (entry && !errorTelemetrySent.current) {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_ERROR,
data: {
widget_name: "weather",
widget_size: widgetSize,
error_type: "load_error",
},
})
);
errorTelemetrySent.current = true;
}
},
[dispatch, widgetSize]
);
useEffect(() => {
if (errorRef.current && !errorTelemetrySent.current) {
const observer = new IntersectionObserver(handleErrorIntersection);
observer.observe(errorRef.current);
return () => {
observer.disconnect();
};
}
return undefined;
}, [handleErrorIntersection, hasError]);
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);
// Check if the WeatherForecast widget is enabled
const nimbusWeatherForecastTrainhopEnabled =
prefs.trainhopConfig?.widgets?.weatherForecastEnabled;
const weatherForecastWidgetEnabled =
nimbusWeatherForecastTrainhopEnabled ||
prefs["widgets.system.weatherForecast.enabled"];
// 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
// @nova-cleanup(remove-conditional): Remove the !showDetailedView branch; after Nova
// ships only the size-based check remains, replace with `widgetSize === "small"`
if (
(novaEnabled ? widgetSize === "small" : !showDetailedView) ||
!weatherData?.initialized ||
!weatherForecastWidgetEnabled ||
!isWeatherEnabled
) {
return null;
}
const weatherOptIn = prefs["system.showWeatherOptIn"];
const nimbusWeatherOptInEnabled =
prefs.trainhopConfig?.weather?.weatherOptInEnabled;
const isOptInEnabled = weatherOptIn || nimbusWeatherOptInEnabled;
const { searchActive } = weatherData;
function handleChangeLocation() {
batch(() => {
dispatch(
ac.BroadcastToContent({
type: at.WEATHER_SEARCH_ACTIVE,
data: true,
})
);
const telemetryData = {
widget_name: "weather",
widget_source: "context_menu",
user_action: USER_ACTION_TYPES.CHANGE_LOCATION,
widget_size: widgetSize,
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
});
}
function handleDetectLocation() {
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WEATHER_USER_OPT_IN_LOCATION,
})
);
const telemetryData = {
widget_name: "weather",
widget_source: "context_menu",
user_action: USER_ACTION_TYPES.DETECT_LOCATION,
widget_size: widgetSize,
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
});
}
function handleChangeTempUnit(unit) {
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.SET_PREF,
data: {
name: "weather.temperatureUnits",
value: unit,
},
})
);
const telemetryData = {
widget_name: "weather",
widget_source: "context_menu",
user_action: USER_ACTION_TYPES.CHANGE_TEMP_UNIT,
widget_size: widgetSize,
action_value: unit,
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
});
}
function handleChangeDisplay(display) {
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.SET_PREF,
data: {
name: "weather.display",
value: display,
},
})
);
const telemetryData = {
widget_name: "weather",
widget_source: "context_menu",
user_action: USER_ACTION_TYPES.CHANGE_DISPLAY,
action_value: "switch_to_mini_widget",
widget_size: widgetSize,
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
});
}
function handleHideWeather() {
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.SET_PREF,
data: {
name: "showWeather",
value: false,
},
})
);
const telemetryData = {
widget_name: "weather",
widget_source: "context_menu",
enabled: false,
widget_size: widgetSize,
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_ENABLED,
data: telemetryData,
})
);
});
}
function handleLearnMore() {
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.OPEN_LINK,
data: {
url: "https://support.mozilla.org/kb/firefox-new-tab-widgets",
},
})
);
const telemetryData = {
widget_name: "weather",
widget_source: "context_menu",
user_action: USER_ACTION_TYPES.LEARN_MORE,
widget_size: widgetSize,
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
});
}
function handleProviderLinkClick() {
const telemetryData = {
widget_name: "weather",
widget_source: "widget",
user_action: USER_ACTION_TYPES.PROVIDER_LINK_CLICK,
widget_size: widgetSize,
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
}
function renderContextMenu() {
return (
);
}
return (
{
forecastRef.current = [el];
}}
>
{!hasError && (
)}
{searchActive ? (
) : (
{weatherData.locationData.city}
)}
{renderContextMenu()}
{!isSmallSize && !hasError && (
<>
{
WEATHER_SUGGESTION.current_conditions.temperature[
prefs["weather.temperatureUnits"]
]
}
°{prefs["weather.temperatureUnits"]}
{WEATHER_SUGGESTION.current_conditions.summary}
{
WEATHER_SUGGESTION.forecast.high[
prefs["weather.temperatureUnits"]
]
}
°
{
WEATHER_SUGGESTION.forecast.low[
prefs["weather.temperatureUnits"]
]
}
°
>
)}
{/* Error state for medium sized card */}
{hasError && (
)}
{!hasError && (
{!isSmallSize && (
)}
{HOURLY_FORECASTS.map(slot => (
-
{slot.temperature[prefs["weather.temperatureUnits"]]}°
{(() => {
const date = new Date(slot.date_time);
const hours = date.getHours() % 12 || 12; // displays a 12-hour format
return `${hours}:${String(date.getMinutes()).padStart(2, "0")}`; // gets rid of the extra :00 at the end
})()}
))}
)}
);
}
export { WeatherForecast };