Weather.jsx (16781B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { connect, batch } from "react-redux"; 6 import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; 7 import { LocationSearch } from "content-src/components/Weather/LocationSearch"; 8 import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; 9 import { useIntersectionObserver } from "../../lib/utils"; 10 import React, { useState } from "react"; 11 12 const VISIBLE = "visible"; 13 const VISIBILITY_CHANGE_EVENT = "visibilitychange"; 14 const PREF_SYSTEM_SHOW_WEATHER = "system.showWeather"; 15 16 function WeatherPlaceholder() { 17 const [isSeen, setIsSeen] = useState(false); 18 19 // We are setting up a visibility and intersection event 20 // so animations don't happen with headless automation. 21 // The animations causes tests to fail beause they never stop, 22 // and many tests wait until everything has stopped before passing. 23 const ref = useIntersectionObserver(() => setIsSeen(true), 1); 24 25 const isSeenClassName = isSeen ? `placeholder-seen` : ``; 26 27 return ( 28 <div 29 className={`weather weather-placeholder ${isSeenClassName}`} 30 ref={el => { 31 ref.current = [el]; 32 }} 33 > 34 <div className="placeholder-image placeholder-fill" /> 35 <div className="placeholder-context"> 36 <div className="placeholder-header placeholder-fill" /> 37 <div className="placeholder-description placeholder-fill" /> 38 </div> 39 </div> 40 ); 41 } 42 43 export class _Weather extends React.PureComponent { 44 constructor(props) { 45 super(props); 46 this.state = { 47 contextMenuKeyboard: false, 48 showContextMenu: false, 49 url: "https://example.com", 50 impressionSeen: false, 51 errorSeen: false, 52 }; 53 this.setImpressionRef = element => { 54 this.impressionElement = element; 55 }; 56 this.setErrorRef = element => { 57 this.errorElement = element; 58 }; 59 this.onClick = this.onClick.bind(this); 60 this.onKeyDown = this.onKeyDown.bind(this); 61 this.onUpdate = this.onUpdate.bind(this); 62 this.onProviderClick = this.onProviderClick.bind(this); 63 } 64 65 componentDidMount() { 66 const { props } = this; 67 68 if (!props.dispatch) { 69 return; 70 } 71 72 if (props.document.visibilityState === VISIBLE) { 73 // Setup the impression observer once the page is visible. 74 this.setImpressionObservers(); 75 } else { 76 // We should only ever send the latest impression stats ping, so remove any 77 // older listeners. 78 if (this._onVisibilityChange) { 79 props.document.removeEventListener( 80 VISIBILITY_CHANGE_EVENT, 81 this._onVisibilityChange 82 ); 83 } 84 85 this._onVisibilityChange = () => { 86 if (props.document.visibilityState === VISIBLE) { 87 // Setup the impression observer once the page is visible. 88 this.setImpressionObservers(); 89 props.document.removeEventListener( 90 VISIBILITY_CHANGE_EVENT, 91 this._onVisibilityChange 92 ); 93 } 94 }; 95 props.document.addEventListener( 96 VISIBILITY_CHANGE_EVENT, 97 this._onVisibilityChange 98 ); 99 } 100 } 101 102 componentWillUnmount() { 103 // Remove observers on unmount 104 if (this.observer && this.impressionElement) { 105 this.observer.unobserve(this.impressionElement); 106 } 107 if (this.observer && this.errorElement) { 108 this.observer.unobserve(this.errorElement); 109 } 110 if (this._onVisibilityChange) { 111 this.props.document.removeEventListener( 112 VISIBILITY_CHANGE_EVENT, 113 this._onVisibilityChange 114 ); 115 } 116 } 117 118 setImpressionObservers() { 119 if (this.impressionElement) { 120 this.observer = new IntersectionObserver(this.onImpression.bind(this)); 121 this.observer.observe(this.impressionElement); 122 } 123 if (this.errorElement) { 124 this.observer = new IntersectionObserver(this.onError.bind(this)); 125 this.observer.observe(this.errorElement); 126 } 127 } 128 129 onImpression(entries) { 130 if (this.state) { 131 const entry = entries.find(e => e.isIntersecting); 132 133 if (entry) { 134 if (this.impressionElement) { 135 this.observer.unobserve(this.impressionElement); 136 } 137 138 this.props.dispatch( 139 ac.OnlyToMain({ 140 type: at.WEATHER_IMPRESSION, 141 }) 142 ); 143 144 // Stop observing since element has been seen 145 this.setState({ 146 impressionSeen: true, 147 }); 148 } 149 } 150 } 151 152 onError(entries) { 153 if (this.state) { 154 const entry = entries.find(e => e.isIntersecting); 155 156 if (entry) { 157 if (this.errorElement) { 158 this.observer.unobserve(this.errorElement); 159 } 160 161 this.props.dispatch( 162 ac.OnlyToMain({ 163 type: at.WEATHER_LOAD_ERROR, 164 }) 165 ); 166 167 // Stop observing since element has been seen 168 this.setState({ 169 errorSeen: true, 170 }); 171 } 172 } 173 } 174 175 openContextMenu(isKeyBoard) { 176 if (this.props.onUpdate) { 177 this.props.onUpdate(true); 178 } 179 this.setState({ 180 showContextMenu: true, 181 contextMenuKeyboard: isKeyBoard, 182 }); 183 } 184 185 onClick(event) { 186 event.preventDefault(); 187 this.openContextMenu(false, event); 188 } 189 190 onKeyDown(event) { 191 if (event.key === "Enter" || event.key === " ") { 192 event.preventDefault(); 193 this.openContextMenu(true, event); 194 } 195 } 196 197 onUpdate(showContextMenu) { 198 if (this.props.onUpdate) { 199 this.props.onUpdate(showContextMenu); 200 } 201 this.setState({ showContextMenu }); 202 } 203 204 onProviderClick() { 205 this.props.dispatch( 206 ac.OnlyToMain({ 207 type: at.WEATHER_OPEN_PROVIDER_URL, 208 data: { 209 source: "WEATHER", 210 }, 211 }) 212 ); 213 } 214 215 handleRejectOptIn = () => { 216 batch(() => { 217 this.props.dispatch(ac.SetPref("weather.optInAccepted", false)); 218 this.props.dispatch(ac.SetPref("weather.optInDisplayed", false)); 219 220 this.props.dispatch( 221 ac.AlsoToMain({ 222 type: at.WEATHER_OPT_IN_PROMPT_SELECTION, 223 data: "rejected opt-in", 224 }) 225 ); 226 }); 227 }; 228 229 handleAcceptOptIn = () => { 230 batch(() => { 231 this.props.dispatch( 232 ac.AlsoToMain({ 233 type: at.WEATHER_USER_OPT_IN_LOCATION, 234 }) 235 ); 236 237 this.props.dispatch( 238 ac.AlsoToMain({ 239 type: at.WEATHER_OPT_IN_PROMPT_SELECTION, 240 data: "accepted opt-in", 241 }) 242 ); 243 }); 244 }; 245 246 isEnabled() { 247 const { values } = this.props.Prefs; 248 const systemValue = 249 values[PREF_SYSTEM_SHOW_WEATHER] && values["feeds.weatherfeed"]; 250 const experimentValue = values.trainhopConfig?.weather?.enabled; 251 return systemValue || experimentValue; 252 } 253 254 render() { 255 // Check if weather should be rendered 256 if (!this.isEnabled()) { 257 return false; 258 } 259 260 if ( 261 this.props.App.isForStartupCache.Weather || 262 !this.props.Weather.initialized 263 ) { 264 return <WeatherPlaceholder />; 265 } 266 267 const { showContextMenu } = this.state; 268 269 const { props } = this; 270 271 const { dispatch, Prefs, Weather } = props; 272 273 const WEATHER_SUGGESTION = Weather.suggestions?.[0]; 274 275 const outerClassName = [ 276 "weather", 277 Weather.searchActive && "search", 278 props.isInSection && "section-weather", 279 ] 280 .filter(v => v) 281 .join(" "); 282 283 const showDetailedView = Prefs.values["weather.display"] === "detailed"; 284 285 const weatherOptIn = Prefs.values["system.showWeatherOptIn"]; 286 const nimbusWeatherOptInEnabled = 287 Prefs.values.trainhopConfig?.weather?.weatherOptInEnabled; 288 // Bug 2009484: Controls button order in opt-in dialog for A/B testing. 289 // When true, "Not now" gets slot="primary"; 290 // when false/undefined, "Yes" gets slot="primary". 291 // Also note the primary button's position varies by platform: 292 // on Windows, it appears on the left, 293 // while on Linux and macOS, it appears on the right. 294 const reverseOptInButtons = 295 Prefs.values.trainhopConfig?.weather?.reverseOptInButtons; 296 297 const optInDisplayed = Prefs.values["weather.optInDisplayed"]; 298 const optInUserChoice = Prefs.values["weather.optInAccepted"]; 299 const staticWeather = Prefs.values["weather.staticData.enabled"]; 300 301 // Conditionals for rendering feature based on prefs + nimbus experiment variables 302 const isOptInEnabled = weatherOptIn || nimbusWeatherOptInEnabled; 303 304 // Opt-in dialog should only show if: 305 // - weather enabled on customization menu 306 // - weather opt-in pref is enabled 307 // - opt-in prompt is enabled 308 // - user hasn't accepted the opt-in yet 309 const shouldShowOptInDialog = 310 isOptInEnabled && optInDisplayed && !optInUserChoice; 311 312 // Show static weather data only if: 313 // - weather is enabled on customization menu 314 // - weather opt-in pref is enabled 315 // - static weather data is enabled 316 const showStaticData = isOptInEnabled && staticWeather; 317 318 // Note: The temperature units/display options will become secondary menu items 319 const WEATHER_SOURCE_CONTEXT_MENU_OPTIONS = [ 320 ...(Prefs.values["weather.locationSearchEnabled"] 321 ? ["ChangeWeatherLocation"] 322 : []), 323 ...(isOptInEnabled ? ["DetectLocation"] : []), 324 ...(Prefs.values["weather.temperatureUnits"] === "f" 325 ? ["ChangeTempUnitCelsius"] 326 : ["ChangeTempUnitFahrenheit"]), 327 ...(Prefs.values["weather.display"] === "simple" 328 ? ["ChangeWeatherDisplayDetailed"] 329 : ["ChangeWeatherDisplaySimple"]), 330 "HideWeather", 331 "OpenLearnMoreURL", 332 ]; 333 const WEATHER_SOURCE_SHORTENED_CONTEXT_MENU_OPTIONS = [ 334 ...(Prefs.values["weather.locationSearchEnabled"] 335 ? ["ChangeWeatherLocation"] 336 : []), 337 ...(isOptInEnabled ? ["DetectLocation"] : []), 338 "HideWeather", 339 "OpenLearnMoreURL", 340 ]; 341 342 const contextMenu = contextOpts => ( 343 <div className="weatherButtonContextMenuWrapper"> 344 <button 345 aria-haspopup="true" 346 onKeyDown={this.onKeyDown} 347 onClick={this.onClick} 348 data-l10n-id="newtab-menu-section-tooltip" 349 className="weatherButtonContextMenu" 350 > 351 {showContextMenu ? ( 352 <LinkMenu 353 dispatch={dispatch} 354 index={0} 355 source="WEATHER" 356 onUpdate={this.onUpdate} 357 options={contextOpts} 358 site={{ 359 url: "https://support.mozilla.org/kb/customize-items-on-firefox-new-tab-page", 360 }} 361 link="https://support.mozilla.org/kb/customize-items-on-firefox-new-tab-page" 362 shouldSendImpressionStats={false} 363 /> 364 ) : null} 365 </button> 366 </div> 367 ); 368 369 if (Weather.searchActive) { 370 return <LocationSearch outerClassName={outerClassName} />; 371 } else if (WEATHER_SUGGESTION) { 372 return ( 373 <div ref={this.setImpressionRef} className={outerClassName}> 374 <div className="weatherCard"> 375 {showStaticData ? ( 376 <div className="weatherInfoLink staticWeatherInfo"> 377 <div className="weatherIconCol"> 378 <span className="weatherIcon iconId3" /> 379 </div> 380 <div className="weatherText"> 381 <div className="weatherForecastRow"> 382 <span className="weatherTemperature"> 383 22°{Prefs.values["weather.temperatureUnits"]} 384 </span> 385 </div> 386 <div className="weatherCityRow"> 387 <span 388 className="weatherCity" 389 data-l10n-id="newtab-weather-static-city" 390 ></span> 391 </div> 392 </div> 393 </div> 394 ) : ( 395 <a 396 data-l10n-id="newtab-weather-see-forecast" 397 data-l10n-args='{"provider": "AccuWeather®"}' 398 href={WEATHER_SUGGESTION.forecast.url} 399 className="weatherInfoLink" 400 onClick={this.onProviderClick} 401 > 402 <div className="weatherIconCol"> 403 <span 404 className={`weatherIcon iconId${WEATHER_SUGGESTION.current_conditions.icon_id}`} 405 /> 406 </div> 407 <div className="weatherText"> 408 <div className="weatherForecastRow"> 409 <span className="weatherTemperature"> 410 { 411 WEATHER_SUGGESTION.current_conditions.temperature[ 412 Prefs.values["weather.temperatureUnits"] 413 ] 414 } 415 °{Prefs.values["weather.temperatureUnits"]} 416 </span> 417 </div> 418 <div className="weatherCityRow"> 419 <span className="weatherCity"> 420 {Weather.locationData.city} 421 </span> 422 </div> 423 {showDetailedView ? ( 424 <div className="weatherDetailedSummaryRow"> 425 <div className="weatherHighLowTemps"> 426 {/* Low Forecasted Temperature */} 427 <span> 428 { 429 WEATHER_SUGGESTION.forecast.high[ 430 Prefs.values["weather.temperatureUnits"] 431 ] 432 } 433 ° 434 {Prefs.values["weather.temperatureUnits"]} 435 </span> 436 {/* Spacer / Bullet */} 437 <span>•</span> 438 {/* Low Forecasted Temperature */} 439 <span> 440 { 441 WEATHER_SUGGESTION.forecast.low[ 442 Prefs.values["weather.temperatureUnits"] 443 ] 444 } 445 ° 446 {Prefs.values["weather.temperatureUnits"]} 447 </span> 448 </div> 449 <span className="weatherTextSummary"> 450 {WEATHER_SUGGESTION.current_conditions.summary} 451 </span> 452 </div> 453 ) : null} 454 </div> 455 </a> 456 )} 457 458 {contextMenu( 459 showStaticData 460 ? WEATHER_SOURCE_SHORTENED_CONTEXT_MENU_OPTIONS 461 : WEATHER_SOURCE_CONTEXT_MENU_OPTIONS 462 )} 463 </div> 464 <span className="weatherSponsorText"> 465 <span 466 data-l10n-id="newtab-weather-sponsored" 467 data-l10n-args='{"provider": "AccuWeather®"}' 468 ></span> 469 </span> 470 471 {shouldShowOptInDialog && ( 472 <div className="weatherOptIn"> 473 <dialog open={true}> 474 <span className="weatherOptInImg"></span> 475 <div className="weatherOptInContent"> 476 <h3 data-l10n-id="newtab-weather-opt-in-see-weather"></h3> 477 <moz-button-group className="button-group"> 478 <moz-button 479 size="small" 480 type="default" 481 data-l10n-id="newtab-weather-opt-in-yes" 482 onClick={this.handleAcceptOptIn} 483 id="accept-opt-in" 484 slot={reverseOptInButtons ? "" : "primary"} 485 /> 486 <moz-button 487 size="small" 488 type="default" 489 data-l10n-id="newtab-weather-opt-in-not-now" 490 onClick={this.handleRejectOptIn} 491 id="reject-opt-in" 492 slot={reverseOptInButtons ? "primary" : ""} 493 /> 494 </moz-button-group> 495 </div> 496 </dialog> 497 </div> 498 )} 499 </div> 500 ); 501 } 502 503 return ( 504 <div ref={this.setErrorRef} className={outerClassName}> 505 <div className="weatherNotAvailable"> 506 <span className="icon icon-info-warning" />{" "} 507 <p data-l10n-id="newtab-weather-error-not-available"></p> 508 {contextMenu(WEATHER_SOURCE_SHORTENED_CONTEXT_MENU_OPTIONS)} 509 </div> 510 </div> 511 ); 512 } 513 } 514 515 export const Weather = connect(state => ({ 516 App: state.App, 517 Weather: state.Weather, 518 Prefs: state.Prefs, 519 IntersectionObserver: globalThis.IntersectionObserver, 520 document: globalThis.document, 521 }))(_Weather);