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 a5ab98f95bf..70c59547a9b 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -17,7 +17,13 @@ import classNames from 'classnames'; import { useRouter } from 'next/router'; import type { FeedProps } from './Feed'; import Feed from './Feed'; -import { FeedPageLayoutMobile, feedGutter } from './utilities/common'; +import { + FeedPageLayoutMobile, + feedGutter, + feedWidth, +} 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'; @@ -26,7 +32,6 @@ import { AskSearchBanner } from './marketing/banners/AskSearchBanner'; import { FeedEngagementBanner } from './brand/FeedEngagementBanner'; import { ExploreSignupStrip } from './auth/ExploreSignupStrip'; import FeedContext from '../contexts/FeedContext'; -import feedStyles from './Feed.module.css'; import AuthContext from '../contexts/AuthContext'; import type { LoggedUser } from '../lib/user'; import { SharedFeedPage } from './utilities'; @@ -215,10 +220,7 @@ const getQueryBasedOnLogin = ( // The feed's own width: full width normally, and clamped + centered to the // same card-based max-width as the grid on wide screens (desktopL). The CSS // vars feed the `styles.container` max-width calc (grid gap is 2rem). -const feedWidthClassName = classNames( - 'relative flex w-full flex-col laptopL:mx-auto', - feedStyles.container, -); +const feedWidthClassName = classNames('relative flex flex-col', feedWidth); const commentClassName = { container: 'rounded-none border-0 border-b tablet:border-x', commentBox: { @@ -791,6 +793,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 && @@ -858,6 +867,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/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 4c672a717bd..8bb757fed9e 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -417,7 +417,20 @@ function MainLayoutComponent({ /> )} {sidebarOwnsHeader ? ( -
+
{showHomepageTopBanners && ( )} @@ -432,12 +445,22 @@ function MainLayoutComponent({ // No drop shadow — the subtle border defines the floating card // in both themes; shadow-2 cast a heavy bottom shadow. 'laptop:overflow-clip laptop:rounded-24 laptop:border laptop:border-border-subtlest-quaternary laptop:bg-background-default laptop:p-0.5', + // The dock becomes the frame's bottom edge, so the padding + // that would hold it up goes, and the corners it would be + // clipped into square off. + 'laptop:has-[.feed-dock]:rounded-b-none laptop:has-[.feed-dock]:border-b-0 laptop:has-[.feed-dock]:pb-0', LAYOUT_FRAME_CLASS, + // These subtract exactly the chrome above the frame plus its + // own margins, so the frame ends level with the window. With + // a dock the bottom margin is gone, so 0.75rem less comes off + // — a frame that stops short leaves a `sticky bottom-0` dock + // resting at its end, which is what happens for as long as + // the feed is too short to make the page scrollable. !hasTopBanners && !topBanner && (isBannerAvailable - ? 'laptop:min-h-[calc(100vh-3.5rem)]' - : 'laptop:min-h-[calc(100vh-1.5rem)]'), + ? 'laptop:min-h-[calc(100vh-3.5rem)] laptop:has-[.feed-dock]:min-h-[calc(100vh-2.75rem)]' + : 'laptop:min-h-[calc(100vh-1.5rem)] laptop:has-[.feed-dock]:min-h-[calc(100vh-0.75rem)]'), )} > 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({