/* 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. */}
{isLocationSearchEnabled && (
)}
{isOptInEnabled && (
)}
{isFahrenheit ? (
this.handleChangeTempUnit("c")}
/>
) : (
this.handleChangeTempUnit("f")}
/>
)}
{isSimpleDisplay ? (
this.handleChangeDisplay("detailed")}
/>
) : (
this.handleChangeDisplay("simple")}
/>
)}
);
if (Weather.searchActive) {
return ;
} else if (WEATHER_SUGGESTION) {
return (