/* 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 { connect, batch } from "react-redux"; import { LocationSearch } from "content-src/components/Weather/LocationSearch"; import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { useIntersectionObserver } from "../../lib/utils"; import React, { useState } from "react"; const USER_ACTION_TYPES = { CHANGE_DISPLAY: "change_weather_display", CHANGE_LOCATION: "change_location", CHANGE_TEMP_UNIT: "change_temperature_units", DETECT_LOCATION: "detect_location", LEARN_MORE: "learn_more", OPT_IN_ACCEPTED: "opt_in_accepted", PROVIDER_LINK_CLICK: "provider_link_click", }; const VISIBLE = "visible"; const VISIBILITY_CHANGE_EVENT = "visibilitychange"; const PREF_SYSTEM_SHOW_WEATHER = "system.showWeather"; function WeatherPlaceholder() { const [isSeen, setIsSeen] = useState(false); // We are setting up a visibility and intersection event // so animations don't happen with headless automation. // The animations causes tests to fail beause they never stop, // and many tests wait until everything has stopped before passing. const ref = useIntersectionObserver(() => setIsSeen(true), 1); const isSeenClassName = isSeen ? `placeholder-seen` : ``; return (
{ ref.current = [el]; }} >
); } export class _Weather extends React.PureComponent { constructor(props) { super(props); this.state = { url: "https://example.com", impressionSeen: false, errorSeen: false, }; this.setImpressionRef = element => { this.impressionElement = element; }; this.setErrorRef = element => { this.errorElement = element; }; this.setPanelRef = element => { this.panelElement = element; }; this.onProviderClick = this.onProviderClick.bind(this); this.onMenuButtonClick = this.onMenuButtonClick.bind(this); this.onMenuButtonKeyDown = this.onMenuButtonKeyDown.bind(this); } componentDidMount() { const { props } = this; if (!props.dispatch) { return; } if (props.document.visibilityState === VISIBLE) { // Setup the impression observer once the page is visible. this.setImpressionObservers(); } else { // We should only ever send the latest impression stats ping, so remove any // older listeners. if (this._onVisibilityChange) { props.document.removeEventListener( VISIBILITY_CHANGE_EVENT, this._onVisibilityChange ); } this._onVisibilityChange = () => { if (props.document.visibilityState === VISIBLE) { // Setup the impression observer once the page is visible. this.setImpressionObservers(); props.document.removeEventListener( VISIBILITY_CHANGE_EVENT, this._onVisibilityChange ); } }; props.document.addEventListener( VISIBILITY_CHANGE_EVENT, this._onVisibilityChange ); } } componentWillUnmount() { // Remove observers on unmount if (this.observer && this.impressionElement) { this.observer.unobserve(this.impressionElement); } if (this.observer && this.errorElement) { this.observer.unobserve(this.errorElement); } if (this._onVisibilityChange) { this.props.document.removeEventListener( VISIBILITY_CHANGE_EVENT, this._onVisibilityChange ); } } setImpressionObservers() { if (this.impressionElement) { this.observer = new IntersectionObserver(this.onImpression.bind(this)); this.observer.observe(this.impressionElement); } if (this.errorElement) { this.observer = new IntersectionObserver(this.onError.bind(this)); this.observer.observe(this.errorElement); } } onImpression(entries) { if (this.state) { const entry = entries.find(e => e.isIntersecting); if (entry) { if (this.impressionElement) { this.observer.unobserve(this.impressionElement); } batch(() => { // Old event (keep for backward compatibility) this.props.dispatch( ac.OnlyToMain({ type: at.WEATHER_IMPRESSION, }) ); // New unified event this.props.dispatch( ac.OnlyToMain({ type: at.WIDGETS_IMPRESSION, data: { widget_name: "weather", widget_size: "mini", }, }) ); }); // Stop observing since element has been seen this.setState({ impressionSeen: true, }); } } } onError(entries) { if (this.state) { const entry = entries.find(e => e.isIntersecting); if (entry) { if (this.errorElement) { this.observer.unobserve(this.errorElement); } batch(() => { // Old event (keep for backward compatibility) this.props.dispatch( ac.OnlyToMain({ type: at.WEATHER_LOAD_ERROR, }) ); // New unified event this.props.dispatch( ac.OnlyToMain({ type: at.WIDGETS_ERROR, data: { widget_name: "weather", widget_size: "mini", error_type: "load_error", }, }) ); }); // Stop observing since element has been seen this.setState({ errorSeen: true, }); } } } onProviderClick() { batch(() => { // Old event (keep for backward compatibility) this.props.dispatch( ac.OnlyToMain({ type: at.WEATHER_OPEN_PROVIDER_URL, data: { source: "WEATHER", }, }) ); // New unified event this.props.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: "mini", }, }) ); }); } handleChangeLocation = () => { if (this.panelElement) { this.panelElement.hide(); } batch(() => { this.props.dispatch( ac.BroadcastToContent({ type: at.WEATHER_SEARCH_ACTIVE, data: true, }) ); this.props.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: "mini", }, }) ); }); }; handleDetectLocation = () => { if (this.panelElement) { this.panelElement.hide(); } batch(() => { // Old event (keep for backward compatibility) this.props.dispatch( ac.AlsoToMain({ type: at.WEATHER_USER_OPT_IN_LOCATION, }) ); // New unified event this.props.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: "mini", }, }) ); }); }; handleChangeTempUnit = value => { if (this.panelElement) { this.panelElement.hide(); } batch(() => { this.props.dispatch( ac.OnlyToMain({ type: at.SET_PREF, data: { name: "weather.temperatureUnits", value, }, }) ); this.props.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: "mini", action_value: value, }, }) ); }); }; handleChangeDisplay = value => { const weatherForecastEnabled = this.props.Prefs.values["widgets.system.weatherForecast.enabled"]; if (this.panelElement) { this.panelElement.hide(); } batch(() => { this.props.dispatch( ac.OnlyToMain({ type: at.SET_PREF, data: { name: "weather.display", value, }, }) ); this.props.dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "weather", widget_source: "context_menu", user_action: USER_ACTION_TYPES.CHANGE_DISPLAY, widget_size: "mini", action_value: weatherForecastEnabled ? "switch_to_forecast_widget" : value, }, }) ); }); }; handleHideWeather = () => { if (this.panelElement) { this.panelElement.hide(); } batch(() => { this.props.dispatch( ac.OnlyToMain({ type: at.SET_PREF, data: { name: "showWeather", value: false, }, }) ); this.props.dispatch( ac.OnlyToMain({ type: at.WIDGETS_ENABLED, data: { widget_name: "weather", widget_source: "context_menu", enabled: false, widget_size: "mini", }, }) ); }); }; handleLearnMore = () => { if (this.panelElement) { this.panelElement.hide(); } batch(() => { this.props.dispatch( ac.OnlyToMain({ type: at.OPEN_LINK, data: { url: "https://support.mozilla.org/kb/customize-items-on-firefox-new-tab-page", }, }) ); this.props.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: "mini", }, }) ); }); }; onMenuButtonClick(e) { e.preventDefault(); if (this.panelElement) { this.panelElement.toggle(e.currentTarget); } } onMenuButtonKeyDown(e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (this.panelElement) { this.panelElement.toggle(e.currentTarget); } } else if (e.key === "Escape") { if (this.panelElement) { this.panelElement.hide(); } } } handleRejectOptIn = () => { batch(() => { this.props.dispatch(ac.SetPref("weather.optInAccepted", false)); this.props.dispatch(ac.SetPref("weather.optInDisplayed", false)); // Old event (keep for backward compatibility) this.props.dispatch( ac.AlsoToMain({ type: at.WEATHER_OPT_IN_PROMPT_SELECTION, data: "rejected opt-in", }) ); // New unified event this.props.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: "mini", action_value: false, }, }) ); }); }; handleAcceptOptIn = () => { batch(() => { // Old events (keep for backward compatibility) this.props.dispatch( ac.AlsoToMain({ type: at.WEATHER_USER_OPT_IN_LOCATION, }) ); this.props.dispatch( ac.AlsoToMain({ type: at.WEATHER_OPT_IN_PROMPT_SELECTION, data: "accepted opt-in", }) ); // New unified event this.props.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: "mini", action_value: true, }, }) ); }); }; isEnabled() { const { values } = this.props.Prefs; const systemValue = values[PREF_SYSTEM_SHOW_WEATHER] && values["feeds.weatherfeed"]; const experimentValue = values.trainhopConfig?.weather?.enabled; return systemValue || experimentValue; } render() { // Check if weather should be rendered if (!this.isEnabled()) { return false; } if ( this.props.App.isForStartupCache.Weather || !this.props.Weather.initialized ) { return ; } const { props } = this; const { Prefs, Weather } = props; const WEATHER_SUGGESTION = Weather.suggestions?.[0]; const showDetailedView = Prefs.values["weather.display"] === "detailed"; const nimbusWeatherForecastTrainhopEnabled = Prefs.values.trainhopConfig?.widgets?.weatherForecastEnabled; const weatherForecastWidgetEnabled = nimbusWeatherForecastTrainhopEnabled || Prefs.values["widgets.system.weatherForecast.enabled"]; if (showDetailedView && weatherForecastWidgetEnabled) { return null; } const outerClassName = ["weather", Weather.searchActive && "search"] .filter(v => v) .join(" "); const weatherOptIn = Prefs.values["system.showWeatherOptIn"]; const nimbusWeatherOptInEnabled = Prefs.values.trainhopConfig?.weather?.weatherOptInEnabled; // Bug 2009484: Controls button order in opt-in dialog for A/B testing. // When true, "Not now" gets slot="primary"; // when false/undefined, "Yes" gets slot="primary". // Also note the primary button's position varies by platform: // on Windows, it appears on the left, // while on Linux and macOS, it appears on the right. const reverseOptInButtons = Prefs.values.trainhopConfig?.weather?.reverseOptInButtons; const optInDisplayed = Prefs.values["weather.optInDisplayed"]; const optInUserChoice = Prefs.values["weather.optInAccepted"]; const staticWeather = Prefs.values["weather.staticData.enabled"]; // Conditionals for rendering feature based on prefs + nimbus experiment variables const isOptInEnabled = weatherOptIn || nimbusWeatherOptInEnabled; // Opt-in dialog should only show if: // - weather enabled on customization menu // - weather opt-in pref is enabled // - opt-in prompt is enabled // - user hasn't accepted the opt-in yet const shouldShowOptInDialog = isOptInEnabled && optInDisplayed && !optInUserChoice; // Show static weather data only if: // - weather is enabled on customization menu // - weather opt-in pref is enabled // - static weather data is enabled const showStaticData = isOptInEnabled && staticWeather; const isLocationSearchEnabled = Prefs.values["weather.locationSearchEnabled"]; const isFahrenheit = Prefs.values["weather.temperatureUnits"] === "f"; const isSimpleDisplay = Prefs.values["weather.display"] === "simple"; const contextMenu = () => (
{/* Bug 2013136 - Using a custom button instead of moz-button due to styling constraints. The moz-button component cannot be styled to match the existing design, so we use a standard button element that can be fully controlled with CSS. */}
); if (Weather.searchActive) { return ; } else if (WEATHER_SUGGESTION) { return ( ); } return (
{" "}

{contextMenu()}
); } } export const Weather = connect(state => ({ App: state.App, Weather: state.Weather, Prefs: state.Prefs, IntersectionObserver: globalThis.IntersectionObserver, document: globalThis.document, }))(_Weather);