From c393e854c7ba44218e9ed03ca1256d7851e35c9c Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 4 Sep 2026 14:51:16 +0200 Subject: [PATCH 01/20] feat(monetization): add the sponsor strip experiment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A logo wall docked under the main feeds — one static gold slot, four rotating premium slots and a community row that fills whatever the row has left — with the breaking-news ticker stacked underneath it. While the strip is carrying those headlines, the feed drops its Happening Now card. Impressions, IAB viewable impressions, clicks and a new `air time` duration event are logged per logo, keyed by tier and slot. The ad source is a fixture until the `footer_logo` placement exists on the ad server. Behind `sponsor_strip`, default off. Co-Authored-By: Claude Opus 5 (1M context) --- packages/shared/src/components/Feed.spec.tsx | 62 +++ packages/shared/src/components/Feed.tsx | 8 + .../shared/src/components/MainFeedLayout.tsx | 21 + .../src/components/ScrollToTopButton.tsx | 4 +- .../components/feedback/FeedbackWidget.tsx | 4 +- .../monetization/sponsorStrip/SponsorLogo.tsx | 114 ++++++ .../sponsorStrip/SponsorStrip.spec.tsx | 359 ++++++++++++++++++ .../sponsorStrip/SponsorStrip.tsx | 173 +++++++++ .../sponsorStrip/SponsorStripHeadlines.tsx | 96 +++++ .../sponsorStrip/mockSponsorStripAds.ts | 100 +++++ .../sponsorStrip/sponsorLogoSizing.spec.ts | 66 ++++ .../sponsorStrip/sponsorLogoSizing.ts | 59 +++ .../sponsorStrip/sponsorStripCreative.spec.ts | 45 +++ .../sponsorStrip/sponsorStripCreative.ts | 75 ++++ .../sponsorStrip/sponsorStripLog.ts | 38 ++ .../sponsorStrip/sponsorStripOffset.ts | 39 ++ .../sponsorStrip/sponsorStripRotation.spec.ts | 161 ++++++++ .../sponsorStrip/sponsorStripRotation.ts | 141 +++++++ .../sponsorStrip/useSponsorSlotLog.ts | 95 +++++ .../sponsorStrip/useSponsorStrip.spec.tsx | 104 +++++ .../sponsorStrip/useSponsorStrip.ts | 54 +++ .../sponsorStrip/useSponsorStripAds.ts | 222 +++++++++++ .../sponsorStrip/useSponsorStripFeed.spec.tsx | 61 +++ .../sponsorStrip/useSponsorStripFeed.ts | 44 +++ .../sponsorStrip/useStripHeadlines.spec.tsx | 113 ++++++ .../sponsorStrip/useStripHeadlines.ts | 47 +++ .../src/hooks/feed/mockFeedHighlights.spec.ts | 103 +++++ .../src/hooks/feed/mockFeedHighlights.ts | 106 ++++++ packages/shared/src/hooks/useFeed.ts | 24 +- packages/shared/src/lib/ads.ts | 10 + packages/shared/src/lib/featureManagement.ts | 20 +- packages/shared/src/types.ts | 8 + 32 files changed, 2571 insertions(+), 5 deletions(-) create mode 100644 packages/shared/src/features/monetization/sponsorStrip/SponsorLogo.tsx create mode 100644 packages/shared/src/features/monetization/sponsorStrip/SponsorStrip.spec.tsx create mode 100644 packages/shared/src/features/monetization/sponsorStrip/SponsorStrip.tsx create mode 100644 packages/shared/src/features/monetization/sponsorStrip/SponsorStripHeadlines.tsx create mode 100644 packages/shared/src/features/monetization/sponsorStrip/mockSponsorStripAds.ts create mode 100644 packages/shared/src/features/monetization/sponsorStrip/sponsorLogoSizing.spec.ts create mode 100644 packages/shared/src/features/monetization/sponsorStrip/sponsorLogoSizing.ts create mode 100644 packages/shared/src/features/monetization/sponsorStrip/sponsorStripCreative.spec.ts create mode 100644 packages/shared/src/features/monetization/sponsorStrip/sponsorStripCreative.ts create mode 100644 packages/shared/src/features/monetization/sponsorStrip/sponsorStripLog.ts create mode 100644 packages/shared/src/features/monetization/sponsorStrip/sponsorStripOffset.ts create mode 100644 packages/shared/src/features/monetization/sponsorStrip/sponsorStripRotation.spec.ts create mode 100644 packages/shared/src/features/monetization/sponsorStrip/sponsorStripRotation.ts create mode 100644 packages/shared/src/features/monetization/sponsorStrip/useSponsorSlotLog.ts create mode 100644 packages/shared/src/features/monetization/sponsorStrip/useSponsorStrip.spec.tsx create mode 100644 packages/shared/src/features/monetization/sponsorStrip/useSponsorStrip.ts create mode 100644 packages/shared/src/features/monetization/sponsorStrip/useSponsorStripAds.ts create mode 100644 packages/shared/src/features/monetization/sponsorStrip/useSponsorStripFeed.spec.tsx create mode 100644 packages/shared/src/features/monetization/sponsorStrip/useSponsorStripFeed.ts create mode 100644 packages/shared/src/features/monetization/sponsorStrip/useStripHeadlines.spec.tsx create mode 100644 packages/shared/src/features/monetization/sponsorStrip/useStripHeadlines.ts create mode 100644 packages/shared/src/hooks/feed/mockFeedHighlights.spec.ts create mode 100644 packages/shared/src/hooks/feed/mockFeedHighlights.ts diff --git a/packages/shared/src/components/Feed.spec.tsx b/packages/shared/src/components/Feed.spec.tsx index 515c80bc929..37dbc8cb233 100644 --- a/packages/shared/src/components/Feed.spec.tsx +++ b/packages/shared/src/components/Feed.spec.tsx @@ -556,6 +556,68 @@ describe('Feed logged in', () => { ).toEqual(['postItem', 'postItem', 'highlightItem', 'postItem']); }); + // `disableHighlightItems` is set by MainFeedLayout when it mounts the sponsor + // strip, which is what carries breaking news in the card's place. + it('should drop the Happening Now card when the layout says the strip carries it', async () => { + renderComponent( + [ + { + request: { + query: FEED_V2_QUERY, + variables, + }, + result: { + data: { + page: { + pageInfo: defaultFeedPage.pageInfo, + edges: [ + { + node: { + __typename: 'FeedHighlightsItem', + feedMeta: null, + highlights: [ + { + id: 'highlight-1', + channel: 'agents', + headline: 'The first highlight', + highlightedAt: '2026-04-05T09:00:00.000Z', + post: { + id: defaultFeedPage.edges[0].node.id, + commentsPermalink: + defaultFeedPage.edges[0].node.commentsPermalink, + }, + }, + ], + }, + }, + { + node: { + __typename: 'FeedPostItem', + post: defaultFeedPage.edges[0].node, + feedMeta: defaultFeedPage.edges[0].node.feedMeta ?? null, + }, + }, + ], + }, + }, + }, + }, + ], + defaultUser, + SharedFeedPage.MyFeed, + FEED_V2_QUERY, + { disableHighlightItems: true }, + ); + + await waitForNock(); + + // The posts around it still render, so the card is dropped rather than the + // whole response being discarded. + expect(await screen.findAllByTestId('postItem')).not.toHaveLength(0); + expect(screen.queryByTestId('highlightItem')).not.toBeInTheDocument(); + expect(screen.queryByText('Happening Now')).not.toBeInTheDocument(); + }); + it('should send upvote mutation', async () => { let mutationCalled = false; renderComponent([ diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index c4fe7262a42..b96a5c9774a 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -102,6 +102,12 @@ export interface FeedProps showSearch?: boolean; actionButtons?: ReactNode; disableAds?: boolean; + /** + * Drop the Happening Now card. Owned by the layout that renders the sponsor + * strip, which is where breaking news goes while the strip is up — a feed + * with no strip above it must never lose the card. + */ + disableHighlightItems?: boolean; staticAd?: { ad: Ad; index: number }; disableAdRefresh?: boolean; allowFetchMore?: boolean; @@ -211,6 +217,7 @@ export default function Feed({ shortcuts, actionButtons, disableAds, + disableHighlightItems, staticAd, disableAdRefresh = false, allowFetchMore, @@ -374,6 +381,7 @@ export default function Feed({ disableTopHero: isV2, isHorizontal, excludePinnedPosts, + disableHighlightItems, settings: { disableAds, staticAd, diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index bac2a231273..eb679091aaa 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -18,6 +18,8 @@ import { useRouter } from 'next/router'; import type { FeedProps } from './Feed'; import Feed from './Feed'; import { FeedPageLayoutMobile, feedGutter } from './utilities/common'; +import { SponsorStrip } from '../features/monetization/sponsorStrip/SponsorStrip'; +import { useSponsorStripFeed } from '../features/monetization/sponsorStrip/useSponsorStripFeed'; import { ExploreChipsBar } from './feeds/ExploreChipsBar'; import { buildPersonalizedCategories } from './feeds/exploreCategories'; import { useFeeds } from '../hooks/feed/useFeeds'; @@ -778,6 +780,13 @@ export default function MainFeedLayout({ } return ''; }, [customFeedsData, feedName, router.query.slugOrId]); + // Read here rather than inside the feed or the strip: this is the one place + // that owns both, so the card can only ever go missing on a surface that is + // mounting the strip — with headlines in it — in the card's place. + const sponsorStrip = useSponsorStripFeed({ + feedName, + disableAds: feedProps?.disableAds, + }); const v2ActionButtons = feedProps?.actionButtons; const showFeedV2PageHeader = isV2 && @@ -851,6 +860,7 @@ export default function MainFeedLayout({ feedProps && ( + {/* Docked outside the page container so it spans the feed column and + pins to the window, and mounted here rather than in each app's + MainFeedPage because this is the one component both the webapp and + the extension new tab render — and the only place the feed name is + already resolved from `default` to the reader's own feed. */} + {sponsorStrip.isEnabled && ( + + )} ); } diff --git a/packages/shared/src/components/ScrollToTopButton.tsx b/packages/shared/src/components/ScrollToTopButton.tsx index 989fb8ca8c7..01066831926 100644 --- a/packages/shared/src/components/ScrollToTopButton.tsx +++ b/packages/shared/src/components/ScrollToTopButton.tsx @@ -63,7 +63,9 @@ export default function ScrollToTopButton(): ReactElement | null { tabIndex={show ? 0 : -1} {...props} className={classNames( - 'absolute right-4 z-2', + // Lifts over the sponsor strip when one is docked; the property is + // unset otherwise, so the margin collapses to zero. + 'absolute right-4 z-2 mt-[calc(-1_*_var(--sponsor-strip-height,0px))]', showFeedbackButton ? '-top-26 tablet:-top-32' : '-top-12 tablet:-top-18 laptop:-top-24', diff --git a/packages/shared/src/components/feedback/FeedbackWidget.tsx b/packages/shared/src/components/feedback/FeedbackWidget.tsx index 3f548adf7a6..98a79dcab82 100644 --- a/packages/shared/src/components/feedback/FeedbackWidget.tsx +++ b/packages/shared/src/components/feedback/FeedbackWidget.tsx @@ -182,7 +182,9 @@ export function FeedbackWidget({