commit 91e78bdb109604b522c9e68819577df441da68c6 parent 019b9968c30ce766a200e5b87dfec3705eec954b Author: agoloman <agoloman@mozilla.com> Date: Fri, 3 Oct 2025 00:53:11 +0300 Revert "Bug 1972623 - Remove code related to list feed and contexual content r=home-newtab-reviewers,npypchenko" for causing newtab failures @newtab. This reverts commit 54d5972c80392b1dbb6e01d0d89f858ca662a56c. Diffstat:
27 files changed, 2232 insertions(+), 134 deletions(-)
diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js @@ -1982,6 +1982,11 @@ pref("browser.newtabpage.activity-stream.discoverystream.topicLabels.region-topi pref("browser.newtabpage.activity-stream.discoverystream.topicSelection.locale-topics-config", "en-US, en-GB, en-CA"); pref("browser.newtabpage.activity-stream.discoverystream.topicLabels.locale-topic-label-config", "en-US, en-GB, en-CA"); +// List of locales that get contextual content by default +pref("browser.newtabpage.activity-stream.discoverystream.contextualContent.locale-content-config", "en-US,en-GB,en-CA,de"); +// List of regions that get contextual content by default- TODO: update once development is closer to being finished +pref("browser.newtabpage.activity-stream.discoverystream.contextualContent.region-content-config", ""); + // List of locales that get section layout by default pref("browser.newtabpage.activity-stream.discoverystream.sections.locale-content-config", "en-US,en-CA"); // List of regions that get section layout by default diff --git a/browser/components/newtab/metrics.yaml b/browser/components/newtab/metrics.yaml @@ -556,6 +556,138 @@ newtab: send_in_pings: - newtab + fakespot_dismiss: + type: event + description: > + Recorded when a user dissmisses TBR fakespot feed + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1924873 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1924873 + data_sensitivity: + - interaction + notification_emails: + - nbarrett@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + send_in_pings: + - newtab + + fakespot_about_click: + type: event + description: > + Recorded when a user the 'About Fakespot' link in TBR fakespot feed context menu + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1924873 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1924873 + data_sensitivity: + - interaction + notification_emails: + - nbarrett@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + send_in_pings: + - newtab + + fakespot_click: + type: event + description: > + Recorded when a user clicks on a card in TBR fakespot feed + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1924873 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1924873 + data_sensitivity: + - interaction + notification_emails: + - nbarrett@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + product_id: + description: > + id of fakespot product + type: string + category: + description: > + category of fakespot product + type: string + send_in_pings: + - newtab + + fakespot_product_impression: + type: event + description: > + Recorded when a user triggers an impression on a card in TBR fakespot feed + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1924873 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1924873 + data_sensitivity: + - interaction + notification_emails: + - nbarrett@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + product_title: + description: > + title of fakespot product + type: string + product_id: + description: > + id of fakespot product + type: string + category: + description: > + category of fakespot product + type: string + send_in_pings: + - newtab + + fakespot_cta_click: + type: event + description: > + Recorded when a user clicks on the CTA in TBR fakespot feed + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1924873 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1924873 + data_sensitivity: + - interaction + notification_emails: + - nbarrett@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + send_in_pings: + - newtab + + fakespot_category: + type: event + description: > + Recorded when a user changes the category in TBR fakespot feed + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1924873 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1924873 + data_sensitivity: + - interaction + notification_emails: + - nbarrett@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + category: + description: > + category that user selected + type: string + send_in_pings: + - newtab + sections_impression: type: event description: > @@ -1839,6 +1971,10 @@ pocket: description: > The list of topics the user selected type: string + is_list_card: + description: > + Where the article card is in the list component + type: boolean section: description: > If click belongs in a section, the name of the section @@ -1906,6 +2042,10 @@ pocket: description: > The list of topics the user selected type: string + is_list_card: + description: > + Where the article card is in the list component + type: boolean section: description: > If click belongs in a section, the name of the section @@ -1950,6 +2090,10 @@ pocket: corpus_item_id: *corpus_item_id received_rank: *received_rank recommended_at: *recommended_at + is_list_card: + description: > + Where the article card is in the list component + type: boolean section: description: > If click belongs in a section, the name of the section @@ -2016,6 +2160,10 @@ pocket: description: > The list of topics the user selected type: string + is_list_card: + description: > + Where the article card is in the list component + type: boolean section: description: > If click belongs in a section, the name of the section @@ -2157,6 +2305,10 @@ pocket: topic: description: The topic of the recommendation. Like "entertainment". type: string + is_list_card: + description: > + Where the article card is in the list component + type: boolean section: description: > If event belongs in a section, the name of the section @@ -2399,6 +2551,10 @@ newtab_content: description: > The list of topics the user selected type: string + is_list_card: + description: > + Where the article card is in the list component + type: boolean section: description: > If click belongs in a section, the name of the section @@ -2451,6 +2607,10 @@ newtab_content: description: > The list of topics the user selected type: string + is_list_card: + description: > + Where the article card is in the list component + type: boolean section: description: > If click belongs in a section, the name of the section @@ -2542,6 +2702,10 @@ newtab_content: topic: description: The topic of the recommendation. Like "entertainment". type: string + is_list_card: + description: > + Where the article card is in the list component + type: boolean section: description: > If event belongs in a section, the name of the section diff --git a/browser/extensions/newtab/common/Actions.mjs b/browser/extensions/newtab/common/Actions.mjs @@ -84,6 +84,8 @@ for (const type of [ "DISCOVERY_STREAM_TOPICS_LOADING", "DISCOVERY_STREAM_USER_EVENT", "DOWNLOAD_CHANGED", + "FAKESPOT_CTA_CLICK", + "FAKESPOT_DISMISS", "FAKE_FOCUS_SEARCH", "FILL_SEARCH_TERM", "FOLLOW_SECTION", diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx @@ -10,6 +10,9 @@ import React, { useEffect } from "react"; const PREF_AD_SIZE_MEDIUM_RECTANGLE = "newtabAdSize.mediumRectangle"; const PREF_AD_SIZE_BILLBOARD = "newtabAdSize.billboard"; const PREF_AD_SIZE_LEADERBOARD = "newtabAdSize.leaderboard"; +const PREF_CONTEXTUAL_CONTENT_SELECTED_FEED = + "discoverystream.contextualContent.selectedFeed"; +const PREF_CONTEXTUAL_CONTENT_FEEDS = "discoverystream.contextualContent.feeds"; const PREF_SECTIONS_ENABLED = "discoverystream.sections.enabled"; const PREF_SPOC_PLACEMENTS = "discoverystream.placements.spocs"; const PREF_SPOC_COUNTS = "discoverystream.placements.spocs.counts"; @@ -147,6 +150,7 @@ export class DiscoveryStreamAdminUI extends React.PureComponent { this.refreshInferredPersonalization.bind(this); this.refreshTopicSelectionCache = this.refreshTopicSelectionCache.bind(this); + this.toggleTBRFeed = this.toggleTBRFeed.bind(this); this.handleSectionsToggle = this.handleSectionsToggle.bind(this); this.toggleIABBanners = this.toggleIABBanners.bind(this); this.state = { @@ -227,6 +231,12 @@ export class DiscoveryStreamAdminUI extends React.PureComponent { this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SHOW_PLACEHOLDER); } + toggleTBRFeed(e) { + const feed = e.target.value; + const selectedFeed = PREF_CONTEXTUAL_CONTENT_SELECTED_FEED; + this.props.dispatch(ac.SetPref(selectedFeed, feed)); + } + idleDaily() { this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_IDLE_DAILY); } @@ -619,7 +629,14 @@ export class DiscoveryStreamAdminUI extends React.PureComponent { const { config, layout } = this.props.state.DiscoveryStream; const personalized = this.props.otherPrefs["discoverystream.personalization.enabled"]; + const selectedFeed = + this.props.otherPrefs[PREF_CONTEXTUAL_CONTENT_SELECTED_FEED]; const sectionsEnabled = this.props.otherPrefs[PREF_SECTIONS_ENABLED]; + const TBRFeeds = this.props.otherPrefs[PREF_CONTEXTUAL_CONTENT_FEEDS].split( + "," + ) + .map(s => s.trim()) + .filter(item => item); // Prefs for IAB Banners const mediumRectangleEnabled = @@ -670,6 +687,17 @@ export class DiscoveryStreamAdminUI extends React.PureComponent { <button className="button" onClick={this.showPlaceholder}> Show Placeholder Cards </button>{" "} + <select + className="button" + onChange={this.toggleTBRFeed} + value={selectedFeed} + > + {TBRFeeds.map(feed => ( + <option key={feed} value={feed}> + {feed} + </option> + ))} + </select> <div className="toggle-wrapper"> <moz-toggle id="sections-toggle" diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx @@ -5,6 +5,7 @@ import { DSCard, PlaceholderDSCard } from "../DSCard/DSCard.jsx"; import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx"; import { TopicsWidget } from "../TopicsWidget/TopicsWidget.jsx"; +import { ListFeed } from "../ListFeed/ListFeed.jsx"; import { AdBanner } from "../AdBanner/AdBanner.jsx"; import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; import React, { useEffect, useRef } from "react"; @@ -17,6 +18,11 @@ const PREF_TOPICS_SELECTED = "discoverystream.topicSelection.selectedTopics"; const PREF_TOPICS_AVAILABLE = "discoverystream.topicSelection.topics"; const PREF_SPOCS_STARTUPCACHE_ENABLED = "discoverystream.spocs.startupCache.enabled"; +const PREF_LIST_FEED_ENABLED = "discoverystream.contextualContent.enabled"; +const PREF_LIST_FEED_SELECTED_FEED = + "discoverystream.contextualContent.selectedFeed"; +const PREF_FAKESPOT_ENABLED = + "discoverystream.contextualContent.fakespot.enabled"; const PREF_BILLBOARD_ENABLED = "newtabAdSize.billboard"; const PREF_BILLBOARD_POSITION = "newtabAdSize.billboard.position"; const PREF_LEADERBOARD_ENABLED = "newtabAdSize.leaderboard"; @@ -88,6 +94,8 @@ export class _CardGrid extends React.PureComponent { const selectedTopics = prefs[PREF_TOPICS_SELECTED]; const availableTopics = prefs[PREF_TOPICS_AVAILABLE]; const spocsStartupCacheEnabled = prefs[PREF_SPOCS_STARTUPCACHE_ENABLED]; + const listFeedEnabled = prefs[PREF_LIST_FEED_ENABLED]; + const listFeedSelectedFeed = prefs[PREF_LIST_FEED_SELECTED_FEED]; const billboardEnabled = prefs[PREF_BILLBOARD_ENABLED]; const leaderboardEnabled = prefs[PREF_LEADERBOARD_ENABLED]; const trendingEnabled = @@ -96,7 +104,10 @@ export class _CardGrid extends React.PureComponent { prefs[PREF_SEARCH_ENGINE]?.toLowerCase() === "google"; const trendingVariant = prefs[PREF_TRENDING_SEARCH_VARIANT]; - const recs = this.props.data.recommendations.slice(0, items); + // filter out recs that should be in ListFeed + const recs = this.props.data.recommendations + .filter(item => !item.feedName) + .slice(0, items); const cards = []; for (let index = 0; index < items; index++) { @@ -192,6 +203,21 @@ export class _CardGrid extends React.PureComponent { } } } + if (listFeedEnabled) { + const isFakespot = listFeedSelectedFeed === "fakespot"; + const fakespotEnabled = prefs[PREF_FAKESPOT_ENABLED]; + if (!isFakespot || (isFakespot && fakespotEnabled)) { + // Place the list feed as the 3rd element in the card grid + cards.splice( + 2, + 1, + this.renderListFeed( + this.props.data.recommendations, + listFeedSelectedFeed + ) + ); + } + } 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 @@ -274,6 +300,24 @@ export class _CardGrid extends React.PureComponent { ); } + renderListFeed(recommendations, selectedFeed) { + const recs = recommendations.filter(item => item.feedName === selectedFeed); + const isFakespot = selectedFeed === "fakespot"; + // remove duplicates from category list + const categories = [...new Set(recs.map(({ category }) => category))]; + const listFeed = ( + <ListFeed + // only display recs that match selectedFeed for ListFeed + recs={recs} + categories={isFakespot ? categories : []} + firstVisibleTimestamp={this.props.firstVisibleTimestamp} + type={this.props.type} + dispatch={this.props.dispatch} + /> + ); + return listFeed; + } + renderGridClassName() { const { hybridLayout, diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx @@ -105,6 +105,7 @@ export const DefaultMeta = ({ mayHaveThumbsUpDown, onThumbsUpClick, onThumbsDownClick, + isListCard, state, format, topic, @@ -114,7 +115,10 @@ export const DefaultMeta = ({ refinedCardsLayout, }) => { const shouldHaveThumbs = - format !== "rectangle" && mayHaveSectionsCards && mayHaveThumbsUpDown; + !isListCard && + format !== "rectangle" && + mayHaveSectionsCards && + mayHaveThumbsUpDown; const shouldHaveFooterSection = isSectionsCard && (shouldHaveThumbs || showTopics); @@ -145,7 +149,8 @@ export const DefaultMeta = ({ excerpt && <p className="excerpt clamp">{excerpt}</p> )} </div> - {format !== "rectangle" && + {!isListCard && + format !== "rectangle" && !mayHaveSectionsCards && mayHaveThumbsUpDown && !refinedCardsLayout && ( @@ -362,6 +367,7 @@ export class _DSCard extends React.PureComponent { features: this.props.features, matches_selected_topic: matchesSelectedTopic, selected_topics: this.props.selectedTopics, + is_list_card: this.props.isListCard, ...(this.props.format ? { format: this.props.format } : { @@ -401,6 +407,7 @@ export class _DSCard extends React.PureComponent { recommendation_id: this.props.recommendation_id, topic: this.props.topic, selected_topics: this.props.selectedTopics, + is_list_card: this.props.isListCard, ...(this.props.format ? { format: this.props.format } : { @@ -766,6 +773,7 @@ export class _DSCard extends React.PureComponent { isRecentSave, DiscoveryStream, Prefs, + isListCard, isFakespot, mayHaveSectionsCards, format, @@ -798,7 +806,9 @@ export class _DSCard extends React.PureComponent { } return ( <div - className={`ds-card placeholder ${placeholderClassName} ${refinedCardsClassName}`} + className={`ds-card placeholder ${placeholderClassName} ${ + isListCard ? "list-card-placeholder" : "" + } ${refinedCardsClassName}`} ref={this.setPlaceholderRef} > {placeholderElements} @@ -851,6 +861,8 @@ export class _DSCard extends React.PureComponent { const imageGradientClassName = imageGradient ? `ds-card-image-gradient` : ``; + const listCardClassName = isListCard ? `list-feed-card` : ``; + const fakespotClassName = isFakespot ? `fakespot` : ``; const sectionsCardsClassName = [ mayHaveSectionsCards ? `sections-card-ui` : ``, this.props.sectionsClassNames, @@ -864,13 +876,15 @@ export class _DSCard extends React.PureComponent { let images = this.renderImage({ sizes: this.standardCardImageSizes }); if (isMediumRectangle) { images = this.renderImage(); + } else if (isListCard) { + images = this.renderImage({ sizes: this.listCardImageSizes }); } else if (sectionsEnabled) { images = this.renderSectionCardImages(); } return ( <article - className={`ds-card ${sectionsCardsClassName} ${compactImagesClassName} ${imageGradientClassName} ${titleLinesName} ${descLinesClassName} ${spocFormatClassName} ${ctaButtonClassName} ${ctaButtonVariantClassName} ${refinedCardsClassName}`} + className={`ds-card ${listCardClassName} ${fakespotClassName} ${sectionsCardsClassName} ${compactImagesClassName} ${imageGradientClassName} ${titleLinesName} ${descLinesClassName} ${spocFormatClassName} ${ctaButtonClassName} ${ctaButtonVariantClassName} ${refinedCardsClassName}`} ref={this.setContextMenuButtonHostRef} data-position-one={this.props["data-position-one"]} data-position-two={this.props["data-position-one"]} @@ -888,6 +902,7 @@ export class _DSCard extends React.PureComponent { {this.props.showTopics && !this.props.mayHaveSectionsCards && this.props.topic && + !isListCard && !refinedCardsLayout && ( <span className="ds-card-topic" @@ -912,7 +927,9 @@ export class _DSCard extends React.PureComponent { received_rank: this.props.received_rank, topic: this.props.topic, features: this.props.features, + is_list_card: isListCard, ...(format ? { format } : {}), + isFakespot, category: this.props.category, ...(this.props.section ? { @@ -961,9 +978,12 @@ export class _DSCard extends React.PureComponent { onThumbsUpClick={this.onThumbsUpClick} onThumbsDownClick={this.onThumbsDownClick} state={this.state} + isListCard={isListCard} showTopics={!refinedCardsLayout && this.props.showTopics} isSectionsCard={ - this.props.mayHaveSectionsCards && this.props.topic + this.props.mayHaveSectionsCards && + this.props.topic && + !isListCard } format={format} topic={this.props.topic} @@ -999,6 +1019,7 @@ export class _DSCard extends React.PureComponent { scheduled_corpus_item_id={this.props.scheduled_corpus_item_id} recommended_at={this.props.recommended_at} received_rank={this.props.received_rank} + is_list_card={this.props.isListCard} section={this.props.section} section_position={this.props.sectionPosition} is_section_followed={this.props.sectionFollowed} diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx @@ -73,6 +73,7 @@ export class _DSLinkMenu extends React.PureComponent { recommended_at: this.props.recommended_at, received_rank: this.props.received_rank, topic: this.props.topic, + is_list_card: this.props.is_list_card, position: index, ...(this.props.format ? { format: this.props.format } : {}), ...(this.props.section diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/ListFeed/ListFeed.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/ListFeed/ListFeed.jsx @@ -0,0 +1,182 @@ +/* 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 } from "react"; +import { useSelector } from "react-redux"; +import { DSCard } from "../DSCard/DSCard"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { actionCreators as ac } from "common/Actions.mjs"; +const PREF_LISTFEED_TITLE = "discoverystream.contextualContent.listFeedTitle"; +const PREF_FAKESPOT_CATEGROY = + "discoverystream.contextualContent.fakespot.defaultCategoryTitle"; +const PREF_FAKESPOT_FOOTER = + "discoverystream.contextualContent.fakespot.footerCopy"; +const PREF_FAKESPOT_CTA_COPY = + "discoverystream.contextualContent.fakespot.ctaCopy"; +const PREF_FAKESPOT_CTA_URL = + "discoverystream.contextualContent.fakespot.ctaUrl"; +const PREF_CONTEXTUAL_CONTENT_SELECTED_FEED = + "discoverystream.contextualContent.selectedFeed"; + +function ListFeed({ type, firstVisibleTimestamp, recs, categories, dispatch }) { + const [selectedFakespotFeed, setSelectedFakespotFeed] = useState(""); + const prefs = useSelector(state => state.Prefs.values); + const listFeedTitle = prefs[PREF_LISTFEED_TITLE]; + const categoryTitle = prefs[PREF_FAKESPOT_CATEGROY]; + const footerCopy = prefs[PREF_FAKESPOT_FOOTER]; + const ctaCopy = prefs[PREF_FAKESPOT_CTA_COPY]; + const ctaUrl = prefs[PREF_FAKESPOT_CTA_URL]; + + const isFakespot = + prefs[PREF_CONTEXTUAL_CONTENT_SELECTED_FEED] === "fakespot"; + // Todo: need to remove ads while using default recommendations, remove this line once API has been updated. + let listFeedRecs = selectedFakespotFeed + ? recs.filter(rec => rec.category === selectedFakespotFeed) + : recs; + + function handleCtaClick() { + dispatch( + ac.OnlyToMain({ + type: "FAKESPOT_CTA_CLICK", + }) + ); + } + + function handleChange(e) { + setSelectedFakespotFeed(e.target.value); + dispatch( + ac.DiscoveryStreamUserEvent({ + event: "FAKESPOT_CATEGORY", + value: { + category: e.target.value || "", + }, + }) + ); + } + + const contextMenuOptions = ["FakespotDismiss", "AboutFakespot"]; + + const { length: listLength } = listFeedRecs; + // determine if the list should take up all availible height or not + const fullList = listLength >= 5; + return ( + listLength > 0 && ( + <div + className={`list-feed ${fullList ? "full-height" : ""} ${ + listLength > 2 ? "span-2" : "span-1" + }`} + > + <div className="list-feed-inner-wrapper"> + {isFakespot ? ( + <div className="fakespot-heading"> + <div className="dropdown-wrapper"> + <select + className="fakespot-dropdown" + name="fakespot-categories" + value={selectedFakespotFeed} + onChange={handleChange} + > + <option value=""> + {categoryTitle || "Holiday Gift Guide"} + </option> + {categories.map(category => ( + <option key={category} value={category}> + {category} + </option> + ))} + </select> + <div className="context-menu-wrapper"> + <ContextMenuButton> + <LinkMenu + dispatch={dispatch} + options={contextMenuOptions} + shouldSendImpressionStats={true} + site={{ url: "https://www.fakespot.com" }} + /> + </ContextMenuButton> + </div> + </div> + <p className="fakespot-desc">{listFeedTitle}</p> + </div> + ) : ( + <h1 className="list-feed-title" id="list-feed-title"> + <span className="icon icon-newsfeed"></span> + {listFeedTitle} + </h1> + )} + <div + className="list-feed-content" + role="menu" + aria-labelledby="list-feed-title" + > + {listFeedRecs.slice(0, 5).map((rec, index) => { + if (!rec || rec.placeholder) { + return ( + <DSCard + key={`list-card-${index}`} + placeholder={true} + isListCard={true} + /> + ); + } + return ( + <DSCard + key={`list-card-${index}`} + pos={index} + flightId={rec.flight_id} + image_src={rec.image_src} + raw_image_src={rec.raw_image_src} + word_count={rec.word_count} + time_to_read={rec.time_to_read} + title={rec.title} + topic={rec.topic} + excerpt={rec.excerpt} + url={rec.url} + id={rec.id} + shim={rec.shim} + type={type} + context={rec.context} + sponsor={rec.sponsor} + sponsored_by_override={rec.sponsored_by_override} + dispatch={dispatch} + source={rec.domain} + publisher={rec.publisher} + pocket_id={rec.pocket_id} + context_type={rec.context_type} + bookmarkGuid={rec.bookmarkGuid} + firstVisibleTimestamp={firstVisibleTimestamp} + corpus_item_id={rec.corpus_item_id} + scheduled_corpus_item_id={rec.scheduled_corpus_item_id} + recommended_at={rec.recommended_at} + received_rank={rec.received_rank} + isListCard={true} + isFakespot={isFakespot} + category={rec.category} + /> + ); + })} + {isFakespot && ( + <div className="fakespot-footer"> + <p>{footerCopy}</p> + <SafeAnchor + className="fakespot-cta" + url={ctaUrl} + referrer={""} + onLinkClick={handleCtaClick} + dispatch={dispatch} + > + {ctaCopy} + </SafeAnchor> + </div> + )} + </div> + </div> + </div> + ) + ); +} + +export { ListFeed }; diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/ListFeed/_ListFeed.scss b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/ListFeed/_ListFeed.scss @@ -0,0 +1,370 @@ +.list-feed { + grid-column: span 1 / -1; + grid-row: span 2; + height: 100%; + + &.span-1 { + grid-row: span 1; + } + + &.span-2 { + grid-row: span 2; + } + + &.full-height { + display: flex; + + .list-feed-content { + flex: 1; + + .list-feed-card { + flex: 1; + } + } + } + + @media (min-width: $break-point-medium) { + &.span-1 { + grid-row: span 1; + } + + &.span-2 { + grid-row: span 2; + } + } + + @media (min-width: $break-point-layout-variant) { + &.span-1 { + grid-row: span 1 / -1; + } + + &.span-2 { + grid-row: span 2 / -1; + } + } + + .list-feed-inner-wrapper { + box-shadow: $shadow-card; + background-color: var(--newtab-background-color-secondary); + border-radius: var(--border-radius-medium); + display: flex; + flex-direction: column; + flex: 1; + } + + .list-feed-title { + padding-inline-start: var(--space-medium); + padding-block: var(--space-small); + font-size: var(--font-size-small); + color: var(--newtab-text-primary-color); + font-weight: var(--font-weight-bold); + margin: 0; + + .icon { + margin-inline-end: var(--space-small); + transform: none; + fill: var(--newtab-text-primary-color); + vertical-align: text-bottom; + } + } + + .list-feed-content { + list-style: none; + padding-inline-start: 0; + margin: 0; + display: flex; + flex-direction: column; + row-gap: var(--border-width); + } + + .fakespot-dropdown { + background: transparent; + border: none; + border-radius: var(--border-radius-small); + -moz-context-properties: fill; + fill: currentColor; + font-size: var(--font-size-small); + font-weight: var(--font-weight-bold); + margin-block: var(--space-xsmall); + padding-block: var(--space-small); + padding-inline-start: var(--space-medium); + position: relative; + max-width: 18ch; + text-overflow: ellipsis; + + @media (min-width: $break-point-widest) { + max-width: none; + background-image: url('chrome://browser/skin/gift.svg'); + background-repeat: no-repeat; + background-size: 16px; + background-position: left var(--space-medium) center; + padding-inline-start: var(--space-xxlarge); + } + + &:hover { + background-color: var(--newtab-button-hover-background);; + } + } +} + +.fakespot-heading { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + + .dropdown-wrapper { + display: flex; + position: relative; + width: 100%; + } + + .fakespot-desc { + padding-inline: var(--space-medium); + margin-block: 0 var(--space-small); + font-size: var(--font-size-small); + } + + .context-menu-wrapper { + @include context-menu-button; + + .context-menu { + inset-inline-start: auto; + inset-inline-end: var(--space-small); + inset-block-start: var(--space-xxlarge); + } + + .context-menu-button { + opacity: 1; + transform: scale(1); + background-color: transparent; + border-radius: var(--border-radius-small); + box-shadow: none; + inset-inline-end: var(--space-small); + inset-block-start: var(--space-small); + + &:is(:hover) { + background-color: var(--newtab-button-hover-background); + } + + &:is(:focus-visible) { + outline-color: var(--newtab-button-focus-border); + background-color: var(--newtab-button-focus-background); + outline-width: 2px; + } + + &:is(:active) { + background-color: var(--newtab-button-active-background); + } + } + } +} + +.fakespot-footer { + align-items: center; + border-block-start: var(--border-width) solid var(--border-color-deemphasized); + border-end-start-radius: var(--border-radius-medium); + border-end-end-radius: var(--border-radius-medium); + border-start-start-radius: 0; + border-start-end-radius: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: var(--space-medium); + + + p { + font-size: var(--font-size-small); + margin-block-start: 0; + } + + .fakespot-cta { + background-color: var(--button-background-color-primary); + border: var(--button-border); + border-color: var(--button-border-color-primary); + border-radius: var(--button-border-radius); + color: var(--button-text-color-primary); + font-size: var(--font-size-small); + font-weight: var(--button-font-weight); + padding: var(--button-padding); + text-decoration: none; + text-align: center; + align-self: stretch; + + &:hover { + background-color: var(--button-background-color-primary-hover); + border-color: var(--button-border-color-primary-hover); + color: var(--button-text-color-primary-hover); + } + + &:focus-visible { + outline: var(--focus-outline); + outline-offset: var(--focus-outline-offset); + } + } +} + +.ds-card.placeholder.list-card-placeholder { + box-shadow: unset; + padding-inline-end: var(--space-large); + padding-block: var(--space-large) var(--space-small); + flex: 1; + + .placeholder-image { + height: 75px; + width: 75px; + position: absolute; + border-radius: var(--border-radius-medium); + inset-inline-end: var(--space-large); + + @media (min-width: $break-point-widest) { + height: 75px; + width: 75px; + } + } + + .placeholder-label { + margin-block-end: unset; + } + + .placeholder-header, .placeholder-description { + width: 60%; + height: 20px; + } + + + @media (min-width: $break-point-widest) { + .placeholder-image { + height: 75px; + width: 75px; + } + + .placeholder-header, .placeholder-description { + height: 20px; + } + } +} + + +.ds-card-grid .list-feed-content .ds-card.list-feed-card { + // overrides when using the DSCard component + background-color: var(--newtab-background-color-secondary); + border-block-start: var(--border-width) solid var(--border-color-deemphasized); + border-radius: 0; + box-shadow: none; + flex-direction: row-reverse; + min-height: 135px; + gap: var(--space-large); + position: relative; + height: 135px; + + @media (min-width: $break-point-widest) { + height: 140px; + } + + &.fakespot { + flex-direction: row; + min-height: 75px; + } + + .ds-card-link { + inset-block-start: 0; + inset-inline-start: 0; + border-radius: 0; + flex-direction: row; + padding-inline: var(--space-large); + padding-block: var(--space-large) var(--space-small); + } + + .meta { + padding: 0; + + .story-footer { + margin-block-start: var(--space-xsmall); + } + + .source { + margin-block-end: var(--space-xxsmall); + } + + .title { + font-size: var(--font-size-small); + } + + } + + .excerpt { + display: none; + } + + .card-stp-button-hover-background { + border-radius: 0; + height: 100%; + opacity: 1; + background-color: transparent; + padding-block-start: 0; + inset-inline-start: 0; + + .card-stp-button-position-wrapper { + inset-block-end: var(--space-small); + inset-inline-end: var(--space-large); + align-items: flex-end; + } + + .context-menu-button { + opacity: 1; + transform: scale(1); + background-color: transparent; + box-shadow: none; + } + } + + .img-wrapper { + flex-shrink: 0; + height: 55px; + width: 55px; + + img { + height: auto; + border-radius: var(--border-radius-medium); + } + + @media (min-width: $break-point-widest) { + height: 75px; + width: 75px; + } + } + + &:last-child:not(.fakespot) { + border-end-start-radius: var(--border-radius-medium); + border-end-end-radius: var(--border-radius-medium); + border-start-start-radius: 0; + border-start-end-radius: 0; + + .card-stp-button-hover-background { + border-end-start-radius: var(--border-radius-medium); + border-end-end-radius: var(--border-radius-medium); + border-start-start-radius: 0; + border-start-end-radius: 0; + } + } + + &:hover, + &:focus-within { + background-color: var(--newtab-element-secondary-color); + + .card-stp-button-hover-background { + background: transparent; + } + + .card-stp-button-position-wrapper { + align-items: flex-end; + } + + .context-menu-button { + &:hover, &:focus { + background-color: var(--newtab-button-background); + } + } + } +} diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx @@ -55,6 +55,7 @@ export class ImpressionStats extends React.PureComponent { _dispatchImpressionStats() { const { props } = this; + const { isFakespot } = props; const cards = props.rows; if (this.props.flightId) { @@ -87,48 +88,63 @@ export class ImpressionStats extends React.PureComponent { } if (this._needsImpressionStats(cards)) { - props.dispatch( - ac.DiscoveryStreamImpressionStats({ - source: props.source.toUpperCase(), - window_inner_width: window.innerWidth, - window_inner_height: window.innerHeight, - tiles: cards.map(link => ({ - id: link.id, - pos: link.pos, - type: props.flightId ? "spoc" : "organic", - ...(link.shim ? { shim: link.shim } : {}), - recommendation_id: link.recommendation_id, - fetchTimestamp: link.fetchTimestamp, - corpus_item_id: link.corpus_item_id, - scheduled_corpus_item_id: link.scheduled_corpus_item_id, - recommended_at: link.recommended_at, - received_rank: link.received_rank, - topic: link.topic, - features: link.features, - is_list_card: link.is_list_card, - ...(link.format - ? { format: link.format } - : { - format: getActiveCardSize( - window.innerWidth, - link.class_names, - link.section, - link.flightId - ), - }), - ...(link.section - ? { - section: link.section, - section_position: link.section_position, - is_section_followed: link.is_section_followed, - layout_name: link.sectionLayoutName, - } - : {}), - })), - firstVisibleTimestamp: props.firstVisibleTimestamp, - }) - ); - this.impressionCardGuids = cards.map(link => link.id); + if (isFakespot) { + props.dispatch( + ac.DiscoveryStreamImpressionStats({ + source: props.source.toUpperCase(), + window_inner_width: window.innerWidth, + window_inner_height: window.innerHeight, + tiles: cards.map(link => ({ + id: link.id, + type: "fakespot", + category: link.category, + })), + }) + ); + } else { + props.dispatch( + ac.DiscoveryStreamImpressionStats({ + source: props.source.toUpperCase(), + window_inner_width: window.innerWidth, + window_inner_height: window.innerHeight, + tiles: cards.map(link => ({ + id: link.id, + pos: link.pos, + type: props.flightId ? "spoc" : "organic", + ...(link.shim ? { shim: link.shim } : {}), + recommendation_id: link.recommendation_id, + fetchTimestamp: link.fetchTimestamp, + corpus_item_id: link.corpus_item_id, + scheduled_corpus_item_id: link.scheduled_corpus_item_id, + recommended_at: link.recommended_at, + received_rank: link.received_rank, + topic: link.topic, + features: link.features, + is_list_card: link.is_list_card, + ...(link.format + ? { format: link.format } + : { + format: getActiveCardSize( + window.innerWidth, + link.class_names, + link.section, + link.flightId + ), + }), + ...(link.section + ? { + section: link.section, + section_position: link.section_position, + is_section_followed: link.is_section_followed, + layout_name: link.sectionLayoutName, + } + : {}), + })), + firstVisibleTimestamp: props.firstVisibleTimestamp, + }) + ); + this.impressionCardGuids = cards.map(link => link.id); + } } } diff --git a/browser/extensions/newtab/content-src/components/LinkMenu/LinkMenu.jsx b/browser/extensions/newtab/content-src/components/LinkMenu/LinkMenu.jsx @@ -77,6 +77,7 @@ export class _LinkMenu extends React.PureComponent { fetchTimestamp, firstVisibleTimestamp, format, + is_list_card, is_section_followed, received_rank, recommendation_id, @@ -96,6 +97,7 @@ export class _LinkMenu extends React.PureComponent { fetchTimestamp, firstVisibleTimestamp, format, + is_list_card, received_rank, recommendation_id, recommended_at, diff --git a/browser/extensions/newtab/content-src/lib/link-menu-options.mjs b/browser/extensions/newtab/content-src/lib/link-menu-options.mjs @@ -92,6 +92,7 @@ export const LinkMenuOptions = { format: site.format, ...(site.flight_id ? { flight_id: site.flight_id } : {}), is_pocket_card: site.type === "CardGrid", + is_list_card: site.is_list_card, ...(site.section ? { section: site.section, @@ -147,6 +148,7 @@ export const LinkMenuOptions = { position: pos, ...(site.sponsored_tile_id ? { tile_id: site.sponsored_tile_id } : {}), is_pocket_card: site.type === "CardGrid", + is_list_card: site.is_list_card, ...(site.format ? { format: site.format } : {}), ...(site.section ? { @@ -392,6 +394,29 @@ export const LinkMenuOptions = { data: { url: site.url }, }), }), + FakespotDismiss: () => ({ + id: "newtab-menu-dismiss", + action: ac.OnlyToMain({ + type: at.SET_PREF, + data: { + name: "discoverystream.contextualContent.fakespot.enabled", + value: false, + }, + }), + impression: ac.OnlyToMain({ + type: at.FAKESPOT_DISMISS, + }), + }), + AboutFakespot: site => ({ + id: "newtab-menu-about-fakespot", + action: ac.OnlyToMain({ + type: at.OPEN_LINK, + data: { url: site.url }, + }), + impression: ac.OnlyToMain({ + type: at.OPEN_ABOUT_FAKESPOT, + }), + }), SectionBlock: ({ sectionPersonalization, sectionKey, diff --git a/browser/extensions/newtab/content-src/styles/activity-stream.scss b/browser/extensions/newtab/content-src/styles/activity-stream.scss @@ -198,6 +198,7 @@ input { @import '../components/DiscoveryStreamComponents/FeatureHighlight/WallpaperFeatureHighlight'; @import '../components/DiscoveryStreamComponents/FeatureHighlight/ShortcutFeatureHighlight'; @import '../components/DiscoveryStreamComponents/TopicSelection/TopicSelection'; +@import '../components/DiscoveryStreamComponents/ListFeed/ListFeed'; @import '../components/DiscoveryStreamComponents/AdBanner/AdBanner'; @import '../components/DiscoveryStreamComponents/AdBannerContextMenu/AdBannerContextMenu'; @import '../components/DiscoveryStreamComponents/PromoCard/PromoCard'; diff --git a/browser/extensions/newtab/css/activity-stream.css b/browser/extensions/newtab/css/activity-stream.css @@ -8762,6 +8762,357 @@ dialog::after { height: 26px; } +.list-feed { + grid-column: span 1/-1; + grid-row: span 2; + height: 100%; +} +.list-feed.span-1 { + grid-row: span 1; +} +.list-feed.span-2 { + grid-row: span 2; +} +.list-feed.full-height { + display: flex; +} +.list-feed.full-height .list-feed-content { + flex: 1; +} +.list-feed.full-height .list-feed-content .list-feed-card { + flex: 1; +} +@media (min-width: 610px) { + .list-feed.span-1 { + grid-row: span 1; + } + .list-feed.span-2 { + grid-row: span 2; + } +} +@media (min-width: 724px) { + .list-feed.span-1 { + grid-row: span 1/-1; + } + .list-feed.span-2 { + grid-row: span 2/-1; + } +} +.list-feed .list-feed-inner-wrapper { + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + background-color: var(--newtab-background-color-secondary); + border-radius: var(--border-radius-medium); + display: flex; + flex-direction: column; + flex: 1; +} +.list-feed .list-feed-title { + padding-inline-start: var(--space-medium); + padding-block: var(--space-small); + font-size: var(--font-size-small); + color: var(--newtab-text-primary-color); + font-weight: var(--font-weight-bold); + margin: 0; +} +.list-feed .list-feed-title .icon { + margin-inline-end: var(--space-small); + transform: none; + fill: var(--newtab-text-primary-color); + vertical-align: text-bottom; +} +.list-feed .list-feed-content { + list-style: none; + padding-inline-start: 0; + margin: 0; + display: flex; + flex-direction: column; + row-gap: var(--border-width); +} +.list-feed .fakespot-dropdown { + background: transparent; + border: none; + border-radius: var(--border-radius-small); + -moz-context-properties: fill; + fill: currentColor; + font-size: var(--font-size-small); + font-weight: var(--font-weight-bold); + margin-block: var(--space-xsmall); + padding-block: var(--space-small); + padding-inline-start: var(--space-medium); + position: relative; + max-width: 18ch; + text-overflow: ellipsis; +} +@media (min-width: 1122px) { + .list-feed .fakespot-dropdown { + max-width: none; + background-image: url("chrome://browser/skin/gift.svg"); + background-repeat: no-repeat; + background-size: 16px; + background-position: left var(--space-medium) center; + padding-inline-start: var(--space-xxlarge); + } +} +.list-feed .fakespot-dropdown:hover { + background-color: var(--newtab-button-hover-background); +} + +.fakespot-heading { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +} +.fakespot-heading .dropdown-wrapper { + display: flex; + position: relative; + width: 100%; +} +.fakespot-heading .fakespot-desc { + padding-inline: var(--space-medium); + margin-block: 0 var(--space-small); + font-size: var(--font-size-small); +} +.fakespot-heading .context-menu-wrapper .context-menu-button { + background-clip: padding-box; + background-color: var(--newtab-button-background); + background-image: url("chrome://global/skin/icons/more.svg"); + background-position: 50.1%; + border: 0; + outline: 1px solid var(--newtab-border-color); + outline-width: 0; + border-radius: var(--border-radius-circle); + cursor: pointer; + color: var(--button-text-color); + fill: var(--newtab-button-text); + height: 27px; + inset-inline-end: calc(var(--space-medium) * -1); + opacity: 0; + position: absolute; + inset-block-start: calc(var(--space-medium) * -1); + transform: scale(0.25); + transition-duration: 150ms; + transition-property: transform, opacity; + width: 27px; +} +.fakespot-heading .context-menu-wrapper .context-menu-button:is(:active, :focus-visible, :hover) { + opacity: 1; + transform: scale(1); +} +.fakespot-heading .context-menu-wrapper .context-menu-button:is(:hover) { + background-color: var(--newtab-button-hover-background); +} +.fakespot-heading .context-menu-wrapper .context-menu-button:is(:focus-visible) { + outline-color: var(--newtab-button-focus-border); + background-color: var(--newtab-button-focus-background); + outline-width: 4px; +} +.fakespot-heading .context-menu-wrapper .context-menu-button:is(:active) { + background-color: var(--newtab-button-active-background); +} +.fakespot-heading .context-menu-wrapper .context-menu { + inset-inline-start: auto; + inset-inline-end: var(--space-small); + inset-block-start: var(--space-xxlarge); +} +.fakespot-heading .context-menu-wrapper .context-menu-button { + opacity: 1; + transform: scale(1); + background-color: transparent; + border-radius: var(--border-radius-small); + box-shadow: none; + inset-inline-end: var(--space-small); + inset-block-start: var(--space-small); +} +.fakespot-heading .context-menu-wrapper .context-menu-button:is(:hover) { + background-color: var(--newtab-button-hover-background); +} +.fakespot-heading .context-menu-wrapper .context-menu-button:is(:focus-visible) { + outline-color: var(--newtab-button-focus-border); + background-color: var(--newtab-button-focus-background); + outline-width: 2px; +} +.fakespot-heading .context-menu-wrapper .context-menu-button:is(:active) { + background-color: var(--newtab-button-active-background); +} + +.fakespot-footer { + align-items: center; + border-block-start: var(--border-width) solid var(--border-color-deemphasized); + border-end-start-radius: var(--border-radius-medium); + border-end-end-radius: var(--border-radius-medium); + border-start-start-radius: 0; + border-start-end-radius: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: var(--space-medium); +} +.fakespot-footer p { + font-size: var(--font-size-small); + margin-block-start: 0; +} +.fakespot-footer .fakespot-cta { + background-color: var(--button-background-color-primary); + border: var(--button-border); + border-color: var(--button-border-color-primary); + border-radius: var(--button-border-radius); + color: var(--button-text-color-primary); + font-size: var(--font-size-small); + font-weight: var(--button-font-weight); + padding: var(--button-padding); + text-decoration: none; + text-align: center; + align-self: stretch; +} +.fakespot-footer .fakespot-cta:hover { + background-color: var(--button-background-color-primary-hover); + border-color: var(--button-border-color-primary-hover); + color: var(--button-text-color-primary-hover); +} +.fakespot-footer .fakespot-cta:focus-visible { + outline: var(--focus-outline); + outline-offset: var(--focus-outline-offset); +} + +.ds-card.placeholder.list-card-placeholder { + box-shadow: unset; + padding-inline-end: var(--space-large); + padding-block: var(--space-large) var(--space-small); + flex: 1; +} +.ds-card.placeholder.list-card-placeholder .placeholder-image { + height: 75px; + width: 75px; + position: absolute; + border-radius: var(--border-radius-medium); + inset-inline-end: var(--space-large); +} +@media (min-width: 1122px) { + .ds-card.placeholder.list-card-placeholder .placeholder-image { + height: 75px; + width: 75px; + } +} +.ds-card.placeholder.list-card-placeholder .placeholder-label { + margin-block-end: unset; +} +.ds-card.placeholder.list-card-placeholder .placeholder-header, .ds-card.placeholder.list-card-placeholder .placeholder-description { + width: 60%; + height: 20px; +} +@media (min-width: 1122px) { + .ds-card.placeholder.list-card-placeholder .placeholder-image { + height: 75px; + width: 75px; + } + .ds-card.placeholder.list-card-placeholder .placeholder-header, .ds-card.placeholder.list-card-placeholder .placeholder-description { + height: 20px; + } +} + +.ds-card-grid .list-feed-content .ds-card.list-feed-card { + background-color: var(--newtab-background-color-secondary); + border-block-start: var(--border-width) solid var(--border-color-deemphasized); + border-radius: 0; + box-shadow: none; + flex-direction: row-reverse; + min-height: 135px; + gap: var(--space-large); + position: relative; + height: 135px; +} +@media (min-width: 1122px) { + .ds-card-grid .list-feed-content .ds-card.list-feed-card { + height: 140px; + } +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card.fakespot { + flex-direction: row; + min-height: 75px; +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card .ds-card-link { + inset-block-start: 0; + inset-inline-start: 0; + border-radius: 0; + flex-direction: row; + padding-inline: var(--space-large); + padding-block: var(--space-large) var(--space-small); +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card .meta { + padding: 0; +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card .meta .story-footer { + margin-block-start: var(--space-xsmall); +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card .meta .source { + margin-block-end: var(--space-xxsmall); +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card .meta .title { + font-size: var(--font-size-small); +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card .excerpt { + display: none; +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card .card-stp-button-hover-background { + border-radius: 0; + height: 100%; + opacity: 1; + background-color: transparent; + padding-block-start: 0; + inset-inline-start: 0; +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card .card-stp-button-hover-background .card-stp-button-position-wrapper { + inset-block-end: var(--space-small); + inset-inline-end: var(--space-large); + align-items: flex-end; +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card .card-stp-button-hover-background .context-menu-button { + opacity: 1; + transform: scale(1); + background-color: transparent; + box-shadow: none; +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card .img-wrapper { + flex-shrink: 0; + height: 55px; + width: 55px; +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card .img-wrapper img { + height: auto; + border-radius: var(--border-radius-medium); +} +@media (min-width: 1122px) { + .ds-card-grid .list-feed-content .ds-card.list-feed-card .img-wrapper { + height: 75px; + width: 75px; + } +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card:last-child:not(.fakespot) { + border-end-start-radius: var(--border-radius-medium); + border-end-end-radius: var(--border-radius-medium); + border-start-start-radius: 0; + border-start-end-radius: 0; +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card:last-child:not(.fakespot) .card-stp-button-hover-background { + border-end-start-radius: var(--border-radius-medium); + border-end-end-radius: var(--border-radius-medium); + border-start-start-radius: 0; + border-start-end-radius: 0; +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card:hover, .ds-card-grid .list-feed-content .ds-card.list-feed-card:focus-within { + background-color: var(--newtab-element-secondary-color); +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card:hover .card-stp-button-hover-background, .ds-card-grid .list-feed-content .ds-card.list-feed-card:focus-within .card-stp-button-hover-background { + background: transparent; +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card:hover .card-stp-button-position-wrapper, .ds-card-grid .list-feed-content .ds-card.list-feed-card:focus-within .card-stp-button-position-wrapper { + align-items: flex-end; +} +.ds-card-grid .list-feed-content .ds-card.list-feed-card:hover .context-menu-button:hover, .ds-card-grid .list-feed-content .ds-card.list-feed-card:hover .context-menu-button:focus, .ds-card-grid .list-feed-content .ds-card.list-feed-card:focus-within .context-menu-button:hover, .ds-card-grid .list-feed-content .ds-card.list-feed-card:focus-within .context-menu-button:focus { + background-color: var(--newtab-button-background); +} + .ad-banner-wrapper { --banner-padding: var(--space-large); --billboard-width: 970px; diff --git a/browser/extensions/newtab/data/content/activity-stream.bundle.js b/browser/extensions/newtab/data/content/activity-stream.bundle.js @@ -157,6 +157,8 @@ for (const type of [ "DISCOVERY_STREAM_TOPICS_LOADING", "DISCOVERY_STREAM_USER_EVENT", "DOWNLOAD_CHANGED", + "FAKESPOT_CTA_CLICK", + "FAKESPOT_DISMISS", "FAKE_FOCUS_SEARCH", "FILL_SEARCH_TERM", "FOLLOW_SECTION", @@ -618,6 +620,8 @@ function _extends() { return _extends = Object.assign ? Object.assign.bind() : f const PREF_AD_SIZE_MEDIUM_RECTANGLE = "newtabAdSize.mediumRectangle"; const PREF_AD_SIZE_BILLBOARD = "newtabAdSize.billboard"; const PREF_AD_SIZE_LEADERBOARD = "newtabAdSize.leaderboard"; +const PREF_CONTEXTUAL_CONTENT_SELECTED_FEED = "discoverystream.contextualContent.selectedFeed"; +const PREF_CONTEXTUAL_CONTENT_FEEDS = "discoverystream.contextualContent.feeds"; const PREF_SECTIONS_ENABLED = "discoverystream.sections.enabled"; const PREF_SPOC_PLACEMENTS = "discoverystream.placements.spocs"; const PREF_SPOC_COUNTS = "discoverystream.placements.spocs.counts"; @@ -720,6 +724,7 @@ class DiscoveryStreamAdminUI extends (external_React_default()).PureComponent { this.resetBlocks = this.resetBlocks.bind(this); this.refreshInferredPersonalization = this.refreshInferredPersonalization.bind(this); this.refreshTopicSelectionCache = this.refreshTopicSelectionCache.bind(this); + this.toggleTBRFeed = this.toggleTBRFeed.bind(this); this.handleSectionsToggle = this.handleSectionsToggle.bind(this); this.toggleIABBanners = this.toggleIABBanners.bind(this); this.state = { @@ -778,6 +783,11 @@ class DiscoveryStreamAdminUI extends (external_React_default()).PureComponent { showPlaceholder() { this.dispatchSimpleAction(actionTypes.DISCOVERY_STREAM_DEV_SHOW_PLACEHOLDER); } + toggleTBRFeed(e) { + const feed = e.target.value; + const selectedFeed = PREF_CONTEXTUAL_CONTENT_SELECTED_FEED; + this.props.dispatch(actionCreators.SetPref(selectedFeed, feed)); + } idleDaily() { this.dispatchSimpleAction(actionTypes.DISCOVERY_STREAM_DEV_IDLE_DAILY); } @@ -1043,7 +1053,9 @@ class DiscoveryStreamAdminUI extends (external_React_default()).PureComponent { layout } = this.props.state.DiscoveryStream; const personalized = this.props.otherPrefs["discoverystream.personalization.enabled"]; + const selectedFeed = this.props.otherPrefs[PREF_CONTEXTUAL_CONTENT_SELECTED_FEED]; const sectionsEnabled = this.props.otherPrefs[PREF_SECTIONS_ENABLED]; + const TBRFeeds = this.props.otherPrefs[PREF_CONTEXTUAL_CONTENT_FEEDS].split(",").map(s => s.trim()).filter(item => item); // Prefs for IAB Banners const mediumRectangleEnabled = this.props.otherPrefs[PREF_AD_SIZE_MEDIUM_RECTANGLE]; @@ -1080,7 +1092,14 @@ class DiscoveryStreamAdminUI extends (external_React_default()).PureComponent { }, "Refresh Topic selection count"), /*#__PURE__*/external_React_default().createElement("br", null), /*#__PURE__*/external_React_default().createElement("button", { className: "button", onClick: this.showPlaceholder - }, "Show Placeholder Cards"), " ", /*#__PURE__*/external_React_default().createElement("div", { + }, "Show Placeholder Cards"), " ", /*#__PURE__*/external_React_default().createElement("select", { + className: "button", + onChange: this.toggleTBRFeed, + value: selectedFeed + }, TBRFeeds.map(feed => /*#__PURE__*/external_React_default().createElement("option", { + key: feed, + value: feed + }, feed))), /*#__PURE__*/external_React_default().createElement("div", { className: "toggle-wrapper" }, /*#__PURE__*/external_React_default().createElement("moz-toggle", { id: "sections-toggle", @@ -1787,6 +1806,7 @@ const LinkMenuOptions = { format: site.format, ...(site.flight_id ? { flight_id: site.flight_id } : {}), is_pocket_card: site.type === "CardGrid", + is_list_card: site.is_list_card, ...(site.section ? { section: site.section, @@ -1842,6 +1862,7 @@ const LinkMenuOptions = { position: pos, ...(site.sponsored_tile_id ? { tile_id: site.sponsored_tile_id } : {}), is_pocket_card: site.type === "CardGrid", + is_list_card: site.is_list_card, ...(site.format ? { format: site.format } : {}), ...(site.section ? { @@ -2087,6 +2108,29 @@ const LinkMenuOptions = { data: { url: site.url }, }), }), + FakespotDismiss: () => ({ + id: "newtab-menu-dismiss", + action: actionCreators.OnlyToMain({ + type: actionTypes.SET_PREF, + data: { + name: "discoverystream.contextualContent.fakespot.enabled", + value: false, + }, + }), + impression: actionCreators.OnlyToMain({ + type: actionTypes.FAKESPOT_DISMISS, + }), + }), + AboutFakespot: site => ({ + id: "newtab-menu-about-fakespot", + action: actionCreators.OnlyToMain({ + type: actionTypes.OPEN_LINK, + data: { url: site.url }, + }), + impression: actionCreators.OnlyToMain({ + type: actionTypes.OPEN_ABOUT_FAKESPOT, + }), + }), SectionBlock: ({ sectionPersonalization, sectionKey, @@ -2310,6 +2354,7 @@ class _LinkMenu extends (external_React_default()).PureComponent { fetchTimestamp, firstVisibleTimestamp, format, + is_list_card, is_section_followed, received_rank, recommendation_id, @@ -2328,6 +2373,7 @@ class _LinkMenu extends (external_React_default()).PureComponent { fetchTimestamp, firstVisibleTimestamp, format, + is_list_card, received_rank, recommendation_id, recommended_at, @@ -2517,6 +2563,7 @@ class _DSLinkMenu extends (external_React_default()).PureComponent { recommended_at: this.props.recommended_at, received_rank: this.props.received_rank, topic: this.props.topic, + is_list_card: this.props.is_list_card, position: index, ...(this.props.format ? { format: this.props.format @@ -2893,6 +2940,9 @@ class ImpressionStats_ImpressionStats extends (external_React_default()).PureCom const { props } = this; + const { + isFakespot + } = props; const cards = props.rows; if (this.props.flightId) { this.props.dispatch(actionCreators.OnlyToMain({ @@ -2921,41 +2971,54 @@ class ImpressionStats_ImpressionStats extends (external_React_default()).PureCom } } if (this._needsImpressionStats(cards)) { - props.dispatch(actionCreators.DiscoveryStreamImpressionStats({ - source: props.source.toUpperCase(), - window_inner_width: window.innerWidth, - window_inner_height: window.innerHeight, - tiles: cards.map(link => ({ - id: link.id, - pos: link.pos, - type: props.flightId ? "spoc" : "organic", - ...(link.shim ? { - shim: link.shim - } : {}), - recommendation_id: link.recommendation_id, - fetchTimestamp: link.fetchTimestamp, - corpus_item_id: link.corpus_item_id, - scheduled_corpus_item_id: link.scheduled_corpus_item_id, - recommended_at: link.recommended_at, - received_rank: link.received_rank, - topic: link.topic, - features: link.features, - is_list_card: link.is_list_card, - ...(link.format ? { - format: link.format - } : { - format: getActiveCardSize(window.innerWidth, link.class_names, link.section, link.flightId) - }), - ...(link.section ? { - section: link.section, - section_position: link.section_position, - is_section_followed: link.is_section_followed, - layout_name: link.sectionLayoutName - } : {}) - })), - firstVisibleTimestamp: props.firstVisibleTimestamp - })); - this.impressionCardGuids = cards.map(link => link.id); + if (isFakespot) { + props.dispatch(actionCreators.DiscoveryStreamImpressionStats({ + source: props.source.toUpperCase(), + window_inner_width: window.innerWidth, + window_inner_height: window.innerHeight, + tiles: cards.map(link => ({ + id: link.id, + type: "fakespot", + category: link.category + })) + })); + } else { + props.dispatch(actionCreators.DiscoveryStreamImpressionStats({ + source: props.source.toUpperCase(), + window_inner_width: window.innerWidth, + window_inner_height: window.innerHeight, + tiles: cards.map(link => ({ + id: link.id, + pos: link.pos, + type: props.flightId ? "spoc" : "organic", + ...(link.shim ? { + shim: link.shim + } : {}), + recommendation_id: link.recommendation_id, + fetchTimestamp: link.fetchTimestamp, + corpus_item_id: link.corpus_item_id, + scheduled_corpus_item_id: link.scheduled_corpus_item_id, + recommended_at: link.recommended_at, + received_rank: link.received_rank, + topic: link.topic, + features: link.features, + is_list_card: link.is_list_card, + ...(link.format ? { + format: link.format + } : { + format: getActiveCardSize(window.innerWidth, link.class_names, link.section, link.flightId) + }), + ...(link.section ? { + section: link.section, + section_position: link.section_position, + is_section_followed: link.is_section_followed, + layout_name: link.sectionLayoutName + } : {}) + })), + firstVisibleTimestamp: props.firstVisibleTimestamp + })); + this.impressionCardGuids = cards.map(link => link.id); + } } } @@ -3519,6 +3582,7 @@ const DefaultMeta = ({ mayHaveThumbsUpDown, onThumbsUpClick, onThumbsDownClick, + isListCard, state, format, topic, @@ -3527,7 +3591,7 @@ const DefaultMeta = ({ icon_src, refinedCardsLayout }) => { - const shouldHaveThumbs = format !== "rectangle" && mayHaveSectionsCards && mayHaveThumbsUpDown; + const shouldHaveThumbs = !isListCard && format !== "rectangle" && mayHaveSectionsCards && mayHaveThumbsUpDown; const shouldHaveFooterSection = isSectionsCard && (shouldHaveThumbs || showTopics); return /*#__PURE__*/external_React_default().createElement("div", { className: "meta" @@ -3547,7 +3611,7 @@ const DefaultMeta = ({ className: "excerpt clamp" }, "Sponsored content supports our mission to build a better web.") : excerpt && /*#__PURE__*/external_React_default().createElement("p", { className: "excerpt clamp" - }, excerpt)), format !== "rectangle" && !mayHaveSectionsCards && mayHaveThumbsUpDown && !refinedCardsLayout && /*#__PURE__*/external_React_default().createElement(DSThumbsUpDownButtons, { + }, excerpt)), !isListCard && format !== "rectangle" && !mayHaveSectionsCards && mayHaveThumbsUpDown && !refinedCardsLayout && /*#__PURE__*/external_React_default().createElement(DSThumbsUpDownButtons, { onThumbsDownClick: onThumbsDownClick, onThumbsUpClick: onThumbsUpClick, sponsor: sponsor, @@ -3716,6 +3780,7 @@ class _DSCard extends (external_React_default()).PureComponent { features: this.props.features, matches_selected_topic: matchesSelectedTopic, selected_topics: this.props.selectedTopics, + is_list_card: this.props.isListCard, ...(this.props.format ? { format: this.props.format } : { @@ -3744,6 +3809,7 @@ class _DSCard extends (external_React_default()).PureComponent { recommendation_id: this.props.recommendation_id, topic: this.props.topic, selected_topics: this.props.selectedTopics, + is_list_card: this.props.isListCard, ...(this.props.format ? { format: this.props.format } : { @@ -4044,6 +4110,7 @@ class _DSCard extends (external_React_default()).PureComponent { isRecentSave, DiscoveryStream, Prefs, + isListCard, isFakespot, mayHaveSectionsCards, format @@ -4072,7 +4139,7 @@ class _DSCard extends (external_React_default()).PureComponent { })); } return /*#__PURE__*/external_React_default().createElement("div", { - className: `ds-card placeholder ${placeholderClassName} ${refinedCardsClassName}`, + className: `ds-card placeholder ${placeholderClassName} ${isListCard ? "list-card-placeholder" : ""} ${refinedCardsClassName}`, ref: this.setPlaceholderRef }, placeholderElements); } @@ -4108,6 +4175,8 @@ class _DSCard extends (external_React_default()).PureComponent { const ctaButtonClassName = ctaButtonEnabled ? `ds-card-cta-button` : ``; const compactImagesClassName = compactImages ? `ds-card-compact-image` : ``; const imageGradientClassName = imageGradient ? `ds-card-image-gradient` : ``; + const listCardClassName = isListCard ? `list-feed-card` : ``; + const fakespotClassName = isFakespot ? `fakespot` : ``; const sectionsCardsClassName = [mayHaveSectionsCards ? `sections-card-ui` : ``, this.props.sectionsClassNames].join(" "); const titleLinesName = `ds-card-title-lines-${titleLines}`; const descLinesClassName = `ds-card-desc-lines-${descLines}`; @@ -4119,11 +4188,15 @@ class _DSCard extends (external_React_default()).PureComponent { }); if (isMediumRectangle) { images = this.renderImage(); + } else if (isListCard) { + images = this.renderImage({ + sizes: this.listCardImageSizes + }); } else if (sectionsEnabled) { images = this.renderSectionCardImages(); } return /*#__PURE__*/external_React_default().createElement("article", { - className: `ds-card ${sectionsCardsClassName} ${compactImagesClassName} ${imageGradientClassName} ${titleLinesName} ${descLinesClassName} ${spocFormatClassName} ${ctaButtonClassName} ${ctaButtonVariantClassName} ${refinedCardsClassName}`, + className: `ds-card ${listCardClassName} ${fakespotClassName} ${sectionsCardsClassName} ${compactImagesClassName} ${imageGradientClassName} ${titleLinesName} ${descLinesClassName} ${spocFormatClassName} ${ctaButtonClassName} ${ctaButtonVariantClassName} ${refinedCardsClassName}`, ref: this.setContextMenuButtonHostRef, "data-position-one": this.props["data-position-one"], "data-position-two": this.props["data-position-one"], @@ -4136,7 +4209,7 @@ class _DSCard extends (external_React_default()).PureComponent { url: this.props.url, title: this.props.title, isSponsored: !!this.props.flightId - }, this.props.showTopics && !this.props.mayHaveSectionsCards && this.props.topic && !refinedCardsLayout && /*#__PURE__*/external_React_default().createElement("span", { + }, this.props.showTopics && !this.props.mayHaveSectionsCards && this.props.topic && !isListCard && !refinedCardsLayout && /*#__PURE__*/external_React_default().createElement("span", { className: "ds-card-topic", "data-l10n-id": `newtab-topic-label-${this.props.topic}` }), /*#__PURE__*/external_React_default().createElement("div", { @@ -4157,9 +4230,11 @@ class _DSCard extends (external_React_default()).PureComponent { received_rank: this.props.received_rank, topic: this.props.topic, features: this.props.features, + is_list_card: isListCard, ...(format ? { format } : {}), + isFakespot, category: this.props.category, ...(this.props.section ? { section: this.props.section, @@ -4202,8 +4277,9 @@ class _DSCard extends (external_React_default()).PureComponent { onThumbsUpClick: this.onThumbsUpClick, onThumbsDownClick: this.onThumbsDownClick, state: this.state, + isListCard: isListCard, showTopics: !refinedCardsLayout && this.props.showTopics, - isSectionsCard: this.props.mayHaveSectionsCards && this.props.topic, + isSectionsCard: this.props.mayHaveSectionsCards && this.props.topic && !isListCard, format: format, topic: this.props.topic, icon_src: faviconSrc, @@ -4236,6 +4312,7 @@ class _DSCard extends (external_React_default()).PureComponent { scheduled_corpus_item_id: this.props.scheduled_corpus_item_id, recommended_at: this.props.recommended_at, received_rank: this.props.received_rank, + is_list_card: this.props.isListCard, section: this.props.section, section_position: this.props.sectionPosition, is_section_followed: this.props.sectionFollowed, @@ -4467,6 +4544,151 @@ _TopicsWidget.defaultProps = { const TopicsWidget = (0,external_ReactRedux_namespaceObject.connect)(state => ({ DiscoveryStream: state.DiscoveryStream }))(_TopicsWidget); +;// CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/ListFeed/ListFeed.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_LISTFEED_TITLE = "discoverystream.contextualContent.listFeedTitle"; +const PREF_FAKESPOT_CATEGROY = "discoverystream.contextualContent.fakespot.defaultCategoryTitle"; +const PREF_FAKESPOT_FOOTER = "discoverystream.contextualContent.fakespot.footerCopy"; +const PREF_FAKESPOT_CTA_COPY = "discoverystream.contextualContent.fakespot.ctaCopy"; +const PREF_FAKESPOT_CTA_URL = "discoverystream.contextualContent.fakespot.ctaUrl"; +const ListFeed_PREF_CONTEXTUAL_CONTENT_SELECTED_FEED = "discoverystream.contextualContent.selectedFeed"; +function ListFeed({ + type, + firstVisibleTimestamp, + recs, + categories, + dispatch +}) { + const [selectedFakespotFeed, setSelectedFakespotFeed] = (0,external_React_namespaceObject.useState)(""); + const prefs = (0,external_ReactRedux_namespaceObject.useSelector)(state => state.Prefs.values); + const listFeedTitle = prefs[PREF_LISTFEED_TITLE]; + const categoryTitle = prefs[PREF_FAKESPOT_CATEGROY]; + const footerCopy = prefs[PREF_FAKESPOT_FOOTER]; + const ctaCopy = prefs[PREF_FAKESPOT_CTA_COPY]; + const ctaUrl = prefs[PREF_FAKESPOT_CTA_URL]; + const isFakespot = prefs[ListFeed_PREF_CONTEXTUAL_CONTENT_SELECTED_FEED] === "fakespot"; + // Todo: need to remove ads while using default recommendations, remove this line once API has been updated. + let listFeedRecs = selectedFakespotFeed ? recs.filter(rec => rec.category === selectedFakespotFeed) : recs; + function handleCtaClick() { + dispatch(actionCreators.OnlyToMain({ + type: "FAKESPOT_CTA_CLICK" + })); + } + function handleChange(e) { + setSelectedFakespotFeed(e.target.value); + dispatch(actionCreators.DiscoveryStreamUserEvent({ + event: "FAKESPOT_CATEGORY", + value: { + category: e.target.value || "" + } + })); + } + const contextMenuOptions = ["FakespotDismiss", "AboutFakespot"]; + const { + length: listLength + } = listFeedRecs; + // determine if the list should take up all availible height or not + const fullList = listLength >= 5; + return listLength > 0 && /*#__PURE__*/external_React_default().createElement("div", { + className: `list-feed ${fullList ? "full-height" : ""} ${listLength > 2 ? "span-2" : "span-1"}` + }, /*#__PURE__*/external_React_default().createElement("div", { + className: "list-feed-inner-wrapper" + }, isFakespot ? /*#__PURE__*/external_React_default().createElement("div", { + className: "fakespot-heading" + }, /*#__PURE__*/external_React_default().createElement("div", { + className: "dropdown-wrapper" + }, /*#__PURE__*/external_React_default().createElement("select", { + className: "fakespot-dropdown", + name: "fakespot-categories", + value: selectedFakespotFeed, + onChange: handleChange + }, /*#__PURE__*/external_React_default().createElement("option", { + value: "" + }, categoryTitle || "Holiday Gift Guide"), categories.map(category => /*#__PURE__*/external_React_default().createElement("option", { + key: category, + value: category + }, category))), /*#__PURE__*/external_React_default().createElement("div", { + className: "context-menu-wrapper" + }, /*#__PURE__*/external_React_default().createElement(ContextMenuButton, null, /*#__PURE__*/external_React_default().createElement(LinkMenu, { + dispatch: dispatch, + options: contextMenuOptions, + shouldSendImpressionStats: true, + site: { + url: "https://www.fakespot.com" + } + })))), /*#__PURE__*/external_React_default().createElement("p", { + className: "fakespot-desc" + }, listFeedTitle)) : /*#__PURE__*/external_React_default().createElement("h1", { + className: "list-feed-title", + id: "list-feed-title" + }, /*#__PURE__*/external_React_default().createElement("span", { + className: "icon icon-newsfeed" + }), listFeedTitle), /*#__PURE__*/external_React_default().createElement("div", { + className: "list-feed-content", + role: "menu", + "aria-labelledby": "list-feed-title" + }, listFeedRecs.slice(0, 5).map((rec, index) => { + if (!rec || rec.placeholder) { + return /*#__PURE__*/external_React_default().createElement(DSCard, { + key: `list-card-${index}`, + placeholder: true, + isListCard: true + }); + } + return /*#__PURE__*/external_React_default().createElement(DSCard, { + key: `list-card-${index}`, + pos: index, + flightId: rec.flight_id, + image_src: rec.image_src, + raw_image_src: rec.raw_image_src, + word_count: rec.word_count, + time_to_read: rec.time_to_read, + title: rec.title, + topic: rec.topic, + excerpt: rec.excerpt, + url: rec.url, + id: rec.id, + shim: rec.shim, + type: type, + context: rec.context, + sponsor: rec.sponsor, + sponsored_by_override: rec.sponsored_by_override, + dispatch: dispatch, + source: rec.domain, + publisher: rec.publisher, + pocket_id: rec.pocket_id, + context_type: rec.context_type, + bookmarkGuid: rec.bookmarkGuid, + firstVisibleTimestamp: firstVisibleTimestamp, + corpus_item_id: rec.corpus_item_id, + scheduled_corpus_item_id: rec.scheduled_corpus_item_id, + recommended_at: rec.recommended_at, + received_rank: rec.received_rank, + isListCard: true, + isFakespot: isFakespot, + category: rec.category + }); + }), isFakespot && /*#__PURE__*/external_React_default().createElement("div", { + className: "fakespot-footer" + }, /*#__PURE__*/external_React_default().createElement("p", null, footerCopy), /*#__PURE__*/external_React_default().createElement(SafeAnchor, { + className: "fakespot-cta", + url: ctaUrl, + referrer: "", + onLinkClick: handleCtaClick, + dispatch: dispatch + }, ctaCopy))))); +} + ;// CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/AdBannerContextMenu/AdBannerContextMenu.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 @@ -5068,12 +5290,16 @@ function TrendingSearches() { + 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"; const PREF_TOPICS_SELECTED = "discoverystream.topicSelection.selectedTopics"; const PREF_TOPICS_AVAILABLE = "discoverystream.topicSelection.topics"; const PREF_SPOCS_STARTUPCACHE_ENABLED = "discoverystream.spocs.startupCache.enabled"; +const PREF_LIST_FEED_ENABLED = "discoverystream.contextualContent.enabled"; +const PREF_LIST_FEED_SELECTED_FEED = "discoverystream.contextualContent.selectedFeed"; +const PREF_FAKESPOT_ENABLED = "discoverystream.contextualContent.fakespot.enabled"; const PREF_BILLBOARD_ENABLED = "newtabAdSize.billboard"; const PREF_BILLBOARD_POSITION = "newtabAdSize.billboard.position"; const PREF_LEADERBOARD_ENABLED = "newtabAdSize.leaderboard"; @@ -5144,11 +5370,15 @@ class _CardGrid extends (external_React_default()).PureComponent { const selectedTopics = prefs[PREF_TOPICS_SELECTED]; const availableTopics = prefs[PREF_TOPICS_AVAILABLE]; const spocsStartupCacheEnabled = prefs[PREF_SPOCS_STARTUPCACHE_ENABLED]; + const listFeedEnabled = prefs[PREF_LIST_FEED_ENABLED]; + const listFeedSelectedFeed = prefs[PREF_LIST_FEED_SELECTED_FEED]; 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); + + // filter out recs that should be in ListFeed + const recs = this.props.data.recommendations.filter(item => !item.feedName).slice(0, items); const cards = []; for (let index = 0; index < items; index++) { const rec = recs[index]; @@ -5228,6 +5458,14 @@ class _CardGrid extends (external_React_default()).PureComponent { } } } + if (listFeedEnabled) { + const isFakespot = listFeedSelectedFeed === "fakespot"; + const fakespotEnabled = prefs[PREF_FAKESPOT_ENABLED]; + if (!isFakespot || isFakespot && fakespotEnabled) { + // Place the list feed as the 3rd element in the card grid + cards.splice(2, 1, this.renderListFeed(this.props.data.recommendations, listFeedSelectedFeed)); + } + } 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 @@ -5291,6 +5529,24 @@ class _CardGrid extends (external_React_default()).PureComponent { className: gridClassName }, cards)); } + renderListFeed(recommendations, selectedFeed) { + const recs = recommendations.filter(item => item.feedName === selectedFeed); + const isFakespot = selectedFeed === "fakespot"; + // remove duplicates from category list + const categories = [...new Set(recs.map(({ + category + }) => category))]; + const listFeed = /*#__PURE__*/external_React_default().createElement(ListFeed + // only display recs that match selectedFeed for ListFeed + , { + recs: recs, + categories: isFakespot ? categories : [], + firstVisibleTimestamp: this.props.firstVisibleTimestamp, + type: this.props.type, + dispatch: this.props.dispatch + }); + return listFeed; + } renderGridClassName() { const { hybridLayout, diff --git a/browser/extensions/newtab/karma.mc.config.js b/browser/extensions/newtab/karma.mc.config.js @@ -227,7 +227,7 @@ module.exports = function (config) { statements: 94.94, lines: 94.84, functions: 9.91, - branches: 70.72, + branches: 71.69, }, "content-src/components/DiscoveryStreamComponents/CardSections/CardSections.jsx": { diff --git a/browser/extensions/newtab/lib/ActivityStream.sys.mjs b/browser/extensions/newtab/lib/ActivityStream.sys.mjs @@ -82,6 +82,11 @@ const REGION_THUMBS_CONFIG = const LOCALE_THUMBS_CONFIG = "browser.newtabpage.activity-stream.discoverystream.thumbsUpDown.locale-thumbs-config"; +const REGION_CONTEXTUAL_CONTENT_CONFIG = + "browser.newtabpage.activity-stream.discoverystream.contextualContent.region-content-config"; +const LOCALE_CONTEXTUAL_CONTENT_CONFIG = + "browser.newtabpage.activity-stream.discoverystream.contextualContent.locale-content-config"; + const REGION_CONTEXTUAL_AD_CONFIG = "browser.newtabpage.activity-stream.discoverystream.sections.contextualAds.region-config"; const LOCALE_CONTEXTUAL_AD_CONFIG = @@ -185,6 +190,13 @@ function showThumbsUpDown({ geo, locale }) { ); } +function showContextualContent({ geo, locale }) { + return ( + csvPrefHasValue(REGION_CONTEXTUAL_CONTENT_CONFIG, geo) && + csvPrefHasValue(LOCALE_CONTEXTUAL_CONTENT_CONFIG, locale) + ); +} + function showSectionLayout({ geo, locale }) { return ( csvPrefHasValue(REGION_SECTIONS_CONFIG, geo) && @@ -1267,6 +1279,70 @@ export const PREFS_CONFIG = new Map([ }, ], [ + "discoverystream.contextualContent.enabled", + { + title: "Controls if contextual content (List feed) is displayed", + getValue: showContextualContent, + }, + ], + [ + "discoverystream.contextualContent.feeds", + { + title: "CSV list of possible topics for the contextual content feed", + value: "need_to_know, fakespot", + }, + ], + [ + "discoverystream.contextualContent.selectedFeed", + { + title: + "currently selected feed (one of discoverystream.contextualContent.feeds) to display in listfeed", + value: "need_to_know", + }, + ], + [ + "discoverystream.contextualContent.listFeedTitle", + { + title: "Title for currently selected feed", + value: "", + }, + ], + [ + "discoverystream.contextualContent.fakespot.defaultCategoryTitle", + { + title: "Title default category from fakespot endpoint", + value: "", + }, + ], + [ + "discoverystream.contextualContent.fakespot.footerCopy", + { + title: "footer copy for fakespot feed", + value: "", + }, + ], + [ + "discoverystream.contextualContent.fakespot.enabled", + { + title: "User controlled pref that displays fakespot feed", + value: true, + }, + ], + [ + "discoverystream.contextualContent.fakespot.ctaCopy", + { + title: "cta copy for fakespot feed", + value: "", + }, + ], + [ + "discoverystream.contextualContent.fakespot.ctaUrl", + { + title: "cta link for fakespot feed", + value: "", + }, + ], + [ "discoverystream.publisherFavicon.enabled", { title: "Enables publisher favicons on recommended stories", diff --git a/browser/extensions/newtab/lib/DiscoveryStreamFeed.sys.mjs b/browser/extensions/newtab/lib/DiscoveryStreamFeed.sys.mjs @@ -95,7 +95,23 @@ const PREF_TOPIC_SELECTION_PREVIOUS_SELECTED = const PREF_SPOCS_CACHE_TIMEOUT = "discoverystream.spocs.cacheTimeout"; const PREF_SPOCS_STARTUP_CACHE_ENABLED = "discoverystream.spocs.startupCache.enabled"; +const PREF_CONTEXTUAL_CONTENT_ENABLED = + "discoverystream.contextualContent.enabled"; +const PREF_FAKESPOT_ENABLED = + "discoverystream.contextualContent.fakespot.enabled"; const PREF_CONTEXTUAL_ADS = "discoverystream.sections.contextualAds.enabled"; +const PREF_CONTEXTUAL_CONTENT_SELECTED_FEED = + "discoverystream.contextualContent.selectedFeed"; +const PREF_CONTEXTUAL_CONTENT_LISTFEED_TITLE = + "discoverystream.contextualContent.listFeedTitle"; +const PREF_CONTEXTUAL_CONTENT_FAKESPOT_FOOTER = + "discoverystream.contextualContent.fakespot.footerCopy"; +const PREF_CONTEXTUAL_CONTENT_FAKESPOT_CATEGORY = + "discoverystream.contextualContent.fakespot.defaultCategoryTitle"; +const PREF_CONTEXTUAL_CONTENT_FAKESPOT_CTA_COPY = + "discoverystream.contextualContent.fakespot.ctaCopy"; +const PREF_CONTEXTUAL_CONTENT_FAKESPOT_CTA_URL = + "discoverystream.contextualContent.fakespot.ctaUrl"; const PREF_USER_INFERRED_PERSONALIZATION = "discoverystream.sections.personalization.inferred.user.enabled"; const PREF_SYSTEM_INFERRED_PERSONALIZATION = @@ -1818,6 +1834,8 @@ export class DiscoveryStreamFeed { const cachedData = (await this.cache.get()) || {}; const prefs = this.store.getState().Prefs.values; const sectionsEnabled = prefs[PREF_SECTIONS_ENABLED]; + let isFakespot; + const selectedFeedPref = prefs[PREF_CONTEXTUAL_CONTENT_SELECTED_FEED]; // Should we fetch /curated-recommendations over OHTTP const merinoOhttpEnabled = prefs[PREF_MERINO_OHTTP]; let sections = []; @@ -1855,6 +1873,45 @@ export class DiscoveryStreamFeed { topic: item.topic, url: item.url, })); + if (feedResponse.feeds && selectedFeedPref && !sectionsEnabled) { + isFakespot = selectedFeedPref === "fakespot"; + const keyName = isFakespot ? "products" : "recommendations"; + const selectedFeedResponse = feedResponse.feeds[selectedFeedPref]; + selectedFeedResponse?.[keyName]?.forEach(item => + recommendations.push({ + id: isFakespot + ? item.id + : item.corpusItemId || + item.scheduledCorpusItemId || + item.tileId, + scheduled_corpus_item_id: item.scheduledCorpusItemId, + corpus_item_id: item.corpusItemId, + url: item.url, + title: item.title, + topic: item.topic, + excerpt: item.excerpt, + publisher: item.publisher, + raw_image_src: item.imageUrl, + received_rank: item.receivedRank, + recommended_at: feedResponse.recommendedAt, + // property to determine if rec is used in ListFeed or not + feedName: selectedFeedPref, + category: item.category, + icon_src: item.iconUrl, + isTimeSensitive: item.isTimeSensitive, + }) + ); + + const prevTitle = prefs[PREF_CONTEXTUAL_CONTENT_LISTFEED_TITLE]; + + const feedTitle = isFakespot + ? selectedFeedResponse.headerCopy + : selectedFeedResponse.title; + + if (feedTitle && feedTitle !== prevTitle) { + this.handleListfeedStrings(selectedFeedResponse, isFakespot); + } + } if (sectionsEnabled) { for (const [sectionKey, sectionData] of Object.entries( @@ -1981,6 +2038,45 @@ export class DiscoveryStreamFeed { ); } + handleListfeedStrings(feedResponse, isFakespot) { + if (isFakespot) { + this.store.dispatch( + ac.SetPref( + PREF_CONTEXTUAL_CONTENT_LISTFEED_TITLE, + feedResponse.headerCopy + ) + ); + this.store.dispatch( + ac.SetPref( + PREF_CONTEXTUAL_CONTENT_FAKESPOT_CATEGORY, + feedResponse.defaultCategoryName + ) + ); + this.store.dispatch( + ac.SetPref( + PREF_CONTEXTUAL_CONTENT_FAKESPOT_FOOTER, + feedResponse.footerCopy + ) + ); + this.store.dispatch( + ac.SetPref( + PREF_CONTEXTUAL_CONTENT_FAKESPOT_CTA_COPY, + feedResponse.cta.ctaCopy + ) + ); + this.store.dispatch( + ac.SetPref( + PREF_CONTEXTUAL_CONTENT_FAKESPOT_CTA_URL, + feedResponse.cta.url + ) + ); + } else { + this.store.dispatch( + ac.SetPref(PREF_CONTEXTUAL_CONTENT_LISTFEED_TITLE, feedResponse.title) + ); + } + } + formatComponentFeedRequest(sectionPersonalization = {}) { const prefs = this.store.getState().Prefs.values; const inferredPersonalization = @@ -2046,7 +2142,21 @@ export class DiscoveryStreamFeed { const sectionsEnabled = prefs[PREF_SECTIONS_ENABLED]; + // Should we pass the feed param to the merino request + const contextualContentEnabled = prefs[PREF_CONTEXTUAL_CONTENT_ENABLED]; + const selectedFeed = prefs[PREF_CONTEXTUAL_CONTENT_SELECTED_FEED]; + const isFakespot = selectedFeed === "fakespot"; + const fakespotEnabled = prefs[PREF_FAKESPOT_ENABLED]; + + const shouldFetchTBRFeed = + (contextualContentEnabled && !isFakespot) || + (contextualContentEnabled && isFakespot && fakespotEnabled); + + if (shouldFetchTBRFeed) { + body.feeds = [selectedFeed]; + } if (sectionsEnabled) { + // if sections is enabled, it should override the TBR feed body.feeds = ["sections"]; } @@ -2516,6 +2626,8 @@ export class DiscoveryStreamFeed { case PREF_ENDPOINTS: case PREF_SPOC_POSITIONS: case PREF_UNIFIED_ADS_SPOCS_ENABLED: + case PREF_CONTEXTUAL_CONTENT_ENABLED: + case PREF_CONTEXTUAL_CONTENT_SELECTED_FEED: case PREF_SECTIONS_ENABLED: case PREF_INTEREST_PICKER_ENABLED: // This is a config reset directly related to Discovery Stream pref. diff --git a/browser/extensions/newtab/lib/TelemetryFeed.sys.mjs b/browser/extensions/newtab/lib/TelemetryFeed.sys.mjs @@ -779,6 +779,7 @@ export class TelemetryFeed { fetchTimestamp, firstVisibleTimestamp, format, + is_list_card, is_section_followed, layout_name, matches_selected_topic, @@ -826,6 +827,7 @@ export class TelemetryFeed { matches_selected_topic, selected_topics, topic, + is_list_card, position: action.data.action_position, tile_id, event_source, @@ -944,6 +946,23 @@ export class TelemetryFeed { } break; } + case "FAKESPOT_CLICK": { + const { product_id, category } = action.data.value ?? {}; + Glean.newtab.fakespotClick.record({ + newtab_visit_id: session.session_id, + product_id, + category, + }); + break; + } + case "FAKESPOT_CATEGORY": { + const { category } = action.data.value ?? {}; + Glean.newtab.fakespotCategory.record({ + newtab_visit_id: session.session_id, + category, + }); + break; + } // Bug 1969452 - Feature Highlight Telemetry Events case "FEATURE_HIGHLIGHT_DISMISS": case "FEATURE_HIGHLIGHT_IMPRESSION": @@ -1261,6 +1280,33 @@ export class TelemetryFeed { case at.TOPIC_SELECTION_USER_SAVE: this.handleTopicSelectionUserEvent(action); break; + case at.FAKESPOT_DISMISS: { + const session = this.sessions.get(au.getPortIdOfSender(action)); + if (session) { + Glean.newtab.fakespotDismiss.record({ + newtab_visit_id: session.session_id, + }); + } + break; + } + case at.FAKESPOT_CTA_CLICK: { + const session = this.sessions.get(au.getPortIdOfSender(action)); + if (session) { + Glean.newtab.fakespotCtaClick.record({ + newtab_visit_id: session.session_id, + }); + } + break; + } + case at.OPEN_ABOUT_FAKESPOT: { + const session = this.sessions.get(au.getPortIdOfSender(action)); + if (session) { + Glean.newtab.fakespotAboutClick.record({ + newtab_visit_id: session.session_id, + }); + } + break; + } case at.BLOCK_SECTION: // Intentional fall-through case at.CARD_SECTION_IMPRESSION: @@ -1760,6 +1806,7 @@ export class TelemetryFeed { ...(datum.format ? { format: datum.format } : {}), position: datum.position, tile_id: datum.id || datum.tile_id, + is_list_card: datum.is_list_card, ...(datum.section ? { section: datum.section, @@ -1857,49 +1904,57 @@ export class TelemetryFeed { const { tiles } = data; tiles.forEach(tile => { - const { corpus_item_id, scheduled_corpus_item_id } = tile; - const is_sponsored = tile.type === "spoc"; - const gleanData = { - is_sponsored, - ...(tile.format ? { format: tile.format } : {}), - ...(tile.section - ? { - section: tile.section, - section_position: tile.section_position, - ...(this.sectionsPersonalizationEnabled - ? { is_section_followed: !!tile.is_section_followed } - : {}), - layout_name: tile.layout_name, - } - : {}), - position: tile.pos, - tile_id: tile.id, - topic: tile.topic, - selected_topics: tile.selectedTopics, - is_list_card: tile.is_list_card, - // We conditionally add in a few props. - ...(corpus_item_id ? { corpus_item_id } : {}), - ...(scheduled_corpus_item_id ? { scheduled_corpus_item_id } : {}), - ...(corpus_item_id || scheduled_corpus_item_id - ? { - received_rank: tile.received_rank, - recommended_at: tile.recommended_at, - } - : { - recommendation_id: tile.recommendation_id, - }), - }; - Glean.pocket.impression.record({ - ...this.redactNewTabPing( - this.redactPingFor143(gleanData), - is_sponsored - ), - newtab_visit_id: session.session_id, - }); - if (this.privatePingEnabled) { - this.newtabContentPing.recordEvent("impression", gleanData); + // if the tile has a category it is a product tile from fakespot + if (tile.type === "fakespot") { + Glean.newtab.fakespotProductImpression.record({ + newtab_visit_id: session.session_id, + product_id: tile.id, + category: tile.category, + }); + } else { + const { corpus_item_id, scheduled_corpus_item_id } = tile; + const is_sponsored = tile.type === "spoc"; + const gleanData = { + is_sponsored, + ...(tile.format ? { format: tile.format } : {}), + ...(tile.section + ? { + section: tile.section, + section_position: tile.section_position, + ...(this.sectionsPersonalizationEnabled + ? { is_section_followed: !!tile.is_section_followed } + : {}), + layout_name: tile.layout_name, + } + : {}), + position: tile.pos, + tile_id: tile.id, + topic: tile.topic, + selected_topics: tile.selectedTopics, + is_list_card: tile.is_list_card, + // We conditionally add in a few props. + ...(corpus_item_id ? { corpus_item_id } : {}), + ...(scheduled_corpus_item_id ? { scheduled_corpus_item_id } : {}), + ...(corpus_item_id || scheduled_corpus_item_id + ? { + received_rank: tile.received_rank, + recommended_at: tile.recommended_at, + } + : { + recommendation_id: tile.recommendation_id, + }), + }; + Glean.pocket.impression.record({ + ...this.redactNewTabPing( + this.redactPingFor143(gleanData), + is_sponsored + ), + newtab_visit_id: session.session_id, + }); + if (this.privatePingEnabled) { + this.newtabContentPing.recordEvent("impression", gleanData); + } } - if (tile.shim) { if (this.canSendUnifiedAdsSpocCallbacks) { // Send unified ads callback event 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 @@ -89,6 +89,39 @@ describe("<CardGrid>", () => { assert.ok(wrapper.find(TopicsWidget).exists()); }); + it("should create a list feed", () => { + const commonProps = { + items: 12, + data: { + recommendations: [ + { feedName: "foo" }, + { feedName: "foo" }, + { feedName: "foo" }, + { feedName: "foo" }, + { feedName: "foo" }, + { feedName: "foo" }, + ], + }, + Prefs: { + ...INITIAL_STATE.Prefs, + values: { + ...INITIAL_STATE.Prefs.values, + "discoverystream.contextualContent.enabled": true, + "discoverystream.contextualContent.selectedFeed": "foo", + }, + }, + DiscoveryStream: INITIAL_STATE.DiscoveryStream, + }; + + wrapper = mount( + <WrapWithProvider> + <CardGrid {...commonProps} /> + </WrapWithProvider> + ); + + assert.ok(wrapper.find(".list-feed").exists()); + }); + it("should render AdBanner if enabled", () => { const commonProps = { ...INITIAL_STATE, diff --git a/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx b/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx @@ -329,6 +329,7 @@ describe("<DSCard>", () => { features: undefined, matches_selected_topic: undefined, selected_topics: undefined, + is_list_card: undefined, format: "medium-card", }, }) @@ -346,6 +347,7 @@ describe("<DSCard>", () => { recommendation_id: undefined, topic: undefined, selected_topics: undefined, + is_list_card: undefined, format: "medium-card", }, ], @@ -390,6 +392,7 @@ describe("<DSCard>", () => { features: undefined, matches_selected_topic: undefined, selected_topics: undefined, + is_list_card: undefined, format: "spoc", }, }) @@ -407,6 +410,7 @@ describe("<DSCard>", () => { recommendation_id: undefined, topic: undefined, selected_topics: undefined, + is_list_card: undefined, format: "spoc", }, ], @@ -454,6 +458,7 @@ describe("<DSCard>", () => { features: undefined, matches_selected_topic: undefined, selected_topics: undefined, + is_list_card: undefined, format: "medium-card", }, }) @@ -472,6 +477,7 @@ describe("<DSCard>", () => { recommendation_id: undefined, topic: undefined, selected_topics: undefined, + is_list_card: undefined, format: "medium-card", }, ], @@ -480,6 +486,33 @@ describe("<DSCard>", () => { }) ); }); + + it("fakespot onLinkClick should dispatch with the correct events", () => { + wrapper.setProps({ + id: "fooidx", + pos: 1, + type: "foo", + isFakespot: true, + category: "fakespot", + }); + + sandbox + .stub(wrapper.instance(), "doesLinkTopicMatchSelectedTopic") + .returns(undefined); + + wrapper.instance().onLinkClick(); + + assert.calledWith( + dispatch, + ac.DiscoveryStreamUserEvent({ + event: "FAKESPOT_CLICK", + value: { + product_id: "fooidx", + category: "fakespot", + }, + }) + ); + }); }); describe("DSCard with CTA", () => { @@ -1029,6 +1062,68 @@ describe("<PlaceholderDSCard> component", () => { }); }); +describe("Listfeed <DSCard />", () => { + let wrapper; + let sandbox; + let dispatch; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + dispatch = sandbox.stub(); + wrapper = shallow( + <DSCard dispatch={dispatch} {...DEFAULT_PROPS} isListFeed={true} /> + ); + wrapper.setState({ isSeen: true }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should not render thumbs up/down UI", () => { + wrapper.setState({ mayHaveThumbsUpDown: true }); + const thumbs_up_down_buttons_component = wrapper.find( + DSThumbsUpDownButtons + ); + const thumbs_up_down_buttons = thumbs_up_down_buttons_component.find( + ".card-stp-thumbs-buttons" + ); + assert.ok(!thumbs_up_down_buttons.exists()); + }); + + it("should not render the excerpt UI", () => { + const excerpt_element = wrapper.find(".excerpt"); + + assert.ok(!excerpt_element.exists()); + }); +}); + +describe("ListFeed fakespot <DSCard />", () => { + let wrapper; + let sandbox; + let dispatch; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + dispatch = sandbox.stub(); + wrapper = shallow( + <DSCard + dispatch={dispatch} + {...DEFAULT_PROPS} + isListFeed={true} + isFakespot={true} + /> + ); + wrapper.setState({ isSeen: true }); + }); + + it("should not render source element", () => { + const source_element = wrapper.find(".source"); + + assert.ok(!source_element.exists()); + }); +}); + describe("<DSSource> component", () => { it("should return a default source without compact", () => { const wrapper = shallow(<DSSource source="Mozilla" />); diff --git a/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx b/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx @@ -84,6 +84,7 @@ describe("<DSTextPromo>", () => { card_type: undefined, position: 0, is_pocket_card: false, + is_list_card: undefined, }, ]); diff --git a/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx b/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx @@ -148,6 +148,7 @@ describe("<ImpressionStats>", () => { received_rank: undefined, topic: undefined, features: undefined, + is_list_card: undefined, format: "medium-card", }, { @@ -162,6 +163,7 @@ describe("<ImpressionStats>", () => { received_rank: undefined, topic: undefined, features: undefined, + is_list_card: undefined, format: "medium-card", }, { @@ -176,6 +178,7 @@ describe("<ImpressionStats>", () => { received_rank: undefined, topic: undefined, features: undefined, + is_list_card: undefined, format: "medium-card", }, ]); @@ -269,6 +272,7 @@ describe("<ImpressionStats>", () => { fetchTimestamp: TEST_FETCH_TIMESTAMP, topic: undefined, features: undefined, + is_list_card: undefined, format: "medium-card", }, { @@ -283,6 +287,7 @@ describe("<ImpressionStats>", () => { fetchTimestamp: TEST_FETCH_TIMESTAMP, topic: undefined, features: undefined, + is_list_card: undefined, format: "medium-card", }, { @@ -297,6 +302,7 @@ describe("<ImpressionStats>", () => { fetchTimestamp: TEST_FETCH_TIMESTAMP, topic: undefined, features: undefined, + is_list_card: undefined, format: "medium-card", }, ]); diff --git a/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ListFeed.test.jsx b/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ListFeed.test.jsx @@ -0,0 +1,171 @@ +import { mount } from "enzyme"; +import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; +import { ListFeed } from "content-src/components/DiscoveryStreamComponents/ListFeed/ListFeed"; +import { combineReducers, createStore } from "redux"; +import { Provider } from "react-redux"; +import React from "react"; +import { DSCard } from "../../../../../content-src/components/DiscoveryStreamComponents/DSCard/DSCard"; +import { actionCreators as ac } from "common/Actions.mjs"; + +const DEFAULT_PROPS = { + type: "foo", + firstVisibleTimestamp: new Date("March 21, 2024 10:11:12").getTime(), + recs: [{}, {}, {}], + categories: [], +}; + +// Wrap this around any component that uses useSelector, +// or any mount that uses a child that uses redux. +function WrapWithProvider({ children, state = INITIAL_STATE }) { + let store = createStore(combineReducers(reducers), state); + return <Provider store={store}>{children}</Provider>; +} + +describe("Discovery Stream <ListFeed>", () => { + let wrapper; + let sandbox; + let dispatch; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + dispatch = sandbox.stub(); + + wrapper = mount( + <WrapWithProvider> + <ListFeed dispatch={dispatch} {...DEFAULT_PROPS} /> + </WrapWithProvider> + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render", () => { + assert.ok(wrapper.exists()); + assert.ok(wrapper.find(".list-feed").exists()); + }); + + it("should not render if rec prop is an empty array", () => { + wrapper = mount( + <WrapWithProvider> + <ListFeed dispatch={dispatch} {...DEFAULT_PROPS} recs={[]} /> + </WrapWithProvider> + ); + assert.ok(!wrapper.find(".list-feed").exists()); + }); + + it("should render a maximum of 5 cards", () => { + wrapper = mount( + <WrapWithProvider> + <ListFeed + dispatch={dispatch} + {...DEFAULT_PROPS} + recs={[{}, {}, {}, {}, {}, {}, {}]} + /> + </WrapWithProvider> + ); + + assert.lengthOf(wrapper.find(DSCard), 5); + }); + + it("should render placeholder cards if `rec` is undefined or `rec.placeholder` is true", () => { + wrapper = mount( + <WrapWithProvider> + <ListFeed + dispatch={dispatch} + type={"foo"} + firstVisibleTimestamp={new Date("March 21, 2024 10:11:12").getTime()} + recs={[ + { placeholder: true }, + { placeholder: true }, + { placeholder: true }, + { placeholder: true }, + { placeholder: true }, + { placeholder: true }, + ]} + /> + </WrapWithProvider> + ); + + assert.ok(wrapper.find(".list-card-placeholder").exists()); + assert.lengthOf(wrapper.find(".list-card-placeholder"), 5); + }); + + describe("fakespot <ListFeed />", () => { + const PREF_CONTEXTUAL_CONTENT_SELECTED_FEED = + "discoverystream.contextualContent.selectedFeed"; + + beforeEach(() => { + // mock the pref for selected feed + const state = { + ...INITIAL_STATE, + Prefs: { + ...INITIAL_STATE.Prefs, + values: { + ...INITIAL_STATE.Prefs.values, + [PREF_CONTEXTUAL_CONTENT_SELECTED_FEED]: "fakespot", + }, + }, + }; + wrapper = mount( + <WrapWithProvider state={state}> + <ListFeed + dispatch={dispatch} + recs={[ + { category: "foo&bar" }, + { category: "foo&bar" }, + { category: "foo&bar" }, + { category: "foo&bar" }, + { category: "bar" }, + { category: "bar" }, + ]} + categories={["foo&bar", "bar"]} + {...DEFAULT_PROPS} + /> + </WrapWithProvider> + ); + }); + + it("should render fakespot category dropdown", () => { + assert.ok(wrapper.find(".fakespot-dropdown").exists()); + }); + + it("should render heading copy, context menu, footer copy and cta", () => { + assert.ok(wrapper.find(".context-menu-wrapper").exists()); + assert.ok(wrapper.find(".fakespot-desc").exists()); + assert.ok(wrapper.find(".fakespot-footer p").exists()); + assert.ok(wrapper.find(".fakespot-cta").exists()); + }); + + it("when category is selected, the correct event is dispatched", () => { + const select = wrapper.find(".fakespot-dropdown"); + // const barCategoryOption = wrapper.find("option[value='bar']"); + select.simulate("change", { target: { value: "bar" } }); + assert.calledOnce(dispatch); + assert.calledWith( + dispatch, + ac.DiscoveryStreamUserEvent({ + event: "FAKESPOT_CATEGORY", + value: { + category: "bar", + }, + }) + ); + }); + + it("clicking on fakespot CTA should dispatch the correct event", () => { + const safeAnchor = wrapper.find(".fakespot-cta"); + const btn = safeAnchor.find("a"); + btn.simulate("click"); + assert.calledTwice(dispatch); + const secondCall = dispatch.getCall(1); + assert.deepEqual( + secondCall.args[0], + ac.OnlyToMain({ + type: "FAKESPOT_CTA_CLICK", + }) + ); + }); + }); +}); diff --git a/browser/extensions/newtab/test/unit/content-src/components/LinkMenu.test.jsx b/browser/extensions/newtab/test/unit/content-src/components/LinkMenu.test.jsx @@ -290,6 +290,7 @@ describe("<LinkMenu>", () => { recommended_at: undefined, format: undefined, is_pocket_card: false, + is_list_card: undefined, is_sponsored: true, }, "newtab-menu-open-new-private-window": { @@ -312,6 +313,7 @@ describe("<LinkMenu>", () => { card_type: FAKE_SITE.card_type, position: 3, is_pocket_card: false, + is_list_card: undefined, }, ], menu_action_webext_dismiss: { @@ -516,6 +518,7 @@ describe("<LinkMenu>", () => { card_type: undefined, position: 3, is_pocket_card: false, + is_list_card: undefined, }; assert.deepEqual(blockUrlOption.action.data[0], expected); }); @@ -564,6 +567,7 @@ describe("<LinkMenu>", () => { card_type: undefined, position: 3, is_pocket_card: true, + is_list_card: undefined, }; assert.deepEqual(blockUrlOption.action.data[0], expected); }); diff --git a/browser/extensions/newtab/test/unit/lib/ActivityStream.test.js b/browser/extensions/newtab/test/unit/lib/ActivityStream.test.js @@ -577,6 +577,82 @@ describe("ActivityStream", () => { assert.isFalse(PREFS_CONFIG.get(FEATURE_ENABLED_PREF).value); }); }); + describe("showContextualContent", () => { + let stub; + let getStringPrefStub; + const FEATURE_ENABLED_PREF = "discoverystream.contextualContent.enabled"; + const REGION_CONTEXTUAL_CONTENT_CONFIG = + "browser.newtabpage.activity-stream.discoverystream.contextualContent.region-content-config"; + const LOCALE_CONTEXTUAL_CONTENT_CONFIG = + "browser.newtabpage.activity-stream.discoverystream.contextualContent.locale-content-config"; + + beforeEach(() => { + stub = sandbox.stub(global.Region, "home"); + + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + + getStringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref"); + + // Set default regions + getStringPrefStub + .withArgs(REGION_CONTEXTUAL_CONTENT_CONFIG) + .returns("US, CA"); + + // Set default locales + getStringPrefStub + .withArgs(LOCALE_CONTEXTUAL_CONTENT_CONFIG) + .returns("en-US,en-GB,en-CA"); + }); + it("should turn off when region and locale are not set", () => { + stub.get(() => ""); + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(() => ""); + as._updateDynamicPrefs(); + assert.isFalse(PREFS_CONFIG.get(FEATURE_ENABLED_PREF).value); + }); + it("should turn off when region is not set", () => { + stub.get(() => ""); + as._updateDynamicPrefs(); + assert.isFalse(PREFS_CONFIG.get(FEATURE_ENABLED_PREF).value); + }); + it("should turn on when region is supported", () => { + stub.get(() => "US"); + as._updateDynamicPrefs(); + assert.isTrue(PREFS_CONFIG.get(FEATURE_ENABLED_PREF).value); + }); + it("should turn off when region is not supported", () => { + stub.get(() => "JP"); + as._updateDynamicPrefs(); + assert.isFalse(PREFS_CONFIG.get(FEATURE_ENABLED_PREF).value); + }); + it("should turn off when locale is not set", () => { + stub.get(() => "US"); + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(() => ""); + as._updateDynamicPrefs(); + assert.isFalse(PREFS_CONFIG.get(FEATURE_ENABLED_PREF).value); + }); + it("should turn on when locale is supported", () => { + stub.get(() => "US"); + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + as._updateDynamicPrefs(); + assert.isTrue(PREFS_CONFIG.get(FEATURE_ENABLED_PREF).value); + }); + it("should turn off when locale is not supported", () => { + stub.get(() => "US"); + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(() => "fr"); + as._updateDynamicPrefs(); + assert.isFalse(PREFS_CONFIG.get(FEATURE_ENABLED_PREF).value); + }); + it("should turn off when region and locale are both not supported", () => { + stub.get(() => "FR"); + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(() => "fr"); + as._updateDynamicPrefs(); + assert.isFalse(PREFS_CONFIG.get(FEATURE_ENABLED_PREF).value); + }); + }); describe("discoverystream.region-basic-layout config", () => { let getStringPrefStub; beforeEach(() => { diff --git a/browser/locales/en-US/browser/newtab/newtab.ftl b/browser/locales/en-US/browser/newtab/newtab.ftl @@ -117,6 +117,7 @@ newtab-menu-pin = Pin newtab-menu-unpin = Unpin newtab-menu-delete-history = Delete from History newtab-menu-show-privacy-info = Our sponsors & your privacy +newtab-menu-about-fakespot = About { -fakespot-brand-name } # Report is a verb (i.e. report issue with the content). newtab-menu-report = Report # Context menu option to personalize New Tab recommended stories by blocking a section of stories,