/* 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_SIZE: "change_size", LEARN_MORE: "learn_more", OPT_IN_ACCEPTED: "opt_in_accepted", PROVIDER_LINK_CLICK: "provider_link_click", }; const PREF_WEATHER_SIZE = "widgets.weather.size"; function Weather({ dispatch, size }) { 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); const sizeSubmenuRef = useRef(null); const currentWeatherSize = prefs[PREF_WEATHER_SIZE] || "medium"; const handleChangeSize = useCallback( newSize => { batch(() => { dispatch( ac.OnlyToMain({ type: at.SET_PREF, data: { name: PREF_WEATHER_SIZE, value: newSize, }, }) ); 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: newSize, widget_size: newSize, }, }) ); }); }, [dispatch] ); useEffect(() => { const el = sizeSubmenuRef.current; if (!el) { return undefined; } 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; dispatch( ac.AlsoToMain({ type: at.WIDGETS_IMPRESSION, data: { widget_name: "weather", widget_size: size, }, }) ); }, [dispatch, size]); const weatherRef = useIntersectionObserver(handleIntersection); const weatherExperimentEnabled = prefs.trainhopConfig?.weather?.enabled; const isWeatherEnabled = prefs["widgets.weather.enabled"] && (prefs["widgets.system.weather.enabled"] || weatherExperimentEnabled); const WEATHER_SUGGESTION = weatherData?.suggestions?.[0]; const HOURLY_FORECASTS = weatherData?.hourlyForecasts ?? []; const showForecast = size === "medium" || size === "large"; const hasError = !WEATHER_SUGGESTION?.current_conditions || !WEATHER_SUGGESTION?.forecast || (showForecast && !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: size, error_type: "load_error", }, }) ); errorTelemetrySent.current = true; } }, [dispatch, size] ); useEffect(() => { if (errorRef.current && !errorTelemetrySent.current) { const observer = new IntersectionObserver(handleErrorIntersection); observer.observe(errorRef.current); return () => { observer.disconnect(); }; } return undefined; }, [handleErrorIntersection, hasError]); // Must be declared before the early return to satisfy React's Rules of Hooks. const handleOptInLocationSelected = useCallback(() => { dispatch(ac.SetPref("weather.optInAccepted", true)); }, [dispatch]); if (!weatherData?.initialized || !isWeatherEnabled) { return null; } const weatherOptIn = prefs["system.showWeatherOptIn"]; const nimbusWeatherOptInEnabled = prefs.trainhopConfig?.weather?.weatherOptInEnabled; const isOptInEnabled = weatherOptIn || nimbusWeatherOptInEnabled; const optInDisplayed = prefs["weather.optInDisplayed"]; const optInUserChoice = prefs["weather.optInAccepted"]; const showOptInState = isOptInEnabled && optInDisplayed && !optInUserChoice; const { searchActive } = weatherData; function handleChangeLocation() { batch(() => { dispatch( ac.BroadcastToContent({ type: at.WEATHER_SEARCH_ACTIVE, data: true, }) ); dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "weather", widget_source: "context_menu", user_action: USER_ACTION_TYPES.CHANGE_LOCATION, widget_size: size, }, }) ); }); } function handleDetectLocation() { batch(() => { dispatch( ac.AlsoToMain({ type: at.WEATHER_USER_OPT_IN_LOCATION, }) ); dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "weather", widget_source: "context_menu", user_action: USER_ACTION_TYPES.DETECT_LOCATION, widget_size: size, }, }) ); }); } function handleChangeTempUnit(unit) { batch(() => { dispatch( ac.OnlyToMain({ type: at.SET_PREF, data: { name: "weather.temperatureUnits", value: unit, }, }) ); dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "weather", widget_source: "context_menu", user_action: USER_ACTION_TYPES.CHANGE_TEMP_UNIT, widget_size: size, action_value: unit, }, }) ); }); } function handleHideWeather() { batch(() => { dispatch( ac.OnlyToMain({ type: at.SET_PREF, data: { name: "widgets.weather.enabled", value: false, }, }) ); dispatch( ac.OnlyToMain({ type: at.WIDGETS_ENABLED, data: { widget_name: "weather", widget_source: "context_menu", enabled: false, widget_size: size, }, }) ); }); } function handleLearnMore() { batch(() => { dispatch( ac.OnlyToMain({ type: at.OPEN_LINK, data: { url: "https://support.mozilla.org/kb/firefox-new-tab-widgets", }, }) ); dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "weather", widget_source: "context_menu", user_action: USER_ACTION_TYPES.LEARN_MORE, widget_size: size, }, }) ); }); } function handleProviderLinkClick() { dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "weather", widget_source: "widget", user_action: USER_ACTION_TYPES.PROVIDER_LINK_CLICK, widget_size: size, }, }) ); } function handleOptInChooseLocation() { batch(() => { dispatch( ac.AlsoToMain({ type: at.WEATHER_OPT_IN_PROMPT_SELECTION, data: "choose_location", }) ); dispatch( ac.BroadcastToContent({ type: at.WEATHER_SEARCH_ACTIVE, data: true, }) ); dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "weather", widget_source: "widget", user_action: USER_ACTION_TYPES.OPT_IN_ACCEPTED, widget_size: size, action_value: "choose_location", }, }) ); }); } function handleAcceptOptIn() { batch(() => { dispatch( ac.AlsoToMain({ type: at.WEATHER_USER_OPT_IN_LOCATION, }) ); dispatch( ac.AlsoToMain({ type: at.WEATHER_OPT_IN_PROMPT_SELECTION, data: "use_location", }) ); dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "weather", widget_source: "widget", user_action: USER_ACTION_TYPES.OPT_IN_ACCEPTED, widget_size: size, action_value: "use_location", }, }) ); }); } function renderContextMenu() { return (
{!showOptInState && !isOptInEnabled && (prefs["weather.temperatureUnits"] === "f" ? ( handleChangeTempUnit("c")} /> ) : ( handleChangeTempUnit("f")} /> ))} {!showOptInState && prefs["weather.locationSearchEnabled"] && ( )} {!showOptInState && isOptInEnabled && ( )} {/* Only show size options when both system and user prefs are enabled; medium/large sizes require the widgets row, which only renders when both are true. */} {prefs["widgets.system.enabled"] && prefs["widgets.enabled"] && ( {["small", "medium", "large"].map(s => ( ))} )}
); } return (
{ weatherRef.current = [el]; }} > {!hasError && !showOptInState && ( )}
{searchActive && ( )} {!searchActive && !showOptInState && (

{weatherData.locationData.city}

)}
{renderContextMenu()}
{hasError && (
{" "}

)} {showOptInState ? ( !searchActive && (

) ) : ( <>
{!hasError && (
{ WEATHER_SUGGESTION.current_conditions.temperature[ prefs["weather.temperatureUnits"] ] } °{prefs["weather.temperatureUnits"]}
{ WEATHER_SUGGESTION.forecast.high[ prefs["weather.temperatureUnits"] ] } ° { WEATHER_SUGGESTION.forecast.low[ prefs["weather.temperatureUnits"] ] } °
{WEATHER_SUGGESTION.current_conditions.summary}
)} {!hasError && showForecast && (

    {HOURLY_FORECASTS.map(slot => (
  • {slot.temperature[prefs["weather.temperatureUnits"]]} ° {(() => { const date = new Date(slot.date_time); const hours = date.getHours() % 12 || 12; return `${hours}:${String(date.getMinutes()).padStart(2, "0")}`; })()}
  • ))}
)}
{!hasError && (
{showForecast && ( )}
)} )}
); } export { Weather };