/* 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 (
{prefs["weather.locationSearchEnabled"] && ( )} {isOptInEnabled && ( )} {prefs["weather.temperatureUnits"] === "f" ? ( handleChangeTempUnit("c")} /> ) : ( handleChangeTempUnit("f")} /> )} { // @nova-cleanup(remove-conditional): Remove this block; the simple/detailed // display toggle is replaced by the size submenu after Nova ships !novaEnabled && (!showDetailedView ? ( handleChangeDisplay("detailed")} /> ) : ( handleChangeDisplay("simple")} /> )) } { // @nova-cleanup(remove-conditional): Remove the novaEnabled check // Always render the size submenu novaEnabled && ( {["small", "medium", "large"].map(size => ( ))} ) }
); } 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 };