commit fcaecb7d09265efe1a9fd7f70379a319344e3528 parent 1c3f943cbe963cb26bb6db98ecfa75dc08906883 Author: Cosmin Sabou <csabou@mozilla.com> Date: Fri, 21 Nov 2025 23:28:20 +0200 Revert "Bug 2001410 - Remove TrendingSearches experiment code from newtab. r=home-newtab-reviewers,nbarrett" for causing newtab failures. This reverts commit 90b6b7524e1c8877258d2dcfd302d48871e0f12b. Diffstat:
27 files changed, 1878 insertions(+), 37 deletions(-)
diff --git a/browser/components/newtab/metrics.yaml b/browser/components/newtab/metrics.yaml @@ -960,6 +960,66 @@ newtab: - mconley@mozilla.com expires: never + trending_search_impression: + type: event + description: > + records an impression when a trending search widget is visible to the user + bugs: + - https://bugzil.la/1969595 + data_reviews: + - https://bugzil.la/1969595 + data_sensitivity: + - interaction + notification_emails: + - nbarrett@mozilla.com + expires: never + extra_keys: + variant: &variant + description: Variant of widget rendered ("a" || "b") + type: string + newtab_visit_id: *newtab_visit_id + send_in_pings: + - newtab + + trending_search_dismiss: + type: event + description: > + recorded when a user either collapses or dismisses the + trending search widget + bugs: + - https://bugzil.la/1969595 + data_reviews: + - https://bugzil.la/1969595 + data_sensitivity: + - interaction + notification_emails: + - nbarrett@mozilla.com + expires: never + extra_keys: + variant: *variant + newtab_visit_id: *newtab_visit_id + send_in_pings: + - newtab + + trending_search_suggestion_open: + type: event + description: > + recorded when a user opens a suggested search link + bugs: + - https://bugzil.la/1969595 + data_reviews: + - https://bugzil.la/1969595 + data_sensitivity: + - interaction + notification_emails: + - nbarrett@mozilla.com + expires: never + extra_keys: + variant: *variant + newtab_visit_id: *newtab_visit_id + send_in_pings: + - newtab + feature_highlight_dismiss: type: event description: > diff --git a/browser/extensions/newtab/common/Actions.mjs b/browser/extensions/newtab/common/Actions.mjs @@ -194,6 +194,10 @@ for (const type of [ "TOP_SITES_UPDATED", "TOTAL_BOOKMARKS_REQUEST", "TOTAL_BOOKMARKS_RESPONSE", + "TRENDING_SEARCH_IMPRESSION", + "TRENDING_SEARCH_SUGGESTION_OPEN", + "TRENDING_SEARCH_TOGGLE_COLLAPSE", + "TRENDING_SEARCH_UPDATE", "UNBLOCK_SECTION", "UNFOLLOW_SECTION", "UNINIT", diff --git a/browser/extensions/newtab/common/Reducers.sys.mjs b/browser/extensions/newtab/common/Reducers.sys.mjs @@ -171,6 +171,10 @@ export const INITIAL_STATE = { locationSearchString: "", suggestedLocations: [], }, + TrendingSearch: { + suggestions: [], + collapsed: false, + }, // Widgets ListsWidget: { // value pointing to last selectled list @@ -1092,6 +1096,17 @@ function Ads(prevState = INITIAL_STATE.Ads, action) { } } +function TrendingSearch(prevState = INITIAL_STATE.TrendingSearch, action) { + switch (action.type) { + case at.TRENDING_SEARCH_UPDATE: + return { ...prevState, suggestions: action.data }; + case at.TRENDING_SEARCH_TOGGLE_COLLAPSE: + return { ...prevState, collapsed: action.data.collapsed }; + default: + return prevState; + } +} + function TimerWidget(prevState = INITIAL_STATE.TimerWidget, action) { // fallback to current timerType in state if not provided in action const timerType = action.data?.timerType || prevState.timerType; @@ -1195,6 +1210,7 @@ export const reducers = { Search, TimerWidget, ListsWidget, + TrendingSearch, Wallpapers, Weather, }; diff --git a/browser/extensions/newtab/content-src/components/Base/Base.jsx b/browser/extensions/newtab/content-src/components/Base/Base.jsx @@ -650,6 +650,7 @@ export class BaseContent extends React.PureComponent { prefs[PREF_INFERRED_PERSONALIZATION_USER], topSitesRowsCount: prefs.topSitesRows, weatherEnabled: prefs.showWeather, + trendingSearchEnabled: prefs["trendingSearch.enabled"], }; const pocketRegion = prefs["feeds.system.topstories"]; @@ -690,9 +691,15 @@ export class BaseContent extends React.PureComponent { const enabledWidgets = { listsEnabled: prefs["widgets.lists.enabled"], timerEnabled: prefs["widgets.focusTimer.enabled"], + trendingSearchEnabled: prefs["trendingSearch.enabled"], weatherEnabled: prefs.showWeather, }; + // Trending Searches experiment pref check + const mayHaveTrendingSearch = + prefs["system.trendingSearch.enabled"] && + prefs["trendingSearch.defaultSearchEngine"].toLowerCase() === "google"; + // Mobile Download Promo Pref Checks const mobileDownloadPromoEnabled = prefs["mobileDownloadModal.enabled"]; const mobileDownloadPromoVariantAEnabled = @@ -866,6 +873,7 @@ export class BaseContent extends React.PureComponent { mayHaveTopicSections={mayHavePersonalizedTopicSections} mayHaveInferredPersonalization={mayHaveInferredPersonalization} mayHaveWeather={mayHaveWeather} + mayHaveTrendingSearch={mayHaveTrendingSearch} mayHaveWidgets={mayHaveWidgets} mayHaveTimerWidget={mayHaveTimerWidget} mayHaveListsWidget={mayHaveListsWidget} diff --git a/browser/extensions/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx b/browser/extensions/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx @@ -28,7 +28,7 @@ export class ContentSection extends React.PureComponent { } onPreferenceSelect(e) { - // eventSource: WEATHER | TOP_SITES | TOP_STORIES | WIDGET_LISTS | WIDGET_TIMER + // eventSource: WEATHER | TOP_SITES | TOP_STORIES | WIDGET_LISTS | WIDGET_TIMER | TRENDING_SEARCH const { preference, eventSource } = e.target.dataset; let value; if (e.target.nodeName === "SELECT") { @@ -98,6 +98,7 @@ export class ContentSection extends React.PureComponent { pocketRegion, mayHaveInferredPersonalization, mayHaveWeather, + mayHaveTrendingSearch, mayHaveWidgets, mayHaveTimerWidget, mayHaveListsWidget, @@ -113,6 +114,7 @@ export class ContentSection extends React.PureComponent { topSitesEnabled, pocketEnabled, weatherEnabled, + trendingSearchEnabled, showInferredPersonalizationEnabled, topSitesRowsCount, } = enabledSections; @@ -183,6 +185,20 @@ export class ContentSection extends React.PureComponent { /> </div> )} + + {/* Trending Search */} + {mayHaveTrendingSearch && ( + <div id="trending-search-section" className="section"> + <moz-toggle + id="trending-search-toggle" + pressed={trendingSearchEnabled || null} + onToggle={this.onPreferenceSelect} + data-preference="trendingSearch.enabled" + data-eventSource="TRENDING_SEARCH" + data-l10n-id="newtab-custom-widget-trending-search-toggle" + /> + </div> + )} <span className="divider" role="separator"></span> </div> </div> @@ -202,6 +218,20 @@ export class ContentSection extends React.PureComponent { </div> )} + {/* Note: If widgets are enabled, the trending search toggle will be moved under Widgets subsection */} + {!mayHaveWidgets && mayHaveTrendingSearch && ( + <div id="trending-search-section" className="section"> + <moz-toggle + id="trending-search-toggle" + pressed={trendingSearchEnabled || null} + onToggle={this.onPreferenceSelect} + data-preference="trendingSearch.enabled" + data-eventSource="TRENDING_SEARCH" + data-l10n-id="newtab-custom-trending-search-toggle" + /> + </div> + )} + <div id="shortcuts-section" className="section"> <moz-toggle id="shortcuts-toggle" diff --git a/browser/extensions/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx b/browser/extensions/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx @@ -106,6 +106,7 @@ export class _CustomizeMenu extends React.PureComponent { this.props.mayHaveInferredPersonalization } mayHaveWeather={this.props.mayHaveWeather} + mayHaveTrendingSearch={this.props.mayHaveTrendingSearch} mayHaveWidgets={this.props.mayHaveWidgets} mayHaveTimerWidget={this.props.mayHaveTimerWidget} mayHaveListsWidget={this.props.mayHaveListsWidget} diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx @@ -9,6 +9,7 @@ import { AdBanner } from "../AdBanner/AdBanner.jsx"; import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; import React, { useEffect, useRef } from "react"; import { connect } from "react-redux"; +import { TrendingSearches } from "../TrendingSearches/TrendingSearches.jsx"; const PREF_SECTIONS_CARDS_ENABLED = "discoverystream.sections.cards.enabled"; const PREF_THUMBS_UP_DOWN_ENABLED = "discoverystream.thumbsUpDown.enabled"; const PREF_TOPICS_ENABLED = "discoverystream.topicLabels.enabled"; @@ -20,6 +21,10 @@ const PREF_BILLBOARD_ENABLED = "newtabAdSize.billboard"; const PREF_BILLBOARD_POSITION = "newtabAdSize.billboard.position"; const PREF_LEADERBOARD_ENABLED = "newtabAdSize.leaderboard"; const PREF_LEADERBOARD_POSITION = "newtabAdSize.leaderboard.position"; +const PREF_TRENDING_SEARCH = "trendingSearch.enabled"; +const PREF_TRENDING_SEARCH_SYSTEM = "system.trendingSearch.enabled"; +const PREF_SEARCH_ENGINE = "trendingSearch.defaultSearchEngine"; +const PREF_TRENDING_SEARCH_VARIANT = "trendingSearch.variant"; const WIDGET_IDS = { TOPICS: 1, }; @@ -132,6 +137,11 @@ export class _CardGrid extends React.PureComponent { const spocsStartupCacheEnabled = prefs[PREF_SPOCS_STARTUPCACHE_ENABLED]; const billboardEnabled = prefs[PREF_BILLBOARD_ENABLED]; const leaderboardEnabled = prefs[PREF_LEADERBOARD_ENABLED]; + const trendingEnabled = + prefs[PREF_TRENDING_SEARCH] && + prefs[PREF_TRENDING_SEARCH_SYSTEM] && + prefs[PREF_SEARCH_ENGINE]?.toLowerCase() === "google"; + const trendingVariant = prefs[PREF_TRENDING_SEARCH_VARIANT]; const recs = this.props.data.recommendations.slice(0, items); const cards = []; @@ -238,6 +248,14 @@ export class _CardGrid extends React.PureComponent { } } } + if (trendingEnabled && trendingVariant === "b") { + const firstSpocPosition = this.props.spocPositions[0]?.index; + // double check that a spoc/mrec is actually in the index it should be in + const format = cards[firstSpocPosition]?.props?.format; + const isSpoc = format === "spoc" || format === "rectangle"; + // if the spoc is not in its position, place TrendingSearches in the 3rd position + cards.splice(isSpoc ? firstSpocPosition + 1 : 2, 1, <TrendingSearches />); + } // if a banner ad is enabled and we have any available, place them in the grid const { spocs } = this.props.DiscoveryStream; diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/CardSections/CardSections.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/CardSections/CardSections.jsx @@ -18,6 +18,7 @@ import { AdBanner } from "../AdBanner/AdBanner.jsx"; import { PersonalizedCard } from "../PersonalizedCard/PersonalizedCard"; import { FollowSectionButtonHighlight } from "../FeatureHighlight/FollowSectionButtonHighlight"; import { MessageWrapper } from "content-src/components/MessageWrapper/MessageWrapper"; +import { TrendingSearches } from "../TrendingSearches/TrendingSearches.jsx"; import { Weather } from "../../Weather/Weather.jsx"; // Prefs @@ -41,11 +42,20 @@ const PREF_LEADERBOARD_POSITION = "newtabAdSize.leaderboard.position"; const PREF_REFINED_CARDS_ENABLED = "discoverystream.refinedCardsLayout.enabled"; const PREF_INFERRED_PERSONALIZATION_USER = "discoverystream.sections.personalization.inferred.user.enabled"; +const PREF_TRENDING_SEARCH = "trendingSearch.enabled"; +const PREF_TRENDING_SEARCH_SYSTEM = "system.trendingSearch.enabled"; +const PREF_SEARCH_ENGINE = "trendingSearch.defaultSearchEngine"; +const PREF_TRENDING_SEARCH_VARIANT = "trendingSearch.variant"; const PREF_DAILY_BRIEF_SECTIONID = "discoverystream.dailyBrief.sectionId"; const PREF_SPOCS_STARTUPCACHE_ENABLED = "discoverystream.spocs.startupCache.enabled"; -function getLayoutData(responsiveLayouts, index, refinedCardsLayout) { +function getLayoutData( + responsiveLayouts, + index, + refinedCardsLayout, + sectionKey +) { let layoutData = { classNames: [], imageSizes: {}, @@ -54,11 +64,23 @@ function getLayoutData(responsiveLayouts, index, refinedCardsLayout) { responsiveLayouts.forEach(layout => { layout.tiles.forEach((tile, tileIndex) => { if (tile.position === index) { - layoutData.classNames.push(`col-${layout.columnCount}-${tile.size}`); - layoutData.classNames.push( - `col-${layout.columnCount}-position-${tileIndex}` - ); - layoutData.imageSizes[layout.columnCount] = tile.size; + // When trending searches should be placed in the `top_stories_section`, + // we update the layout so that the first item is always a medium card to make + // room for the trending search widget + if (sectionKey === "top_stories_section" && tileIndex === 0) { + layoutData.classNames.push(`col-${layout.columnCount}-medium`); + layoutData.classNames.push( + `col-${layout.columnCount}-position-${tileIndex}` + ); + layoutData.imageSizes[layout.columnCount] = "medium"; + layoutData.classNames.push(`col-${layout.columnCount}-hide-excerpt`); + } else { + layoutData.classNames.push(`col-${layout.columnCount}-${tile.size}`); + layoutData.classNames.push( + `col-${layout.columnCount}-position-${tileIndex}` + ); + layoutData.imageSizes[layout.columnCount] = tile.size; + } // The API tells us whether the tile should show the excerpt or not. // Apply extra styles accordingly. @@ -210,6 +232,14 @@ function CardSection({ const refinedCardsLayout = prefs[PREF_REFINED_CARDS_ENABLED]; const spocsStartupCacheEnabled = prefs[PREF_SPOCS_STARTUPCACHE_ENABLED]; + const trendingEnabled = + prefs[PREF_TRENDING_SEARCH] && + prefs[PREF_TRENDING_SEARCH_SYSTEM] && + prefs[PREF_SEARCH_ENGINE]?.toLowerCase() === "google"; + const trendingVariant = prefs[PREF_TRENDING_SEARCH_VARIANT]; + + const shouldShowTrendingSearch = trendingEnabled && trendingVariant === "b"; + const mayHaveSectionsPersonalization = prefs[PREF_SECTIONS_PERSONALIZATION_ENABLED]; @@ -398,7 +428,8 @@ function CardSection({ const layoutData = getLayoutData( responsiveLayouts, index, - refinedCardsLayout + refinedCardsLayout, + shouldShowTrendingSearch && sectionKey ); const { classNames, imageSizes } = layoutData; @@ -471,7 +502,11 @@ function CardSection({ onFocus={() => onCardFocus(index)} /> ); - return [card]; + return index === 0 && + shouldShowTrendingSearch && + sectionKey === "top_stories_section" + ? [card, <TrendingSearches key="trending" />] + : [card]; })} </div> </section> diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/TrendingSearches/TrendingSearches.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/TrendingSearches/TrendingSearches.jsx @@ -0,0 +1,278 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +import React, { useState, useRef, useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; +import { LinkMenu } from "../../LinkMenu/LinkMenu"; +import { useIntersectionObserver } from "../../../lib/utils"; + +const PREF_TRENDING_VARIANT = "trendingSearch.variant"; +const PREF_REFINED_CARDS_LAYOUT = "discoverystream.refinedCardsLayout.enabled"; + +function TrendingSearches() { + const [showContextMenu, setShowContextMenu] = useState(false); + // The keyboard access parameter is passed down to LinkMenu component + // that uses it to focus on the first context menu option for accessibility. + const [isKeyboardAccess, setIsKeyboardAccess] = useState(false); + const dispatch = useDispatch(); + const { TrendingSearch, Prefs } = useSelector(state => state); + const { values: prefs } = Prefs; + const { suggestions, collapsed } = TrendingSearch; + const variant = prefs[PREF_TRENDING_VARIANT]; + const refinedCards = prefs[PREF_REFINED_CARDS_LAYOUT]; + let resultRef = useRef([]); + let contextMenuHost = useRef(null); + + const TRENDING_SEARCH_CONTEXT_MENU_OPTIONS = [ + "TrendingSearchDismiss", + "TrendingSearchLearnMore", + ]; + + function onArrowClick() { + dispatch( + ac.AlsoToMain({ + type: at.TRENDING_SEARCH_TOGGLE_COLLAPSE, + data: { + collapsed: !collapsed, + variant, + }, + }) + ); + } + + function handleLinkOpen() { + dispatch( + ac.AlsoToMain({ + type: at.TRENDING_SEARCH_SUGGESTION_OPEN, + data: { + variant, + }, + }) + ); + } + + // If the window is small, the context menu in variant B will move closer to the card + // so that it doesn't cut off + const handleContextMenuShow = () => { + const host = contextMenuHost.current; + const isRTL = document.dir === "rtl"; // returns true if page language is right-to-left + const checkRect = host.getBoundingClientRect(); + const maxBounds = 200; + + // Adds the class of "last-item" if the card is near the edge of the window + const checkBounds = isRTL + ? checkRect.left <= maxBounds + : window.innerWidth - checkRect.right <= maxBounds; + + if (checkBounds) { + host.classList.add("last-item"); + } + }; + + const handleContextMenuUpdate = () => { + const host = contextMenuHost.current; + if (!host) { + return; + } + + host.classList.remove("last-item"); + }; + + const toggleContextMenu = isKeyBoard => { + setShowContextMenu(!showContextMenu); + setIsKeyboardAccess(isKeyBoard); + + if (!showContextMenu) { + handleContextMenuShow(); + } else { + handleContextMenuUpdate(); + } + }; + + function onContextMenuClick(e) { + e.preventDefault(); + toggleContextMenu(false); + } + + function onContextMenuKeyDown(e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleContextMenu(true); + } + } + + function onUpdate() { + setShowContextMenu(!showContextMenu); + } + + function handleResultKeyDown(event, index) { + const maxResults = suggestions.length; + let nextIndex = index; + + if (event.key === "ArrowDown") { + event.preventDefault(); + if (index < maxResults - 1) { + nextIndex = index + 1; + } else { + return; + } + } else if (event.key === "ArrowUp") { + event.preventDefault(); + if (index > 0) { + nextIndex = index - 1; + } else { + return; + } + } + + resultRef.current[index].tabIndex = -1; + resultRef.current[nextIndex].tabIndex = 0; + resultRef.current[nextIndex].focus(); + } + + const handleIntersection = useCallback(() => { + dispatch( + ac.AlsoToMain({ + type: at.TRENDING_SEARCH_IMPRESSION, + data: { + variant, + }, + }) + ); + }, [dispatch, variant]); + + const ref = useIntersectionObserver(handleIntersection); + if (!suggestions?.length) { + return null; + } else if (variant === "a" || variant === "c") { + return ( + <section + ref={el => { + ref.current = [el]; + }} + // Variant C matches the design of variant A but should only + // appear on hover + className={`trending-searches-pill-wrapper ${variant === "c" ? "hover-only" : ""}`} + > + <div className="trending-searches-title-wrapper"> + <span className="trending-searches-icon icon icon-arrow-trending"></span> + <h2 + className="trending-searches-title" + data-l10n-id="newtab-trending-searches-title" + ></h2> + <div className="close-open-trending-searches"> + <moz-button + iconsrc={`chrome://global/skin/icons/arrow-${collapsed ? "down" : "up"}.svg`} + onClick={onArrowClick} + className={`icon icon-arrowhead-up`} + type="icon ghost" + data-l10n-id={`newtab-trending-searches-${collapsed ? "show" : "hide"}-trending`} + ></moz-button> + </div> + </div> + {!collapsed && ( + <ul className="trending-searches-list"> + {suggestions.map((result, index) => { + return ( + <li + key={result.suggestion} + className="trending-search-item" + onKeyDown={e => handleResultKeyDown(e, index)} + > + <SafeAnchor + url={result.searchUrl} + onLinkClick={handleLinkOpen} + title={result.suggestion} + setRef={item => (resultRef.current[index] = item)} + tabIndex={index === 0 ? 0 : -1} + > + {result.lowerCaseSuggestion} + </SafeAnchor> + </li> + ); + })} + </ul> + )} + </section> + ); + } else if (variant === "b") { + return ( + <div + ref={el => { + ref.current = [el]; + contextMenuHost.current = el; + }} + className="trending-searches-list-view" + > + <div className="trending-searches-list-view-header"> + <h3 data-l10n-id="newtab-trending-searches-title"></h3> + <div className="trending-searches-context-menu-wrapper"> + <div + className={`trending-searches-context-menu ${showContextMenu ? "context-menu-open" : ""}`} + > + <moz-button + type="icon ghost" + size="default" + data-l10n-id="newtab-menu-section-tooltip" + iconsrc="chrome://global/skin/icons/more.svg" + onClick={onContextMenuClick} + onKeyDown={onContextMenuKeyDown} + /> + {showContextMenu && ( + <LinkMenu + onUpdate={onUpdate} + dispatch={dispatch} + keyboardAccess={isKeyboardAccess} + options={TRENDING_SEARCH_CONTEXT_MENU_OPTIONS} + shouldSendImpressionStats={true} + site={{ + url: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/trending-searches-new-tab", + variant, + }} + /> + )} + </div> + </div> + </div> + <ul className="trending-searches-list-items"> + {suggestions.slice(0, 6).map((result, index) => { + return ( + <li + key={result.suggestion} + className={`trending-searches-list-item ${refinedCards ? "compact" : ""}`} + onKeyDown={e => handleResultKeyDown(e, index)} + > + <SafeAnchor + url={result.searchUrl} + onLinkClick={handleLinkOpen} + title={result.suggestion} + setRef={item => (resultRef.current[index] = item)} + tabIndex={index === 0 ? 0 : -1} + > + {result.icon ? ( + <div className="trending-icon-wrapper"> + <img src={result.icon} alt="" className="trending-icon" /> + <div className="trending-info-wrapper"> + {result.lowerCaseSuggestion} + <small>{result.description}</small> + </div> + </div> + ) : ( + <> + <span className="trending-searches-icon icon icon-arrow-trending"></span> + {result.lowerCaseSuggestion} + </> + )} + </SafeAnchor> + </li> + ); + })} + </ul> + </div> + ); + } +} + +export { TrendingSearches }; diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/TrendingSearches/_TrendingSearches.scss b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/TrendingSearches/_TrendingSearches.scss @@ -0,0 +1,242 @@ +// Variant A + C styles + +.search-inner-wrapper:has(.trending-searches-pill-wrapper.hover-only) { + &:hover, + &:focus-within, + &:focus-visible { + .hover-only { + @media (prefers-reduced-motion: no-preference) { + max-height: 100px; + } + } + } + + .fake-focus & { + .hover-only { + @media (prefers-reduced-motion: no-preference) { + max-height: 100px; + } + } + } +} + +.trending-searches-pill-wrapper { + .fixed-search & { + display: none; + } + + &.hover-only { + // variant c shouldnt display the up/down chevron + .close-open-trending-searches { + display: none; + } + + @media (prefers-reduced-motion: no-preference) { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-in-out; + } + } + + .trending-searches-title-wrapper { + display: flex; + align-items: center; + gap: var(--space-small); + margin-block-start: var(--space-medium); + + .trending-searches-title { + margin: 0; + font-size: var(--font-size-root); + color: var(--newtab-contextual-text-primary-color); + } + + .close-open-trending-searches { + margin-inline-start: auto; + } + } + + .trending-searches-list { + list-style: none; + margin: 0; + margin-block-start: var(--space-small); + padding: 0; + display: flex; + flex-wrap: wrap; + height: 45px; + overflow: hidden; + } + + .trending-search-item { + max-width: 24ch; + height: min-content; + + a { + text-decoration: none; + font-size: var(--font-size-root); + background-color: var(--background-color-canvas); + outline: var(--border-width) solid + var(--table-row-background-color-alternate); + border-radius: var(--border-radius-circle); + color: var(--newtab-contextual-text-primary-color); + padding: var(--space-small) var(--space-medium); + white-space: nowrap; + margin: var(--space-xsmall); + overflow: hidden; + text-overflow: ellipsis; + display: block; + + &:hover { + background-color: var(--newtab-button-static-hover-background); + cursor: pointer; + } + + &:hover:active { + background-color: var(--newtab-button-static-active-background); + } + + &:focus-within { + outline: var(--focus-outline); + } + } + + } +} + +// Variant B styles +.trending-searches-list-view { + .ds-column-grid & { + // Match width of other items in grid layout + width: var(--newtab-card-grid-layout-width); + } + + .ds-section-grid & { + order: 1; + grid-row: span 2; + grid-column: span 1; + width: 100%; + + & ~ .col-3-small { + @media (min-width: $break-point-widest) and (max-width: $break-point-sections-variant) { + // only hide the small cards in the 3 column layout + // to allow for trending searches to have space within the grid + display: none; + } + } + } + + &:not(.placeholder) { + border-radius: var(--border-radius-large); + box-shadow: var(--box-shadow-card); + background: var(--newtab-background-card); + + .trending-searches-list-view-header { + margin-block: 0; + margin-inline: var(--space-large); + padding-block: var(--space-medium) var(--space-small); + border-block-end: 0.5px solid var(--newtab-button-static-hover-background); + display: flex; + align-items: center; + justify-content: space-between; + } + + .trending-searches-context-menu-wrapper { + opacity: 0; + transition: opacity; + } + + .trending-searches-context-menu { + position: relative; + } + + h3 { + font-size: var(--font-size-root); + margin-block: 0; + } + + .trending-searches-list-items { + list-style: none; + padding-inline: var(--space-small); + margin-block: 0; + } + + .trending-searches-list-item { + .trending-icon-wrapper { + display: flex; + align-items: center; + } + + .trending-searches-icon { + color: var(--newtab-contextual-text-primary-color); + margin-inline: var(--space-medium) var(--space-large); + } + + .trending-info-wrapper { + display: flex; + flex-direction: column; + } + + .trending-info-wrapper small { + color: var(--newtab-contextual-text-secondary-color); + } + } + + .trending-searches-list-item a { + border-radius: var(--border-radius-medium); + color: var(--newtab-text-primary-color); + display: block; + max-width: 100%; + padding: var(--space-medium) var(--space-small); + overflow: hidden; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + word-break: break-word; + + &:hover { + background-color: var(--newtab-background-color); + } + + &:focus { + outline: var(--focus-outline);; + } + + &:has(.trending-icon) { + display: flex; + align-items: center; + } + } + + // Refined cards are a bit smaller, so we recover a bit of space on the list items. + .trending-searches-list-item.compact a { + padding: var(--space-small); + } + + .trending-searches-list-item.compact:first-child a { + padding-block-start: var(--space-medium); + } + + .trending-searches-list-item a img { + height: var(--icon-size-xlarge); + width: var(--icon-size-xlarge); + margin-inline: 0 var(--space-medium); + border-radius: var(--border-radius-medium); + object-fit: contain; + } + } + + &.last-item .trending-searches-context-menu .context-menu { + margin-block-start: var(--space-large); + margin-inline-end: var(--space-large); + margin-inline-start: auto; + inset-inline-end: 0; + inset-inline-start: auto; + } + + &:focus-within, + &:hover { + .trending-searches-context-menu-wrapper { + opacity: 1; + transition-duration: 0.3s; + } + } +} diff --git a/browser/extensions/newtab/content-src/components/Search/Search.jsx b/browser/extensions/newtab/content-src/components/Search/Search.jsx @@ -9,6 +9,7 @@ import { connect } from "react-redux"; import { IS_NEWTAB } from "content-src/lib/constants"; import { Logo } from "content-src/components/Logo/Logo"; import React from "react"; +import { TrendingSearches } from "../DiscoveryStreamComponents/TrendingSearches/TrendingSearches"; export class _Search extends React.PureComponent { constructor(props) { @@ -140,6 +141,15 @@ export class _Search extends React.PureComponent { ] .filter(v => v) .join(" "); + const prefs = this.props.Prefs.values; + + const trendingSearchEnabled = + prefs["trendingSearch.enabled"] && + prefs["system.trendingSearch.enabled"] && + prefs["trendingSearch.defaultSearchEngine"]?.toLowerCase() === "google"; + + const trendingSearchVariant = + this.props.Prefs.values["trendingSearch.variant"]; return ( <> @@ -160,6 +170,9 @@ export class _Search extends React.PureComponent { data-l10n-id="newtab-search-box-search-button" onClick={this.onSearchClick} /> + {trendingSearchEnabled && + (trendingSearchVariant === "a" || + trendingSearchVariant === "c") && <TrendingSearches />} </div> )} {this.props.handoffEnabled && ( @@ -187,6 +200,9 @@ export class _Search extends React.PureComponent { }} /> </button> + {trendingSearchEnabled && + (trendingSearchVariant === "a" || + trendingSearchVariant === "c") && <TrendingSearches />} </div> )} </div> diff --git a/browser/extensions/newtab/content-src/components/Search/_Search.scss b/browser/extensions/newtab/content-src/components/Search/_Search.scss @@ -52,6 +52,22 @@ $glyph-forward: url('chrome://browser/skin/forward.svg'); } + .search-inner-wrapper:has(.trending-searches-pill-wrapper) { + display: flex; + flex-direction: column; + + .search-handoff-button { + height: 52px; + } + + &.no-handoff { + #newtab-search-text, + .search-button { + height: 52px; + } + } + } + .search-handoff-button, input { background: var(--newtab-background-color-secondary) var(--newtab-search-icon) $search-icon-padding center no-repeat; diff --git a/browser/extensions/newtab/content-src/lib/link-menu-options.mjs b/browser/extensions/newtab/content-src/lib/link-menu-options.mjs @@ -515,4 +515,33 @@ export const LinkMenuOptions = { }), }; }, + TrendingSearchLearnMore: site => ({ + id: "newtab-trending-searches-learn-more", + action: ac.OnlyToMain({ + type: at.OPEN_LINK, + data: { url: site.url }, + }), + impression: ac.OnlyToMain({ + type: at.TRENDING_SEARCH_LEARN_MORE, + data: { + variant: site.variant, + }, + }), + }), + TrendingSearchDismiss: site => ({ + id: "newtab-trending-searches-dismiss", + action: ac.OnlyToMain({ + type: at.SET_PREF, + data: { + name: "trendingSearch.enabled", + value: false, + }, + }), + impression: ac.OnlyToMain({ + type: at.TRENDING_SEARCH_DISMISS, + data: { + variant: site.variant, + }, + }), + }), }; diff --git a/browser/extensions/newtab/content-src/styles/activity-stream.scss b/browser/extensions/newtab/content-src/styles/activity-stream.scss @@ -202,5 +202,6 @@ input { @import '../components/DiscoveryStreamComponents/InterestPicker/InterestPicker'; @import '../components/DiscoveryStreamComponents/ReportContent/ReportContent'; @import '../components/DiscoveryStreamComponents/PersonalizedCard/PersonalizedCard'; +@import '../components/DiscoveryStreamComponents/TrendingSearches/TrendingSearches'; // stylelint-enable no-invalid-position-at-import-rule diff --git a/browser/extensions/newtab/css/activity-stream.css b/browser/extensions/newtab/css/activity-stream.css @@ -1510,6 +1510,17 @@ main section { .search-wrapper .search-inner-wrapper.no-handoff input { padding-inline-end: calc(var(--size-item-xlarge) + var(--space-medium)); } +.search-wrapper .search-inner-wrapper:has(.trending-searches-pill-wrapper) { + display: flex; + flex-direction: column; +} +.search-wrapper .search-inner-wrapper:has(.trending-searches-pill-wrapper) .search-handoff-button { + height: 52px; +} +.search-wrapper .search-inner-wrapper:has(.trending-searches-pill-wrapper).no-handoff #newtab-search-text, +.search-wrapper .search-inner-wrapper:has(.trending-searches-pill-wrapper).no-handoff .search-button { + height: 52px; +} .search-wrapper .search-handoff-button, .search-wrapper input { background: var(--newtab-background-color-secondary) var(--newtab-search-icon) 16px center no-repeat; @@ -9218,3 +9229,186 @@ dialog:dir(rtl)::after { padding: var(--space-xxlarge); } } + +@media (prefers-reduced-motion: no-preference) { + .search-inner-wrapper:has(.trending-searches-pill-wrapper.hover-only):hover .hover-only, .search-inner-wrapper:has(.trending-searches-pill-wrapper.hover-only):focus-within .hover-only, .search-inner-wrapper:has(.trending-searches-pill-wrapper.hover-only):focus-visible .hover-only { + max-height: 100px; + } +} +@media (prefers-reduced-motion: no-preference) { + .fake-focus .search-inner-wrapper:has(.trending-searches-pill-wrapper.hover-only) .hover-only { + max-height: 100px; + } +} + +.fixed-search .trending-searches-pill-wrapper { + display: none; +} +.trending-searches-pill-wrapper.hover-only .close-open-trending-searches { + display: none; +} +@media (prefers-reduced-motion: no-preference) { + .trending-searches-pill-wrapper.hover-only { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-in-out; + } +} +.trending-searches-pill-wrapper .trending-searches-title-wrapper { + display: flex; + align-items: center; + gap: var(--space-small); + margin-block-start: var(--space-medium); +} +.trending-searches-pill-wrapper .trending-searches-title-wrapper .trending-searches-title { + margin: 0; + font-size: var(--font-size-root); + color: var(--newtab-contextual-text-primary-color); +} +.trending-searches-pill-wrapper .trending-searches-title-wrapper .close-open-trending-searches { + margin-inline-start: auto; +} +.trending-searches-pill-wrapper .trending-searches-list { + list-style: none; + margin: 0; + margin-block-start: var(--space-small); + padding: 0; + display: flex; + flex-wrap: wrap; + height: 45px; + overflow: hidden; +} +.trending-searches-pill-wrapper .trending-search-item { + max-width: 24ch; + height: min-content; +} +.trending-searches-pill-wrapper .trending-search-item a { + text-decoration: none; + font-size: var(--font-size-root); + background-color: var(--background-color-canvas); + outline: var(--border-width) solid var(--table-row-background-color-alternate); + border-radius: var(--border-radius-circle); + color: var(--newtab-contextual-text-primary-color); + padding: var(--space-small) var(--space-medium); + white-space: nowrap; + margin: var(--space-xsmall); + overflow: hidden; + text-overflow: ellipsis; + display: block; +} +.trending-searches-pill-wrapper .trending-search-item a:hover { + background-color: var(--newtab-button-static-hover-background); + cursor: pointer; +} +.trending-searches-pill-wrapper .trending-search-item a:hover:active { + background-color: var(--newtab-button-static-active-background); +} +.trending-searches-pill-wrapper .trending-search-item a:focus-within { + outline: var(--focus-outline); +} + +.ds-column-grid .trending-searches-list-view { + width: var(--newtab-card-grid-layout-width); +} +.ds-section-grid .trending-searches-list-view { + order: 1; + grid-row: span 2; + grid-column: span 1; + width: 100%; +} +@media (min-width: 1122px) and (max-width: 1390px) { + .ds-section-grid .trending-searches-list-view ~ .col-3-small { + display: none; + } +} +.trending-searches-list-view:not(.placeholder) { + border-radius: var(--border-radius-large); + box-shadow: var(--box-shadow-card); + background: var(--newtab-background-card); +} +.trending-searches-list-view:not(.placeholder) .trending-searches-list-view-header { + margin-block: 0; + margin-inline: var(--space-large); + padding-block: var(--space-medium) var(--space-small); + border-block-end: 0.5px solid var(--newtab-button-static-hover-background); + display: flex; + align-items: center; + justify-content: space-between; +} +.trending-searches-list-view:not(.placeholder) .trending-searches-context-menu-wrapper { + opacity: 0; + transition: opacity; +} +.trending-searches-list-view:not(.placeholder) .trending-searches-context-menu { + position: relative; +} +.trending-searches-list-view:not(.placeholder) h3 { + font-size: var(--font-size-root); + margin-block: 0; +} +.trending-searches-list-view:not(.placeholder) .trending-searches-list-items { + list-style: none; + padding-inline: var(--space-small); + margin-block: 0; +} +.trending-searches-list-view:not(.placeholder) .trending-searches-list-item .trending-icon-wrapper { + display: flex; + align-items: center; +} +.trending-searches-list-view:not(.placeholder) .trending-searches-list-item .trending-searches-icon { + color: var(--newtab-contextual-text-primary-color); + margin-inline: var(--space-medium) var(--space-large); +} +.trending-searches-list-view:not(.placeholder) .trending-searches-list-item .trending-info-wrapper { + display: flex; + flex-direction: column; +} +.trending-searches-list-view:not(.placeholder) .trending-searches-list-item .trending-info-wrapper small { + color: var(--newtab-contextual-text-secondary-color); +} +.trending-searches-list-view:not(.placeholder) .trending-searches-list-item a { + border-radius: var(--border-radius-medium); + color: var(--newtab-text-primary-color); + display: block; + max-width: 100%; + padding: var(--space-medium) var(--space-small); + overflow: hidden; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + word-break: break-word; +} +.trending-searches-list-view:not(.placeholder) .trending-searches-list-item a:hover { + background-color: var(--newtab-background-color); +} +.trending-searches-list-view:not(.placeholder) .trending-searches-list-item a:focus { + outline: var(--focus-outline); +} +.trending-searches-list-view:not(.placeholder) .trending-searches-list-item a:has(.trending-icon) { + display: flex; + align-items: center; +} +.trending-searches-list-view:not(.placeholder) .trending-searches-list-item.compact a { + padding: var(--space-small); +} +.trending-searches-list-view:not(.placeholder) .trending-searches-list-item.compact:first-child a { + padding-block-start: var(--space-medium); +} +.trending-searches-list-view:not(.placeholder) .trending-searches-list-item a img { + height: var(--icon-size-xlarge); + width: var(--icon-size-xlarge); + margin-inline: 0 var(--space-medium); + border-radius: var(--border-radius-medium); + object-fit: contain; +} +.trending-searches-list-view.last-item .trending-searches-context-menu .context-menu { + margin-block-start: var(--space-large); + margin-inline-end: var(--space-large); + margin-inline-start: auto; + inset-inline-end: 0; + inset-inline-start: auto; +} +.trending-searches-list-view:focus-within .trending-searches-context-menu-wrapper, .trending-searches-list-view:hover .trending-searches-context-menu-wrapper { + opacity: 1; + transition-duration: 0.3s; +} diff --git a/browser/extensions/newtab/data/content/activity-stream.bundle.js b/browser/extensions/newtab/data/content/activity-stream.bundle.js @@ -267,6 +267,10 @@ for (const type of [ "TOP_SITES_UPDATED", "TOTAL_BOOKMARKS_REQUEST", "TOTAL_BOOKMARKS_RESPONSE", + "TRENDING_SEARCH_IMPRESSION", + "TRENDING_SEARCH_SUGGESTION_OPEN", + "TRENDING_SEARCH_TOGGLE_COLLAPSE", + "TRENDING_SEARCH_UPDATE", "UNBLOCK_SECTION", "UNFOLLOW_SECTION", "UNINIT", @@ -2226,6 +2230,35 @@ const LinkMenuOptions = { }), }; }, + TrendingSearchLearnMore: site => ({ + id: "newtab-trending-searches-learn-more", + action: actionCreators.OnlyToMain({ + type: actionTypes.OPEN_LINK, + data: { url: site.url }, + }), + impression: actionCreators.OnlyToMain({ + type: actionTypes.TRENDING_SEARCH_LEARN_MORE, + data: { + variant: site.variant, + }, + }), + }), + TrendingSearchDismiss: site => ({ + id: "newtab-trending-searches-dismiss", + action: actionCreators.OnlyToMain({ + type: actionTypes.SET_PREF, + data: { + name: "trendingSearch.enabled", + value: false, + }, + }), + impression: actionCreators.OnlyToMain({ + type: actionTypes.TRENDING_SEARCH_DISMISS, + data: { + variant: site.variant, + }, + }), + }), }; ;// CONCATENATED MODULE: ./content-src/components/LinkMenu/LinkMenu.jsx @@ -4821,6 +4854,233 @@ const AdBanner = ({ toggleActive: toggleActive }))), promoCardEnabled && /*#__PURE__*/external_React_default().createElement(PromoCard, null)); }; +;// CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/TrendingSearches/TrendingSearches.jsx +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + + + + + + +const PREF_TRENDING_VARIANT = "trendingSearch.variant"; +const PREF_REFINED_CARDS_LAYOUT = "discoverystream.refinedCardsLayout.enabled"; +function TrendingSearches() { + const [showContextMenu, setShowContextMenu] = (0,external_React_namespaceObject.useState)(false); + // The keyboard access parameter is passed down to LinkMenu component + // that uses it to focus on the first context menu option for accessibility. + const [isKeyboardAccess, setIsKeyboardAccess] = (0,external_React_namespaceObject.useState)(false); + const dispatch = (0,external_ReactRedux_namespaceObject.useDispatch)(); + const { + TrendingSearch, + Prefs + } = (0,external_ReactRedux_namespaceObject.useSelector)(state => state); + const { + values: prefs + } = Prefs; + const { + suggestions, + collapsed + } = TrendingSearch; + const variant = prefs[PREF_TRENDING_VARIANT]; + const refinedCards = prefs[PREF_REFINED_CARDS_LAYOUT]; + let resultRef = (0,external_React_namespaceObject.useRef)([]); + let contextMenuHost = (0,external_React_namespaceObject.useRef)(null); + const TRENDING_SEARCH_CONTEXT_MENU_OPTIONS = ["TrendingSearchDismiss", "TrendingSearchLearnMore"]; + function onArrowClick() { + dispatch(actionCreators.AlsoToMain({ + type: actionTypes.TRENDING_SEARCH_TOGGLE_COLLAPSE, + data: { + collapsed: !collapsed, + variant + } + })); + } + function handleLinkOpen() { + dispatch(actionCreators.AlsoToMain({ + type: actionTypes.TRENDING_SEARCH_SUGGESTION_OPEN, + data: { + variant + } + })); + } + + // If the window is small, the context menu in variant B will move closer to the card + // so that it doesn't cut off + const handleContextMenuShow = () => { + const host = contextMenuHost.current; + const isRTL = document.dir === "rtl"; // returns true if page language is right-to-left + const checkRect = host.getBoundingClientRect(); + const maxBounds = 200; + + // Adds the class of "last-item" if the card is near the edge of the window + const checkBounds = isRTL ? checkRect.left <= maxBounds : window.innerWidth - checkRect.right <= maxBounds; + if (checkBounds) { + host.classList.add("last-item"); + } + }; + const handleContextMenuUpdate = () => { + const host = contextMenuHost.current; + if (!host) { + return; + } + host.classList.remove("last-item"); + }; + const toggleContextMenu = isKeyBoard => { + setShowContextMenu(!showContextMenu); + setIsKeyboardAccess(isKeyBoard); + if (!showContextMenu) { + handleContextMenuShow(); + } else { + handleContextMenuUpdate(); + } + }; + function onContextMenuClick(e) { + e.preventDefault(); + toggleContextMenu(false); + } + function onContextMenuKeyDown(e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleContextMenu(true); + } + } + function onUpdate() { + setShowContextMenu(!showContextMenu); + } + function handleResultKeyDown(event, index) { + const maxResults = suggestions.length; + let nextIndex = index; + if (event.key === "ArrowDown") { + event.preventDefault(); + if (index < maxResults - 1) { + nextIndex = index + 1; + } else { + return; + } + } else if (event.key === "ArrowUp") { + event.preventDefault(); + if (index > 0) { + nextIndex = index - 1; + } else { + return; + } + } + resultRef.current[index].tabIndex = -1; + resultRef.current[nextIndex].tabIndex = 0; + resultRef.current[nextIndex].focus(); + } + const handleIntersection = (0,external_React_namespaceObject.useCallback)(() => { + dispatch(actionCreators.AlsoToMain({ + type: actionTypes.TRENDING_SEARCH_IMPRESSION, + data: { + variant + } + })); + }, [dispatch, variant]); + const ref = useIntersectionObserver(handleIntersection); + if (!suggestions?.length) { + return null; + } else if (variant === "a" || variant === "c") { + return /*#__PURE__*/external_React_default().createElement("section", { + ref: el => { + ref.current = [el]; + } + // Variant C matches the design of variant A but should only + // appear on hover + , + className: `trending-searches-pill-wrapper ${variant === "c" ? "hover-only" : ""}` + }, /*#__PURE__*/external_React_default().createElement("div", { + className: "trending-searches-title-wrapper" + }, /*#__PURE__*/external_React_default().createElement("span", { + className: "trending-searches-icon icon icon-arrow-trending" + }), /*#__PURE__*/external_React_default().createElement("h2", { + className: "trending-searches-title", + "data-l10n-id": "newtab-trending-searches-title" + }), /*#__PURE__*/external_React_default().createElement("div", { + className: "close-open-trending-searches" + }, /*#__PURE__*/external_React_default().createElement("moz-button", { + iconsrc: `chrome://global/skin/icons/arrow-${collapsed ? "down" : "up"}.svg`, + onClick: onArrowClick, + className: `icon icon-arrowhead-up`, + type: "icon ghost", + "data-l10n-id": `newtab-trending-searches-${collapsed ? "show" : "hide"}-trending` + }))), !collapsed && /*#__PURE__*/external_React_default().createElement("ul", { + className: "trending-searches-list" + }, suggestions.map((result, index) => { + return /*#__PURE__*/external_React_default().createElement("li", { + key: result.suggestion, + className: "trending-search-item", + onKeyDown: e => handleResultKeyDown(e, index) + }, /*#__PURE__*/external_React_default().createElement(SafeAnchor, { + url: result.searchUrl, + onLinkClick: handleLinkOpen, + title: result.suggestion, + setRef: item => resultRef.current[index] = item, + tabIndex: index === 0 ? 0 : -1 + }, result.lowerCaseSuggestion)); + }))); + } else if (variant === "b") { + return /*#__PURE__*/external_React_default().createElement("div", { + ref: el => { + ref.current = [el]; + contextMenuHost.current = el; + }, + className: "trending-searches-list-view" + }, /*#__PURE__*/external_React_default().createElement("div", { + className: "trending-searches-list-view-header" + }, /*#__PURE__*/external_React_default().createElement("h3", { + "data-l10n-id": "newtab-trending-searches-title" + }), /*#__PURE__*/external_React_default().createElement("div", { + className: "trending-searches-context-menu-wrapper" + }, /*#__PURE__*/external_React_default().createElement("div", { + className: `trending-searches-context-menu ${showContextMenu ? "context-menu-open" : ""}` + }, /*#__PURE__*/external_React_default().createElement("moz-button", { + type: "icon ghost", + size: "default", + "data-l10n-id": "newtab-menu-section-tooltip", + iconsrc: "chrome://global/skin/icons/more.svg", + onClick: onContextMenuClick, + onKeyDown: onContextMenuKeyDown + }), showContextMenu && /*#__PURE__*/external_React_default().createElement(LinkMenu, { + onUpdate: onUpdate, + dispatch: dispatch, + keyboardAccess: isKeyboardAccess, + options: TRENDING_SEARCH_CONTEXT_MENU_OPTIONS, + shouldSendImpressionStats: true, + site: { + url: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/trending-searches-new-tab", + variant + } + })))), /*#__PURE__*/external_React_default().createElement("ul", { + className: "trending-searches-list-items" + }, suggestions.slice(0, 6).map((result, index) => { + return /*#__PURE__*/external_React_default().createElement("li", { + key: result.suggestion, + className: `trending-searches-list-item ${refinedCards ? "compact" : ""}`, + onKeyDown: e => handleResultKeyDown(e, index) + }, /*#__PURE__*/external_React_default().createElement(SafeAnchor, { + url: result.searchUrl, + onLinkClick: handleLinkOpen, + title: result.suggestion, + setRef: item => resultRef.current[index] = item, + tabIndex: index === 0 ? 0 : -1 + }, result.icon ? /*#__PURE__*/external_React_default().createElement("div", { + className: "trending-icon-wrapper" + }, /*#__PURE__*/external_React_default().createElement("img", { + src: result.icon, + alt: "", + className: "trending-icon" + }), /*#__PURE__*/external_React_default().createElement("div", { + className: "trending-info-wrapper" + }, result.lowerCaseSuggestion, /*#__PURE__*/external_React_default().createElement("small", null, result.description))) : /*#__PURE__*/external_React_default().createElement((external_React_default()).Fragment, null, /*#__PURE__*/external_React_default().createElement("span", { + className: "trending-searches-icon icon icon-arrow-trending" + }), result.lowerCaseSuggestion))); + }))); + } +} + ;// CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, @@ -4833,6 +5093,7 @@ const AdBanner = ({ + const PREF_SECTIONS_CARDS_ENABLED = "discoverystream.sections.cards.enabled"; const PREF_THUMBS_UP_DOWN_ENABLED = "discoverystream.thumbsUpDown.enabled"; const PREF_TOPICS_ENABLED = "discoverystream.topicLabels.enabled"; @@ -4843,6 +5104,10 @@ const PREF_BILLBOARD_ENABLED = "newtabAdSize.billboard"; const PREF_BILLBOARD_POSITION = "newtabAdSize.billboard.position"; const PREF_LEADERBOARD_ENABLED = "newtabAdSize.leaderboard"; const PREF_LEADERBOARD_POSITION = "newtabAdSize.leaderboard.position"; +const PREF_TRENDING_SEARCH = "trendingSearch.enabled"; +const PREF_TRENDING_SEARCH_SYSTEM = "system.trendingSearch.enabled"; +const PREF_SEARCH_ENGINE = "trendingSearch.defaultSearchEngine"; +const PREF_TRENDING_SEARCH_VARIANT = "trendingSearch.variant"; const WIDGET_IDS = { TOPICS: 1 }; @@ -4947,6 +5212,8 @@ class _CardGrid extends (external_React_default()).PureComponent { const spocsStartupCacheEnabled = prefs[PREF_SPOCS_STARTUPCACHE_ENABLED]; const billboardEnabled = prefs[PREF_BILLBOARD_ENABLED]; const leaderboardEnabled = prefs[PREF_LEADERBOARD_ENABLED]; + const trendingEnabled = prefs[PREF_TRENDING_SEARCH] && prefs[PREF_TRENDING_SEARCH_SYSTEM] && prefs[PREF_SEARCH_ENGINE]?.toLowerCase() === "google"; + const trendingVariant = prefs[PREF_TRENDING_SEARCH_VARIANT]; const recs = this.props.data.recommendations.slice(0, items); const cards = []; let cardIndex = 0; @@ -5037,6 +5304,14 @@ class _CardGrid extends (external_React_default()).PureComponent { } } } + if (trendingEnabled && trendingVariant === "b") { + const firstSpocPosition = this.props.spocPositions[0]?.index; + // double check that a spoc/mrec is actually in the index it should be in + const format = cards[firstSpocPosition]?.props?.format; + const isSpoc = format === "spoc" || format === "rectangle"; + // if the spoc is not in its position, place TrendingSearches in the 3rd position + cards.splice(isSpoc ? firstSpocPosition + 1 : 2, 1, /*#__PURE__*/external_React_default().createElement(TrendingSearches, null)); + } // if a banner ad is enabled and we have any available, place them in the grid const { @@ -6719,6 +6994,10 @@ const INITIAL_STATE = { locationSearchString: "", suggestedLocations: [], }, + TrendingSearch: { + suggestions: [], + collapsed: false, + }, // Widgets ListsWidget: { // value pointing to last selectled list @@ -7640,6 +7919,17 @@ function Ads(prevState = INITIAL_STATE.Ads, action) { } } +function TrendingSearch(prevState = INITIAL_STATE.TrendingSearch, action) { + switch (action.type) { + case actionTypes.TRENDING_SEARCH_UPDATE: + return { ...prevState, suggestions: action.data }; + case actionTypes.TRENDING_SEARCH_TOGGLE_COLLAPSE: + return { ...prevState, collapsed: action.data.collapsed }; + default: + return prevState; + } +} + function TimerWidget(prevState = INITIAL_STATE.TimerWidget, action) { // fallback to current timerType in state if not provided in action const timerType = action.data?.timerType || prevState.timerType; @@ -7743,6 +8033,7 @@ const reducers = { Search, TimerWidget, ListsWidget, + TrendingSearch, Wallpapers, Weather, }; @@ -11299,6 +11590,7 @@ const Weather_Weather = (0,external_ReactRedux_namespaceObject.connect)(state => + // Prefs const CardSections_PREF_SECTIONS_CARDS_ENABLED = "discoverystream.sections.cards.enabled"; const PREF_SECTIONS_CARDS_THUMBS_UP_DOWN_ENABLED = "discoverystream.sections.cards.thumbsUpDown.enabled"; @@ -11315,9 +11607,13 @@ const CardSections_PREF_LEADERBOARD_ENABLED = "newtabAdSize.leaderboard"; const CardSections_PREF_LEADERBOARD_POSITION = "newtabAdSize.leaderboard.position"; const PREF_REFINED_CARDS_ENABLED = "discoverystream.refinedCardsLayout.enabled"; const PREF_INFERRED_PERSONALIZATION_USER = "discoverystream.sections.personalization.inferred.user.enabled"; +const CardSections_PREF_TRENDING_SEARCH = "trendingSearch.enabled"; +const CardSections_PREF_TRENDING_SEARCH_SYSTEM = "system.trendingSearch.enabled"; +const CardSections_PREF_SEARCH_ENGINE = "trendingSearch.defaultSearchEngine"; +const CardSections_PREF_TRENDING_SEARCH_VARIANT = "trendingSearch.variant"; const CardSections_PREF_DAILY_BRIEF_SECTIONID = "discoverystream.dailyBrief.sectionId"; const CardSections_PREF_SPOCS_STARTUPCACHE_ENABLED = "discoverystream.spocs.startupCache.enabled"; -function getLayoutData(responsiveLayouts, index, refinedCardsLayout) { +function getLayoutData(responsiveLayouts, index, refinedCardsLayout, sectionKey) { let layoutData = { classNames: [], imageSizes: {} @@ -11325,9 +11621,19 @@ function getLayoutData(responsiveLayouts, index, refinedCardsLayout) { responsiveLayouts.forEach(layout => { layout.tiles.forEach((tile, tileIndex) => { if (tile.position === index) { - layoutData.classNames.push(`col-${layout.columnCount}-${tile.size}`); - layoutData.classNames.push(`col-${layout.columnCount}-position-${tileIndex}`); - layoutData.imageSizes[layout.columnCount] = tile.size; + // When trending searches should be placed in the `top_stories_section`, + // we update the layout so that the first item is always a medium card to make + // room for the trending search widget + if (sectionKey === "top_stories_section" && tileIndex === 0) { + layoutData.classNames.push(`col-${layout.columnCount}-medium`); + layoutData.classNames.push(`col-${layout.columnCount}-position-${tileIndex}`); + layoutData.imageSizes[layout.columnCount] = "medium"; + layoutData.classNames.push(`col-${layout.columnCount}-hide-excerpt`); + } else { + layoutData.classNames.push(`col-${layout.columnCount}-${tile.size}`); + layoutData.classNames.push(`col-${layout.columnCount}-position-${tileIndex}`); + layoutData.imageSizes[layout.columnCount] = tile.size; + } // The API tells us whether the tile should show the excerpt or not. // Apply extra styles accordingly. @@ -11451,6 +11757,9 @@ function CardSection({ const availableTopics = prefs[CardSections_PREF_TOPICS_AVAILABLE]; const refinedCardsLayout = prefs[PREF_REFINED_CARDS_ENABLED]; const spocsStartupCacheEnabled = prefs[CardSections_PREF_SPOCS_STARTUPCACHE_ENABLED]; + const trendingEnabled = prefs[CardSections_PREF_TRENDING_SEARCH] && prefs[CardSections_PREF_TRENDING_SEARCH_SYSTEM] && prefs[CardSections_PREF_SEARCH_ENGINE]?.toLowerCase() === "google"; + const trendingVariant = prefs[CardSections_PREF_TRENDING_SEARCH_VARIANT]; + const shouldShowTrendingSearch = trendingEnabled && trendingVariant === "b"; const mayHaveSectionsPersonalization = prefs[PREF_SECTIONS_PERSONALIZATION_ENABLED]; const { sectionKey, @@ -11600,7 +11909,7 @@ function CardSection({ className: `ds-section-grid ds-card-grid`, onKeyDown: handleCardKeyDown }, section.data.slice(0, maxTile).map((rec, index) => { - const layoutData = getLayoutData(responsiveLayouts, index, refinedCardsLayout); + const layoutData = getLayoutData(responsiveLayouts, index, refinedCardsLayout, shouldShowTrendingSearch && sectionKey); const { classNames, imageSizes @@ -11666,7 +11975,9 @@ function CardSection({ tabIndex: index === focusedIndex ? 0 : -1, onFocus: () => onCardFocus(index) }); - return [card]; + return index === 0 && shouldShowTrendingSearch && sectionKey === "top_stories_section" ? [card, /*#__PURE__*/external_React_default().createElement(TrendingSearches, { + key: "trending" + })] : [card]; }))); } function CardSections({ @@ -14582,7 +14893,7 @@ class ContentSection extends (external_React_default()).PureComponent { })); } onPreferenceSelect(e) { - // eventSource: WEATHER | TOP_SITES | TOP_STORIES | WIDGET_LISTS | WIDGET_TIMER + // eventSource: WEATHER | TOP_SITES | TOP_STORIES | WIDGET_LISTS | WIDGET_TIMER | TRENDING_SEARCH const { preference, eventSource @@ -14640,6 +14951,7 @@ class ContentSection extends (external_React_default()).PureComponent { pocketRegion, mayHaveInferredPersonalization, mayHaveWeather, + mayHaveTrendingSearch, mayHaveWidgets, mayHaveTimerWidget, mayHaveListsWidget, @@ -14655,6 +14967,7 @@ class ContentSection extends (external_React_default()).PureComponent { topSitesEnabled, pocketEnabled, weatherEnabled, + trendingSearchEnabled, showInferredPersonalizationEnabled, topSitesRowsCount } = enabledSections; @@ -14712,6 +15025,16 @@ class ContentSection extends (external_React_default()).PureComponent { "data-preference": "widgets.focusTimer.enabled", "data-eventSource": "WIDGET_TIMER", "data-l10n-id": "newtab-custom-widget-timer-toggle" + })), mayHaveTrendingSearch && /*#__PURE__*/external_React_default().createElement("div", { + id: "trending-search-section", + className: "section" + }, /*#__PURE__*/external_React_default().createElement("moz-toggle", { + id: "trending-search-toggle", + pressed: trendingSearchEnabled || null, + onToggle: this.onPreferenceSelect, + "data-preference": "trendingSearch.enabled", + "data-eventSource": "TRENDING_SEARCH", + "data-l10n-id": "newtab-custom-widget-trending-search-toggle" })), /*#__PURE__*/external_React_default().createElement("span", { className: "divider", role: "separator" @@ -14727,6 +15050,16 @@ class ContentSection extends (external_React_default()).PureComponent { "data-preference": "showWeather", "data-eventSource": "WEATHER", "data-l10n-id": "newtab-custom-weather-toggle" + })), !mayHaveWidgets && mayHaveTrendingSearch && /*#__PURE__*/external_React_default().createElement("div", { + id: "trending-search-section", + className: "section" + }, /*#__PURE__*/external_React_default().createElement("moz-toggle", { + id: "trending-search-toggle", + pressed: trendingSearchEnabled || null, + onToggle: this.onPreferenceSelect, + "data-preference": "trendingSearch.enabled", + "data-eventSource": "TRENDING_SEARCH", + "data-l10n-id": "newtab-custom-trending-search-toggle" })), /*#__PURE__*/external_React_default().createElement("div", { id: "shortcuts-section", className: "section" @@ -14917,6 +15250,7 @@ class _CustomizeMenu extends (external_React_default()).PureComponent { mayHaveTopicSections: this.props.mayHaveTopicSections, mayHaveInferredPersonalization: this.props.mayHaveInferredPersonalization, mayHaveWeather: this.props.mayHaveWeather, + mayHaveTrendingSearch: this.props.mayHaveTrendingSearch, mayHaveWidgets: this.props.mayHaveWidgets, mayHaveTimerWidget: this.props.mayHaveTimerWidget, mayHaveListsWidget: this.props.mayHaveListsWidget, @@ -15001,6 +15335,7 @@ function Logo() { + class _Search extends (external_React_default()).PureComponent { constructor(props) { super(props); @@ -15116,6 +15451,9 @@ class _Search extends (external_React_default()).PureComponent { */ render() { const wrapperClassName = ["search-wrapper", this.props.disable && "search-disabled", this.props.fakeFocus && "fake-focus"].filter(v => v).join(" "); + const prefs = this.props.Prefs.values; + const trendingSearchEnabled = prefs["trendingSearch.enabled"] && prefs["system.trendingSearch.enabled"] && prefs["trendingSearch.defaultSearchEngine"]?.toLowerCase() === "google"; + const trendingSearchVariant = this.props.Prefs.values["trendingSearch.variant"]; return /*#__PURE__*/external_React_default().createElement((external_React_default()).Fragment, null, /*#__PURE__*/external_React_default().createElement("div", { className: wrapperClassName }, this.props.showLogo && /*#__PURE__*/external_React_default().createElement(Logo, null), !this.props.handoffEnabled && /*#__PURE__*/external_React_default().createElement("div", { @@ -15131,7 +15469,7 @@ class _Search extends (external_React_default()).PureComponent { className: "search-button", "data-l10n-id": "newtab-search-box-search-button", onClick: this.onSearchClick - })), this.props.handoffEnabled && /*#__PURE__*/external_React_default().createElement("div", { + }), trendingSearchEnabled && (trendingSearchVariant === "a" || trendingSearchVariant === "c") && /*#__PURE__*/external_React_default().createElement(TrendingSearches, null)), this.props.handoffEnabled && /*#__PURE__*/external_React_default().createElement("div", { className: "search-inner-wrapper" }, /*#__PURE__*/external_React_default().createElement("button", { className: "search-handoff-button", @@ -15153,7 +15491,7 @@ class _Search extends (external_React_default()).PureComponent { ref: el => { this.fakeCaret = el; } - }))))); + })), trendingSearchEnabled && (trendingSearchVariant === "a" || trendingSearchVariant === "c") && /*#__PURE__*/external_React_default().createElement(TrendingSearches, null)))); } } const Search_Search = (0,external_ReactRedux_namespaceObject.connect)(state => ({ @@ -16306,7 +16644,8 @@ class BaseContent extends (external_React_default()).PureComponent { pocketEnabled: prefs["feeds.section.topstories"], showInferredPersonalizationEnabled: prefs[Base_PREF_INFERRED_PERSONALIZATION_USER], topSitesRowsCount: prefs.topSitesRows, - weatherEnabled: prefs.showWeather + weatherEnabled: prefs.showWeather, + trendingSearchEnabled: prefs["trendingSearch.enabled"] }; const pocketRegion = prefs["feeds.system.topstories"]; const mayHaveInferredPersonalization = prefs[PREF_INFERRED_PERSONALIZATION_SYSTEM]; @@ -16331,9 +16670,13 @@ class BaseContent extends (external_React_default()).PureComponent { const enabledWidgets = { listsEnabled: prefs["widgets.lists.enabled"], timerEnabled: prefs["widgets.focusTimer.enabled"], + trendingSearchEnabled: prefs["trendingSearch.enabled"], weatherEnabled: prefs.showWeather }; + // Trending Searches experiment pref check + const mayHaveTrendingSearch = prefs["system.trendingSearch.enabled"] && prefs["trendingSearch.defaultSearchEngine"].toLowerCase() === "google"; + // Mobile Download Promo Pref Checks const mobileDownloadPromoEnabled = prefs["mobileDownloadModal.enabled"]; const mobileDownloadPromoVariantAEnabled = prefs["mobileDownloadModal.variant-a"]; @@ -16417,6 +16760,7 @@ class BaseContent extends (external_React_default()).PureComponent { mayHaveTopicSections: mayHavePersonalizedTopicSections, mayHaveInferredPersonalization: mayHaveInferredPersonalization, mayHaveWeather: mayHaveWeather, + mayHaveTrendingSearch: mayHaveTrendingSearch, mayHaveWidgets: mayHaveWidgets, mayHaveTimerWidget: mayHaveTimerWidget, mayHaveListsWidget: mayHaveListsWidget, diff --git a/browser/extensions/newtab/karma.mc.config.js b/browser/extensions/newtab/karma.mc.config.js @@ -272,6 +272,13 @@ module.exports = function (config) { lines: 75, branches: 50, }, + "content-src/components/DiscoveryStreamComponents/TrendingSearches/TrendingSearches.jsx": + { + statements: 0, + lines: 0, + functions: 0, + branches: 0, + }, "content-src/components/DiscoveryStreamComponents/**/*.jsx": { statements: 80.95, lines: 80.95, diff --git a/browser/extensions/newtab/lib/ActivityStream.sys.mjs b/browser/extensions/newtab/lib/ActivityStream.sys.mjs @@ -45,6 +45,7 @@ ChromeUtils.defineESModuleGetters(lazy, { TimerFeed: "resource://newtab/lib/Widgets/TimerFeed.sys.mjs", TopSitesFeed: "resource://newtab/lib/TopSitesFeed.sys.mjs", TopStoriesFeed: "resource://newtab/lib/TopStoriesFeed.sys.mjs", + TrendingSearchFeed: "resource://newtab/lib/TrendingSearchFeed.sys.mjs", WallpaperFeed: "resource://newtab/lib/Wallpapers/WallpaperFeed.sys.mjs", WeatherFeed: "resource://newtab/lib/WeatherFeed.sys.mjs", }); @@ -93,6 +94,7 @@ const REGION_SECTIONS_CONFIG = const LOCALE_SECTIONS_CONFIG = "browser.newtabpage.activity-stream.discoverystream.sections.locale-content-config"; +const BROWSER_URLBAR_PLACEHOLDERNAME = "browser.urlbar.placeholderName"; const PREF_SHOULD_AS_INITIALIZE_FEEDS = "browser.newtabpage.activity-stream.testing.shouldInitializeFeeds"; @@ -956,6 +958,36 @@ export const PREFS_CONFIG = new Map([ }, ], [ + "trendingSearch.enabled", + { + title: "Enables the trending search widget", + value: true, + }, + ], + [ + "trendingSearch.variant", + { + title: "Determines the layout variant for the trending search widget", + value: "", + }, + ], + [ + "system.trendingSearch.enabled", + { + title: "Enables the trending search experiment in Nimbus", + value: false, + }, + ], + [ + "trendingSearch.defaultSearchEngine", + { + title: "Placeholder the trending search experiment in Nimbus", + getValue: () => { + return Services.prefs.getCharPref(BROWSER_URLBAR_PLACEHOLDERNAME, ""); + }, + }, + ], + [ "widgets.system.enabled", { title: "Enables visibility of all widgets and controls to enable them", @@ -1541,6 +1573,12 @@ const FEEDS_DATA = [ value: true, }, { + name: "trendingsearchfeed", + factory: () => new lazy.TrendingSearchFeed(), + title: "Handles fetching the google trending search API", + value: true, + }, + { name: "listsfeed", factory: () => new lazy.ListsFeed(), title: "Handles the data for the Todo list widget", @@ -1591,8 +1629,17 @@ export class ActivityStream { this._updateDynamicPrefs(); this._defaultPrefs.init(); Services.obs.addObserver(this, "intl:app-locales-changed"); + Services.obs.addObserver(this, "browser-search-engine-modified"); lazy.NewTabActorRegistry.init(); + // Bug 1969587: Because our pref system does not support async getValue(), + // we mirror the value of the BROWSER_URLBAR_PLACEHOLDERNAME pref into + // `trendingSearch.defaultEngine` using a lazily evaluated sync fallback. + // + // In some cases, BROWSER_URLBAR_PLACEHOLDERNAME is read before it's been set, + // so we also observe it and update our mirrored value when it changes initially. + Services.prefs.addObserver(BROWSER_URLBAR_PLACEHOLDERNAME, this); + // Hook up the store and let all feeds and pages initialize this.store.init( this.feeds, @@ -1650,6 +1697,9 @@ export class ActivityStream { delete this.geo; Services.obs.removeObserver(this, "intl:app-locales-changed"); + Services.obs.removeObserver(this, "browser-search-engine-modified"); + + Services.prefs.removeObserver(BROWSER_URLBAR_PLACEHOLDERNAME, this); this.store.uninit(); this.initialized = false; @@ -1707,8 +1757,15 @@ export class ActivityStream { } } - observe(subject, topic) { + observe(subject, topic, data) { + // Custom logic for BROWSER_URLBAR_PLACEHOLDERNAME + if (topic === "nsPref:changed" && data === BROWSER_URLBAR_PLACEHOLDERNAME) { + this._updateDynamicPrefs(); + return; + } + switch (topic) { + case "browser-search-engine-modified": case "intl:app-locales-changed": case lazy.Region.REGION_TOPIC: this._updateDynamicPrefs(); diff --git a/browser/extensions/newtab/lib/TelemetryFeed.sys.mjs b/browser/extensions/newtab/lib/TelemetryFeed.sys.mjs @@ -1408,6 +1408,16 @@ export class TelemetryFeed { case at.REPORT_CONTENT_SUBMIT: this.handleReportContentUserEvent(action); break; + case at.TRENDING_SEARCH_IMPRESSION: + case at.TRENDING_SEARCH_SUGGESTION_OPEN: + this.handleTrendingSearchUserEvent(action); + break; + case at.TRENDING_SEARCH_TOGGLE_COLLAPSE: + // only send telemetry if a user is collapsing the widget + if (!action.data.collapsed) { + this.handleTrendingSearchUserEvent(action); + } + break; case at.WIDGETS_LISTS_USER_EVENT: case at.WIDGETS_LISTS_USER_IMPRESSION: case at.WIDGETS_TIMER_USER_EVENT: @@ -1472,6 +1482,27 @@ export class TelemetryFeed { } } + handleTrendingSearchUserEvent(action) { + const session = this.sessions.get(au.getPortIdOfSender(action)); + if (session) { + const payload = { + newtab_visit_id: session.visit_id, + variant: action.data.variant || "", + }; + switch (action.type) { + case "TRENDING_SEARCH_IMPRESSION": + Glean.newtab.trendingSearchImpression.record(payload); + break; + case "TRENDING_SEARCH_TOGGLE_COLLAPSE": + Glean.newtab.trendingSearchDismiss.record(payload); + break; + case "TRENDING_SEARCH_SUGGESTION_OPEN": + Glean.newtab.trendingSearchSuggestionOpen.record(payload); + break; + } + } + } + async handleReportAdUserEvent(action) { const { placement_id, position, report_reason, reporting_url } = action.data || {}; @@ -1707,6 +1738,7 @@ export class TelemetryFeed { } handleSetPref(action) { + const prefs = this.store.getState()?.Prefs.values; const session = this.sessions.get(au.getPortIdOfSender(action)); if (!session) { return; @@ -1718,6 +1750,15 @@ export class TelemetryFeed { weather_display_mode: action.data.value, }); break; + case "trendingSearch.enabled": + if (!action.data.value) { + const variant = prefs["trendingSearch.variant"] || ""; + Glean.newtab.trendingSearchDismiss.record({ + newtab_visit_id: session.session_id, + variant, + }); + } + break; case "widgets.lists.enabled": Glean.newtab.widgetsListsChangeDisplay.record({ newtab_visit_id: session.session_id, diff --git a/browser/extensions/newtab/lib/TrendingSearchFeed.sys.mjs b/browser/extensions/newtab/lib/TrendingSearchFeed.sys.mjs @@ -0,0 +1,229 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { + actionTypes as at, + actionCreators as ac, +} from "resource://newtab/common/Actions.mjs"; +import { ImportHelper } from "resource://newtab/lib/ImportHelper.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + BrowserSearchTelemetry: + "moz-src:///browser/components/search/BrowserSearchTelemetry.sys.mjs", + PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs", + SearchSuggestionController: + "moz-src:///toolkit/components/search/SearchSuggestionController.sys.mjs", +}); + +/** + * @backward-compat { version 145 } + * + * UrlbarUtils.sys.mjs was moved to moz-src in 145. + */ +ChromeUtils.defineLazyGetter(lazy, "UrlbarUtils", () => { + return ImportHelper.import( + "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs", + "resource:///modules/" + ).UrlbarUtils; +}); + +const PREF_SHOW_TRENDING_SEARCH = "trendingSearch.enabled"; +const PREF_SHOW_TRENDING_SEARCH_SYSTEM = "system.trendingSearch.enabled"; +const PREF_TRENDING_SEARCH_DEFAULT = "trendingSearch.defaultSearchEngine"; +const TRENDING_SEARCH_UPDATE_TIME = 15 * 60 * 1000; // 15 minutes +const CACHE_KEY = "trending_search"; + +/** + * A feature that periodically fetches trending search suggestions for HNT. + */ +export class TrendingSearchFeed { + constructor() { + this.initialized = false; + this.fetchTimer = null; + this.suggestions = []; + this.lastUpdated = null; + this.defaultEngine = null; + this.cache = this.PersistentCache(CACHE_KEY, true); + } + + get enabled() { + const prefs = this.store.getState()?.Prefs.values; + const trendingSearchEnabled = + prefs[PREF_SHOW_TRENDING_SEARCH] && + prefs[PREF_SHOW_TRENDING_SEARCH_SYSTEM]; + const isGoogle = + prefs[PREF_TRENDING_SEARCH_DEFAULT]?.toLowerCase() === "google"; + return trendingSearchEnabled && isGoogle; + } + + async init() { + this.initialized = true; + const engine = await Services.search.getDefault(); + this.defaultEngine = engine; + await this.loadTrendingSearch(true); + } + + async loadTrendingSearch(isStartup = false) { + this.initialized = true; + const cachedData = (await this.cache.get()) || {}; + const { trendingSearch } = cachedData; + + // If we have nothing in cache, or cache has expired, we can make a fresh fetch. + if ( + !trendingSearch?.lastUpdated || + !( + this.Date().now() - trendingSearch?.lastUpdated < + TRENDING_SEARCH_UPDATE_TIME + ) + ) { + await this.fetch(isStartup); + } else if (!this.lastUpdated) { + this.suggestions = trendingSearch.suggestions; + this.lastUpdated = trendingSearch.lastUpdated; + this.update(); + } + } + + async fetch() { + const suggestions = await this.fetchHelper(); + this.suggestions = suggestions; + + if (this.suggestions?.length) { + this.lastUpdated = this.Date().now(); + await this.cache.set("trendingSearch", { + suggestions: this.suggestions, + lastUpdated: this.lastUpdated, + }); + } + this.update(); + } + + update() { + this.store.dispatch( + ac.BroadcastToContent({ + type: at.TRENDING_SEARCH_UPDATE, + data: this.suggestions, + }) + ); + } + + async fetchHelper() { + if (!this.defaultEngine) { + const engine = await Services.search.getDefault(); + this.defaultEngine = engine; + } + + this.suggestionsController = this.SearchSuggestionController(); + this.suggestionsController.maxLocalResults = 0; + + let suggestionPromise; + if (Services.vc.compare(AppConstants.MOZ_APP_VERSION, "144.0a1") >= 0) { + suggestionPromise = this.suggestionsController.fetch({ + searchString: "", + inPrivateBrowsing: false, + engine: this.defaultEngine, + fetchTrending: true, + }); + } else { + suggestionPromise = this.suggestionsController.fetch( + "", // searchString + false, // privateMode + this.defaultEngine, // engine + 0, + false, //restrictToEngine + false, // dedupeRemoteAndLocal + true // fetchTrending + ); + } + + let fetchData = await suggestionPromise; + + if (!fetchData) { + return null; + } + + let results = []; + + for (let entry of fetchData.remote) { + // Construct the fully formatted search URL for the current trending result + const [searchUrl] = await lazy.UrlbarUtils.getSearchQueryUrl( + this.defaultEngine, + entry.value + ); + + results.push({ + engine: this.defaultEngine.name, + suggestion: entry.value, + lowerCaseSuggestion: entry.value.toLocaleLowerCase(), + icon: !entry.value ? await this.defaultEngine.getIconUrl() : entry.icon, + description: entry.description || undefined, + isRichSuggestion: !!entry.icon, + searchUrl, + }); + } + return results; + } + + handleSearchTelemetry(browser) { + lazy.BrowserSearchTelemetry.recordSearch( + browser, + this.defaultEngine, + "newtab" + ); + } + + async onAction(action) { + switch (action.type) { + case at.INIT: + if (this.enabled) { + await this.init(); + } + break; + case at.DISCOVERY_STREAM_CONFIG_CHANGE: + await this.cache.set("trendingSearch", {}); + if (this.enabled) { + await this.loadTrendingSearch(); + } + break; + case at.DISCOVERY_STREAM_DEV_SYSTEM_TICK: + case at.SYSTEM_TICK: + if (this.enabled) { + await this.loadTrendingSearch(); + } + break; + case at.TRENDING_SEARCH_SUGGESTION_OPEN: + this.handleSearchTelemetry(action._target.browser); + break; + case at.PREF_CHANGED: + { + const { name, value } = action.data; + + const isTrendingShowPref = + (name === PREF_SHOW_TRENDING_SEARCH || + name === PREF_SHOW_TRENDING_SEARCH_SYSTEM) && + value; + const isTrendingDefaultPref = name === PREF_TRENDING_SEARCH_DEFAULT; + + if (isTrendingShowPref || isTrendingDefaultPref) { + if (this.enabled) { + await this.loadTrendingSearch(); + } + } + } + break; + } + } +} + +TrendingSearchFeed.prototype.Date = () => { + return Date; +}; +TrendingSearchFeed.prototype.PersistentCache = (...args) => { + return new lazy.PersistentCache(...args); +}; +TrendingSearchFeed.prototype.SearchSuggestionController = (...args) => { + return new lazy.SearchSuggestionController(...args); +}; diff --git a/browser/extensions/newtab/test/browser/browser.toml b/browser/extensions/newtab/test/browser/browser.toml @@ -23,9 +23,9 @@ prefs = [ tags = "newtab" ["browser_SmartShortcuts.js"] -skip-if = [ - "os == 'win' && os_version == '11.26100' && verify-standalone", -] +skip-if = ["os == 'win' && os_version == '11.26100' && verify-standalone"] + +["browser_TrendingSearch.js"] ["browser_WallpaperThemeWorker.js"] @@ -53,33 +53,25 @@ https_first_disabled = true ["browser_getScreenshots.js"] ["browser_highlights_section.js"] -skip-if = [ - "os == 'linux' && os_version == '24.04' && arch == 'x86_64' && display == 'x11' && opt && socketprocess_networking", # Bug 1846916 -] # Bug 1846916 +skip-if = ["os == 'linux' && os_version == '24.04' && processor == 'x86_64' && display == 'x11' && opt && socketprocess_networking"] # Bug 1846916 ["browser_newtab_glean.js"] ["browser_newtab_header.js"] skip-if = [ - "os == 'linux' && os_version == '24.04' && arch == 'x86_64' && display == 'x11' && opt && socketprocess_networking", # Bug 1945649 + "os == 'linux' && os_version == '24.04' && processor == 'x86_64' && display == 'x11' && opt && socketprocess_networking", # Bug 1945649 ] ["browser_newtab_last_LinkMenu.js"] -skip-if = [ - "os == 'linux' && os_version == '24.04' && arch == 'x86_64' && display == 'x11' && opt && socketprocess_networking", # Bug 1945649 -] # Bug 1945649 +skip-if = ["os == 'linux' && os_version == '24.04' && processor == 'x86_64' && display == 'x11' && opt && socketprocess_networking"] # Bug 1945649 ["browser_newtab_overrides.js"] ["browser_newtab_ping.js"] -skip-if = [ - "os == 'linux' && os_version == '24.04' && arch == 'x86_64' && display == 'x11' && opt && socketprocess_networking", # Bug 1945649 -] # Bug 1945649 +skip-if = ["os == 'linux' && os_version == '24.04' && processor == 'x86_64' && display == 'x11' && opt && socketprocess_networking"] # Bug 1945649 ["browser_newtab_towindow.js"] -skip-if = [ - "os == 'linux' && os_version == '24.04' && arch == 'x86_64' && display == 'x11' && opt && socketprocess_networking", # Bug 1945649 -] # Bug 1945649 +skip-if = ["os == 'linux' && os_version == '24.04' && processor == 'x86_64' && display == 'x11' && opt && socketprocess_networking"] # Bug 1945649 ["browser_newtab_trigger.js"] @@ -87,8 +79,8 @@ skip-if = [ ["browser_open_tab_focus.js"] run-if = [ - "os == 'mac'", # Test setup only implemented for OSX and Windows "os == 'win'", # Test setup only implemented for OSX and Windows + "os == 'mac'", # Test setup only implemented for OSX and Windows ] ["browser_topsites_contextMenu_options.js"] diff --git a/browser/extensions/newtab/test/browser/browser_TrendingSearch.js b/browser/extensions/newtab/test/browser/browser_TrendingSearch.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExperimentAPI } = ChromeUtils.importESModule( + "resource://nimbus/ExperimentAPI.sys.mjs" +); +const { NimbusTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); +const { TrendingSearchFeed } = ChromeUtils.importESModule( + "resource://newtab/lib/TrendingSearchFeed.sys.mjs" +); + +const searchControllerResponse = { + remote: [ + { + value: "response1", + matchPrefix: null, + tail: null, + trending: true, + icon: null, + description: null, + }, + { + value: "response2", + matchPrefix: null, + tail: null, + trending: true, + icon: null, + description: null, + }, + ], +}; + +add_task(async function test_nimbus_experiment_enabled() { + sinon + .stub(TrendingSearchFeed.prototype, "SearchSuggestionController") + .returns({ + fetch: () => searchControllerResponse, + }); + let trendingsearchfeed = AboutNewTab.activityStream.store.feeds.get( + "feeds.trendingsearchfeed" + ); + + // Initialize the feed, because that doesn't happen by default. + await trendingsearchfeed.onAction({ type: "INIT" }); + + ok(!trendingsearchfeed?.initialized, "Should initially not be loaded."); + + // Setup the experiment. + await ExperimentAPI.ready(); + let doExperimentCleanup = await NimbusTestUtils.enrollWithFeatureConfig({ + featureId: "newtabTrendingSearchWidget", + value: { enabled: true }, + }); + + ok(trendingsearchfeed?.initialized, "Should now be loaded."); + + await doExperimentCleanup(); +}); diff --git a/browser/extensions/newtab/test/unit/content-src/components/CustomizeMenu/ContentSection.test.jsx b/browser/extensions/newtab/test/unit/content-src/components/CustomizeMenu/ContentSection.test.jsx @@ -10,6 +10,7 @@ import { Provider } from "react-redux"; const DEFAULT_PROPS = { mayHaveWidgets: false, mayHaveWeather: true, + mayHaveTrendingSearch: true, mayHaveTimerWidget: false, mayHaveListsWidget: false, wallpapersEnabled: false, @@ -19,6 +20,7 @@ const DEFAULT_PROPS = { topSitesEnabled: true, pocketEnabled: true, weatherEnabled: true, + trendingSearchEnabled: true, showInferredPersonalizationEnabled: false, topSitesRowsCount: 1, }, @@ -153,6 +155,56 @@ describe("ContentSection", () => { assert.isFalse(wrapper.find(".widgets-section #weather-section").exists()); }); + it("places Trending Search under Widgets when widgets are enabled and doesn't render default Trending Search placement", () => { + wrapper = mount( + <WrapWithProvider> + <ContentSection + {...DEFAULT_PROPS} + mayHaveWidgets={true} + mayHaveTrendingSearch={true} + enabledSections={{ + ...DEFAULT_PROPS.enabledSections, + trendingSearchEnabled: true, + }} + /> + </WrapWithProvider> + ); + + assert.isTrue( + wrapper + .find( + ".widgets-section #trending-search-section #trending-search-toggle" + ) + .exists() + ); + assert.isFalse( + wrapper.find(".settings-toggles #trending-search-section").exists() + ); + }); + + it("places Trending Search in the default area when widgets are disabled", () => { + wrapper = mount( + <WrapWithProvider> + <ContentSection + {...DEFAULT_PROPS} + mayHaveWidgets={false} + mayHaveTrendingSearch={true} + /> + </WrapWithProvider> + ); + + assert.isTrue( + wrapper + .find( + ".settings-toggles #trending-search-section #trending-search-toggle" + ) + .exists() + ); + assert.isFalse( + wrapper.find(".widgets-section #trending-search-section").exists() + ); + }); + it("renders Lists toggle only when mayHaveListsWidget = true in Widgets section", () => { wrapper = mount( <WrapWithProvider> diff --git a/browser/extensions/newtab/test/unit/content-src/components/CustomizeMenu/CustomizeMenu.test.jsx b/browser/extensions/newtab/test/unit/content-src/components/CustomizeMenu/CustomizeMenu.test.jsx @@ -42,6 +42,7 @@ describe("<CustomizeMenu>", () => { topSitesEnabled: true, pocketEnabled: true, weatherEnabled: true, + trendingSearchEnabled: true, showInferredPersonalizationEnabled: false, topSitesRowsCount: 1, selectedWallpaper: "", @@ -53,6 +54,7 @@ describe("<CustomizeMenu>", () => { mayHaveTopicSections: false, mayHaveInferredPersonalization: false, mayHaveWeather: true, + mayHaveTrendingSearch: true, mayHaveWidgets: false, mayHaveTimerWidget: false, mayHaveListsWidget: false, diff --git a/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx b/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx @@ -128,6 +128,78 @@ describe("<CardGrid>", () => { assert.ok(wrapper.find(".ad-banner-wrapper").exists()); }); + it("should render TrendingSearch if enabled", () => { + const commonProps = { + spocPositions: [{ index: 1 }, { index: 5 }, { index: 7 }], + items: 12, + data: { + recommendations: [ + {}, + { format: "spoc" }, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + ], + }, + Prefs: { + ...INITIAL_STATE.Prefs, + values: { + ...INITIAL_STATE.Prefs.values, + "trendingSearch.enabled": true, + "system.trendingSearch.enabled": true, + "trendingSearch.variant": "b", + "trendingSearch.defaultSearchEngine": "Google", + }, + }, + DiscoveryStream: INITIAL_STATE.DiscoveryStream, + }; + + wrapper = mount( + <WrapWithProvider + state={{ + ...INITIAL_STATE, + Prefs: { + ...INITIAL_STATE.Prefs, + values: { + ...INITIAL_STATE.Prefs.values, + "trendingSearch.variant": "b", + }, + }, + TrendingSearch: { + suggestions: [ + { + suggestion: "foo", + searchUrl: "foo", + lowerCaseSuggestion: "foo", + }, + { + suggestion: "bar", + searchUrl: "bar", + lowerCaseSuggestion: "foo", + }, + ], + }, + }} + > + <CardGrid {...commonProps} /> + </WrapWithProvider> + ); + + assert.ok(wrapper.find(".trending-searches-list-view").exists()); + const grid = wrapper.find(".ds-card-grid").first(); + // assert that the spoc has been placed in the correct position + assert.equal(grid.childAt(1).prop("format"), "spoc"); + // confrim that the next child is the trending search widget + assert.ok(grid.childAt(2).find(".trending-searches-list-view").exists()); + }); + describe("Keyboard navigation", () => { beforeEach(() => { const commonProps = { diff --git a/browser/locales/en-US/browser/newtab/newtab.ftl b/browser/locales/en-US/browser/newtab/newtab.ftl @@ -254,8 +254,13 @@ newtab-custom-stories-personalized-checkbox-label = Personalized stories based o newtab-custom-weather-toggle = .label = Weather .description = Today’s forecast at a glance +newtab-custom-trending-search-toggle = + .label = Trending searches + .description = Popular and frequently searched topics newtab-custom-widget-weather-toggle = .label = Weather +newtab-custom-widget-trending-search-toggle = + .label = Trending searches newtab-custom-widget-lists-toggle = .label = Lists newtab-custom-widget-timer-toggle = @@ -543,6 +548,17 @@ newtab-report-submit = Submit newtab-toast-thanks-for-reporting = .message = Thank you for reporting this. +## Strings for trending searches + +# "Trending searches refers to popular searches from search engines +newtab-trending-searches-title = Trending searches +newtab-trending-searches-show-trending = + .title = Show trending searches +newtab-trending-searches-hide-trending = + .title = Hide trending searches +newtab-trending-searches-learn-more = Learn more +newtab-trending-searches-dismiss = Hide trending searches + ## Strings for task / to-do list productivity widget newtab-widget-section-title = Widgets diff --git a/toolkit/components/nimbus/FeatureManifest.yaml b/toolkit/components/nimbus/FeatureManifest.yaml @@ -1606,6 +1606,25 @@ newtabTrainhopAddon: branch: user pref: browser.newtabpage.trainhopAddon.version +newtabTrendingSearchWidget: + description: >- + Adds trending search results widget to new tab + owner: nbarrett@mozilla.com + hasExposure: false + variables: + enabled: + type: boolean + setPref: + branch: user + pref: browser.newtabpage.activity-stream.system.trendingSearch.enabled + description: Enables the refined cards layout for newtab + variant: + type: string + setPref: + branch: user + pref: browser.newtabpage.activity-stream.trendingSearch.variant + description: Determines the layout variant for the trending search widget + newtabTopicSelection: description: the about:newtab topic selection experience. owner: nbarrett@mozilla.com