tor-browser

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

Base.jsx (36107B)


      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 { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
      6 import { DiscoveryStreamAdmin } from "content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin";
      7 import { ConfirmDialog } from "content-src/components/ConfirmDialog/ConfirmDialog";
      8 import { connect } from "react-redux";
      9 import { DiscoveryStreamBase } from "content-src/components/DiscoveryStreamBase/DiscoveryStreamBase";
     10 import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary";
     11 import { CustomizeMenu } from "content-src/components/CustomizeMenu/CustomizeMenu";
     12 import React, { useState, useEffect } from "react";
     13 import { Search } from "content-src/components/Search/Search";
     14 import { Sections } from "content-src/components/Sections/Sections";
     15 import { Logo } from "content-src/components/Logo/Logo";
     16 import { Weather } from "content-src/components/Weather/Weather";
     17 import { DownloadModalToggle } from "content-src/components/DownloadModalToggle/DownloadModalToggle";
     18 import { Notifications } from "content-src/components/Notifications/Notifications";
     19 import { TopicSelection } from "content-src/components/DiscoveryStreamComponents/TopicSelection/TopicSelection";
     20 import { DownloadMobilePromoHighlight } from "../DiscoveryStreamComponents/FeatureHighlight/DownloadMobilePromoHighlight";
     21 import { WallpaperFeatureHighlight } from "../DiscoveryStreamComponents/FeatureHighlight/WallpaperFeatureHighlight";
     22 import { MessageWrapper } from "content-src/components/MessageWrapper/MessageWrapper";
     23 import { selectWeatherPlacement } from "../../lib/utils";
     24 
     25 const VISIBLE = "visible";
     26 const VISIBILITY_CHANGE_EVENT = "visibilitychange";
     27 const PREF_INFERRED_PERSONALIZATION_SYSTEM =
     28  "discoverystream.sections.personalization.inferred.enabled";
     29 const PREF_INFERRED_PERSONALIZATION_USER =
     30  "discoverystream.sections.personalization.inferred.user.enabled";
     31 
     32 // Returns a function will not be continuously triggered when called. The
     33 // function will be triggered if called again after `wait` milliseconds.
     34 function debounce(func, wait) {
     35  let timer;
     36  return (...args) => {
     37    if (timer) {
     38      return;
     39    }
     40 
     41    let wakeUp = () => {
     42      timer = null;
     43    };
     44 
     45    timer = setTimeout(wakeUp, wait);
     46    func.apply(this, args);
     47  };
     48 }
     49 
     50 export function WithDsAdmin(props) {
     51  const { hash = globalThis?.location?.hash || "" } = props;
     52 
     53  const [devtoolsCollapsed, setDevtoolsCollapsed] = useState(
     54    !hash.startsWith("#devtools")
     55  );
     56 
     57  useEffect(() => {
     58    const onHashChange = () => {
     59      const h = globalThis?.location?.hash || "";
     60      setDevtoolsCollapsed(!h.startsWith("#devtools"));
     61    };
     62 
     63    // run once in case hash changed before mount
     64    onHashChange();
     65 
     66    globalThis?.addEventListener("hashchange", onHashChange);
     67    return () => globalThis?.removeEventListener("hashchange", onHashChange);
     68  }, []);
     69 
     70  return (
     71    <>
     72      <DiscoveryStreamAdmin devtoolsCollapsed={devtoolsCollapsed} />
     73      {devtoolsCollapsed ? <BaseContent {...props} /> : null}
     74    </>
     75  );
     76 }
     77 
     78 export function _Base(props) {
     79  const isDevtoolsEnabled = props.Prefs.values["asrouter.devtoolsEnabled"];
     80  const { App } = props;
     81 
     82  if (!App.initialized) {
     83    return null;
     84  }
     85 
     86  return (
     87    <ErrorBoundary className="base-content-fallback">
     88      {isDevtoolsEnabled ? (
     89        <WithDsAdmin {...props} />
     90      ) : (
     91        <BaseContent {...props} />
     92      )}
     93    </ErrorBoundary>
     94  );
     95 }
     96 
     97 export class BaseContent extends React.PureComponent {
     98  constructor(props) {
     99    super(props);
    100    this.openPreferences = this.openPreferences.bind(this);
    101    this.openCustomizationMenu = this.openCustomizationMenu.bind(this);
    102    this.closeCustomizationMenu = this.closeCustomizationMenu.bind(this);
    103    this.handleOnKeyDown = this.handleOnKeyDown.bind(this);
    104    this.onWindowScroll = debounce(this.onWindowScroll.bind(this), 5);
    105    this.setPref = this.setPref.bind(this);
    106    this.shouldShowOMCHighlight = this.shouldShowOMCHighlight.bind(this);
    107    this.updateWallpaper = this.updateWallpaper.bind(this);
    108    this.prefersDarkQuery = null;
    109    this.handleColorModeChange = this.handleColorModeChange.bind(this);
    110    this.onVisible = this.onVisible.bind(this);
    111    this.toggleDownloadHighlight = this.toggleDownloadHighlight.bind(this);
    112    this.handleDismissDownloadHighlight =
    113      this.handleDismissDownloadHighlight.bind(this);
    114    this.applyBodyClasses = this.applyBodyClasses.bind(this);
    115    this.toggleSectionsMgmtPanel = this.toggleSectionsMgmtPanel.bind(this);
    116    this.state = {
    117      fixedSearch: false,
    118      firstVisibleTimestamp: null,
    119      colorMode: "",
    120      fixedNavStyle: {},
    121      wallpaperTheme: "",
    122      showDownloadHighlightOverride: null,
    123      visible: false,
    124      showSectionsMgmtPanel: false,
    125    };
    126    this.spocPlaceholderStartTime = null;
    127  }
    128 
    129  setFirstVisibleTimestamp() {
    130    if (!this.state.firstVisibleTimestamp) {
    131      this.setState({
    132        firstVisibleTimestamp: Date.now(),
    133      });
    134    }
    135  }
    136 
    137  onVisible() {
    138    this.setState({
    139      visible: true,
    140    });
    141    this.setFirstVisibleTimestamp();
    142    this.shouldDisplayTopicSelectionModal();
    143    this.onVisibilityDispatch();
    144 
    145    if (this.isSpocsOnDemandExpired && !this.spocPlaceholderStartTime) {
    146      this.spocPlaceholderStartTime = Date.now();
    147    }
    148  }
    149 
    150  onVisibilityDispatch() {
    151    const { onDemand = {} } = this.props.DiscoveryStream.spocs;
    152 
    153    // We only need to dispatch this if:
    154    // 1. onDemand is enabled,
    155    // 2. onDemand spocs have not been loaded on this tab.
    156    // 3. Spocs are expired.
    157    if (onDemand.enabled && !onDemand.loaded && this.isSpocsOnDemandExpired) {
    158      // This dispatches that spocs are expired and we need to update them.
    159      this.props.dispatch(
    160        ac.OnlyToMain({
    161          type: at.DISCOVERY_STREAM_SPOCS_ONDEMAND_UPDATE,
    162        })
    163      );
    164    }
    165  }
    166 
    167  get isSpocsOnDemandExpired() {
    168    const {
    169      onDemand = {},
    170      cacheUpdateTime,
    171      lastUpdated,
    172    } = this.props.DiscoveryStream.spocs;
    173 
    174    // We can bail early if:
    175    // 1. onDemand is off,
    176    // 2. onDemand spocs have been loaded on this tab.
    177    if (!onDemand.enabled || onDemand.loaded) {
    178      return false;
    179    }
    180 
    181    return Date.now() - lastUpdated >= cacheUpdateTime;
    182  }
    183 
    184  spocsOnDemandUpdated() {
    185    const { onDemand = {}, loaded } = this.props.DiscoveryStream.spocs;
    186 
    187    // We only need to fire this if:
    188    // 1. Spoc data is loaded.
    189    // 2. onDemand is enabled.
    190    // 3. The component is visible (not preloaded tab).
    191    // 4. onDemand spocs have not been loaded on this tab.
    192    // 5. Spocs are not expired.
    193    if (
    194      loaded &&
    195      onDemand.enabled &&
    196      this.state.visible &&
    197      !onDemand.loaded &&
    198      !this.isSpocsOnDemandExpired
    199    ) {
    200      // This dispatches that spocs have been loaded on this tab
    201      // and we don't need to update them again for this tab.
    202      this.props.dispatch(
    203        ac.BroadcastToContent({ type: at.DISCOVERY_STREAM_SPOCS_ONDEMAND_LOAD })
    204      );
    205    }
    206  }
    207 
    208  componentDidMount() {
    209    this.applyBodyClasses();
    210    global.addEventListener("scroll", this.onWindowScroll);
    211    global.addEventListener("keydown", this.handleOnKeyDown);
    212    const prefs = this.props.Prefs.values;
    213    const wallpapersEnabled = prefs["newtabWallpapers.enabled"];
    214 
    215    if (!prefs["externalComponents.enabled"]) {
    216      if (prefs["search.useHandoffComponent"]) {
    217        // Dynamically import the contentSearchHandoffUI module, but don't worry
    218        // about webpacking this one.
    219        import(
    220          /* webpackIgnore: true */ "chrome://browser/content/contentSearchHandoffUI.mjs"
    221        );
    222      } else {
    223        const scriptURL = "chrome://browser/content/contentSearchHandoffUI.js";
    224        const scriptEl = document.createElement("script");
    225        scriptEl.src = scriptURL;
    226        document.head.appendChild(scriptEl);
    227      }
    228    }
    229 
    230    if (this.props.document.visibilityState === VISIBLE) {
    231      this.onVisible();
    232    } else {
    233      this._onVisibilityChange = () => {
    234        if (this.props.document.visibilityState === VISIBLE) {
    235          this.onVisible();
    236          this.props.document.removeEventListener(
    237            VISIBILITY_CHANGE_EVENT,
    238            this._onVisibilityChange
    239          );
    240          this._onVisibilityChange = null;
    241        }
    242      };
    243      this.props.document.addEventListener(
    244        VISIBILITY_CHANGE_EVENT,
    245        this._onVisibilityChange
    246      );
    247    }
    248    // track change event to dark/light mode
    249    this.prefersDarkQuery = globalThis.matchMedia(
    250      "(prefers-color-scheme: dark)"
    251    );
    252 
    253    this.prefersDarkQuery.addEventListener(
    254      "change",
    255      this.handleColorModeChange
    256    );
    257    this.handleColorModeChange();
    258    if (wallpapersEnabled) {
    259      this.updateWallpaper();
    260    }
    261 
    262    this._onHashChange = () => {
    263      const hash = globalThis.location?.hash || "";
    264      if (hash === "#customize" || hash === "#customize-topics") {
    265        this.openCustomizationMenu();
    266 
    267        if (hash === "#customize-topics") {
    268          this.toggleSectionsMgmtPanel();
    269        }
    270      } else if (this.props.App.customizeMenuVisible) {
    271        this.closeCustomizationMenu();
    272      }
    273    };
    274 
    275    // Using the Performance API to detect page reload vs fresh navigation.
    276    // Only open customize menu on fresh navigation, not on page refresh.
    277    // See: https://developer.mozilla.org/en-US/docs/Web/API/Performance/getEntriesByType
    278    // See: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEntry/entryType#navigation
    279    // See: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming/type
    280    const isReload =
    281      globalThis.performance?.getEntriesByType("navigation")[0]?.type ===
    282      "reload";
    283 
    284    if (!isReload) {
    285      this._onHashChange();
    286    }
    287 
    288    globalThis.addEventListener("hashchange", this._onHashChange);
    289  }
    290 
    291  componentDidUpdate(prevProps) {
    292    this.applyBodyClasses();
    293    const prefs = this.props.Prefs.values;
    294 
    295    // Check if weather widget was re-enabled from customization menu
    296    const wasWeatherDisabled = !prevProps.Prefs.values.showWeather;
    297    const isWeatherEnabled = this.props.Prefs.values.showWeather;
    298 
    299    if (wasWeatherDisabled && isWeatherEnabled) {
    300      // If weather widget was enabled from customization menu, display opt-in dialog
    301      this.props.dispatch(ac.SetPref("weather.optInDisplayed", true));
    302    }
    303 
    304    const wallpapersEnabled = prefs["newtabWallpapers.enabled"];
    305    if (wallpapersEnabled) {
    306      // destructure current and previous props with fallbacks
    307      // (preventing undefined errors)
    308      const {
    309        Wallpapers: { uploadedWallpaper = null, wallpaperList = null } = {},
    310      } = this.props;
    311 
    312      const {
    313        Wallpapers: {
    314          uploadedWallpaper: prevUploadedWallpaper = null,
    315          wallpaperList: prevWallpaperList = null,
    316        } = {},
    317        Prefs: { values: prevPrefs = {} } = {},
    318      } = prevProps;
    319 
    320      const selectedWallpaper = prefs["newtabWallpapers.wallpaper"];
    321      const prevSelectedWallpaper = prevPrefs["newtabWallpapers.wallpaper"];
    322      const uploadedWallpaperTheme =
    323        prefs["newtabWallpapers.customWallpaper.theme"];
    324      const prevUploadedWallpaperTheme =
    325        prevPrefs["newtabWallpapers.customWallpaper.theme"];
    326 
    327      // don't update wallpaper unless the wallpaper is being changed.
    328      if (
    329        selectedWallpaper !== prevSelectedWallpaper || // selecting a new wallpaper
    330        uploadedWallpaper !== prevUploadedWallpaper || // uploading a new wallpaper
    331        wallpaperList !== prevWallpaperList || // remote settings wallpaper list updates
    332        this.props.App.isForStartupCache.Wallpaper !==
    333          prevProps.App.isForStartupCache.Wallpaper || // Startup cached page wallpaper is updating
    334        uploadedWallpaperTheme !== prevUploadedWallpaperTheme
    335      ) {
    336        this.updateWallpaper();
    337      }
    338    }
    339 
    340    this.spocsOnDemandUpdated();
    341    this.trackSpocPlaceholderDuration(prevProps);
    342  }
    343 
    344  trackSpocPlaceholderDuration(prevProps) {
    345    // isExpired returns true when the current props have expired spocs (showing placeholders)
    346    const isExpired = this.isSpocsOnDemandExpired;
    347 
    348    // Init tracking when placeholders become visible
    349    if (isExpired && this.state.visible && !this.spocPlaceholderStartTime) {
    350      this.spocPlaceholderStartTime = Date.now();
    351    }
    352 
    353    // wasExpired returns true when the previous props had expired spocs (showing placeholders)
    354    const wasExpired =
    355      prevProps.DiscoveryStream.spocs.onDemand?.enabled &&
    356      !prevProps.DiscoveryStream.spocs.onDemand?.loaded &&
    357      Date.now() - prevProps.DiscoveryStream.spocs.lastUpdated >=
    358        prevProps.DiscoveryStream.spocs.cacheUpdateTime;
    359 
    360    // Record duration telemetry event when placeholders are replaced with real content
    361    if (wasExpired && !isExpired && this.spocPlaceholderStartTime) {
    362      const duration = Date.now() - this.spocPlaceholderStartTime;
    363      this.props.dispatch(
    364        ac.OnlyToMain({
    365          type: at.DISCOVERY_STREAM_SPOC_PLACEHOLDER_DURATION,
    366          data: { duration },
    367        })
    368      );
    369      this.spocPlaceholderStartTime = null;
    370    }
    371  }
    372 
    373  handleColorModeChange() {
    374    const colorMode = this.prefersDarkQuery?.matches ? "dark" : "light";
    375    if (colorMode !== this.state.colorMode) {
    376      this.setState({ colorMode });
    377      this.updateWallpaper();
    378    }
    379  }
    380 
    381  componentWillUnmount() {
    382    this.prefersDarkQuery?.removeEventListener(
    383      "change",
    384      this.handleColorModeChange
    385    );
    386    global.removeEventListener("scroll", this.onWindowScroll);
    387    global.removeEventListener("keydown", this.handleOnKeyDown);
    388    if (this._onVisibilityChange) {
    389      this.props.document.removeEventListener(
    390        VISIBILITY_CHANGE_EVENT,
    391        this._onVisibilityChange
    392      );
    393    }
    394    if (this._onHashChange) {
    395      globalThis.removeEventListener("hashchange", this._onHashChange);
    396    }
    397  }
    398 
    399  onWindowScroll() {
    400    if (window.innerHeight <= 700) {
    401      // Bug 1937296: Only apply fixed-search logic
    402      // if the page is tall enough to support it.
    403      return;
    404    }
    405 
    406    const prefs = this.props.Prefs.values;
    407    const { showSearch } = prefs;
    408 
    409    if (!showSearch) {
    410      // Bug 1944718: Only apply fixed-search logic
    411      // if search is visible.
    412      return;
    413    }
    414 
    415    const logoAlwaysVisible = prefs["logowordmark.alwaysVisible"];
    416 
    417    /* Bug 1917937: The logic presented below is fragile but accurate to the pixel. As new tab experiments with layouts, we have a tech debt of competing styles and classes the slightly modify where the search bar sits on the page. The larger solution for this is to replace everything with an intersection observer, but would require a larger refactor of this file. In the interim, we can programmatically calculate when to fire the fixed-scroll event and account for the moved elements so that topsites/etc stays in the same place. The CSS this references has been flagged to reference this logic so (hopefully) keep them in sync. */
    418 
    419    let SCROLL_THRESHOLD = 0; // When the fixed-scroll event fires
    420    let MAIN_OFFSET_PADDING = 0; // The padding to compensate for the moved elements
    421 
    422    const CSS_VAR_SPACE_XXLARGE = 32.04; // Custom Acorn themed variable (8 * 0.267rem);
    423 
    424    let layout = {
    425      outerWrapperPaddingTop: 32.04,
    426      searchWrapperPaddingTop: 16.02,
    427      searchWrapperPaddingBottom: CSS_VAR_SPACE_XXLARGE,
    428      searchWrapperFixedScrollPaddingTop: 24.03,
    429      searchWrapperFixedScrollPaddingBottom: 24.03,
    430      searchInnerWrapperMinHeight: 52,
    431      logoAndWordmarkWrapperHeight: 0,
    432      logoAndWordmarkWrapperMarginBottom: 0,
    433    };
    434 
    435    // Logo visibility applies to all layouts
    436    if (!logoAlwaysVisible) {
    437      layout.logoAndWordmarkWrapperHeight = 0;
    438      layout.logoAndWordmarkWrapperMarginBottom = 0;
    439    }
    440 
    441    SCROLL_THRESHOLD =
    442      layout.outerWrapperPaddingTop +
    443      layout.searchWrapperPaddingTop +
    444      layout.logoAndWordmarkWrapperHeight +
    445      layout.logoAndWordmarkWrapperMarginBottom -
    446      layout.searchWrapperFixedScrollPaddingTop;
    447 
    448    MAIN_OFFSET_PADDING =
    449      layout.searchWrapperPaddingTop +
    450      layout.searchWrapperPaddingBottom +
    451      layout.searchInnerWrapperMinHeight +
    452      layout.logoAndWordmarkWrapperHeight +
    453      layout.logoAndWordmarkWrapperMarginBottom;
    454 
    455    // Edge case if logo and thums are turned off, but Var A is enabled
    456    if (SCROLL_THRESHOLD < 1) {
    457      SCROLL_THRESHOLD = 1;
    458    }
    459 
    460    if (global.scrollY > SCROLL_THRESHOLD && !this.state.fixedSearch) {
    461      this.setState({
    462        fixedSearch: true,
    463        fixedNavStyle: { paddingBlockStart: `${MAIN_OFFSET_PADDING}px` },
    464      });
    465    } else if (global.scrollY <= SCROLL_THRESHOLD && this.state.fixedSearch) {
    466      this.setState({ fixedSearch: false, fixedNavStyle: {} });
    467    }
    468  }
    469 
    470  openPreferences() {
    471    this.props.dispatch(ac.OnlyToMain({ type: at.SETTINGS_OPEN }));
    472    this.props.dispatch(ac.UserEvent({ event: "OPEN_NEWTAB_PREFS" }));
    473  }
    474 
    475  openCustomizationMenu() {
    476    this.props.dispatch({ type: at.SHOW_PERSONALIZE });
    477    this.props.dispatch(ac.UserEvent({ event: "SHOW_PERSONALIZE" }));
    478  }
    479 
    480  closeCustomizationMenu() {
    481    if (this.props.App.customizeMenuVisible) {
    482      this.props.dispatch({ type: at.HIDE_PERSONALIZE });
    483      this.props.dispatch(ac.UserEvent({ event: "HIDE_PERSONALIZE" }));
    484    }
    485  }
    486 
    487  handleOnKeyDown(e) {
    488    if (e.key === "Escape") {
    489      this.closeCustomizationMenu();
    490    }
    491  }
    492 
    493  setPref(pref, value) {
    494    this.props.dispatch(ac.SetPref(pref, value));
    495  }
    496 
    497  applyBodyClasses() {
    498    const { body } = this.props.document;
    499    if (!body) {
    500      return;
    501    }
    502 
    503    if (!body.classList.contains("activity-stream")) {
    504      body.classList.add("activity-stream");
    505    }
    506  }
    507 
    508  renderWallpaperAttribution() {
    509    const { wallpaperList } = this.props.Wallpapers;
    510    const activeWallpaper =
    511      this.props.Prefs.values[`newtabWallpapers.wallpaper`];
    512    const selected = wallpaperList.find(wp => wp.title === activeWallpaper);
    513    // make sure a wallpaper is selected and that the attribution also exists
    514    if (!selected?.attribution) {
    515      return null;
    516    }
    517 
    518    const { name: authorDetails, webpage } = selected.attribution;
    519    if (activeWallpaper && wallpaperList && authorDetails.url) {
    520      return (
    521        <p
    522          className={`wallpaper-attribution`}
    523          key={authorDetails.string}
    524          data-l10n-id="newtab-wallpaper-attribution"
    525          data-l10n-args={JSON.stringify({
    526            author_string: authorDetails.string,
    527            author_url: authorDetails.url,
    528            webpage_string: webpage.string,
    529            webpage_url: webpage.url,
    530          })}
    531        >
    532          <a data-l10n-name="name-link" href={authorDetails.url}>
    533            {authorDetails.string}
    534          </a>
    535          <a data-l10n-name="webpage-link" href={webpage.url}>
    536            {webpage.string}
    537          </a>
    538        </p>
    539      );
    540    }
    541    return null;
    542  }
    543 
    544  async updateWallpaper() {
    545    const prefs = this.props.Prefs.values;
    546    const selectedWallpaper = prefs["newtabWallpapers.wallpaper"];
    547    const { wallpaperList, uploadedWallpaper: uploadedWallpaperUrl } =
    548      this.props.Wallpapers;
    549    const uploadedWallpaperTheme =
    550      prefs["newtabWallpapers.customWallpaper.theme"];
    551    // Uuse this.prefersDarkQuery since this.state.colorMode can be undefined when this is called
    552    const colorMode = this.prefersDarkQuery?.matches ? "dark" : "light";
    553    let url = "";
    554    let color = "transparent";
    555    let newTheme = colorMode;
    556    let backgroundPosition = "center";
    557 
    558    // if no selected wallpaper fallback to browser/theme styles
    559    if (!selectedWallpaper) {
    560      global.document?.body.style.removeProperty("--newtab-wallpaper");
    561      global.document?.body.style.removeProperty("--newtab-wallpaper-color");
    562      global.document?.body.style.removeProperty(
    563        "--newtab-wallpaper-backgroundPosition"
    564      );
    565      global.document?.body.classList.remove("lightWallpaper", "darkWallpaper");
    566      return;
    567    }
    568 
    569    // uploaded wallpaper
    570    if (selectedWallpaper === "custom" && uploadedWallpaperUrl) {
    571      url = uploadedWallpaperUrl;
    572      color = "transparent";
    573      // Note: There is no method to set a specific background position for custom wallpapers
    574      backgroundPosition = "center";
    575      newTheme = uploadedWallpaperTheme || colorMode;
    576    } else if (wallpaperList) {
    577      const wallpaper = wallpaperList.find(
    578        wp => wp.title === selectedWallpaper
    579      );
    580      // solid color picker
    581      if (selectedWallpaper.includes("solid-color-picker")) {
    582        const regexRGB = /#([a-fA-F0-9]{6})/;
    583        const hex = selectedWallpaper.match(regexRGB)?.[0];
    584        url = "";
    585        color = hex;
    586        const rgbColors = this.getRGBColors(hex);
    587        newTheme = this.isWallpaperColorDark(rgbColors) ? "dark" : "light";
    588        // standard wallpaper & solid colors
    589      } else if (selectedWallpaper) {
    590        url = wallpaper?.wallpaperUrl || "";
    591        backgroundPosition = wallpaper?.background_position || "center";
    592        color = wallpaper?.solid_color || "transparent";
    593        newTheme = wallpaper?.theme || colorMode;
    594        // if a solid color, determine if dark or light
    595        if (wallpaper?.solid_color) {
    596          const rgbColors = this.getRGBColors(wallpaper.solid_color);
    597          const isColorDark = this.isWallpaperColorDark(rgbColors);
    598          newTheme = isColorDark ? "dark" : "light";
    599        }
    600      }
    601    }
    602    global.document?.body.style.setProperty(
    603      "--newtab-wallpaper",
    604      `url(${url})`
    605    );
    606    global.document?.body.style.setProperty(
    607      "--newtab-wallpaper-backgroundPosition",
    608      backgroundPosition
    609    );
    610    global.document?.body.style.setProperty(
    611      "--newtab-wallpaper-color",
    612      color || "transparent"
    613    );
    614 
    615    global.document?.body.classList.remove("lightWallpaper", "darkWallpaper");
    616    global.document?.body.classList.add(
    617      newTheme === "dark" ? "darkWallpaper" : "lightWallpaper"
    618    );
    619  }
    620 
    621  shouldShowOMCHighlight(componentId) {
    622    const messageData = this.props.Messages?.messageData;
    623    if (!messageData || Object.keys(messageData).length === 0) {
    624      return false;
    625    }
    626    return messageData?.content?.messageType === componentId;
    627  }
    628 
    629  toggleDownloadHighlight() {
    630    this.setState(prevState => {
    631      const override = !(
    632        prevState.showDownloadHighlightOverride ??
    633        this.shouldShowOMCHighlight("DownloadMobilePromoHighlight")
    634      );
    635 
    636      if (override) {
    637        // Emit an open event manually since OMC isn't handling it
    638        this.props.dispatch(
    639          ac.DiscoveryStreamUserEvent({
    640            event: "FEATURE_HIGHLIGHT_OPEN",
    641            source: "FEATURE_HIGHLIGHT",
    642            value: { feature: "FEATURE_DOWNLOAD_MOBILE_PROMO" },
    643          })
    644        );
    645      }
    646 
    647      return {
    648        showDownloadHighlightOverride: override,
    649      };
    650    });
    651  }
    652 
    653  handleDismissDownloadHighlight() {
    654    this.setState({ showDownloadHighlightOverride: false });
    655  }
    656 
    657  getRGBColors(input) {
    658    if (input.length !== 7) {
    659      return [];
    660    }
    661 
    662    const r = parseInt(input.substr(1, 2), 16);
    663    const g = parseInt(input.substr(3, 2), 16);
    664    const b = parseInt(input.substr(5, 2), 16);
    665 
    666    return [r, g, b];
    667  }
    668 
    669  isWallpaperColorDark([r, g, b]) {
    670    return 0.2125 * r + 0.7154 * g + 0.0721 * b <= 110;
    671  }
    672 
    673  toggleSectionsMgmtPanel() {
    674    this.setState(prevState => ({
    675      showSectionsMgmtPanel: !prevState.showSectionsMgmtPanel,
    676    }));
    677  }
    678 
    679  shouldDisplayTopicSelectionModal() {
    680    const prefs = this.props.Prefs.values;
    681    const pocketEnabled =
    682      prefs["feeds.section.topstories"] && prefs["feeds.system.topstories"];
    683    const topicSelectionOnboardingEnabled =
    684      prefs["discoverystream.topicSelection.onboarding.enabled"] &&
    685      pocketEnabled;
    686    const maybeShowModal =
    687      prefs["discoverystream.topicSelection.onboarding.maybeDisplay"];
    688    const displayTimeout =
    689      prefs["discoverystream.topicSelection.onboarding.displayTimeout"];
    690    const lastDisplayed =
    691      prefs["discoverystream.topicSelection.onboarding.lastDisplayed"];
    692    const displayCount =
    693      prefs["discoverystream.topicSelection.onboarding.displayCount"];
    694 
    695    if (
    696      !maybeShowModal ||
    697      !prefs["discoverystream.topicSelection.enabled"] ||
    698      !topicSelectionOnboardingEnabled
    699    ) {
    700      return;
    701    }
    702 
    703    const day = 24 * 60 * 60 * 1000;
    704    const now = new Date().getTime();
    705 
    706    const timeoutOccured = now - parseFloat(lastDisplayed) > displayTimeout;
    707    if (displayCount < 3) {
    708      if (displayCount === 0 || timeoutOccured) {
    709        this.props.dispatch(
    710          ac.BroadcastToContent({ type: at.TOPIC_SELECTION_SPOTLIGHT_OPEN })
    711        );
    712        this.setPref(
    713          "discoverystream.topicSelection.onboarding.displayTimeout",
    714          day
    715        );
    716      }
    717    }
    718  }
    719 
    720  // eslint-disable-next-line max-statements, complexity
    721  render() {
    722    const { props } = this;
    723    const { App, DiscoveryStream } = props;
    724    const { initialized, customizeMenuVisible } = App;
    725    const prefs = props.Prefs.values;
    726 
    727    const activeWallpaper = prefs[`newtabWallpapers.wallpaper`];
    728    const wallpapersEnabled = prefs["newtabWallpapers.enabled"];
    729    const weatherEnabled = prefs.showWeather;
    730    const { showTopicSelection } = DiscoveryStream;
    731    const mayShowTopicSelection =
    732      showTopicSelection && prefs["discoverystream.topicSelection.enabled"];
    733 
    734    const isDiscoveryStream =
    735      props.DiscoveryStream.config && props.DiscoveryStream.config.enabled;
    736    let filteredSections = props.Sections.filter(
    737      section => section.id !== "topstories"
    738    );
    739 
    740    const pocketEnabled =
    741      prefs["feeds.section.topstories"] && prefs["feeds.system.topstories"];
    742    const noSectionsEnabled =
    743      !prefs["feeds.topsites"] &&
    744      !pocketEnabled &&
    745      filteredSections.filter(section => section.enabled).length === 0;
    746    const enabledSections = {
    747      topSitesEnabled: prefs["feeds.topsites"],
    748      pocketEnabled: prefs["feeds.section.topstories"],
    749      showInferredPersonalizationEnabled:
    750        prefs[PREF_INFERRED_PERSONALIZATION_USER],
    751      topSitesRowsCount: prefs.topSitesRows,
    752      weatherEnabled: prefs.showWeather,
    753    };
    754 
    755    const pocketRegion = prefs["feeds.system.topstories"];
    756    const mayHaveInferredPersonalization =
    757      prefs[PREF_INFERRED_PERSONALIZATION_SYSTEM];
    758    const mayHaveWeather =
    759      prefs["system.showWeather"] || prefs.trainhopConfig?.weather?.enabled;
    760    const supportUrl = prefs["support.url"];
    761 
    762    // Weather can be enabled and not rendered in the top right corner
    763    const shouldDisplayWeather =
    764      prefs.showWeather && this.props.weatherPlacement === "header";
    765 
    766    // Widgets experiment pref check
    767    const nimbusWidgetsEnabled = prefs.widgetsConfig?.enabled;
    768    const nimbusListsEnabled = prefs.widgetsConfig?.listsEnabled;
    769    const nimbusTimerEnabled = prefs.widgetsConfig?.timerEnabled;
    770    const nimbusWidgetsTrainhopEnabled = prefs.trainhopConfig?.widgets?.enabled;
    771    const nimbusListsTrainhopEnabled =
    772      prefs.trainhopConfig?.widgets?.listsEnabled;
    773    const nimbusTimerTrainhopEnabled =
    774      prefs.trainhopConfig?.widgets?.timerEnabled;
    775 
    776    const mayHaveWidgets =
    777      prefs["widgets.system.enabled"] ||
    778      nimbusWidgetsEnabled ||
    779      nimbusWidgetsTrainhopEnabled;
    780    const mayHaveListsWidget =
    781      prefs["widgets.system.lists.enabled"] ||
    782      nimbusListsEnabled ||
    783      nimbusListsTrainhopEnabled;
    784    const mayHaveTimerWidget =
    785      prefs["widgets.system.focusTimer.enabled"] ||
    786      nimbusTimerEnabled ||
    787      nimbusTimerTrainhopEnabled;
    788 
    789    // These prefs set the initial values on the Customize panel toggle switches
    790    const enabledWidgets = {
    791      listsEnabled: prefs["widgets.lists.enabled"],
    792      timerEnabled: prefs["widgets.focusTimer.enabled"],
    793      weatherEnabled: prefs.showWeather,
    794    };
    795 
    796    // Mobile Download Promo Pref Checks
    797    const mobileDownloadPromoEnabled = prefs["mobileDownloadModal.enabled"];
    798    const mobileDownloadPromoVariantAEnabled =
    799      prefs["mobileDownloadModal.variant-a"];
    800    const mobileDownloadPromoVariantBEnabled =
    801      prefs["mobileDownloadModal.variant-b"];
    802    const mobileDownloadPromoVariantCEnabled =
    803      prefs["mobileDownloadModal.variant-c"];
    804    const mobileDownloadPromoVariantABorC =
    805      mobileDownloadPromoVariantAEnabled ||
    806      mobileDownloadPromoVariantBEnabled ||
    807      mobileDownloadPromoVariantCEnabled;
    808    const mobileDownloadPromoWrapperHeightModifier =
    809      prefs["weather.display"] === "detailed" &&
    810      weatherEnabled &&
    811      shouldDisplayWeather &&
    812      mayHaveWeather
    813        ? "is-tall"
    814        : "";
    815    const sectionsEnabled = prefs["discoverystream.sections.enabled"];
    816    const topicLabelsEnabled = prefs["discoverystream.topicLabels.enabled"];
    817    const sectionsCustomizeMenuPanelEnabled =
    818      prefs["discoverystream.sections.customizeMenuPanel.enabled"];
    819    const sectionsPersonalizationEnabled =
    820      prefs["discoverystream.sections.personalization.enabled"];
    821 
    822    // Logic to show follow/block topic mgmt panel in Customize panel
    823    const mayHavePersonalizedTopicSections =
    824      sectionsPersonalizationEnabled &&
    825      topicLabelsEnabled &&
    826      sectionsEnabled &&
    827      sectionsCustomizeMenuPanelEnabled &&
    828      DiscoveryStream.feeds.loaded;
    829 
    830    const featureClassName = [
    831      mobileDownloadPromoEnabled &&
    832        mobileDownloadPromoVariantABorC &&
    833        "has-mobile-download-promo", // Mobile download promo modal is enabled/visible
    834      weatherEnabled && mayHaveWeather && shouldDisplayWeather && "has-weather", // Weather widget is enabled/visible
    835      prefs.showSearch ? "has-search" : "no-search",
    836      // layoutsVariantAEnabled ? "layout-variant-a" : "", // Layout experiment variant A
    837      // layoutsVariantBEnabled ? "layout-variant-b" : "", // Layout experiment variant B
    838      pocketEnabled ? "has-recommended-stories" : "no-recommended-stories",
    839      sectionsEnabled ? "has-sections-grid" : "",
    840    ]
    841      .filter(v => v)
    842      .join(" ");
    843 
    844    const outerClassName = [
    845      "outer-wrapper",
    846      isDiscoveryStream && pocketEnabled && "ds-outer-wrapper-search-alignment",
    847      isDiscoveryStream && "ds-outer-wrapper-breakpoint-override",
    848      prefs.showSearch &&
    849        this.state.fixedSearch &&
    850        !noSectionsEnabled &&
    851        "fixed-search",
    852      prefs.showSearch && noSectionsEnabled && "only-search",
    853      prefs["feeds.topsites"] &&
    854        !pocketEnabled &&
    855        !prefs.showSearch &&
    856        "only-topsites",
    857      noSectionsEnabled && "no-sections",
    858      prefs["logowordmark.alwaysVisible"] && "visible-logo",
    859    ]
    860      .filter(v => v)
    861      .join(" ");
    862 
    863    // If state.showDownloadHighlightOverride has value, let it override the logic
    864    // Otherwise, defer to OMC message display logic
    865    const shouldShowDownloadHighlight =
    866      this.state.showDownloadHighlightOverride ??
    867      this.shouldShowOMCHighlight("DownloadMobilePromoHighlight");
    868 
    869    return (
    870      <div className={featureClassName}>
    871        <div className="weatherWrapper">
    872          {shouldDisplayWeather && (
    873            <ErrorBoundary>
    874              <Weather />
    875            </ErrorBoundary>
    876          )}
    877        </div>
    878        <div
    879          className={`mobileDownloadPromoWrapper ${mobileDownloadPromoWrapperHeightModifier}`}
    880        >
    881          {mobileDownloadPromoEnabled && mobileDownloadPromoVariantABorC && (
    882            <ErrorBoundary>
    883              <DownloadModalToggle
    884                isActive={shouldShowDownloadHighlight}
    885                onClick={this.toggleDownloadHighlight}
    886              />
    887              {shouldShowDownloadHighlight && (
    888                <MessageWrapper
    889                  hiddenOverride={shouldShowDownloadHighlight}
    890                  onDismiss={this.handleDismissDownloadHighlight}
    891                  dispatch={this.props.dispatch}
    892                >
    893                  <DownloadMobilePromoHighlight
    894                    position={`inset-inline-start inset-block-end`}
    895                    dispatch={this.props.dispatch}
    896                  />
    897                </MessageWrapper>
    898              )}
    899            </ErrorBoundary>
    900          )}
    901        </div>
    902 
    903        {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/}
    904        <div className={outerClassName} onClick={this.closeCustomizationMenu}>
    905          <main className="newtab-main" style={this.state.fixedNavStyle}>
    906            {prefs.showSearch && (
    907              <div className="non-collapsible-section">
    908                <ErrorBoundary>
    909                  <Search
    910                    showLogo={
    911                      noSectionsEnabled || prefs["logowordmark.alwaysVisible"]
    912                    }
    913                    {...props.Search}
    914                  />
    915                </ErrorBoundary>
    916              </div>
    917            )}
    918            {/* Bug 1914055: Show logo regardless if search is enabled */}
    919            {!prefs.showSearch && !noSectionsEnabled && <Logo />}
    920            <div className={`body-wrapper${initialized ? " on" : ""}`}>
    921              {isDiscoveryStream ? (
    922                <ErrorBoundary className="borderless-error">
    923                  <DiscoveryStreamBase
    924                    locale={props.App.locale}
    925                    firstVisibleTimestamp={this.state.firstVisibleTimestamp}
    926                    placeholder={this.isSpocsOnDemandExpired}
    927                  />
    928                </ErrorBoundary>
    929              ) : (
    930                <Sections />
    931              )}
    932            </div>
    933            <ConfirmDialog />
    934            {wallpapersEnabled && this.renderWallpaperAttribution()}
    935          </main>
    936          <aside>
    937            {this.props.Notifications?.showNotifications && (
    938              <ErrorBoundary>
    939                <Notifications dispatch={this.props.dispatch} />
    940              </ErrorBoundary>
    941            )}
    942          </aside>
    943          {/* Only show the modal on currently visible pages (not preloaded) */}
    944          {mayShowTopicSelection && pocketEnabled && (
    945            <TopicSelection supportUrl={supportUrl} />
    946          )}
    947        </div>
    948        {/* Floating menu for customize menu toggle */}
    949        <menu className="personalizeButtonWrapper">
    950          <CustomizeMenu
    951            onClose={this.closeCustomizationMenu}
    952            onOpen={this.openCustomizationMenu}
    953            openPreferences={this.openPreferences}
    954            setPref={this.setPref}
    955            enabledSections={enabledSections}
    956            enabledWidgets={enabledWidgets}
    957            wallpapersEnabled={wallpapersEnabled}
    958            activeWallpaper={activeWallpaper}
    959            pocketRegion={pocketRegion}
    960            mayHaveTopicSections={mayHavePersonalizedTopicSections}
    961            mayHaveInferredPersonalization={mayHaveInferredPersonalization}
    962            mayHaveWeather={mayHaveWeather}
    963            mayHaveWidgets={mayHaveWidgets}
    964            mayHaveTimerWidget={mayHaveTimerWidget}
    965            mayHaveListsWidget={mayHaveListsWidget}
    966            showing={customizeMenuVisible}
    967            toggleSectionsMgmtPanel={this.toggleSectionsMgmtPanel}
    968            showSectionsMgmtPanel={this.state.showSectionsMgmtPanel}
    969          />
    970          {this.shouldShowOMCHighlight("CustomWallpaperHighlight") && (
    971            <MessageWrapper dispatch={this.props.dispatch}>
    972              <WallpaperFeatureHighlight
    973                position="inset-block-start inset-inline-start"
    974                dispatch={this.props.dispatch}
    975              />
    976            </MessageWrapper>
    977          )}
    978        </menu>
    979      </div>
    980    );
    981  }
    982 }
    983 
    984 BaseContent.defaultProps = {
    985  document: global.document,
    986 };
    987 
    988 export const Base = connect(state => ({
    989  App: state.App,
    990  Prefs: state.Prefs,
    991  Sections: state.Sections,
    992  DiscoveryStream: state.DiscoveryStream,
    993  Messages: state.Messages,
    994  Notifications: state.Notifications,
    995  Search: state.Search,
    996  Wallpapers: state.Wallpapers,
    997  Weather: state.Weather,
    998  weatherPlacement: selectWeatherPlacement(state),
    999 }))(_Base);