tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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&deg;{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                      &deg;{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                          &deg;
    434                          {Prefs.values["weather.temperatureUnits"]}
    435                        </span>
    436                        {/* Spacer / Bullet */}
    437                        <span>&bull;</span>
    438                        {/* Low Forecasted Temperature */}
    439                        <span>
    440                          {
    441                            WEATHER_SUGGESTION.forecast.low[
    442                              Prefs.values["weather.temperatureUnits"]
    443                            ]
    444                          }
    445                          &deg;
    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);