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);