diff --git a/packages/shared/src/components/Feed.spec.tsx b/packages/shared/src/components/Feed.spec.tsx index 515c80bc929..78948fa4a2c 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([ diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index 3dfe2044b2f..0eeb4c0dcb5 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -101,6 +101,8 @@ export interface FeedProps showSearch?: boolean; actionButtons?: ReactNode; disableAds?: boolean; + /** The surface shows the highlights itself, so keep them out of the grid. */ + disableHighlightCards?: boolean; staticAd?: { ad: Ad; index: number }; disableAdRefresh?: boolean; allowFetchMore?: boolean; @@ -203,6 +205,7 @@ export default function Feed({ shortcuts, actionButtons, disableAds, + disableHighlightCards, staticAd, disableAdRefresh = false, allowFetchMore, @@ -366,6 +369,7 @@ export default function Feed({ excludePinnedPosts, settings: { disableAds, + disableHighlightCards, staticAd, adPostLength: isSquadFeed ? 2 : undefined, feedName, diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 94629930ecc..8964dcf8259 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -13,21 +13,19 @@ import type { FeedPostClick } from '../hooks/feed/useFeedOnPostClick'; import { LogEvent, Origin, TargetType } from '../lib/log'; 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 type { FeaturedWideColSpan } from './cards/common/featuredWide'; import { PostTypeToWideCard } from './cards/common/wideCards'; +import { PostTypeToListCard } from './cards/common/listCards'; import { ShareGrid } from './cards/share/ShareGrid'; -import { ShareList } from './cards/share/ShareList'; import { CollectionGrid } from './cards/collection'; import type { UseBookmarkPost } from '../hooks/useBookmarkPost'; import { AdActions } from '../lib/ads'; @@ -50,11 +48,8 @@ import { 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 { LiveRoomPostGrid } from './cards/liveRoom/LiveRoomPostGrid'; -import { LiveRoomPostList } from './cards/liveRoom/LiveRoomPostList'; import { SignalList } from './cards/common/list/SignalList'; import { OtherFeedPage } from '../lib/query'; import { isSourceSquadOrMachine } from '../graphql/sources'; @@ -135,21 +130,6 @@ const PostTypeToTagCard: Record> = { [PostType.LiveRoom]: LiveRoomPostGrid, }; -// 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, - [PostType.LiveRoom]: LiveRoomPostList, -}; - const getPostTypeForCard = (post?: Post): PostType => { if (!post) { return PostType.Article; @@ -177,7 +157,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; diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 6b863d8fc04..b8759b610be 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -71,6 +71,7 @@ import { discussedFeedVersion, feature, featureFeedChips, + featureFeedHero, FeedChipsVariant, followingFeedVersion, latestFeedVersion, @@ -78,6 +79,8 @@ import { upvotedFeedVersion, } from '../lib/featureManagement'; import type { FeedContainerProps } from './feeds'; +import { FeedHero } from './feeds/hero/FeedHero'; +import { useFeedHeroPreview } from './feeds/hero/useFeedHeroPreview'; import { getFeedName } from '../lib/feed'; import CommentFeed from './CommentFeed'; import { COMMENT_FEED_QUERY } from '../graphql/comments'; @@ -357,6 +360,16 @@ export default function MainFeedLayout({ [showExploreChips, exploreCategories, feeds, isV2], ); + const isMainFeedPage = + feedName === SharedFeedPage.MyFeed || feedName === SharedFeedPage.Popular; + const { value: isFeedHeroFlagOn } = useConditionalFeature({ + feature: featureFeedHero, + shouldEvaluate: isMainFeedPage, + }); + const isFeedHeroPreview = useFeedHeroPreview(); + const isFeedHeroEnabled = + isMainFeedPage && (isFeedHeroFlagOn || isFeedHeroPreview); + const { isSearchPageLaptop } = useSearchResultsLayout(); const config = useMemo(() => { @@ -746,6 +759,25 @@ export default function MainFeedLayout({ } return ''; }, [customFeedsData, feedName, router.query.slugOrId]); + const chipsTopContent = + (isExploreTag || shouldUseListFeedLayout) && chipsNode ? ( +
+ {chipsNode} +
+ ) : undefined; + // Left undefined when the hero is off so `Feed` keeps its own top slot for + // the reading reminder. + const topContent = isFeedHeroEnabled ? ( + <> + + {chipsTopContent} + + ) : ( + chipsTopContent + ); + const v2ActionButtons = feedProps?.actionButtons; const showFeedV2PageHeader = isV2 && @@ -823,18 +855,8 @@ export default function MainFeedLayout({ - {chipsNode} - - ) : undefined - } + topContent={topContent} + disableHighlightCards={isFeedHeroEnabled} className={classNames( shouldUseListFeedLayout && !isFinder && 'laptop:px-6', )} diff --git a/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx index de22714156d..627bc15828f 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx @@ -11,7 +11,15 @@ 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, + HERO_DESCRIPTION_CLASS_NAME, + HERO_TEXT_FIT_CLASS_NAME, + HERO_TITLE_CLASS_NAME, + INNER_GRID_COLS, + TEXT_COL_SPAN, + TITLE_CLASS_NAME, +} from '../common/featuredWide'; import { FeaturedWideCardShell } from '../common/FeaturedWideCardShell'; import { FeaturedWideImageColumn } from '../common/FeaturedWideImageColumn'; import { FeaturedWideActions } from '../common/FeaturedWideActions'; @@ -34,6 +42,7 @@ export const FreeformFeaturedWideGridCard = forwardRef( eagerLoadImage = false, enableSourceHeader = false, wideColSpan = 2, + hero, }: FeaturedWideCardProps, ref: Ref, ): ReactElement { @@ -64,13 +73,25 @@ export const FreeformFeaturedWideGridCard = forwardRef( image || overlay ? INNER_GRID_COLS[wideColSpan] : 'grid-cols-1', )} > -
- +
+ -

+

{title}

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

+

{description}

)} @@ -106,6 +132,7 @@ export const FreeformFeaturedWideGridCard = forwardRef( image={image} alt={post.title ?? ''} wideColSpan={wideColSpan} + coverImage={hero} overlay={overlay} eagerLoadImage={eagerLoadImage} /> diff --git a/packages/shared/src/components/cards/ad/AdGrid.tsx b/packages/shared/src/components/cards/ad/AdGrid.tsx index 6fba38b2791..d41335586d2 100644 --- a/packages/shared/src/components/cards/ad/AdGrid.tsx +++ b/packages/shared/src/components/cards/ad/AdGrid.tsx @@ -1,108 +1,30 @@ import type { ReactElement } from 'react'; import React, { forwardRef } from 'react'; -import { - Card, - CardImage, - CardSpace, - CardTextContainer, - CardTitle, -} from '../common/Card'; -import AdLink from './common/AdLink'; -import { combinedClicks } from '../../../lib/click'; -import AdAttribution, { adAttributionSpacing } from './common/AdAttribution'; -import { AdImage } from './common/AdImage'; -import { AdPixel } from './common/AdPixel'; -import { AdMeasurement } from './common/AdMeasurement'; -import { AdViewability } from './common/AdViewability'; -import { useAdClickUrl } from '../../../features/monetization/useAdClickUrl'; +import { Card } from '../common/Card'; +import { AdCardContent } from './common/AdCardContent'; import type { AdCardProps } from './common/common'; -import { RemoveAd } from './common/RemoveAd'; -import { usePlusSubscription } from '../../../hooks/usePlusSubscription'; import type { InViewRef } from '../../../hooks/feed/useAutoRotatingAds'; import { useAutoRotatingAds } from '../../../hooks/feed/useAutoRotatingAds'; -import { Button } from '../../buttons/Button'; -import { ButtonSize, ButtonVariant } from '../../buttons/common'; -import { AdFavicon } from './common/AdFavicon'; -import PostTags from '../common/PostTags'; -import { useFeature } from '../../GrowthBookProvider'; -import { adImprovementsV3Feature } from '../../../lib/featureManagement'; -import { TargetId } from '../../../lib/log'; -import { AdvertiseLink } from './common/AdvertiseLink'; -import { useAdLabel } from '../../../features/monetization/useAdLabel'; export const AdGrid = forwardRef(function AdGrid( { ad, onLinkClick, onViewable, domProps, index, feedIndex }, forwardedRef, ): ReactElement { - const { isPlus } = usePlusSubscription(); - const adImprovementsV3 = useFeature(adImprovementsV3Feature); - const { showAdvertiseLink } = useAdLabel(); const { ref } = useAutoRotatingAds( ad, index, feedIndex, forwardedRef as InViewRef, ); - const matchingTags = ad?.matchingTags ?? []; - const clickUrl = useAdClickUrl(ad); return ( - - - - {ad.description} - - {adImprovementsV3 && matchingTags.length > 0 ? ( - - ) : null} - - - - -
- {!!ad.callToAction && ( - - )} - {showAdvertiseLink && ( - - )} -
- {!isPlus && ( - - )} -
-
-
- - - onViewable?.(ad, data)} /> +
); }); diff --git a/packages/shared/src/components/cards/ad/common/AdCardContent.tsx b/packages/shared/src/components/cards/ad/common/AdCardContent.tsx new file mode 100644 index 00000000000..5b5c378693b --- /dev/null +++ b/packages/shared/src/components/cards/ad/common/AdCardContent.tsx @@ -0,0 +1,111 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { Ad } from '../../../../graphql/posts'; +import type { ViewabilityData } from '../../../../features/monetization/viewability'; +import { + CardImage, + CardSpace, + CardTextContainer, + CardTitle, +} from '../../common/Card'; +import AdLink from './AdLink'; +import { combinedClicks } from '../../../../lib/click'; +import AdAttribution, { adAttributionSpacing } from './AdAttribution'; +import { AdImage } from './AdImage'; +import { AdPixel } from './AdPixel'; +import { AdMeasurement } from './AdMeasurement'; +import { AdViewability } from './AdViewability'; +import { useAdClickUrl } from '../../../../features/monetization/useAdClickUrl'; +import { RemoveAd } from './RemoveAd'; +import { usePlusSubscription } from '../../../../hooks/usePlusSubscription'; +import { Button } from '../../../buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../../buttons/common'; +import { AdFavicon } from './AdFavicon'; +import PostTags from '../../common/PostTags'; +import { useFeature } from '../../../GrowthBookProvider'; +import { adImprovementsV3Feature } from '../../../../lib/featureManagement'; +import { TargetId } from '../../../../lib/log'; +import { AdvertiseLink } from './AdvertiseLink'; +import { useAdLabel } from '../../../../features/monetization/useAdLabel'; + +interface AdCardContentProps { + ad: Ad; + onLinkClick?: (ad: Ad) => unknown; + onViewable?: (ad: Ad, data: ViewabilityData) => void; +} + +/** + * The full-size ad card, without a container. The feed wraps it in a `Card`; + * surfaces that want the same creative without the card chrome bring their own. + * All three trackers position against the container's `relative` root. + */ +export const AdCardContent = ({ + ad, + onLinkClick, + onViewable, +}: AdCardContentProps): ReactElement => { + const { isPlus } = usePlusSubscription(); + const adImprovementsV3 = useFeature(adImprovementsV3Feature); + const { showAdvertiseLink } = useAdLabel(); + const matchingTags = ad?.matchingTags ?? []; + const clickUrl = useAdClickUrl(ad); + + return ( + <> + + + + {ad.description} + + {adImprovementsV3 && matchingTags.length > 0 ? ( + + ) : null} + + + + +
+ {!!ad.callToAction && ( + + )} + {showAdvertiseLink && ( + + )} +
+ {!isPlus && ( + + )} +
+
+
+ + + onViewable?.(ad, data)} /> + + ); +}; diff --git a/packages/shared/src/components/cards/ad/common/AdFavicon.tsx b/packages/shared/src/components/cards/ad/common/AdFavicon.tsx index 8ef3936c50a..332fa8544c4 100644 --- a/packages/shared/src/components/cards/ad/common/AdFavicon.tsx +++ b/packages/shared/src/components/cards/ad/common/AdFavicon.tsx @@ -10,9 +10,14 @@ import { getAdFaviconImageLink } from './getAdFaviconImageLink'; type AdFaviconProps = { ad: Ad; + size?: ProfileImageSize; className?: string; }; -export const AdFavicon = ({ ad, className }: AdFaviconProps): ReactElement => { +export const AdFavicon = ({ + ad, + size = ProfileImageSize.Medium, + className, +}: AdFaviconProps): ReactElement => { const adImprovementsV3 = useFeature(adImprovementsV3Feature); const imageLink = getAdFaviconImageLink({ ad, @@ -23,7 +28,7 @@ export const AdFavicon = ({ ad, className }: AdFaviconProps): ReactElement => { : null; const renderComponent = ( - props: Partial = {}, + props: Partial< + PostCardProps & { wideColSpan?: 2 | 3 | 4 | 5; 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 +109,30 @@ it('renders no chip when post has no highlight', () => { expect(screen.queryByText('Major')).not.toBeInTheDocument(); expect(screen.queryByText('Notable')).not.toBeInTheDocument(); }); + +describe('hero sizing', () => { + // The shared fixture has no summary, and the summary is the element under + // test here. + 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, wideColSpan: 5 }); + + const summary = summaryOf(); + // Every element is `flex-shrink: 0` by default in base.css, so the summary + // and the block holding it have to opt back in or the actions get pushed + // out through the bottom of the fixed-height card. + 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..6750d878d3a 100644 --- a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx @@ -15,7 +15,15 @@ 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, + HERO_DESCRIPTION_CLASS_NAME, + HERO_TEXT_FIT_CLASS_NAME, + HERO_TITLE_CLASS_NAME, + INNER_GRID_COLS, + TEXT_COL_SPAN, + TITLE_CLASS_NAME, +} from '../common/featuredWide'; import { FeaturedWideCardShell } from '../common/FeaturedWideCardShell'; import { FeaturedWideImageColumn } from '../common/FeaturedWideImageColumn'; import { FeaturedWideActions } from '../common/FeaturedWideActions'; @@ -39,6 +47,7 @@ export const ArticleFeaturedWideGridCard = forwardRef( domProps = {}, eagerLoadImage = false, wideColSpan = 2, + hero, }: FeaturedWideCardProps, ref: Ref, ): ReactElement { @@ -97,7 +106,9 @@ export const ArticleFeaturedWideGridCard = forwardRef( const standardContent = ( <> - + -

+

{title}

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

+

{description}

)} @@ -155,7 +176,12 @@ export const ArticleFeaturedWideGridCard = forwardRef( image || overlay ? INNER_GRID_COLS[wideColSpan] : 'grid-cols-1', )} > -
+
{showFeedback ? feedbackContent : standardContent}
{(!!image || !!overlay) && ( @@ -163,6 +189,7 @@ export const ArticleFeaturedWideGridCard = forwardRef( image={image} alt={post.title ?? ''} wideColSpan={wideColSpan} + coverImage={hero} overlay={overlay} isVideoType={isVideoType} eagerLoadImage={eagerLoadImage} diff --git a/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx index e75a084a0de..393154dfcf1 100644 --- a/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx @@ -12,7 +12,15 @@ import { useCardCover } from '../../../hooks/feed/useCardCover'; import { CollectionCardHeader } from './CollectionCardHeader'; import { HighlightChip } from '../common/HighlightChip'; import type { FeaturedWideCardProps } from '../common/featuredWide'; -import { INNER_GRID_COLS } from '../common/featuredWide'; +import { + DESCRIPTION_CLASS_NAME, + HERO_DESCRIPTION_CLASS_NAME, + HERO_TEXT_FIT_CLASS_NAME, + HERO_TITLE_CLASS_NAME, + INNER_GRID_COLS, + TEXT_COL_SPAN, + TITLE_CLASS_NAME, +} from '../common/featuredWide'; import { FeaturedWideCardShell } from '../common/FeaturedWideCardShell'; import { FeaturedWideImageColumn } from '../common/FeaturedWideImageColumn'; import { FeaturedWideActions } from '../common/FeaturedWideActions'; @@ -34,6 +42,7 @@ export const CollectionFeaturedWideGridCard = forwardRef( domProps = {}, eagerLoadImage = false, wideColSpan = 2, + hero, }: FeaturedWideCardProps, ref: Ref, ): ReactElement { @@ -61,10 +70,22 @@ export const CollectionFeaturedWideGridCard = forwardRef( image || overlay ? INNER_GRID_COLS[wideColSpan] : 'grid-cols-1', )} > -
- +
+ -

+

{title}

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

+

{post.summary}

)} @@ -104,6 +130,7 @@ export const CollectionFeaturedWideGridCard = forwardRef( image={image} alt={post.title ?? ''} wideColSpan={wideColSpan} + coverImage={hero} overlay={overlay} eagerLoadImage={eagerLoadImage} /> diff --git a/packages/shared/src/components/cards/common/Card.tsx b/packages/shared/src/components/cards/common/Card.tsx index c3ccaf13ada..789a9c261f8 100644 --- a/packages/shared/src/components/cards/common/Card.tsx +++ b/packages/shared/src/components/cards/common/Card.tsx @@ -54,6 +54,17 @@ const cardClassess = export const Card = classed('article', styles.card, cardClassess); +/** + * A card without its chrome, for surfaces that sit on the page background + * rather than in the feed grid. Keeps the module class, which is what routes + * pointer events past the card body to the links inside it. + */ +export const FlatCard = classed( + 'article', + styles.card, + 'relative flex h-full max-h-cardLarge flex-col rounded-16 py-3 transition-colors hover:bg-surface-hover', +); + 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..1fc902c75b7 100644 --- a/packages/shared/src/components/cards/common/FeaturedWideImageColumn.tsx +++ b/packages/shared/src/components/cards/common/FeaturedWideImageColumn.tsx @@ -14,6 +14,12 @@ export type FeaturedWideImageColumnProps = { overlay?: ReactNode; isVideoType?: boolean; eagerLoadImage?: boolean; + /** + * Crop the image to fill its column, inset with its own corners, the way the + * feed cards treat a cover. Off keeps the letterboxed image over a blurred + * backdrop that the in-feed wide cards use. + */ + coverImage?: boolean; }; export const FeaturedWideImageColumn = ({ @@ -23,14 +29,16 @@ export const FeaturedWideImageColumn = ({ overlay, isVideoType, eagerLoadImage, + coverImage, }: FeaturedWideImageColumnProps): ReactElement => (
- {!!image && ( + {!!image && !coverImage && (
{children}
; +}): ReactElement => ( +
{children}
+); diff --git a/packages/shared/src/components/cards/common/featuredWide.ts b/packages/shared/src/components/cards/common/featuredWide.ts index 17fb8506fc0..f3e9be29846 100644 --- a/packages/shared/src/components/cards/common/featuredWide.ts +++ b/packages/shared/src/components/cards/common/featuredWide.ts @@ -1,19 +1,62 @@ import type { PostCardProps } from './common'; -export type FeaturedWideColSpan = 2 | 3 | 4; +export type FeaturedWideColSpan = 2 | 3 | 4 | 5; export type FeaturedWideCardProps = PostCardProps & { wideColSpan?: FeaturedWideColSpan; + /** + * The standalone hero treatment: the cover is cropped to fill its column + * instead of being letterboxed, and the text trades headline size for lines + * because it runs in a third of the card's width. The in-feed wide cards + * share a row with normal cards and keep the original sizes. + */ + 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'; + +/** + * The hero's card height is fixed, so a headline that runs to five lines would + * otherwise push the action row out through the bottom edge. `base.css` resets + * every element to `flex-shrink: 0`, so the text block and the summary opt back + * in: the summary is the only shrinkable child, which makes it the one that + * gives way while the headline above it keeps every line. + */ +/** + * The bottom padding matches the fade, so at full height the gradient covers + * only padding and the last line stays solid; once the block is squeezed the + * padding goes first and the line being cut fades out instead of showing a row + * of sliced glyphs. The padding belongs here rather than on the summary because + * `overflow: hidden` clips at the padding edge, which would let a seventh line + * leak out past the clamp. + */ +export const HERO_TEXT_FIT_CLASS_NAME = + 'min-h-0 shrink overflow-hidden pb-5 [mask-image:linear-gradient(to_bottom,black_calc(100%-1.25rem),transparent)]'; +export const HERO_DESCRIPTION_CLASS_NAME = 'line-clamp-6 min-h-0 shrink'; + export const INNER_GRID_COLS: Record = { 2: 'grid-cols-2', 3: 'grid-cols-3', 4: 'grid-cols-4', + 5: 'grid-cols-5', }; export const IMAGE_COL_SPAN: Record = { 2: 'col-span-1', 3: 'col-span-2', 4: 'col-span-3', + 5: 'col-span-3', +}; + +/** + * Every span but 5 leaves the text a single column. 5 exists for the 40/60 + * split, which is two of five — the only ratio here that needs saying. + */ +export const TEXT_COL_SPAN: Record = { + 2: 'col-span-1', + 3: 'col-span-1', + 4: 'col-span-1', + 5: 'col-span-2', }; 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..236d5d6cda5 --- /dev/null +++ b/packages/shared/src/components/cards/common/listCards.ts @@ -0,0 +1,25 @@ +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 { LiveRoomPostList } from '../liveRoom/LiveRoomPostList'; +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, + [PostType.LiveRoom]: LiveRoomPostList, +}; diff --git a/packages/shared/src/components/cards/highlight/common.tsx b/packages/shared/src/components/cards/highlight/common.tsx index e7c0806c6f2..4c6b5031cc5 100644 --- a/packages/shared/src/components/cards/highlight/common.tsx +++ b/packages/shared/src/components/cards/highlight/common.tsx @@ -1,5 +1,5 @@ -import type { ReactElement } from 'react'; -import React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import React, { Fragment } from 'react'; import classNames from 'classnames'; import type { PostHighlight } from '../../../graphql/highlights'; import { webappUrl } from '../../../lib/constants'; @@ -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,25 +70,38 @@ const HighlightRow = ({ highlight, index, onHighlightClick, + compact, }: { highlight: PostHighlight; index: number; onHighlightClick?: (highlight: PostHighlight, position: number) => void; + compact?: boolean; }): ReactElement => { return ( onHighlightClick?.(highlight, index + 1)} > - + {highlight.headline} @@ -95,17 +113,57 @@ 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, + insertedItem, +}: HighlightCardProps & { + variant: 'grid' | 'list'; + /** Flush against its container, for a surface without card chrome. */ + compact?: boolean; + /** + * Slots in as the second row, sharing the headlines' treatment, so the + * freshest headline still leads the list. Takes the first row when there + * are no headlines to sit under. + */ + insertedItem?: ReactNode; +}): 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'), + // The list scrolls, so let the last visible row fade out instead of being + // sliced flat by the pinned footer. + isFlushGrid && + '[mask-image:linear-gradient(to_bottom,black_calc(100%-1.25rem),transparent)]', + ); + const footerClassName = classNames( + variant === 'list' && 'pt-1.5', + variant === 'grid' && (isFlushGrid ? 'px-4 pt-2' : 'px-1 pb-1'), + ); const firstHighlight = highlights[0]; + const rows: ReactNode[] = highlights.map((highlight, index) => ( + + )); + + if (insertedItem) { + rows.splice( + Math.min(1, rows.length), + 0, + {insertedItem}, + ); + } return ( <> @@ -113,26 +171,19 @@ export const HighlightCardContent = ({

Happening Now

-
- {highlights.map((highlight, index) => ( - - ))} -
+
{rows}
diff --git a/packages/shared/src/components/cards/share/ShareFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/share/ShareFeaturedWideGridCard.tsx index 333f0733141..19f1cc0f9b4 100644 --- a/packages/shared/src/components/cards/share/ShareFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/share/ShareFeaturedWideGridCard.tsx @@ -16,7 +16,15 @@ 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, + HERO_DESCRIPTION_CLASS_NAME, + HERO_TEXT_FIT_CLASS_NAME, + HERO_TITLE_CLASS_NAME, + INNER_GRID_COLS, + TEXT_COL_SPAN, + TITLE_CLASS_NAME, +} from '../common/featuredWide'; import { FeaturedWideCardShell } from '../common/FeaturedWideCardShell'; import { FeaturedWideImageColumn } from '../common/FeaturedWideImageColumn'; import { FeaturedWideActions } from '../common/FeaturedWideActions'; @@ -40,6 +48,7 @@ export const ShareFeaturedWideGridCard = forwardRef( domProps = {}, eagerLoadImage = false, wideColSpan = 2, + hero, }: FeaturedWideCardProps, ref: Ref, ): ReactElement { @@ -77,8 +86,15 @@ export const ShareFeaturedWideGridCard = forwardRef( image || overlay ? INNER_GRID_COLS[wideColSpan] : 'grid-cols-1', )} > -
- +
+ {(!isSharedTweet || post.title) && ( -

+

{title}

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

+

{sharedSummary}

)} @@ -148,6 +176,7 @@ export const ShareFeaturedWideGridCard = forwardRef( image={image} alt={sharedTitle || post.title || ''} wideColSpan={wideColSpan} + coverImage={hero} overlay={overlay} isVideoType={isVideoType} eagerLoadImage={eagerLoadImage} diff --git a/packages/shared/src/components/feeds/hero/FeedHero.tsx b/packages/shared/src/components/feeds/hero/FeedHero.tsx new file mode 100644 index 00000000000..47cedc6448b --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHero.tsx @@ -0,0 +1,185 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { Ad, Post } from '../../../graphql/posts'; +import type { Connection } from '../../../graphql/common'; +import { gqlClient } from '../../../graphql/common'; +import { + FEED_BY_IDS_QUERY, + supportedTypesForPrivateSources, +} from '../../../graphql/feed'; +import { majorHeadlinesQueryOptions } from '../../../graphql/highlights'; +import { useAdQuery } from '../../../features/monetization/useAdQuery'; +import type { ViewabilityData } from '../../../features/monetization/viewability'; +import { viewabilityLogExtra } from '../../../features/monetization/viewability'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { usePlusSubscription } from '../../../hooks/usePlusSubscription'; +import { useVotePost } from '../../../hooks'; +import { useBookmarkPost } from '../../../hooks/useBookmarkPost'; +import { useCopyLink } from '../../../hooks/useCopy'; +import { ImpressionStatus } from '../../../hooks/feed/useLogImpression'; +import { adLogEvent, usePostLogEvent } from '../../../lib/feed'; +import { AdActions, AdPlacement } from '../../../lib/ads'; +import { LogEvent, Origin } from '../../../lib/log'; +import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; +import { FeedHeroSection } from './FeedHeroSection'; + +const HIGHLIGHT_COUNT = 6; +const FEATURED_POST_COUNT = 4; + +type AdSlot = { + ad?: Ad; + onAction: (action: AdActions, extra?: Record) => void; +}; + +/** + * One rail placement. Each slot keeps its own query key so the two ask the ad + * server separately and can come back with different creatives. + */ +const useHeroAdSlot = (slot: string, enabled: boolean): AdSlot => { + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + + const { data: ad } = useAdQuery({ + placement: AdPlacement.Feed, + queryKey: generateQueryKey(RequestKey.Ads, user, `feed-hero-${slot}`), + enabled, + staleTime: StaleTime.OneHour, + }); + + const onAction = useCallback( + (action: AdActions, extra?: Record) => { + if (!ad) { + return; + } + + logEvent( + adLogEvent(action, ad, { + extra: { origin: 'feed hero', slot, ...extra }, + }), + ); + }, + [ad, logEvent, slot], + ); + + useEffect(() => { + if (!ad || ad.impressionStatus === ImpressionStatus.LOGGED) { + return; + } + + onAction(AdActions.Impression); + ad.impressionStatus = ImpressionStatus.LOGGED; + }, [ad, onAction]); + + return { ad: ad ?? undefined, onAction }; +}; + +/** + * The carousel and the Happening Now list are the same headlines: the top few + * get their full post fetched for a card, the rest stay as rows. + */ +export const FeedHero = ({ + className, +}: { + className?: string; +}): ReactElement | null => { + const { user, tokenRefreshed } = useAuthContext(); + const { isPlus } = usePlusSubscription(); + const { logEvent } = useLogContext(); + const postLogEvent = usePostLogEvent(); + const { toggleUpvote, toggleDownvote } = useVotePost(); + const { toggleBookmark } = useBookmarkPost(); + const [, copyLink] = useCopyLink(); + + 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 adsEnabled = !isPlus && tokenRefreshed; + const rowAd = useHeroAdSlot('row', adsEnabled); + const cardAd = useHeroAdSlot('card', adsEnabled); + + const cardProps = useMemo( + () => ({ + onPostClick: (post: Post) => + logEvent( + postLogEvent(LogEvent.Click, post, { + extra: { origin: Origin.Feed }, + }), + ), + 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 (!posts.length) { + return null; + } + + return ( + rowAd.onAction(AdActions.Click)} + onAdViewable={(_, data: ViewabilityData) => + rowAd.onAction(AdActions.Viewable, viewabilityLogExtra(data)) + } + onCardAdLinkClick={() => cardAd.onAction(AdActions.Click)} + onCardAdViewable={(_, data: ViewabilityData) => + cardAd.onAction(AdActions.Viewable, viewabilityLogExtra(data)) + } + /> + ); +}; diff --git a/packages/shared/src/components/feeds/hero/FeedHeroAd.tsx b/packages/shared/src/components/feeds/hero/FeedHeroAd.tsx new file mode 100644 index 00000000000..0b271fa19d2 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroAd.tsx @@ -0,0 +1,86 @@ +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 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 { AdViewability } from '../../cards/ad/common/AdViewability'; +import PostTags from '../../cards/common/PostTags'; +import { ProfileImageSize } from '../../ProfilePicture'; +import { Image } from '../../image/Image'; +import classed from '../../../lib/classed'; + +const AdCover = classed(Image, 'h-full object-cover'); + +interface FeedHeroAdProps { + ad: Ad; + onLinkClick?: (ad: Ad) => unknown; + onViewable?: (ad: Ad, data: ViewabilityData) => void; + className?: string; +} + +export const FeedHeroAd = ({ + ad, + onLinkClick, + onViewable, + className, +}: FeedHeroAdProps): ReactElement => { + const matchingTags = ad.matchingTags ?? []; + + return ( +
+ +
+ + {ad.description} + +
+ + {/* The disclosure sits where a headline puts its timestamp, so it + takes that colour rather than the ad default. */} + +
+ {matchingTags.length > 0 && ( + + )} +
+ {!!ad.image && ( + + )} + {/* Out of flow so the row's `gap-4` doesn't reserve a column for it. */} +
+ +
+ {!!onViewable && ( + onViewable(ad, 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..1682a99cc48 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroAdCard.tsx @@ -0,0 +1,28 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { Ad } from '../../../graphql/posts'; +import type { ViewabilityData } from '../../../features/monetization/viewability'; +import { FlatCard } from '../../cards/common/Card'; +import { AdCardContent } from '../../cards/ad/common/AdCardContent'; + +interface FeedHeroAdCardProps { + ad: Ad; + onLinkClick?: (ad: Ad) => unknown; + onViewable?: (ad: Ad, data: ViewabilityData) => void; + className?: string; +} + +/** + * The feed's ad card, flattened for the hero rail: same creative and layout, + * without the card's own background and border so it sits on the page. + */ +export const FeedHeroAdCard = ({ + ad, + onLinkClick, + onViewable, + className, +}: FeedHeroAdCardProps): ReactElement => ( + + + +); 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..1f136fc93cc --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.spec.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, 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'; + +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): RenderResult => + render( + + + , + ); + +const getTitle = (title: string) => + screen.getByRole('heading', { name: title }); + +describe('FeedHeroCarousel', () => { + it('renders the first post and both neighbours as navigation labels', () => { + renderComponent(); + + expect(getTitle(titles[0])).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: `Next: ${titles[1]}` }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: `Previous: ${titles[2]}` }), + ).toBeInTheDocument(); + }); + + it('wraps around when paging past the last post', () => { + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: `Next: ${titles[1]}` })); + expect(getTitle(titles[1])).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: `Next: ${titles[2]}` })); + fireEvent.click(screen.getByRole('button', { name: `Next: ${titles[0]}` })); + expect(getTitle(titles[0])).toBeInTheDocument(); + }); + + it('jumps to the post picked from the indicators', () => { + renderComponent(); + + fireEvent.click( + screen.getByRole('button', { name: 'Show featured post 3' }), + ); + + expect(getTitle(titles[2])).toBeInTheDocument(); + }); + + it('hides the controls for a single post', () => { + renderComponent([posts[0]]); + + expect(getTitle(titles[0])).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /^Show featured post/ }), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('carouselProgress')).not.toBeInTheDocument(); + }); + + it('advances once the active indicator finishes filling', () => { + renderComponent(); + + const progress = screen.getByTestId('carouselProgress'); + expect( + screen.getByRole('button', { name: 'Show featured post 1' }), + ).toContainElement(progress); + + fireEvent.animationEnd(progress); + + expect(getTitle(titles[1])).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Show featured post 2' }), + ).toContainElement(screen.getByTestId('carouselProgress')); + }); + + it('keeps the outgoing post mounted until its fade finishes', () => { + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: `Next: ${titles[1]}` })); + + const leaving = screen.getByTestId('carouselOutgoing'); + expect(leaving).toHaveTextContent(titles[0]); + expect(getTitle(titles[1])).toBeInTheDocument(); + + fireEvent.animationEnd(leaving); + + expect(screen.queryByTestId('carouselOutgoing')).not.toBeInTheDocument(); + }); + + it('only announces a change the reader asked for', () => { + renderComponent(); + + const slide = getTitle(titles[0]).closest('[aria-live]'); + expect(slide).toHaveAttribute('aria-live', 'off'); + + fireEvent.click(screen.getByRole('button', { name: `Next: ${titles[1]}` })); + expect(getTitle(titles[1]).closest('[aria-live]')).toHaveAttribute( + 'aria-live', + 'polite', + ); + }); +}); 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..2b92d15845d --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.tsx @@ -0,0 +1,185 @@ +import type { CSSProperties, ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import type { FeaturedWideCardProps } from '../../cards/common/featuredWide'; +import { PostTypeToWideCard } from '../../cards/common/wideCards'; +import { PostTypeToListCard } from '../../cards/common/listCards'; +import { ArticleFeaturedWideGridCard } from '../../cards/article/ArticleFeaturedWideGridCard'; +import { ArticleList } from '../../cards/article/ArticleList'; +import { useViewSize, ViewSize } from '../../../hooks'; +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; + className?: string; +}; + +const wrapIndex = (index: number, total: number): number => + (index + total) % total; + +export const FeedHeroCarousel = ({ + posts, + autoplayMs = 6000, + className, + wideColSpan, + ...cardProps +}: FeedHeroCarouselProps): ReactElement | null => { + const [slide, setSlide] = useState<{ index: number; from: number | null }>({ + index: 0, + from: null, + }); + const [isManualChange, setIsManualChange] = useState(false); + // Below laptop the feed itself renders list cards, so the featured post does + // too — a two-column wide card leaves the headline about 180px on a phone. + const isLaptop = useViewSize(ViewSize.Laptop); + // The 40/60 split is only readable once the text column can still hold a + // headline; on a 1024px laptop it leaves about 230px, so that falls back to + // an even split. + const isLaptopL = useViewSize(ViewSize.LaptopL); + + if (!posts.length) { + return null; + } + + const total = posts.length; + const active = wrapIndex(slide.index, total); + + const moveTo = (position: number) => { + if (wrapIndex(position, total) === active) { + return; + } + setSlide({ index: position, from: active }); + }; + + const goTo = (position: number) => { + setIsManualChange(true); + moveTo(position); + }; + + const post = posts[active]; + const outgoing = slide.from === null ? null : posts[slide.from]; + const cardFor = (item: Post) => + isLaptop + ? PostTypeToWideCard[item.type] ?? ArticleFeaturedWideGridCard + : PostTypeToListCard[item.type] ?? ArticleList; + const Card = cardFor(post); + const wideProps = isLaptop + ? { wideColSpan: wideColSpan ?? (isLaptopL ? 5 : 2), hero: true } + : {}; + const previous = posts[wrapIndex(active - 1, total)]; + const next = posts[wrapIndex(active + 1, total)]; + + // The slide being replaced stays mounted on top of the new one until its + // fade finishes, 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..31b1c6c0dbe --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroSection.tsx @@ -0,0 +1,101 @@ +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 { useViewSize, ViewSize } from '../../../hooks'; +import { HighlightCardContent } from '../../cards/highlight/common'; +import { FeedHeroAd } from './FeedHeroAd'; +import { FeedHeroAdCard } from './FeedHeroAdCard'; +import { FeedHeroCarousel } from './FeedHeroCarousel'; + +interface FeedHeroSectionProps { + posts: Post[]; + highlights: PostHighlight[]; + /** Sits as the second row of the headline list. */ + ad?: Ad; + /** The full-size placement in its own column, beside the headlines. */ + cardAd?: Ad; + cardProps?: Omit; + onAdLinkClick?: (ad: Ad) => unknown; + onAdViewable?: (ad: Ad, data: ViewabilityData) => void; + onCardAdLinkClick?: (ad: Ad) => unknown; + onCardAdViewable?: (ad: Ad, data: ViewabilityData) => void; + onHighlightClick?: (highlight: PostHighlight, position: number) => void; + onReadAllClick?: () => void; + className?: string; +} + +export const FeedHeroSection = ({ + posts, + highlights, + ad, + cardAd, + cardProps, + onAdLinkClick, + onAdViewable, + onCardAdLinkClick, + onCardAdViewable, + onHighlightClick, + onReadAllClick, + className, +}: FeedHeroSectionProps): ReactElement => { + // Three columns only fit from 1360px. On a 1024px laptop they leave the ad + // about 220px, which drops its "Remove" control off the end, so that width + // keeps the two-column rail and sits the placement out. + const isLaptopL = useViewSize(ViewSize.LaptopL); + const hasAdColumn = !!cardAd && isLaptopL; + + return ( +
+ + + {!!cardAd && ( + + )} +
+ ); +}; diff --git a/packages/shared/src/components/feeds/hero/FeedSectionToolbar.tsx b/packages/shared/src/components/feeds/hero/FeedSectionToolbar.tsx new file mode 100644 index 00000000000..fe19af62272 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedSectionToolbar.tsx @@ -0,0 +1,63 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import Link from '../../utilities/Link'; +import { Tooltip } from '../../tooltip/Tooltip'; +import { Button } from '../../buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; +import { BookmarkIcon, FilterIcon, SearchIcon } from '../../icons'; + +interface FeedSectionToolbarProps { + title: string; + searchHref?: string; + bookmarksHref?: string; + onFiltersClick?: () => void; + className?: string; +} + +const iconLinkProps = { + tag: 'a', + variant: ButtonVariant.Tertiary, + size: ButtonSize.Medium, +} as const; + +export const FeedSectionToolbar = ({ + title, + searchHref, + bookmarksHref, + onFiltersClick, + className, +}: FeedSectionToolbarProps): ReactElement => ( +
+

{title}

+ {!!searchHref && ( + +
+ +
+
+ )} + {!!onFiltersClick && ( + +
+ + )} +
+); diff --git a/packages/shared/src/components/feeds/hero/useFeedHeroPreview.spec.ts b/packages/shared/src/components/feeds/hero/useFeedHeroPreview.spec.ts new file mode 100644 index 00000000000..24b73cd688e --- /dev/null +++ b/packages/shared/src/components/feeds/hero/useFeedHeroPreview.spec.ts @@ -0,0 +1,38 @@ +import { renderHook } from '@testing-library/react'; +import { useFeedHeroPreview } from './useFeedHeroPreview'; + +const setSearch = (search: string): void => { + window.history.replaceState({}, '', `/${search}`); +}; + +beforeEach(() => { + window.localStorage.clear(); + setSearch(''); +}); + +describe('useFeedHeroPreview', () => { + it('is off without the param', () => { + const { result } = renderHook(() => useFeedHeroPreview()); + + expect(result.current).toBe(false); + }); + + it('turns on with the param and remembers it', () => { + setSearch('?feed_hero=1'); + expect(renderHook(() => useFeedHeroPreview()).result.current).toBe(true); + + setSearch(''); + expect(renderHook(() => useFeedHeroPreview()).result.current).toBe(true); + }); + + it('turns back off with feed_hero=0', () => { + setSearch('?feed_hero=1'); + renderHook(() => useFeedHeroPreview()); + + setSearch('?feed_hero=0'); + expect(renderHook(() => useFeedHeroPreview()).result.current).toBe(false); + + setSearch(''); + expect(renderHook(() => useFeedHeroPreview()).result.current).toBe(false); + }); +}); diff --git a/packages/shared/src/components/feeds/hero/useFeedHeroPreview.ts b/packages/shared/src/components/feeds/hero/useFeedHeroPreview.ts new file mode 100644 index 00000000000..628b981a688 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/useFeedHeroPreview.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; + +const STORAGE_KEY = 'feed_hero_preview'; + +const readStored = (): boolean => { + try { + return globalThis.localStorage?.getItem(STORAGE_KEY) === '1'; + } catch { + return false; + } +}; + +const store = (enabled: boolean): void => { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, enabled ? '1' : '0'); + } catch { + // Private windows and blocked site data: the switch just won't stick. + } +}; + +/** + * Temporary review switch: `?feed_hero=1` turns the hero on for this browser, + * `?feed_hero=0` turns it back off. A preview deploy is a production build, so + * GrowthBook devtools can't force the flag there. Remove this once `feed_hero` + * is configured in GrowthBook — a URL that opts someone into an experiment arm + * would skew the allocation. + */ +export const useFeedHeroPreview = (): boolean => { + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + const param = new URLSearchParams(globalThis.location?.search).get( + 'feed_hero', + ); + + if (param === null) { + setEnabled(readStored()); + return; + } + + const next = param !== '0' && param !== 'false'; + store(next); + setEnabled(next); + }, []); + + return enabled; +}; diff --git a/packages/shared/src/hooks/useFeed.ts b/packages/shared/src/hooks/useFeed.ts index 3feecc7b36d..d2db9d63a39 100644 --- a/packages/shared/src/hooks/useFeed.ts +++ b/packages/shared/src/hooks/useFeed.ts @@ -204,6 +204,11 @@ export type FeedReturnType = { type UseFeedSettingParams = { adPostLength?: number; disableAds?: boolean; + /** + * Set when the surface already shows the highlights somewhere else (the feed + * hero), so the grid doesn't repeat them in a card. + */ + disableHighlightCards?: boolean; feedName?: string; staticAd?: { ad: Ad; index: number }; }; @@ -666,7 +671,7 @@ export default function useFeed( } if (node.itemType === 'highlight') { - if (!node.highlights.length) { + if (!node.highlights.length || settings?.disableHighlightCards) { return; } pushAndAdvance({ @@ -719,6 +724,7 @@ export default function useFeed( feedQuery.dataUpdatedAt, placeholdersPerPage, getAd, + settings?.disableHighlightCards, settings?.staticAd, heroCardsConfig, virtualizedNumCards, diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 40025801bce..25d35b75246 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -276,6 +276,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/styles/utilities.css b/packages/shared/src/styles/utilities.css index 81b7d7362dd..a26aa4a9b06 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -450,6 +450,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..392423c8fd4 --- /dev/null +++ b/packages/storybook/stories/features/feed/FeedHero.stories.tsx @@ -0,0 +1,338 @@ +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 { FeedHeroAd } from '@dailydotdev/shared/src/components/feeds/hero/FeedHeroAd'; +import { FeedHeroCarousel } from '@dailydotdev/shared/src/components/feeds/hero/FeedHeroCarousel'; +import { FeedHeroSection } from '@dailydotdev/shared/src/components/feeds/hero/FeedHeroSection'; +import { FeedSectionToolbar } from '@dailydotdev/shared/src/components/feeds/hero/FeedSectionToolbar'; +import { + adWithLongCopy, + adWithoutAdvertiser, + adWithoutImage, + adWithoutTags, + cardHandlers, + exploreCategories, + feedPosts, + FeedHeroProviders, + heroAd, + heroPosts, + highlights, + longTitleHeroPost, + mixedTypeHeroPosts, + noImageHeroPost, + readHeroPost, +} from './feedHero.mocks'; + +const Page = ({ children }: { children: ReactNode }): ReactElement => ( + +
+
+ {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: () => ( + + + + ), +}; + +// 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 same story rendered at each breakpoint. Laptop and up is the + two-column hero; below that the rail stacks under the carousel. +

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