diff --git a/packages/shared/src/components/Feed.spec.tsx b/packages/shared/src/components/Feed.spec.tsx index 515c80bc929..b0cbd860e76 100644 --- a/packages/shared/src/components/Feed.spec.tsx +++ b/packages/shared/src/components/Feed.spec.tsx @@ -556,6 +556,64 @@ describe('Feed logged in', () => { ).toEqual(['postItem', 'postItem', 'highlightItem', 'postItem']); }); + it('should drop feedV2 highlights when the surface shows them itself', async () => { + renderComponent( + [ + { + request: { + query: FEED_V2_QUERY, + variables, + }, + result: { + data: { + page: { + pageInfo: defaultFeedPage.pageInfo, + edges: [ + { + node: { + __typename: 'FeedPostItem', + post: defaultFeedPage.edges[0].node, + feedMeta: defaultFeedPage.edges[0].node.feedMeta ?? null, + }, + }, + { + 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, + }, + }, + ], + }, + }, + ], + }, + }, + }, + }, + ], + defaultUser, + SharedFeedPage.MyFeed, + FEED_V2_QUERY, + { disableHighlightCards: true }, + ); + + await waitForNock(); + + expect(await screen.findByTestId('postItem')).toBeInTheDocument(); + expect(screen.queryByTestId('highlightItem')).not.toBeInTheDocument(); + expect(screen.queryByText('Happening Now')).not.toBeInTheDocument(); + }); + it('should send upvote mutation', async () => { let mutationCalled = false; renderComponent([ @@ -1889,6 +1947,7 @@ interface HighlightLayoutRenderParams { briefBannerPage?: number; staticAd?: { ad: Ad; index: number }; disableAds?: boolean; + skipFirstAd?: boolean; user?: LoggedUser; isHorizontal?: boolean; feedName?: AllFeedPages; @@ -1906,6 +1965,7 @@ const renderWithHighlightLayout = ({ briefBannerPage, staticAd, disableAds, + skipFirstAd, user = defaultUser, isHorizontal, feedName = SharedFeedPage.MyFeed, @@ -2012,6 +2072,7 @@ const renderWithHighlightLayout = ({ variables={variables} staticAd={staticAd} disableAds={disableAds} + skipFirstAd={skipFirstAd} isHorizontal={isHorizontal} /> @@ -2096,6 +2157,38 @@ describe('Feed ad cadence with highlight cards', () => { expect(order.slice(2).every((t) => t === 'postItem')).toBe(true); }); + // The survivor keeps index 12: the dropped ad no longer occupies a cell + // against the cadence, so the next slot comes due one post later. + it('drops the first ad slot when the surface shows one above the feed', async () => { + const posts = Array.from({ length: 20 }, (_, i) => buildPost(`p${i}`)); + + renderWithHighlightLayout({ + posts, + highlightEnabled: false, + skipFirstAd: true, + }); + + const order = await getFeedItemTestIds(); + const adIndices = order + .map((type, index) => (type === 'adItem' ? index : -1)) + .filter((index) => index >= 0); + + expect(adIndices).toEqual([12]); + }); + + it('keeps both ad slots when nothing is shown above the feed', async () => { + const posts = Array.from({ length: 20 }, (_, i) => buildPost(`p${i}`)); + + renderWithHighlightLayout({ posts, highlightEnabled: false }); + + const order = await getFeedItemTestIds(2); + const adIndices = order + .map((type, index) => (type === 'adItem' ? index : -1)) + .filter((index) => index >= 0); + + expect(adIndices).toEqual([4, 12]); + }); + // Same fixture, flag off: layout disabled → wide card collapses to 1 cell // (every item contributes 1 to visualCellsSoFar). Ad falls at the original // 4th position. diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index c4fe7262a42..c865b954934 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -102,6 +102,14 @@ export interface FeedProps showSearch?: boolean; actionButtons?: ReactNode; disableAds?: boolean; + /** The surface shows the highlights itself, so keep them out of the grid. */ + disableHighlightCards?: boolean; + /** The surface owns the top slot, so the feed must not render or measure its own hero. */ + disableTopHero?: boolean; + /** The surface shows an ad above the feed, so drop the grid's first one. */ + skipFirstAd?: boolean; + /** The surface leads with a featured card, so keep wide ones out of row one. */ + deferWideCards?: boolean; staticAd?: { ad: Ad; index: number }; disableAdRefresh?: boolean; allowFetchMore?: boolean; @@ -211,6 +219,10 @@ export default function Feed({ shortcuts, actionButtons, disableAds, + disableHighlightCards, + disableTopHero, + skipFirstAd, + deferWideCards, staticAd, disableAdRefresh = false, allowFetchMore, @@ -371,11 +383,14 @@ export default function Feed({ isBriefBannerEligible: !user?.isPlus && isMyFeed, engagementStripEligible: !isHorizontal && isEngagementAdFeed(feedName), firstSlotOffset: Number(eligibleFirstSlotCard !== null), - disableTopHero: isV2, + disableTopHero: isV2 || disableTopHero, isHorizontal, excludePinnedPosts, settings: { disableAds, + disableHighlightCards, + skipFirstAd, + deferWideCards, staticAd, adPostLength: isSquadFeed ? 2 : undefined, feedName, diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 95d1321a2e8..c1f1b4bf243 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -14,22 +14,18 @@ import { LogEvent, Origin, TargetType } from '../lib/log'; import type { SearchLogExtra } from '../lib/searchLog'; import type { UseVotePost } from '../hooks'; import { useFeedLayout } from '../hooks'; -import { CollectionList } from './cards/collection/CollectionList'; import { FeedItemType } from './cards/common/common'; import { AdGrid } from './cards/ad/AdGrid'; import { AdList } from './cards/ad/AdList'; import { SignalAdList } from './cards/ad/SignalAdList'; import type { AdCardProps } from './cards/ad/common/common'; -import { FreeformGrid } from './cards/Freeform/FreeformGrid'; -import { FreeformList } from './cards/Freeform/FreeformList'; import type { PostClick } from '../lib/click'; import { ArticleList } from './cards/article/ArticleList'; import { ArticleGrid } from './cards/article/ArticleGrid'; +import { PostTypeToGridCard } from './cards/common/gridCards'; import type { FeaturedWideColSpan } from './cards/common/featuredWide'; import { PostTypeToWideCard } from './cards/common/wideCards'; -import { ShareGrid } from './cards/share/ShareGrid'; -import { ShareList } from './cards/share/ShareList'; -import { CollectionGrid } from './cards/collection'; +import { PostTypeToListCard } from './cards/common/listCards'; import type { UseBookmarkPost } from '../hooks/useBookmarkPost'; import { AdActions } from '../lib/ads'; import { useFeedCardContext } from '../features/posts/FeedCardContext'; @@ -38,7 +34,6 @@ import { AdMeasurement } from './cards/ad/common/AdMeasurement'; import { AdViewability } from './cards/ad/common/AdViewability'; import type { ViewabilityData } from '../features/monetization/viewability'; import { viewabilityLogExtra } from '../features/monetization/viewability'; -import { BriefCard } from './cards/brief/BriefCard/BriefCard'; import { ActivePostContextProvider } from '../contexts/ActivePostContext'; import { LogExtraContextProvider } from '../contexts/LogExtraContext'; import { SquadAdList } from './cards/ad/squad/SquadAdList'; @@ -50,10 +45,6 @@ import { } from '../lib/engagementAds'; import { useEngagementAdsContext } from '../contexts/EngagementAdsContext'; import { useLogContext } from '../contexts/LogContext'; -import PollGrid from './cards/poll/PollGrid'; -import { PollList } from './cards/poll/PollList'; -import { SocialTwitterGrid } from './cards/socialTwitter/SocialTwitterGrid'; -import { SocialTwitterList } from './cards/socialTwitter/SocialTwitterList'; import { SignalList } from './cards/common/list/SignalList'; import { OtherFeedPage } from '../lib/query'; import { isSourceSquadOrMachine } from '../graphql/sources'; @@ -121,34 +112,6 @@ export function getFeedItemKey(item: FeedItem, index: number): string { } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const PostTypeToTagCard: Record> = { - [PostType.Article]: ArticleGrid, - [PostType.Share]: ShareGrid, - [PostType.Welcome]: FreeformGrid, - [PostType.Freeform]: FreeformGrid, - [PostType.VideoYouTube]: ArticleGrid, - [PostType.Collection]: CollectionGrid, - [PostType.Brief]: BriefCard, - [PostType.Poll]: PollGrid, - [PostType.SocialTwitter]: SocialTwitterGrid, - [PostType.Digest]: ArticleGrid, -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const PostTypeToTagList: Record> = { - [PostType.Article]: ArticleList, - [PostType.Share]: ShareList, - [PostType.Welcome]: FreeformList, - [PostType.Freeform]: FreeformList, - [PostType.VideoYouTube]: ArticleList, - [PostType.Collection]: CollectionList, - [PostType.Brief]: BriefCard, - [PostType.Poll]: PollList, - [PostType.SocialTwitter]: SocialTwitterList, - [PostType.Digest]: ArticleList, -}; - const getPostTypeForCard = (post?: Post): PostType => { if (!post) { return PostType.Article; @@ -176,7 +139,7 @@ const getTags = ({ }: GetTagsProps) => { const useListCards = isListFeedLayout || shouldUseListMode; const isSignalFeed = feedName === OtherFeedPage.AgentsVibes; - const listPostTag = isSignalFeed ? SignalList : PostTypeToTagList[postType]; + const listPostTag = isSignalFeed ? SignalList : PostTypeToListCard[postType]; const listPlaceholderTag = isSignalFeed ? SignalPlaceholderList : PlaceholderList; @@ -185,7 +148,7 @@ const getTags = ({ return { PostTag: useListCards ? listPostTag ?? ArticleList - : PostTypeToTagCard[postType] ?? ArticleGrid, + : PostTypeToGridCard[postType] ?? ArticleGrid, AdTag: useListCards ? listAdTag : AdGrid, SquadAdTag: useListCards ? SquadAdList : SquadAdGrid, PlaceholderTag: useListCards ? listPlaceholderTag : PlaceholderGrid, diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index dc555d19bf4..8515b43fd8a 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -64,13 +64,14 @@ import { useViewSize, ViewSize, } from '../hooks'; -import { feedNameToHeading } from './feeds/FeedContainer'; +import { feedNameToHeading, v2FeedSideInsetClass } from './feeds/FeedContainer'; import { pageHeaderClassName } from './layout/PageHeader'; import { customFeedVersion, discussedFeedVersion, feature, featureFeedChips, + featureFeedHero, FeedChipsVariant, followingFeedVersion, latestFeedVersion, @@ -78,6 +79,7 @@ import { upvotedFeedVersion, } from '../lib/featureManagement'; import type { FeedContainerProps } from './feeds'; +import { FeedHero } from './feeds/hero/FeedHero'; import { getFeedName } from '../lib/feed'; import CommentFeed from './CommentFeed'; import { COMMENT_FEED_QUERY } from '../graphql/comments'; @@ -385,6 +387,18 @@ export default function MainFeedLayout({ [showExploreChips, exploreCategories, feeds, isV2], ); + const isMainFeedPage = + feedName === SharedFeedPage.MyFeed || feedName === SharedFeedPage.Popular; + const { value: isFeedHeroEnabled } = useConditionalFeature({ + feature: featureFeedHero, + shouldEvaluate: isMainFeedPage, + }); + // The hero reports back rather than being asked: it only has a placement once + // its column exists and an ad has come back for it, and it renders nothing at + // all until its headlines resolve. + const [isHeroAdVisible, setIsHeroAdVisible] = useState(false); + const [isHeroRendered, setIsHeroRendered] = useState(false); + const { isSearchPageLaptop } = useSearchResultsLayout(); const config = useMemo(() => { @@ -791,6 +805,43 @@ export default function MainFeedLayout({ } return ''; }, [customFeedsData, feedName, router.query.slugOrId]); + const chipsTopContent = + (isExploreTag || shouldUseListFeedLayout) && chipsNode ? ( +
+ {chipsNode} +
+ ) : undefined; + // The hero is a sibling of the v2 grid, so it repeats the grid's inset and + // card border rules. No bottom margin from `tablet` up, where the grid + // already opens with that inset; mobile keeps one as the only separator. + const isV2Grid = isV2 && !shouldUseListFeedLayout; + const heroClassName = classNames( + 'w-full tablet:pt-6', + isV2Grid + ? classNames( + v2FeedSideInsetClass, + 'mb-8 tablet:mb-0', + '[&_article:hover]:!border-border-subtlest-tertiary [&_article]:!border-border-subtlest-quaternary', + ) + : 'mb-8', + ); + // Left undefined when the hero is off so `Feed` keeps its own top slot. + const topContent = isFeedHeroEnabled ? ( + <> + + {chipsTopContent} + + ) : ( + chipsTopContent + ); + const v2ActionButtons = feedProps?.actionButtons; const showFeedV2PageHeader = isV2 && @@ -861,18 +912,16 @@ export default function MainFeedLayout({ - {chipsNode} - - ) : undefined - } + topContent={topContent} + // The flag, not the hero's render: this placement logs an + // impression, so it has to be suppressed from the first paint + // rather than flickering in and out as the hero resolves. + disableTopHero={isFeedHeroEnabled} + // The render, so a hero that finds no headlines hands the + // highlights card and row one back to the grid. + disableHighlightCards={isHeroRendered} + skipFirstAd={isHeroAdVisible} + deferWideCards={isHeroRendered} className={classNames(!isFinder && feedGutter)} /> ) diff --git a/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx index de22714156d..a39786ea24f 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx @@ -11,11 +11,21 @@ import { useCardCover } from '../../../hooks/feed/useCardCover'; import { stripHtmlTags } from '../../../lib/strings'; import { HighlightChip } from '../common/HighlightChip'; import type { FeaturedWideCardProps } from '../common/featuredWide'; -import { INNER_GRID_COLS } from '../common/featuredWide'; +import { + DESCRIPTION_CLASS_NAME, + featuredWideGridClass, + featuredWideTextColClass, + HERO_DESCRIPTION_CLASS_NAME, + HERO_DESCRIPTION_MAX_LINES, + HERO_TEXT_FIT_CLASS_NAME, + HERO_TITLE_CLASS_NAME, + TITLE_CLASS_NAME, +} from '../common/featuredWide'; import { FeaturedWideCardShell } from '../common/FeaturedWideCardShell'; import { FeaturedWideImageColumn } from '../common/FeaturedWideImageColumn'; import { FeaturedWideActions } from '../common/FeaturedWideActions'; import { FeaturedWideTextContainer } from '../common/FeaturedWideTextContainer'; +import { useFittedLineClamp } from '../../../hooks/useFittedLineClamp'; export const FreeformFeaturedWideGridCard = forwardRef( function FreeformFeaturedWideGridCard( @@ -34,6 +44,7 @@ export const FreeformFeaturedWideGridCard = forwardRef( eagerLoadImage = false, enableSourceHeader = false, wideColSpan = 2, + hero, }: FeaturedWideCardProps, ref: Ref, ): ReactElement { @@ -42,6 +53,8 @@ export const FreeformFeaturedWideGridCard = forwardRef( const image = usePostImage(post); const significance = post.hero?.significance; const { overlay } = useCardCover({ post, onShare }); + const hasMedia = !!image || !!overlay; + const textFit = useFittedLineClamp(HERO_DESCRIPTION_MAX_LINES); const description = useMemo( () => stripHtmlTags(post.contentHtml ?? '').trim(), [post.contentHtml], @@ -61,16 +74,29 @@ export const FreeformFeaturedWideGridCard = forwardRef(
-
- +
+ -

+

{title}

@@ -87,7 +113,14 @@ export const FreeformFeaturedWideGridCard = forwardRef( className="mt-1" /> {!!description && ( -

+

{description}

)} @@ -101,11 +134,12 @@ export const FreeformFeaturedWideGridCard = forwardRef( onDownvoteClick={onDownvoteClick} />
- {(!!image || !!overlay) && ( + {hasMedia && ( diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx index 25b111db9b6..4bfb156e3e3 100644 --- a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx @@ -7,6 +7,7 @@ import type { NextRouter } from 'next/router'; import { useRouter } from 'next/router'; import post from '../../../../__tests__/fixture/post'; import type { PostCardProps } from '../common/common'; +import type { FeaturedWideColSpan } from '../common/featuredWide'; import type { Post } from '../../../graphql/posts'; import type { PostHero, PostHeroSignificance } from '../../../graphql/types'; import { TestBootProvider } from '../../../../__tests__/helpers/boot'; @@ -59,7 +60,9 @@ const makeHero = (significance: PostHeroSignificance | null): PostHero | null => : null; const renderComponent = ( - props: Partial = {}, + props: Partial< + PostCardProps & { wideColSpan?: FeaturedWideColSpan; hero?: boolean } + > = {}, ): RenderResult => { // HighlightChip short-circuits when the experiment flag is off; the // chip-label tests need it on, so override the GrowthBook value here. @@ -107,3 +110,26 @@ it('renders no chip when post has no highlight', () => { expect(screen.queryByText('Major')).not.toBeInTheDocument(); expect(screen.queryByText('Notable')).not.toBeInTheDocument(); }); + +describe('hero sizing', () => { + const summarised: Post = { ...post, summary: 'What the post is about.' }; + const summaryOf = (): HTMLElement => + screen.getByText(summarised.summary as string); + + it('lets the summary give way so the action row keeps its place', () => { + renderComponent({ post: summarised, hero: true }); + + const summary = summaryOf(); + // `base.css` resets every element to `flex-shrink: 0`. + expect(summary).toHaveClass('shrink'); + expect(summary.parentElement).toHaveClass('shrink', 'min-h-0'); + }); + + it('leaves the in-feed card unable to shrink its summary', () => { + renderComponent({ post: summarised, wideColSpan: 2 }); + + const summary = summaryOf(); + expect(summary).not.toHaveClass('shrink'); + expect(summary.parentElement).not.toHaveClass('shrink'); + }); +}); diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx index 5e2b309e09c..a9c0363bf7a 100644 --- a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx @@ -15,11 +15,21 @@ import { useCardCover } from '../../../hooks/feed/useCardCover'; import { stripHtmlTags } from '../../../lib/strings'; import { HighlightChip } from '../common/HighlightChip'; import type { FeaturedWideCardProps } from '../common/featuredWide'; -import { INNER_GRID_COLS } from '../common/featuredWide'; +import { + DESCRIPTION_CLASS_NAME, + featuredWideGridClass, + featuredWideTextColClass, + HERO_DESCRIPTION_CLASS_NAME, + HERO_DESCRIPTION_MAX_LINES, + HERO_TEXT_FIT_CLASS_NAME, + HERO_TITLE_CLASS_NAME, + TITLE_CLASS_NAME, +} from '../common/featuredWide'; import { FeaturedWideCardShell } from '../common/FeaturedWideCardShell'; import { FeaturedWideImageColumn } from '../common/FeaturedWideImageColumn'; import { FeaturedWideActions } from '../common/FeaturedWideActions'; import { FeaturedWideTextContainer } from '../common/FeaturedWideTextContainer'; +import { useFittedLineClamp } from '../../../hooks/useFittedLineClamp'; export const ArticleFeaturedWideGridCard = forwardRef( function ArticleFeaturedWideGridCard( @@ -39,6 +49,7 @@ export const ArticleFeaturedWideGridCard = forwardRef( domProps = {}, eagerLoadImage = false, wideColSpan = 2, + hero, }: FeaturedWideCardProps, ref: Ref, ): ReactElement { @@ -48,6 +59,8 @@ export const ArticleFeaturedWideGridCard = forwardRef( const isVideoType = isVideoPost(post); const image = usePostImage(post); const { overlay } = useCardCover({ post, onShare }); + const hasMedia = !!image || !!overlay; + const textFit = useFittedLineClamp(HERO_DESCRIPTION_MAX_LINES); const significance = post.hero?.significance; const isTweetPost = post.type === PostType.SocialTwitter || @@ -97,7 +110,10 @@ export const ArticleFeaturedWideGridCard = forwardRef( const standardContent = ( <> - + -

+

{title}

@@ -122,7 +143,14 @@ export const ArticleFeaturedWideGridCard = forwardRef( className="mt-1" /> {!!description && ( -

+

{description}

)} @@ -152,17 +180,23 @@ export const ArticleFeaturedWideGridCard = forwardRef(
-
+
{showFeedback ? feedbackContent : standardContent}
- {(!!image || !!overlay) && ( + {hasMedia && ( , ): ReactElement { @@ -43,6 +54,8 @@ export const CollectionFeaturedWideGridCard = forwardRef( const significance = post.hero?.significance; const wasUpdated = isPostUpdated(post); const { overlay } = useCardCover({ post, onShare }); + const hasMedia = !!image || !!overlay; + const textFit = useFittedLineClamp(HERO_DESCRIPTION_MAX_LINES); return ( -
- +
+ -

+

{title}

@@ -85,7 +111,14 @@ export const CollectionFeaturedWideGridCard = forwardRef( className="mt-1" /> {!!post.summary && ( -

+

{post.summary}

)} @@ -99,11 +132,12 @@ export const CollectionFeaturedWideGridCard = forwardRef( onDownvoteClick={onDownvoteClick} />
- {(!!image || !!overlay) && ( + {hasMedia && ( diff --git a/packages/shared/src/components/cards/common/Card.tsx b/packages/shared/src/components/cards/common/Card.tsx index c3ccaf13ada..f8cab03cc4b 100644 --- a/packages/shared/src/components/cards/common/Card.tsx +++ b/packages/shared/src/components/cards/common/Card.tsx @@ -54,6 +54,16 @@ const cardClassess = export const Card = classed('article', styles.card, cardClassess); +/** + * A card without its chrome. Keeps the module class, which routes pointer + * events past the card body to the links inside it. + */ +export const FlatCard = classed( + 'article', + styles.card, + 'relative flex flex-col', +); + export const ClickableCard = classed('article', cardClassess); export const ChecklistCardComponent = classed( diff --git a/packages/shared/src/components/cards/common/FeaturedWideImageColumn.tsx b/packages/shared/src/components/cards/common/FeaturedWideImageColumn.tsx index 4dbe756565a..0f1e2ab83e4 100644 --- a/packages/shared/src/components/cards/common/FeaturedWideImageColumn.tsx +++ b/packages/shared/src/components/cards/common/FeaturedWideImageColumn.tsx @@ -5,12 +5,17 @@ import { HIGH_PRIORITY_IMAGE_PROPS, Image, ImageType } from '../../image/Image'; import { PlayIcon } from '../../icons'; import { IconSize } from '../../Icon'; import type { FeaturedWideColSpan } from './featuredWide'; -import { IMAGE_COL_SPAN } from './featuredWide'; +import { featuredWideImageColClass } from './featuredWide'; export type FeaturedWideImageColumnProps = { image?: string; alt: string; wideColSpan: FeaturedWideColSpan; + /** + * Container-query spans, and the cover cropped to fill its column. Off keeps + * the letterboxed image over a blurred backdrop. + */ + hero?: boolean; overlay?: ReactNode; isVideoType?: boolean; eagerLoadImage?: boolean; @@ -23,14 +28,16 @@ export const FeaturedWideImageColumn = ({ overlay, isVideoType, eagerLoadImage, + hero, }: FeaturedWideImageColumnProps): ReactElement => (
- {!!image && ( + {!!image && !hero && (
{children}
; +export const FeaturedWideTextContainer = forwardRef( + function FeaturedWideTextContainer( + { + className, + children, + }: { + className?: string; + children: ReactNode; + }, + ref: Ref, + ): ReactElement { + return ( +
+ {children} +
+ ); + }, +); diff --git a/packages/shared/src/components/cards/common/featuredWide.ts b/packages/shared/src/components/cards/common/featuredWide.ts index 17fb8506fc0..6c4bf46ad18 100644 --- a/packages/shared/src/components/cards/common/featuredWide.ts +++ b/packages/shared/src/components/cards/common/featuredWide.ts @@ -4,8 +4,41 @@ export type FeaturedWideColSpan = 2 | 3 | 4; export type FeaturedWideCardProps = PostCardProps & { wideColSpan?: FeaturedWideColSpan; + /** The standalone hero treatment, sized from the card's own width. */ + hero?: boolean; }; +export const TITLE_CLASS_NAME = 'line-clamp-4 typo-title1'; +export const HERO_TITLE_CLASS_NAME = 'line-clamp-5 typo-title2'; +export const DESCRIPTION_CLASS_NAME = 'line-clamp-3'; + +/** + * `base.css` resets every element to `flex-shrink: 0`, so the text block has to + * opt back in; the summary is then the only shrinkable child, and gives way + * before the headline does. `grow` puts the block's floor at the action row, + * which is what `useFittedLineClamp` measures down to. + */ +export const HERO_TEXT_FIT_CLASS_NAME = 'min-h-0 shrink grow overflow-hidden'; + +/** A ceiling for the first paint; `useFittedLineClamp` replaces it once measured. */ +export const HERO_DESCRIPTION_CLASS_NAME = 'line-clamp-6 min-h-0 shrink'; +export const HERO_DESCRIPTION_MAX_LINES = 6; + +/** + * Container queries, not viewport ones: the hero is only ever as wide as the + * reader's feed grid, so a wide monitor set to three cards gives this card half + * the room a five-card feed does. Needs `@container/wide` on an ancestor, which + * the carousel slide provides. + */ +const HERO_INNER_GRID_CLASS_NAME = + 'grid-cols-1 grid-rows-[minmax(0,1fr)_10rem] @[40rem]/wide:grid-cols-2 @[40rem]/wide:grid-rows-[minmax(0,1fr)] @[52rem]/wide:grid-cols-5'; + +const HERO_TEXT_COL_CLASS_NAME = + 'col-span-1 row-start-1 @[52rem]/wide:col-span-2'; + +const HERO_IMAGE_COL_CLASS_NAME = + 'col-span-1 row-start-2 @[40rem]/wide:row-start-1 @[52rem]/wide:col-span-3'; + export const INNER_GRID_COLS: Record = { 2: 'grid-cols-2', 3: 'grid-cols-3', @@ -17,3 +50,34 @@ export const IMAGE_COL_SPAN: Record = { 3: 'col-span-2', 4: 'col-span-3', }; + +type FeaturedWideLayout = { + hasMedia: boolean; + wideColSpan: FeaturedWideColSpan; + hero?: boolean; +}; + +export const featuredWideGridClass = ({ + hasMedia, + wideColSpan, + hero, +}: FeaturedWideLayout): string => { + if (!hasMedia) { + return 'grid-cols-1'; + } + + return hero ? HERO_INNER_GRID_CLASS_NAME : INNER_GRID_COLS[wideColSpan]; +}; + +/** Only the hero states a span; every other layout's text column defaults to 1. */ +export const featuredWideTextColClass = ({ + hasMedia, + hero, +}: Pick): string | undefined => + hasMedia && hero ? HERO_TEXT_COL_CLASS_NAME : undefined; + +export const featuredWideImageColClass = ({ + wideColSpan, + hero, +}: Pick): string => + hero ? HERO_IMAGE_COL_CLASS_NAME : IMAGE_COL_SPAN[wideColSpan]; diff --git a/packages/shared/src/components/cards/common/gridCards.ts b/packages/shared/src/components/cards/common/gridCards.ts new file mode 100644 index 00000000000..4edffccc43a --- /dev/null +++ b/packages/shared/src/components/cards/common/gridCards.ts @@ -0,0 +1,23 @@ +import type React from 'react'; +import { PostType } from '../../../graphql/posts'; +import { ArticleGrid } from '../article/ArticleGrid'; +import { ShareGrid } from '../share/ShareGrid'; +import { FreeformGrid } from '../Freeform/FreeformGrid'; +import { CollectionGrid } from '../collection/CollectionGrid'; +import PollGrid from '../poll/PollGrid'; +import { SocialTwitterGrid } from '../socialTwitter/SocialTwitterGrid'; +import { BriefCard } from '../brief/BriefCard/BriefCard'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const PostTypeToGridCard: Record> = { + [PostType.Article]: ArticleGrid, + [PostType.Share]: ShareGrid, + [PostType.Welcome]: FreeformGrid, + [PostType.Freeform]: FreeformGrid, + [PostType.VideoYouTube]: ArticleGrid, + [PostType.Collection]: CollectionGrid, + [PostType.Brief]: BriefCard, + [PostType.Poll]: PollGrid, + [PostType.SocialTwitter]: SocialTwitterGrid, + [PostType.Digest]: ArticleGrid, +}; diff --git a/packages/shared/src/components/cards/common/listCards.ts b/packages/shared/src/components/cards/common/listCards.ts new file mode 100644 index 00000000000..cd686fdc88a --- /dev/null +++ b/packages/shared/src/components/cards/common/listCards.ts @@ -0,0 +1,23 @@ +import type React from 'react'; +import { PostType } from '../../../graphql/posts'; +import { ArticleList } from '../article/ArticleList'; +import { ShareList } from '../share/ShareList'; +import { FreeformList } from '../Freeform/FreeformList'; +import { CollectionList } from '../collection/CollectionList'; +import { PollList } from '../poll/PollList'; +import { SocialTwitterList } from '../socialTwitter/SocialTwitterList'; +import { BriefCard } from '../brief/BriefCard/BriefCard'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const PostTypeToListCard: Record> = { + [PostType.Article]: ArticleList, + [PostType.Share]: ShareList, + [PostType.Welcome]: FreeformList, + [PostType.Freeform]: FreeformList, + [PostType.VideoYouTube]: ArticleList, + [PostType.Collection]: CollectionList, + [PostType.Brief]: BriefCard, + [PostType.Poll]: PollList, + [PostType.SocialTwitter]: SocialTwitterList, + [PostType.Digest]: ArticleList, +}; diff --git a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx index 605cadf9ddf..e62a0dc6460 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx @@ -62,6 +62,20 @@ describe('Highlight cards', () => { expect(screen.getByText('Read all')).toBeInTheDocument(); }); + // The hero passes `compact`; the in-feed cards do not, and must keep the + // row they had before it existed. + it('keeps the in-feed row bordered, with the timestamp on its own line', () => { + render(); + + const row = screen.getByRole('link', { name: /the first highlight/i }); + + expect(row).toHaveClass('border-b'); + expect(row.className).not.toMatch(/after:/); + expect( + screen.getByText('The first highlight').querySelector('time'), + ).toBeNull(); + }); + it('should trigger the highlight callbacks without blocking navigation', async () => { const onHighlightClick = jest.fn(); const onReadAllClick = jest.fn(); diff --git a/packages/shared/src/components/cards/highlight/common.tsx b/packages/shared/src/components/cards/highlight/common.tsx index e7c0806c6f2..8341d6bb2e1 100644 --- a/packages/shared/src/components/cards/highlight/common.tsx +++ b/packages/shared/src/components/cards/highlight/common.tsx @@ -27,10 +27,12 @@ const getHighlightUrl = (highlight: PostHighlight): string => export const ReadAllHighlightsFooter = ({ highlightId, onClick, + compact, className, }: { highlightId?: string; onClick?: () => void; + compact?: boolean; className?: string; }): ReactElement => { const href = getHighlightsUrl(highlightId); @@ -39,7 +41,10 @@ export const ReadAllHighlightsFooter = ({ onClick?.()} > @@ -65,26 +70,49 @@ const HighlightRow = ({ highlight, index, onHighlightClick, + compact, }: { highlight: PostHighlight; index: number; onHighlightClick?: (highlight: PostHighlight, position: number) => void; + compact?: boolean; }): ReactElement => { + const timestamp = ( + + ); + return ( onHighlightClick?.(highlight, index + 1)} > {highlight.headline} + {/* Compact trails the headline in its own text flow, so it wraps + with the last word rather than taking a line of its own. */} + {!!compact && ( + + · + {timestamp} + + )} - + {!compact && timestamp} ); @@ -95,16 +123,33 @@ export const HighlightCardContent = ({ onHighlightClick, onReadAllClick, variant, -}: HighlightCardProps & { variant: 'grid' | 'list' }): ReactElement => { - const headerClassName = - variant === 'list' - ? 'flex items-center pb-4' - : 'flex items-center px-4 py-4'; - const contentClassName = - variant === 'list' - ? 'flex flex-col gap-2' - : 'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-y-auto px-2.5 pb-1 pt-0'; - const footerClassName = variant === 'list' ? 'pt-1.5' : 'px-1 pb-1'; + compact, +}: HighlightCardProps & { + variant: 'grid' | 'list'; + /** Flush against its container, for a surface without card chrome. */ + compact?: boolean; +}): ReactElement => { + const isFlushGrid = variant === 'grid' && compact; + const headerClassName = classNames( + 'flex items-center', + variant === 'list' && 'pb-4', + variant === 'grid' && (isFlushGrid ? 'px-4 pb-2' : 'px-4 py-4'), + ); + const contentClassName = classNames( + variant === 'list' && 'flex flex-col gap-2', + variant === 'grid' && + 'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-y-auto pt-0', + variant === 'grid' && !isFlushGrid && 'px-2.5 pb-1', + // Only where the column has a fixed height and actually scrolls: the fade + // stops the pinned footer slicing a row flat. + isFlushGrid && + 'laptop:[mask-image:linear-gradient(to_bottom,black_calc(100%-1.25rem),transparent)]', + ); + const footerClassName = classNames( + variant === 'list' && 'pt-1.5', + // Matches the ad card's padding, so the columns finish on one line. + variant === 'grid' && (isFlushGrid ? 'px-4 pb-3 pt-2' : 'px-1 pb-1'), + ); const firstHighlight = highlights[0]; return ( @@ -127,12 +172,14 @@ export const HighlightCardContent = ({ highlight={highlight} index={index} onHighlightClick={onHighlightClick} + compact={compact} /> ))}
diff --git a/packages/shared/src/components/cards/share/ShareFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/share/ShareFeaturedWideGridCard.tsx index 333f0733141..834b467c384 100644 --- a/packages/shared/src/components/cards/share/ShareFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/share/ShareFeaturedWideGridCard.tsx @@ -16,11 +16,21 @@ import { DeletedPostId } from '../../../lib/constants'; import { stripHtmlTags } from '../../../lib/strings'; import { HighlightChip } from '../common/HighlightChip'; import type { FeaturedWideCardProps } from '../common/featuredWide'; -import { INNER_GRID_COLS } from '../common/featuredWide'; +import { + DESCRIPTION_CLASS_NAME, + featuredWideGridClass, + featuredWideTextColClass, + HERO_DESCRIPTION_CLASS_NAME, + HERO_DESCRIPTION_MAX_LINES, + HERO_TEXT_FIT_CLASS_NAME, + HERO_TITLE_CLASS_NAME, + TITLE_CLASS_NAME, +} from '../common/featuredWide'; import { FeaturedWideCardShell } from '../common/FeaturedWideCardShell'; import { FeaturedWideImageColumn } from '../common/FeaturedWideImageColumn'; import { FeaturedWideActions } from '../common/FeaturedWideActions'; import { FeaturedWideTextContainer } from '../common/FeaturedWideTextContainer'; +import { useFittedLineClamp } from '../../../hooks/useFittedLineClamp'; export const ShareFeaturedWideGridCard = forwardRef( function ShareFeaturedWideGridCard( @@ -40,6 +50,7 @@ export const ShareFeaturedWideGridCard = forwardRef( domProps = {}, eagerLoadImage = false, wideColSpan = 2, + hero, }: FeaturedWideCardProps, ref: Ref, ): ReactElement { @@ -58,6 +69,8 @@ export const ShareFeaturedWideGridCard = forwardRef( ? stripHtmlTags(sharedPost?.contentHtml ?? post.contentHtml ?? '').trim() : ''; const { overlay } = useCardCover({ post, onShare }); + const hasMedia = !!image || !!overlay; + const textFit = useFittedLineClamp(HERO_DESCRIPTION_MAX_LINES); return ( -
- +
+ {(!isSharedTweet || post.title) && ( -

+

{title}

)} @@ -122,7 +148,16 @@ export const ShareFeaturedWideGridCard = forwardRef( ) : ( <> {!!sharedSummary && ( -

+

{sharedSummary}

)} @@ -143,11 +178,12 @@ export const ShareFeaturedWideGridCard = forwardRef( onDownvoteClick={onDownvoteClick} />
- {(!!image || !!overlay) && ( + {hasMedia && ( void; + /** + * Whether the hero found anything to show. It returns `null` without posts, + * and the grid has to take back the highlights card and its first-row wide + * cards when it does, or the reader gets neither. + */ + onRenderedChange?: (isRendered: boolean) => void; +}): ReactElement | null => { + const { user, tokenRefreshed } = useAuthContext(); + const { logEvent } = useLogContext(); + const postLogEvent = usePostLogEvent(); + const { toggleUpvote, toggleDownvote } = useVotePost(); + const { toggleBookmark } = useBookmarkPost(); + const [, copyLink] = useCopyLink(); + + const { ad, placement, shape } = useFeedHeroAd(); + + const { data: headlines } = useQuery({ + ...majorHeadlinesQueryOptions({ first: HIGHLIGHT_COUNT }), + enabled: tokenRefreshed, + }); + const highlights = useMemo( + () => headlines?.majorHeadlines?.edges?.map(({ node }) => node) ?? [], + [headlines], + ); + + const postIds = useMemo( + () => highlights.slice(0, FEATURED_POST_COUNT).map(({ post }) => post.id), + [highlights], + ); + + const { data: featured } = useQuery({ + queryKey: generateQueryKey(RequestKey.FeedByIds, user, 'hero', ...postIds), + queryFn: () => + gqlClient.request<{ page: Connection }>(FEED_BY_IDS_QUERY, { + first: postIds.length, + postIds, + loggedIn: !!user, + supportedTypes: supportedTypesForPrivateSources, + }), + enabled: tokenRefreshed && postIds.length > 0, + staleTime: StaleTime.Default, + }); + + // `feedByIds` answers in its own order, so re-key by id to keep the carousel + // in the same order as the headlines beside it. + const posts = useMemo(() => { + const byId = new Map( + featured?.page?.edges?.map(({ node }) => [node.id, node]) ?? [], + ); + + return postIds.map((id) => byId.get(id)).filter(Boolean) as Post[]; + }, [featured, postIds]); + + const isRendered = posts.length > 0; + const adPlacement = isRendered ? placement : 'none'; + const isAdShown = adPlacement !== 'none'; + + // Stacked, the lead story is already a card above the list, so drop it from + // the list rather than showing it twice a few pixels apart. + const railHighlights = + shape.layout === 'stacked' ? highlights.slice(1) : highlights; + + const onAdAction = useCallback( + (action: AdActions, extra?: Record) => { + if (!ad) { + return; + } + + logEvent( + adLogEvent(action, ad, { extra: { origin: HERO_ORIGIN, ...extra } }), + ); + }, + [ad, logEvent], + ); + + const logHighlightsClick = useCallback( + (action: string, clickedHighlight?: PostHighlight, position?: number) => + logEvent( + feedHighlightsLogEvent(LogEvent.Click, { + feedName, + action, + position, + count: railHighlights.length, + clickedHighlight, + highlightIds: railHighlights.map(({ id }) => id), + origin: Origin.Feed, + }), + ), + [feedName, logEvent, railHighlights], + ); + + useEffect(() => { + onAdVisibleChange?.(isAdShown); + }, [isAdShown, onAdVisibleChange]); + + useEffect(() => { + onRenderedChange?.(isRendered); + }, [isRendered, onRenderedChange]); + + useEffect(() => { + // Gated on `isAdShown`, not just on the ad existing: logging here while the + // hero renders nothing would mark the cached ad LOGGED and swallow the + // impression for the render that actually puts it on screen. + if (!ad || !isAdShown || ad.impressionStatus === ImpressionStatus.LOGGED) { + return; + } + + onAdAction(AdActions.Impression); + ad.impressionStatus = ImpressionStatus.LOGGED; + }, [ad, isAdShown, onAdAction]); + + const cardProps = useMemo( + () => ({ + onPostClick: (post: Post) => + logEvent( + postLogEvent(LogEvent.Click, post, { + extra: { origin: HERO_ORIGIN }, + }), + ), + onUpvoteClick: (post: Post, origin = Origin.Feed) => + toggleUpvote({ payload: post, origin }), + onDownvoteClick: (post: Post, origin = Origin.Feed) => + toggleDownvote({ payload: post, origin }), + onBookmarkClick: (post: Post, origin = Origin.Feed) => + toggleBookmark({ post, origin }), + onCopyLinkClick: (_: React.MouseEvent, post: Post) => + copyLink({ link: post.commentsPermalink }), + }), + [ + copyLink, + logEvent, + postLogEvent, + toggleBookmark, + toggleDownvote, + toggleUpvote, + ], + ); + + if (!isRendered) { + return null; + } + + return ( + + logEvent( + postLogEvent(LogEvent.Impression, post, { + extra: { origin: HERO_ORIGIN }, + }), + ) + } + onHighlightClick={(highlight, position) => + logHighlightsClick('highlight_click', highlight, position) + } + onReadAllClick={() => logHighlightsClick('read_all_click')} + onAdLinkClick={() => onAdAction(AdActions.Click)} + onAdViewable={(_, data: ViewabilityData) => + onAdAction(AdActions.Viewable, viewabilityLogExtra(data)) + } + /> + ); +}; diff --git a/packages/shared/src/components/feeds/hero/FeedHeroAdCard.tsx b/packages/shared/src/components/feeds/hero/FeedHeroAdCard.tsx new file mode 100644 index 00000000000..9a7c7936a63 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroAdCard.tsx @@ -0,0 +1,109 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Ad } from '../../../graphql/posts'; +import type { ViewabilityData } from '../../../features/monetization/viewability'; +import { FlatCard } from '../../cards/common/Card'; +import AdLink from '../../cards/ad/common/AdLink'; +import AdAttribution from '../../cards/ad/common/AdAttribution'; +import { AdFavicon } from '../../cards/ad/common/AdFavicon'; +import { AdImage } from '../../cards/ad/common/AdImage'; +import { AdPixel } from '../../cards/ad/common/AdPixel'; +import { AdMeasurement } from '../../cards/ad/common/AdMeasurement'; +import { AdViewability } from '../../cards/ad/common/AdViewability'; +import { RemoveAd } from '../../cards/ad/common/RemoveAd'; +import { AdvertiseLink } from '../../cards/ad/common/AdvertiseLink'; +import PostTags from '../../cards/common/PostTags'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; +import { Image } from '../../image/Image'; +import classed from '../../../lib/classed'; +import { useAdLabel } from '../../../features/monetization/useAdLabel'; +import { usePlusSubscription } from '../../../hooks/usePlusSubscription'; +import { TargetId } from '../../../lib/log'; + +// `CardImage`'s own height, so the creative matches the covers on the row below. +const AdCover = classed(Image, 'h-40 w-full rounded-12 object-cover'); + +interface FeedHeroAdCardProps { + ad: Ad; + onLinkClick?: (ad: Ad) => unknown; + onViewable?: (ad: Ad, data: ViewabilityData) => void; + className?: string; +} + +/** + * The rail's ad, following the featured card beside it in order and scale. The + * two controls sit below the card rather than inside it, so the hover highlight + * covers what the card links to and stops short of buttons that go elsewhere. + */ +export const FeedHeroAdCard = ({ + ad, + onLinkClick, + onViewable, + className, +}: FeedHeroAdCardProps): ReactElement => { + const { isPlus } = usePlusSubscription(); + const { showAdvertiseLink } = useAdLabel(); + const matchingTags = ad.matchingTags ?? []; + + return ( +
+ + + + + {ad.description} + + {/* The copy takes the column's slack so the cover keeps its height. */} +
+ {matchingTags.length > 0 && ( + + )} + + {!!ad.image && ( + + )} + {/* Out of flow so the column's spacing doesn't reserve a row for it. */} +
+ +
+ + onViewable?.(ad, data)} /> + + {/* Outside the card, so hovering them does not light the creative up as + though it were the link. The negative margins cancel the padding + `ButtonSize.Small` adds, so the labels line up with the copy above. */} +
+ {showAdvertiseLink && ( + + )} + {!isPlus && ( + + )} +
+
+ ); +}; diff --git a/packages/shared/src/components/feeds/hero/FeedHeroCarousel.spec.tsx b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.spec.tsx new file mode 100644 index 00000000000..a21a54bc73c --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.spec.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import basePost from '../../../../__tests__/fixture/post'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import type { Post } from '../../../graphql/posts'; +import { FeedHeroCarousel } from './FeedHeroCarousel'; +import type { FeedHeroLayout } from './feedHeroShape'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(useRouter) + .mockImplementation(() => ({ pathname: '/' } as unknown as NextRouter)); +}); + +const titles = ['First hero post', 'Second hero post', 'Third hero post']; + +const posts: Post[] = titles.map((title, index) => ({ + ...basePost, + id: `hero-${index}`, + title, +})); + +const renderComponent = ( + carouselPosts: Post[] = posts, + layout: FeedHeroLayout = 'stacked', +): RenderResult => + render( + + + , + ); + +const getTitle = (title: string) => + screen.getByRole('heading', { name: title }); + +describe('FeedHeroCarousel stacked', () => { + it('leads with the first post at full width', () => { + renderComponent(); + + expect(getTitle(titles[0])).toBeInTheDocument(); + }); + + it('leaves the rest of the posts to the headline list', () => { + renderComponent(); + + expect( + screen.queryByRole('heading', { name: titles[1] }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: titles[2] }), + ).not.toBeInTheDocument(); + }); + + it('has nothing to page, so no indicators, arrows or autoplay', () => { + renderComponent(); + + expect( + screen.queryByRole('button', { name: /^Show featured post/ }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /^Next:/ }), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('carouselProgress')).not.toBeInTheDocument(); + }); + + it('renders nothing without posts', () => { + const { container } = renderComponent([]); + + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('FeedHeroCarousel wide', () => { + it('pages through every post once the section has columns', () => { + renderComponent(posts, 'wide'); + + expect( + screen.getByRole('button', { name: `Next: ${titles[1]}` }), + ).toBeInTheDocument(); + expect( + screen.getAllByRole('button', { name: /^Show featured post/ }), + ).toHaveLength(titles.length); + }); +}); + +describe('FeedHeroCarousel split', () => { + it('pages through every post', () => { + renderComponent(posts, 'split'); + + expect( + screen.getAllByRole('button', { name: /^Show featured post/ }), + ).toHaveLength(titles.length); + }); + + it('uses the standard card, not the wide one', () => { + renderComponent(posts, 'split'); + + expect(getTitle(titles[0])).toBeInTheDocument(); + expect( + screen.getByRole('region', { name: 'Featured posts' }), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/feeds/hero/FeedHeroCarousel.tsx b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.tsx new file mode 100644 index 00000000000..02973da6356 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.tsx @@ -0,0 +1,255 @@ +import type { CSSProperties, ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { useSwipeable } from 'react-swipeable'; +import { useInView } from 'react-intersection-observer'; +import type { Post } from '../../../graphql/posts'; +import type { FeaturedWideCardProps } from '../../cards/common/featuredWide'; +import { PostTypeToWideCard } from '../../cards/common/wideCards'; +import { ArticleFeaturedWideGridCard } from '../../cards/article/ArticleFeaturedWideGridCard'; +import { PostTypeToGridCard } from '../../cards/common/gridCards'; +import { PostTypeToListCard } from '../../cards/common/listCards'; +import { ArticleList } from '../../cards/article/ArticleList'; +import { ArticleGrid } from '../../cards/article/ArticleGrid'; +import type { FeedHeroLayout } from './feedHeroShape'; +import { Button } from '../../buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; +import { Tooltip } from '../../tooltip/Tooltip'; +import { ArrowIcon } from '../../icons'; + +export type FeedHeroCarouselProps = Omit & { + posts: Post[]; + autoplayMs?: number; + /** Passed in, not measured, so the card and the layout share one number. */ + layout?: FeedHeroLayout; + /** Called once per post brought on screen, so clicks have a denominator. */ + onPostImpression?: (post: Post) => void; + className?: string; +}; + +const wrapIndex = (index: number, total: number): number => + (index + total) % total; + +export const FeedHeroCarousel = ({ + posts, + autoplayMs = 6000, + layout = 'stacked', + onPostImpression, + className, + ...cardProps +}: FeedHeroCarouselProps): ReactElement | null => { + const [slide, setSlide] = useState<{ index: number; from: number | null }>({ + index: 0, + from: null, + }); + const [isManualChange, setIsManualChange] = useState(false); + // Slides the reader has actually been shown. Only the active one is visible, + // so the rest are deliberately never counted. + const logged = useRef(new Set()); + // The same threshold the grid's cards use, so a hero impression and a card + // impression mean the same thing when the two are compared. + const { ref: inViewRef, inView } = useInView({ threshold: 0.5 }); + const active = posts.length ? wrapIndex(slide.index, posts.length) : 0; + const shown = layout === 'stacked' ? posts[0] : posts[active]; + + useEffect(() => { + if (!inView || !shown || logged.current.has(shown.id)) { + return; + } + + logged.current.add(shown.id); + onPostImpression?.(shown); + }, [inView, shown, onPostImpression]); + + const total = posts.length; + + const moveTo = (position: number) => { + if (!total || wrapIndex(position, total) === active) { + return; + } + setSlide({ index: position, from: active }); + }; + + const goTo = (position: number) => { + setIsManualChange(true); + moveTo(position); + }; + + // A touch laptop gets the grid layouts but no swipe from the dots and arrows + // alone, so the same gesture the rest of the app's carousels accept. + const swipeHandlers = useSwipeable({ + onSwipedLeft: () => goTo(active + 1), + onSwipedRight: () => goTo(active - 1), + preventScrollOnSwipe: false, + trackMouse: false, + }); + + // The region is only live for the slide a manual change brings in. Reset on + // a timer rather than on the next automatic advance, which never arrives + // while the reader is hovering or focused — and would leave every later + // rotation announcing itself. + useEffect(() => { + if (!isManualChange) { + return undefined; + } + + const timeout = setTimeout(() => setIsManualChange(false), 1000); + + return () => clearTimeout(timeout); + }, [isManualChange, active]); + + if (!posts.length) { + return null; + } + + // No paging at one column: a slide has to stop short of the edge for the + // next to peek, which left every card too narrow to read. The stories it + // would have paged through are the headline list's first rows. + if (layout === 'stacked') { + const [lead] = posts; + const LeadCard = PostTypeToListCard[lead.type] ?? ArticleList; + + return ( +
+ +
+ ); + } + + const post = posts[active]; + const outgoing = slide.from === null ? null : posts[slide.from]; + const isWide = layout === 'wide'; + const cardFor = (item: Post) => { + if (isWide) { + return PostTypeToWideCard[item.type] ?? ArticleFeaturedWideGridCard; + } + + return PostTypeToGridCard[item.type] ?? ArticleGrid; + }; + const Card = cardFor(post); + const wideProps = isWide ? { hero: true } : {}; + const previous = posts[wrapIndex(active - 1, total)]; + const next = posts[wrapIndex(active + 1, total)]; + + // The outgoing slide stays mounted until its fade ends, so the two cross + // over instead of the card popping. + let outgoingSlide: ReactElement | null = null; + if (outgoing) { + const OutgoingCard = cardFor(outgoing); + outgoingSlide = ( +
{ + if (event.target !== event.currentTarget) { + return; + } + setSlide((current) => ({ ...current, from: null })); + }} + > + +
+ ); + } + + return ( +
+
+ {outgoingSlide} +
+ +
+
+ {total > 1 && ( +
+
+ {posts.map((item, position) => ( + + ))} +
+
+ +
+
+ )} +
+ ); +}; diff --git a/packages/shared/src/components/feeds/hero/FeedHeroSection.tsx b/packages/shared/src/components/feeds/hero/FeedHeroSection.tsx new file mode 100644 index 00000000000..04bb3dd9822 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroSection.tsx @@ -0,0 +1,118 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Ad, Post } from '../../../graphql/posts'; +import type { PostHighlight } from '../../../graphql/highlights'; +import type { ViewabilityData } from '../../../features/monetization/viewability'; +import type { FeaturedWideCardProps } from '../../cards/common/featuredWide'; +import { HighlightCardContent } from '../../cards/highlight/common'; +import { FeedHeroAdCard } from './FeedHeroAdCard'; +import { FeedHeroCarousel } from './FeedHeroCarousel'; +import type { FeedHeroAdPlacement, FeedHeroShape } from './feedHeroShape'; +import { feedHeroShape } from './feedHeroShape'; + +/** + * Written out, not built: Tailwind only generates the classes it can see. Must + * cover every count up to `MAX_HERO_COLUMNS`, which is what `feedHeroShape` + * stacks above — `feedHeroShape.spec.ts` pins the two together. + */ +const gridColsClass: Partial> = { + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4', + 5: 'grid-cols-5', + 6: 'grid-cols-6', +}; + +const colSpanClass: Partial> = { + 1: 'col-span-1', + 2: 'col-span-2', + 3: 'col-span-3', + 4: 'col-span-4', +}; + +interface FeedHeroSectionProps { + posts: Post[]; + highlights: PostHighlight[]; + ad?: Ad; + adPlacement?: FeedHeroAdPlacement; + /** The section's row, measured against the feed grid's column count. */ + shape?: FeedHeroShape; + cardProps?: Omit; + /** Called once per post the carousel actually brings on screen. */ + onPostImpression?: (post: Post) => void; + onAdLinkClick?: (ad: Ad) => unknown; + onAdViewable?: (ad: Ad, data: ViewabilityData) => void; + onHighlightClick?: (highlight: PostHighlight, position: number) => void; + onReadAllClick?: () => void; + className?: string; +} + +export function FeedHeroSection({ + posts, + highlights, + ad, + adPlacement = 'none', + shape = feedHeroShape(1), + cardProps, + onPostImpression, + onAdLinkClick, + onAdViewable, + onHighlightClick, + onReadAllClick, + className, +}: FeedHeroSectionProps): ReactElement { + const adProps = { onLinkClick: onAdLinkClick, onViewable: onAdViewable }; + const { columns, featuredSpan, railSpan, layout } = shape; + const isStacked = layout === 'stacked'; + + return ( +
+
+ + + {!!ad && adPlacement === 'column' && ( + + )} +
+
+ ); +} diff --git a/packages/shared/src/components/feeds/hero/feedHeroShape.spec.ts b/packages/shared/src/components/feeds/hero/feedHeroShape.spec.ts new file mode 100644 index 00000000000..a7c397080c5 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/feedHeroShape.spec.ts @@ -0,0 +1,67 @@ +import { feedHeroShape, MAX_HERO_COLUMNS } from './feedHeroShape'; + +describe('feedHeroShape', () => { + // One column is excluded: it stacks rather than laying out on a grid. + it.each([2, 3, 4, 5, 6])('fills all %i columns', (columns) => { + const { featuredSpan, railSpan, adSpan } = feedHeroShape(columns); + + expect(featuredSpan + railSpan + adSpan).toBe(columns); + }); + + // The section writes its column classes out by hand and stops at this count. + // A shape that laid out beyond it would ask for classes that do not exist. + it('lays out on every column count the section has classes for', () => { + for (let columns = 2; columns <= MAX_HERO_COLUMNS; columns += 1) { + expect(feedHeroShape(columns).layout).not.toBe('stacked'); + } + }); + + it('stacks a count past the widest row the section can class', () => { + expect(feedHeroShape(MAX_HERO_COLUMNS + 1)).toMatchObject({ + columns: 1, + layout: 'stacked', + adPlacement: 'none', + }); + }); + + it('stacks the phone, where there is one column to share', () => { + expect(feedHeroShape(1)).toMatchObject({ + layout: 'stacked', + adPlacement: 'none', + }); + }); + + it.each([2, 3, 4, 5, 6])( + 'stacks a %i-column feed that renders as a list', + (columns) => { + expect(feedHeroShape(columns, true)).toMatchObject({ + columns: 1, + layout: 'stacked', + adPlacement: 'none', + }); + }, + ); + + it('gives the rail a column of its own from two columns up', () => { + expect(feedHeroShape(2)).toMatchObject({ featuredSpan: 1, railSpan: 1 }); + }); + + it('takes the standard card at one column and the wide card beyond it', () => { + expect(feedHeroShape(2).layout).toBe('split'); + expect(feedHeroShape(3).layout).toBe('wide'); + }); + + it('spares the ad a column only once there are four', () => { + expect(feedHeroShape(3)).toMatchObject({ adSpan: 0, adPlacement: 'none' }); + expect(feedHeroShape(4)).toMatchObject({ + adSpan: 1, + adPlacement: 'column', + }); + }); + + it('widens the featured card, then the rail', () => { + expect(feedHeroShape(4)).toMatchObject({ featuredSpan: 2, railSpan: 1 }); + expect(feedHeroShape(5)).toMatchObject({ featuredSpan: 3, railSpan: 1 }); + expect(feedHeroShape(6)).toMatchObject({ featuredSpan: 3, railSpan: 2 }); + }); +}); diff --git a/packages/shared/src/components/feeds/hero/feedHeroShape.ts b/packages/shared/src/components/feeds/hero/feedHeroShape.ts new file mode 100644 index 00000000000..6ebca49ba82 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/feedHeroShape.ts @@ -0,0 +1,62 @@ +/** `none` hands the placement back to the feed, which shows it in its own slot. */ +export type FeedHeroAdPlacement = 'none' | 'column'; + +/** + * Which card the featured post takes: `stacked` a list card, `split` the + * standard grid card, `wide` the featured-wide card. The wide card's copy + * clips mid-sentence at one column, which is why `split` exists. + */ +export type FeedHeroLayout = 'stacked' | 'split' | 'wide'; + +/** + * Widest row the section has column classes written out for — Tailwind only + * generates what it can see, so `FeedHeroSection`'s maps stop here too. A count + * beyond it stacks rather than laying out on classes that do not exist. + */ +export const MAX_HERO_COLUMNS = 6; + +export type FeedHeroShape = { + /** Columns in the hero's row — the feed grid's own count. */ + columns: number; + featuredSpan: number; + railSpan: number; + /** 0 when the ad has no column of its own. */ + adSpan: number; + layout: FeedHeroLayout; + adPlacement: FeedHeroAdPlacement; +}; + +/** + * The hero's row, laid out on the feed grid's own column count rather than on + * viewport thresholds, so the section reflows when the feed does and its column + * edges land on the grid's. The featured card takes what the rail and the ad + * leave, which is what keeps the row exactly as wide as the grid beneath it. + */ +export const feedHeroShape = ( + columns: number, + isList = false, +): FeedHeroShape => { + if (isList || columns <= 1 || columns > MAX_HERO_COLUMNS) { + return { + columns: 1, + featuredSpan: 1, + railSpan: 1, + adSpan: 0, + layout: 'stacked', + adPlacement: 'none', + }; + } + + const adSpan = columns >= 4 ? 1 : 0; + const railSpan = columns >= 6 ? 2 : 1; + const featuredSpan = columns - railSpan - adSpan; + + return { + columns, + featuredSpan, + railSpan, + adSpan, + layout: featuredSpan > 1 ? 'wide' : 'split', + adPlacement: adSpan > 0 ? 'column' : 'none', + }; +}; diff --git a/packages/shared/src/components/feeds/hero/useFeedHeroAd.ts b/packages/shared/src/components/feeds/hero/useFeedHeroAd.ts new file mode 100644 index 00000000000..9b7b63e037f --- /dev/null +++ b/packages/shared/src/components/feeds/hero/useFeedHeroAd.ts @@ -0,0 +1,47 @@ +import { useContext } from 'react'; +import type { Ad } from '../../../graphql/posts'; +import { useAdQuery } from '../../../features/monetization/useAdQuery'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import FeedContext from '../../../contexts/FeedContext'; +import { useFeedLayout } from '../../../hooks/useFeedLayout'; +import { usePlusSubscription } from '../../../hooks/usePlusSubscription'; +import { AdPlacement } from '../../../lib/ads'; +import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; +import type { FeedHeroAdPlacement, FeedHeroShape } from './feedHeroShape'; +import { feedHeroShape } from './feedHeroShape'; + +export type FeedHeroAdSlot = { + ad?: Ad; + placement: FeedHeroAdPlacement; + /** The section's row, so the card and the layout around it share one number. */ + shape: FeedHeroShape; +}; + +/** + * The hero's ad. Whether there is room comes from the grid's column count, + * which is there on the first render — measuring the section instead missed a + * load that painted straight at its final size and so never fired a resize. + */ +export const useFeedHeroAd = (): FeedHeroAdSlot => { + const { user, tokenRefreshed } = useAuthContext(); + const { isPlus } = usePlusSubscription(); + const { numCards } = useContext(FeedContext); + // The same call the feed makes, so the two agree on what a list is. + const { shouldUseListFeedLayout } = useFeedLayout(); + // `.eco` regardless of the reader's spaciness: the count the grid renders + // with — see `FeedContainer`. + const shape = feedHeroShape(numCards.eco, shouldUseListFeedLayout); + + const { data: ad } = useAdQuery({ + placement: AdPlacement.Feed, + queryKey: generateQueryKey(RequestKey.Ads, user, 'feed-hero'), + enabled: tokenRefreshed && !isPlus && shape.adPlacement !== 'none', + staleTime: StaleTime.OneHour, + }); + + return { + ad: ad ?? undefined, + placement: ad ? shape.adPlacement : 'none', + shape, + }; +}; diff --git a/packages/shared/src/hooks/useFeed.ts b/packages/shared/src/hooks/useFeed.ts index 3176ad002c7..595e996b0e0 100644 --- a/packages/shared/src/hooks/useFeed.ts +++ b/packages/shared/src/hooks/useFeed.ts @@ -206,6 +206,12 @@ export type FeedReturnType = { type UseFeedSettingParams = { adPostLength?: number; disableAds?: boolean; + /** The surface shows the highlights itself, so keep them out of the grid. */ + disableHighlightCards?: boolean; + /** The surface shows an ad above the feed, so drop the grid's first slot. */ + skipFirstAd?: boolean; + /** The surface leads with a featured card, so keep wide ones out of row one. */ + deferWideCards?: boolean; feedName?: string; staticAd?: { ad: Ad; index: number }; /** Set on search feeds so every fetch can be logged as a search execution. */ @@ -571,7 +577,7 @@ export default function useFeed( const adRepeat = adTemplate?.adRepeat ?? pageSize + 1; const adJitter = adTemplate?.adJitter ?? 0; - const adPage = getAdSlotIndex({ + const slot = getAdSlotIndex({ index, adStart, adRepeat, @@ -579,7 +585,15 @@ export default function useFeed( seed: adJitterSeedRef.current ?? '', }); - if (adPage === undefined) { + if (slot === undefined) { + return undefined; + } + + // Shifted rather than skipped, so the creative the first slot would have + // shown moves down to the second instead of being fetched and discarded. + const adPage = settings?.skipFirstAd ? slot - 1 : slot; + + if (adPage < 0) { return undefined; } @@ -618,6 +632,7 @@ export default function useFeed( adTemplate?.adJitter, adsUpdatedAt, pageSize, + settings?.skipFirstAd, ], ); @@ -662,6 +677,7 @@ export default function useFeed( startIndex: heroCardsConfig.startIndex, widenableTypes, firstSlotOffset: effectiveFirstSlotOffset, + minWideCardRow: settings?.deferWideCards ? 1 : 0, }); const staticAd = settings?.staticAd; @@ -707,7 +723,7 @@ export default function useFeed( } if (node.itemType === 'highlight') { - if (!node.highlights.length) { + if (!node.highlights.length || settings?.disableHighlightCards) { return; } pushAndAdvance({ @@ -760,6 +776,7 @@ export default function useFeed( feedQuery.dataUpdatedAt, placeholdersPerPage, getAd, + settings?.disableHighlightCards, settings?.staticAd, heroCardsConfig, virtualizedNumCards, @@ -770,6 +787,7 @@ export default function useFeed( widenableTypes, excludePinnedPosts, effectiveFirstSlotOffset, + settings?.deferWideCards, ]); const placements = useMemo( @@ -785,6 +803,7 @@ export default function useFeed( fullRowInsertionBeforeIndex, cadence, firstSlotOffset: effectiveFirstSlotOffset, + minWideCardRow: settings?.deferWideCards ? 1 : 0, }), [ items, @@ -796,6 +815,7 @@ export default function useFeed( cadence, widenableTypes, effectiveFirstSlotOffset, + settings?.deferWideCards, ], ); diff --git a/packages/shared/src/hooks/useFittedLineClamp.spec.ts b/packages/shared/src/hooks/useFittedLineClamp.spec.ts new file mode 100644 index 00000000000..f9f3d676923 --- /dev/null +++ b/packages/shared/src/hooks/useFittedLineClamp.spec.ts @@ -0,0 +1,100 @@ +import { act, renderHook } from '@testing-library/react'; +import { useFittedLineClamp } from './useFittedLineClamp'; + +const LINE_HEIGHT = 20; +const MAX_LINES = 6; + +let notify: (() => void) | undefined; + +beforeEach(() => { + notify = undefined; + + Object.defineProperty(global, 'ResizeObserver', { + writable: true, + value: jest.fn().mockImplementation((callback: () => void) => { + notify = callback; + + return { + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + }; + }), + }); + + jest + .spyOn(window, 'getComputedStyle') + // `line-height` resolves even on a box that is not rendered, so the hook's + // own guard cannot stand in for keeping the element in flow. + .mockImplementation( + () => + ({ + lineHeight: `${LINE_HEIGHT}px`, + paddingBottom: '0px', + } as CSSStyleDeclaration), + ); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +/** A container whose floor sits `room` pixels below where the text starts. */ +const attach = ( + result: { current: ReturnType }, + room: number, +) => { + const container = document.createElement('div'); + const text = document.createElement('p'); + + container.getBoundingClientRect = () => ({ bottom: room } as DOMRect); + text.getBoundingClientRect = () => ({ top: 0 } as DOMRect); + + act(() => { + result.current.containerRef(container); + result.current.textRef(text); + }); +}; + +describe('useFittedLineClamp', () => { + it('clamps to the whole lines that fit', () => { + const { result } = renderHook(() => useFittedLineClamp(MAX_LINES)); + attach(result, LINE_HEIGHT * 3); + + expect(result.current.lines).toBe(3); + expect(result.current.style).toEqual({ WebkitLineClamp: 3 }); + }); + + it('never exceeds the ceiling however much room there is', () => { + const { result } = renderHook(() => useFittedLineClamp(MAX_LINES)); + attach(result, LINE_HEIGHT * 50); + + expect(result.current.lines).toBe(MAX_LINES); + }); + + // `display: none` reports a 0x0 box at the origin, which measures as a full + // viewport of room, brings the text back, and oscillates. The box has to stay + // in flow for the next measurement to read the same geometry. + it('hides the text without removing it from layout when nothing fits', () => { + const { result } = renderHook(() => useFittedLineClamp(MAX_LINES)); + attach(result, 0); + + expect(result.current.lines).toBe(0); + // Exactly one declaration: `display` here would take the box out of flow. + expect(Object.keys(result.current.style)).toEqual(['visibility']); + expect(result.current.style).toEqual({ visibility: 'hidden' }); + }); + + it('settles at zero instead of oscillating once hidden', () => { + const { result } = renderHook(() => useFittedLineClamp(MAX_LINES)); + attach(result, 0); + + expect(result.current.lines).toBe(0); + + // The element is still in flow, so a re-measure reads the same geometry. + act(() => notify?.()); + + expect(result.current.lines).toBe(0); + expect(result.current.style).toEqual({ visibility: 'hidden' }); + }); +}); diff --git a/packages/shared/src/hooks/useFittedLineClamp.ts b/packages/shared/src/hooks/useFittedLineClamp.ts new file mode 100644 index 00000000000..9cbcb8ed620 --- /dev/null +++ b/packages/shared/src/hooks/useFittedLineClamp.ts @@ -0,0 +1,88 @@ +import type { CSSProperties } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +interface FittedLineClamp { + /** The box the text has to fit inside. */ + containerRef: (node: HTMLElement | null) => void; + /** The text itself, which must be the last thing in that box. */ + textRef: (node: HTMLElement | null) => void; + /** Whole lines that fit, never more than `maxLines`. */ + lines: number; + /** + * Apply to the text. `-webkit-line-clamp: 0` is invalid and would be dropped, + * leaving the text unclamped for its `overflow-hidden` parent to slice + * through a glyph row, so no room at all hides the block instead. + * + * `visibility`, not `display`: the measurement reads this element's own top + * edge, and `display: none` reports a 0x0 box at the origin — which measures + * as a full viewport of room, brings the text back, and oscillates. + */ + style: CSSProperties; +} + +/** + * How many whole lines of a block still fit the room left under everything + * above it. `-webkit-line-clamp` takes a number, not a height, and the free + * space is only known once the flex column above has been laid out, so it is + * measured and handed back as that number. Clamping to a count that no longer + * fits would let the box clip a row of glyphs through the middle. + * + * Safe against feedback: the text is the last child, so shortening it does not + * move where it starts. + */ +export const useFittedLineClamp = (maxLines: number): FittedLineClamp => { + // Nodes as state, not refs, so the effect re-runs when they attach. + const [container, setContainer] = useState(null); + const [text, setText] = useState(null); + const [lines, setLines] = useState(maxLines); + + useEffect(() => { + if (!container || !text || typeof ResizeObserver === 'undefined') { + return undefined; + } + + const measure = () => { + const style = getComputedStyle(text); + const lineHeight = parseFloat(style.lineHeight); + + // `normal` gives no number to divide by; leave the CSS clamp in charge. + if (!lineHeight) { + return; + } + + const bottom = + container.getBoundingClientRect().bottom - + parseFloat(getComputedStyle(container).paddingBottom || '0'); + const available = bottom - text.getBoundingClientRect().top; + + setLines( + Math.max(0, Math.min(maxLines, Math.floor(available / lineHeight))), + ); + }; + + // The container's height moves the floor, the text's moves the ceiling. + const observer = new ResizeObserver(measure); + + observer.observe(container); + observer.observe(text); + measure(); + + return () => observer.disconnect(); + }, [container, text, maxLines]); + + const style = useMemo( + () => (lines > 0 ? { WebkitLineClamp: lines } : { visibility: 'hidden' }), + [lines], + ); + + return { + containerRef: useCallback((node: HTMLElement | null) => { + setContainer(node); + }, []), + textRef: useCallback((node: HTMLElement | null) => { + setText(node); + }, []), + lines, + style, + }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index a8446e8c602..0ba00a8ef80 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -271,6 +271,10 @@ export const featureHeroCards = new Feature('hero_cards', { }, }); +// Experiment: a hero section above the feed — a carousel of the current +// headlines, with the Happening Now list and a direct ad placement beside it. +export const featureFeedHero = new Feature('feed_hero', false); + // Experiment: skip layout/paint for off-screen feed cards via CSS // `content-visibility: auto` to keep long feeds responsive. export const featureFeedContentVisibility = new Feature( diff --git a/packages/shared/src/lib/feedHighlightColSpan.spec.ts b/packages/shared/src/lib/feedHighlightColSpan.spec.ts index 79e8b554d7e..985079946d9 100644 --- a/packages/shared/src/lib/feedHighlightColSpan.spec.ts +++ b/packages/shared/src/lib/feedHighlightColSpan.spec.ts @@ -422,6 +422,7 @@ describe('computePlacements', () => { minSpacing: 10, startIndex: 0, widenableTypes: ALL_WIDENABLE, + minWideCardRow: 0, }; const colSpans = (items: FeedItem[], o = opts) => @@ -514,6 +515,35 @@ describe('computePlacements', () => { ]); }); + describe('minWideCardRow', () => { + it('keeps wide cards out of the rows below the floor', () => { + const items = Array.from({ length: 6 }, () => + makePostItem(makePost({ significance: 'breaking' })), + ); + + expect(colSpans(items, { ...opts, minSpacing: 0 })).toEqual([ + 4, 4, 4, 4, 4, 4, + ]); + expect( + colSpans(items, { ...opts, minSpacing: 0, minWideCardRow: 1 }), + ).toEqual([1, 1, 1, 1, 4, 4]); + }); + + it('measures the floor in rows, not items', () => { + const items = Array.from({ length: 6 }, (_, index) => + makePostItem(makePost(index === 5 ? { significance: 'breaking' } : {})), + ); + // Five cards fill row 0 and spill into row 1, so the sixth widens — to + // the 3 columns left in its row, since the fit-to-row clamp applies. + const placements = computePlacements(items, { + ...opts, + minWideCardRow: 1, + }); + + expect(placements[5]).toEqual({ colSpan: 3, row: 1, column: 1 }); + }); + }); + it('caps wide cards to one per ten items', () => { const items = [ makePostItem(makePost({ significance: 'notable' })), diff --git a/packages/shared/src/lib/feedHighlightColSpan.ts b/packages/shared/src/lib/feedHighlightColSpan.ts index e43040e06fb..d2ec8db0be8 100644 --- a/packages/shared/src/lib/feedHighlightColSpan.ts +++ b/packages/shared/src/lib/feedHighlightColSpan.ts @@ -59,6 +59,11 @@ export interface PlacementBuilderOptions { startIndex: number; widenableTypes: ReadonlySet; firstSlotOffset?: number; + /** + * First grid row a wide card may occupy. `startIndex` gates on item index, + * which at five columns still lets one land in the opening row. + */ + minWideCardRow?: number; } /** @@ -167,6 +172,7 @@ export const createPlacementBuilder = ({ startIndex, widenableTypes, firstSlotOffset = 0, + minWideCardRow = 0, }: PlacementBuilderOptions): PlacementBuilder => { const layoutEnabled = isEnabled && !isMobile && !isList && numCards > 1; const safeNumCards = Math.max(numCards, 1); @@ -212,6 +218,9 @@ export const createPlacementBuilder = ({ if (itemIdx < startIndex) { return 1; } + if (row < minWideCardRow) { + return 1; + } if (itemIdx - lastLargeIndex < minSpacing) { return 1; } diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 269f0e41dd3..d6eda7dab28 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -340,6 +340,70 @@ } } +@keyframes feed-hero-slide-in { + from { + opacity: 0; + transform: scale(0.985); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes feed-hero-slide-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +.feed-hero-slide-in { + animation: feed-hero-slide-in 420ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.feed-hero-slide-out { + animation: feed-hero-slide-out 320ms ease-out both; +} + +/* Near-zero rather than `none`: the outgoing slide is unmounted on its own + `animationend`, which never arrives if the animation is removed outright. */ +@media (prefers-reduced-motion: reduce) { + .feed-hero-slide-in, + .feed-hero-slide-out { + animation-duration: 1ms; + } +} + +@keyframes feed-hero-carousel-progress { + from { + transform: scaleX(0); + } + + to { + transform: scaleX(1); + } +} + +/* The slide advances on this animation's `animationend`, so pausing it also + pauses the rotation and reduced motion stops the carousel altogether. */ +.feed-hero-carousel-progress { + transform-origin: left center; + animation: feed-hero-carousel-progress + var(--feed-hero-carousel-duration, 6s) linear forwards; +} + +@media (prefers-reduced-motion: reduce) { + .feed-hero-carousel-progress { + animation: none; + transform: scaleX(1); + } +} + .feed-highlights-new-item-border-bottom { border-style: solid; border-width: 0 0 0.0625rem; diff --git a/packages/storybook/stories/features/feed/FeedHero.stories.tsx b/packages/storybook/stories/features/feed/FeedHero.stories.tsx new file mode 100644 index 00000000000..2a5fb600762 --- /dev/null +++ b/packages/storybook/stories/features/feed/FeedHero.stories.tsx @@ -0,0 +1,478 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { fn } from 'storybook/test'; +import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid'; +import { ExploreChipsBar } from '@dailydotdev/shared/src/components/feeds/ExploreChipsBar'; +import { FeedHeroAdCard } from '@dailydotdev/shared/src/components/feeds/hero/FeedHeroAdCard'; +import { FeedHeroCarousel } from '@dailydotdev/shared/src/components/feeds/hero/FeedHeroCarousel'; +import { FeedHeroSection } from '@dailydotdev/shared/src/components/feeds/hero/FeedHeroSection'; +import { feedHeroShape } from '@dailydotdev/shared/src/components/feeds/hero/feedHeroShape'; +import { + adWithLongCopy, + adWithoutAdvertiser, + adWithoutCta, + adWithoutImage, + adWithoutTags, + cardHandlers, + exploreCategories, + feedPosts, + FeedHeroProviders, + heroAd, + heroPosts, + highlights, + longTitleHeroPost, + mixedTypeHeroPosts, + noImageHeroPost, + readHeroPost, +} from './feedHero.mocks'; + +const Page = ({ children }: { children: ReactNode }): ReactElement => ( + +
+ {/* A four-card feed, the narrowest one the ad column appears in. */} +
+ {children} +
+
+
+); + +const Case = ({ + title, + note, + width, + children, +}: { + title: string; + note?: string; + width?: string; + children: ReactNode; +}): ReactElement => ( +
+

{title}

+ {!!note &&

{note}

} +
+ {children} +
+
+); + +const FeedGrid = (): ReactElement => ( +
+ {feedPosts.map((post) => ( + + ))} +
+); + +const meta: Meta = { + title: 'Features/Feed/Hero', + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const FullLayout: Story = { + name: 'Hero + all posts', + render: () => ( + + +
+ + +
+
+ ), +}; + +export const HeroOnly: Story = { + name: 'Hero section', + render: () => ( + + + + ), +}; + +// The hero lays out on the feed grid's own columns, so its stage is a column +// count rather than a width — the same count the grid under it is using, which +// `FeedContext` derives from the viewport, the sidebar's state and the reader's +// card-count setting together. The widths here are only what that many columns +// come to at a typical window; the shape does not depend on them. +const FEED_STAGES = [ + { + columns: 2, + px: 720, + note: 'the featured post takes one column — the feed card at its own size — and the feed below keeps the ad', + }, + { + columns: 3, + px: 980, + note: 'the featured card takes two columns and turns wide; a third for the ad would leave every column too narrow, so the feed keeps it', + }, + { + columns: 4, + px: 1250, + note: 'the ad earns a column, at the 270px the card was drawn for', + }, + { + columns: 5, + px: 1560, + note: 'the slack goes to the featured card', + }, + { + columns: 6, + px: 1880, + note: 'wide enough that the headline list earns a second column too', + }, +]; + +export const WideFeed: Story = { + name: 'Hero at each stage', + render: () => ( + +
+ {FEED_STAGES.map(({ columns, px, note }) => { + const shape = feedHeroShape(columns); + + return ( +
+

+ {columns} columns · about {px}px of feed +

+

{note}

+
+ +
+
+ ); + })} +
+
+ ), +}; + +// Each breakpoint gets its own iframe so Tailwind's media queries resolve +// against a real viewport width, not a resized container. +const BREAKPOINTS = [ + { label: 'Mobile', width: 390, height: 900 }, + { label: 'Tablet', width: 768, height: 900 }, + { label: 'Laptop', width: 1024, height: 760 }, + { label: 'Desktop', width: 1440, height: 760 }, +]; + +export const Responsive: Story = { + name: 'Responsive breakpoints', + render: (args, { globals }) => ( +
+

+ The four-column shape rendered at each breakpoint, so what these show is + the section holding that shape as the window narrows — not which shape a + window picks. That comes from the feed grid's column count, which + no story can set from a viewport: see “Hero at each stage” + for the stages themselves, and “Hero in list view” for the + one-column list shape. +

+
+ {BREAKPOINTS.map(({ label, width, height }) => ( +
+ + {label} · {width}px + +