From d1b755b9f035c2a2b1bbf9e6ce07202bc3d385e4 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 21 May 2026 10:02:35 +0300 Subject: [PATCH 1/9] feat(shared): add top active squads and popular tags wide feed cards Introduce 2x1 My Feed cards for curated top active squads and popular tags behind the my_feed_multi_card experiment, including grid packing, dev layout seeds, and squad hover cards on avatars. Co-authored-by: Cursor --- packages/shared/src/components/Feed.tsx | 427 ++++++++++-------- .../src/components/FeedItemComponent.tsx | 151 +++---- .../cards/article/PopularTagsGridCard.tsx | 231 ++++++++++ .../cards/article/TopSquadsGridCard.tsx | 236 ++++++++++ .../src/components/feeds/FeedContainer.tsx | 10 + packages/shared/src/graphql/squads.ts | 59 +-- .../shared/src/hooks/usePopularTagsWrapFit.ts | 159 +++++++ .../shared/src/hooks/useTopActiveSquads.ts | 97 ++++ packages/shared/src/lib/featureManagement.ts | 11 + .../shared/src/lib/feedGridPacker.spec.ts | 256 +++++++++++ packages/shared/src/lib/feedGridPacker.ts | 261 +++++++++++ .../shared/src/lib/feedLayoutHint.spec.ts | 119 +++++ packages/shared/src/lib/feedLayoutHint.ts | 155 +++++++ 13 files changed, 1846 insertions(+), 326 deletions(-) create mode 100644 packages/shared/src/components/cards/article/PopularTagsGridCard.tsx create mode 100644 packages/shared/src/components/cards/article/TopSquadsGridCard.tsx create mode 100644 packages/shared/src/hooks/usePopularTagsWrapFit.ts create mode 100644 packages/shared/src/hooks/useTopActiveSquads.ts create mode 100644 packages/shared/src/lib/feedGridPacker.spec.ts create mode 100644 packages/shared/src/lib/feedGridPacker.ts create mode 100644 packages/shared/src/lib/feedLayoutHint.spec.ts create mode 100644 packages/shared/src/lib/feedLayoutHint.ts diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index 55fffced7b2..52daa159281 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -52,7 +52,7 @@ import { useProfileCompletionCard } from '../hooks/profile/useProfileCompletionC import type { AllFeedPages } from '../lib/query'; import { OtherFeedPage, RequestKey } from '../lib/query'; -import { MarketingCtaVariant } from './marketing/cta/common'; +import { MarketingCtaVariant } from './marketingCta/common'; import { isNullOrUndefined } from '../lib/func'; import { useSearchResultsLayout } from '../hooks/search/useSearchResultsLayout'; import { SearchResultsLayout } from './search/SearchResults/SearchResultsLayout'; @@ -67,17 +67,23 @@ import { briefCardFeedFeature, briefFeedEntrypointPage, featureFeedAdTemplate, + featureMyFeedMultiCard, } from '../lib/featureManagement'; -import { useNewD1ExperienceFeature } from '../hooks/useNewD1ExperienceFeature'; +import { + getDevSeededLayoutHint, + getRawLayoutHintFromItem, + resolveLayoutHint, +} from '../lib/feedLayoutHint'; +import { packFeedItems } from '../lib/feedGridPacker'; +import { isDevelopment } from '../lib/constants'; import type { AwardProps } from '../graphql/njord'; import { getProductsQueryOptions } from '../graphql/njord'; import { useUpdateQuery } from '../hooks/useUpdateQuery'; import { BriefBannerFeed } from './cards/brief/BriefBanner/BriefBannerFeed'; import { ActionType } from '../graphql/actions'; -import { TopHero } from './marketing/banners/HeroBottomBanner'; +import { TopHero } from './banners/HeroBottomBanner'; import { useReadingReminderFeedHero } from '../hooks/notifications/useReadingReminderFeedHero'; -import { useLegacyPostLayoutOptOut } from './post/reader/hooks/useLegacyPostLayoutOptOut'; -import { useReaderModalEligibility } from './post/reader/hooks/useReaderModalEligibility'; +import { useTopActiveSquads } from '../hooks/useTopActiveSquads'; const FeedErrorScreen = dynamic( () => import(/* webpackChunkName: "feedErrorScreen" */ './FeedErrorScreen'), @@ -106,7 +112,6 @@ export interface FeedProps isHorizontal?: boolean; feedContainerRef?: React.Ref; disableListFrame?: boolean; - topContent?: ReactNode; } interface RankVariables { @@ -147,13 +152,6 @@ const SocialTwitterPostModal = dynamic( ), ); -const ReaderPostModal = dynamic( - () => - import( - /* webpackChunkName: "readerPostModal" */ './modals/ReaderPostModal' - ), -); - const BriefCardFeed = dynamic( () => import( @@ -173,19 +171,18 @@ const calculateRow = (index: number, numCards: number): number => const calculateColumn = (index: number, numCards: number): number => index % numCards; -export const PostModalMap: Partial> = - { - [PostType.Article]: ArticlePostModal, - [PostType.Share]: SharePostModal, - [PostType.Welcome]: SharePostModal, - [PostType.Freeform]: SharePostModal, - [PostType.VideoYouTube]: ArticlePostModal, - [PostType.Collection]: CollectionPostModal, - [PostType.Brief]: BriefPostModal, - [PostType.Digest]: ArticlePostModal, - [PostType.Poll]: PollPostModal, - [PostType.SocialTwitter]: SocialTwitterPostModal, - }; +export const PostModalMap: Record = { + [PostType.Article]: ArticlePostModal, + [PostType.Share]: SharePostModal, + [PostType.Welcome]: SharePostModal, + [PostType.Freeform]: SharePostModal, + [PostType.VideoYouTube]: ArticlePostModal, + [PostType.Collection]: CollectionPostModal, + [PostType.Brief]: BriefPostModal, + [PostType.Digest]: ArticlePostModal, + [PostType.Poll]: PollPostModal, + [PostType.SocialTwitter]: SocialTwitterPostModal, +}; export default function Feed({ feedName, @@ -210,7 +207,6 @@ export default function Feed({ isHorizontal = false, feedContainerRef, disableListFrame = false, - topContent: topContentProp, }: FeedProps): ReactElement { const origin = Origin.Feed; const { logEvent } = useLogContext(); @@ -232,8 +228,7 @@ export default function Feed({ const marketingCta = getMarketingCta(MarketingCtaVariant.Card) || getMarketingCta(MarketingCtaVariant.BriefCard) || - getMarketingCta(MarketingCtaVariant.YearInReview) || - getMarketingCta(MarketingCtaVariant.Video); + getMarketingCta(MarketingCtaVariant.YearInReview); const { plusEntryFeed } = usePlusEntry(); const hasDismissBriefCta = isActionsFetched && checkHasCompleted(ActionType.DisableBriefCardCta); @@ -263,11 +258,7 @@ export default function Feed({ feature: briefCardFeedFeature, shouldEvaluate: shouldEvaluateBriefCard, }); - const { value: isNewD1Experience } = useNewD1ExperienceFeature({ - shouldEvaluate: shouldEvaluateBriefCard, - }); - const showBriefCard = - shouldEvaluateBriefCard && briefCardFeatureValue && !isNewD1Experience; + const showBriefCard = shouldEvaluateBriefCard && briefCardFeatureValue; const [getProducts] = useUpdateQuery(getProductsQueryOptions()); const adTemplate = currentSettings.adTemplate ?? featureFeedAdTemplate.defaultValue?.default ?? { adStart: 1 }; @@ -320,6 +311,59 @@ export default function Feed({ const { onMenuClick, postMenuIndex, postMenuLocation } = useFeedContextMenu(); const useList = isListMode && numCards > 1; const virtualizedNumCards = useList ? 1 : numCards; + const isMobile = !useViewSize(ViewSize.Tablet); + const { value: isMyFeedMultiCardEnabled } = useConditionalFeature({ + feature: featureMyFeedMultiCard, + shouldEvaluate: isMyFeed, + }); + const isMultiCardLayoutEnabled = + isMyFeed && + isMyFeedMultiCardEnabled && + !useList && + !shouldUseListFeedLayout && + !isHorizontal && + !isMobile && + virtualizedNumCards > 1; + const placements = useMemo(() => { + const hints = items.map((item, itemIndex) => { + const rawHint = + getRawLayoutHintFromItem(item) ?? + (isDevelopment && isMultiCardLayoutEnabled + ? getDevSeededLayoutHint(itemIndex) + : undefined); + return resolveLayoutHint({ + rawHint, + itemType: item.type, + isMobile, + isDisabled: !isMultiCardLayoutEnabled, + }); + }); + return packFeedItems({ hints, columns: virtualizedNumCards }); + }, [items, virtualizedNumCards, isMobile, isMultiCardLayoutEnabled]); + // Horizontal-wide (2x1) slots alternate top-active-squads and popular-tags. + const horizontalWideVariantByIndex = useMemo(() => { + const map = new Map(); + const sequence = ['topSquads', 'popularTags'] as const; + let wideCount = 0; + placements.forEach((placement, index) => { + if (placement.colSpan > 1 && placement.rowSpan === 1) { + map.set(index, sequence[wideCount % sequence.length]); + wideCount += 1; + } + }); + return map; + }, [placements]); + const hasTopSquadsSlot = useMemo( + () => + Array.from(horizontalWideVariantByIndex.values()).some( + (variant) => variant === 'topSquads', + ), + [horizontalWideVariantByIndex], + ); + const { squads: topActiveSquads, isPending: isTopActiveSquadsPending } = + useTopActiveSquads({ + enabled: isMyFeed && isMultiCardLayoutEnabled && hasTopSquadsSlot, + }); const showFirstSlotCard = showProfileCompletionCard || showBriefCard; const { onOpenModal, @@ -336,35 +380,6 @@ export default function Feed({ canFetchMore, feedName, }); - const { - isEligible: isReaderEligible, - isReaderModalEnabled: readerModalFromGrowthBook, - isReaderFeatureLoading, - } = useReaderModalEligibility(); - const { isOptedOut: isLegacyLayoutOptedOut } = useLegacyPostLayoutOptOut(); - const isTabletViewport = useViewSize(ViewSize.Tablet); - const isReaderModalOn = - isReaderEligible && - readerModalFromGrowthBook && - !isLegacyLayoutOptedOut && - isTabletViewport; - const isReaderModalFeatureReady = !isReaderFeatureLoading; - const readerEligiblePostTypes = useMemo( - () => - new Set([ - PostType.Article, - PostType.Digest, - PostType.VideoYouTube, - ]), - [], - ); - const isReaderEligiblePost = useCallback( - (post: Post): boolean => - isReaderModalFeatureReady && - isReaderModalOn && - readerEligiblePostTypes.has(post.type), - [isReaderModalFeatureReady, isReaderModalOn, readerEligiblePostTypes], - ); const { adjustedHeroInsertIndex, shouldShowTopHero, @@ -540,25 +555,6 @@ export default function Feed({ [openSharePost, virtualizedNumCards], ); - const PostModal = useMemo(() => { - if (!selectedPost) { - return undefined; - } - const readerEligibleTypes = new Set([ - PostType.Article, - PostType.Digest, - PostType.VideoYouTube, - ]); - if ( - isReaderModalFeatureReady && - isReaderModalOn && - readerEligibleTypes.has(selectedPost.type) - ) { - return ReaderPostModal; - } - return PostModalMap[selectedPost.type]; - }, [selectedPost, isReaderModalFeatureReady, isReaderModalOn]); - if (!loadedSettings || isFallback) { return <>; } @@ -591,25 +587,11 @@ export default function Feed({ row, column, isAuxClick, - event, ) => { - const isMiddleClick = event?.type === 'auxclick' || event?.button === 1; - const isModifierClick = !!(event && (event.ctrlKey || event.metaKey)); - const readerEligible = isReaderEligiblePost(post); - const skipsPostModal = post.type === PostType.LiveRoom; - const shouldOpenModal = - !skipsPostModal && - !isAuxClick && - !isMiddleClick && - !isModifierClick && - (!shouldUseListFeedLayout || readerEligible); - if (shouldOpenModal && shouldUseListFeedLayout && event) { - event.preventDefault(); - } await onPostClick(post, index, row, column, { skipPostUpdate: true, }); - if (shouldOpenModal) { + if (!isAuxClick && !shouldUseListFeedLayout) { onPostModalOpen({ index, row, column }); } }; @@ -640,11 +622,13 @@ export default function Feed({ is_ad: isAd, }), ); - if (!shouldUseListFeedLayout || isReaderEligiblePost(post)) { + if (!shouldUseListFeedLayout) { onPostModalOpen({ index, row, column }); } }; + const PostModal = selectedPost ? PostModalMap[selectedPost.type] : undefined; + if (isError) { return ; } @@ -671,17 +655,15 @@ export default function Feed({ const containerProps = isSearchPageLaptop ? {} : { - topContent: - topContentProp ?? - (shouldShowTopHero ? ( - onEnableHero(NotificationCtaPlacement.TopHero)} - onClose={() => onDismissHero(NotificationCtaPlacement.TopHero)} - /> - ) : undefined), + topContent: shouldShowTopHero ? ( + onEnableHero(NotificationCtaPlacement.TopHero)} + onClose={() => onDismissHero(NotificationCtaPlacement.TopHero)} + /> + ) : undefined, header, inlineHeader, className, @@ -692,6 +674,7 @@ export default function Feed({ feedContainerRef, showBriefCard, disableListFrame, + isMultiCardLayout: isMultiCardLayoutEnabled, }; return ( @@ -716,87 +699,159 @@ export default function Feed({ }} /> )} - {items.map((item, index) => ( - - {showPromoBanner && index === indexWhenShowingPromoBanner && ( - - )} - {shouldShowInFeedHero && index === adjustedHeroInsertIndex && ( -
- - onEnableHero(NotificationCtaPlacement.InFeedHero) - } - onClose={() => - onDismissHero(NotificationCtaPlacement.InFeedHero) - } + {items.map((item, index) => { + const placement = placements[index]; + const itemRow = + placement?.row ?? calculateRow(index, virtualizedNumCards); + const itemColumn = + placement?.column ?? + calculateColumn(index, virtualizedNumCards); + const shouldApplySpan = + isMultiCardLayoutEnabled && + !!placement && + (placement.colSpan > 1 || placement.rowSpan > 1); + const horizontalWideVariant = + horizontalWideVariantByIndex.get(index); + const isTopSquadsSlot = horizontalWideVariant === 'topSquads'; + const isPopularTagsSlot = + horizontalWideVariant === 'popularTags'; + const topActiveSquadsForCard = isTopSquadsSlot + ? topActiveSquads + : undefined; + // The packer chooses an exact column and row for every spanned + // card. Pin both axes so CSS Grid `dense` flow honours the + // planned placement instead of re-packing from left-to-right. + // This matters for `1x2` verticals (colSpan=1) where omitting + // gridColumn would let the browser auto-place them in the first + // free column rather than the rightmost slot the packer picked. + const spanStyle = shouldApplySpan + ? { + gridColumn: `${placement.column + 1} / span ${placement.colSpan}`, + gridRow: `${placement.row + 1} / span ${placement.rowSpan}`, + } + : undefined; + return ( + + {showPromoBanner && index === indexWhenShowingPromoBanner && ( + + )} + {shouldShowInFeedHero && + index === adjustedHeroInsertIndex && ( +
+ + onEnableHero(NotificationCtaPlacement.InFeedHero) + } + onClose={() => + onDismissHero(NotificationCtaPlacement.InFeedHero) + } + /> +
+ )} + {shouldApplySpan ? ( +
*]:h-full` lets the inner card fill the + // assigned grid track(s); removing `max-h-cardLarge` + // is required so vertical spans (`rowSpan > 1`) aren't + // clamped to a single-row card height. + className="flex h-full w-full [&>*]:h-full [&>*]:w-full [&_.max-h-cardLarge]:max-h-none" + style={spanStyle} + data-testid="feedItemSpanWrapper" + > + +
+ ) : ( + -
- )} - -
- ))} + )} + + ); + })} {!isFetching && !isInitialLoading && !isHorizontal && ( )} - {selectedPost && - PostModal && - (!shouldUseListFeedLayout || - isReaderEligiblePost(selectedPost)) && ( - - )} + {!shouldUseListFeedLayout && selectedPost && PostModal && ( + + )} )} diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 594ae9b9e8d..8a98c561d75 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -10,12 +10,12 @@ import { isSocialTwitterPost, PostType } from '../graphql/posts'; import type { LoggedUser } from '../lib/user'; import useLogImpression from '../hooks/feed/useLogImpression'; import type { FeedPostClick } from '../hooks/feed/useFeedOnPostClick'; -import { LogEvent, Origin, TargetType } from '../lib/log'; +import { Origin, TargetType } from '../lib/log'; import type { UseVotePost } from '../hooks'; import { useFeedLayout } from '../hooks'; import { CollectionList } from './cards/collection/CollectionList'; -import { MarketingCtaCard } from './marketing/cta'; -import { MarketingCtaList } from './marketing/cta/MarketingCtaList'; +import { MarketingCtaCard } from './marketingCta'; +import { MarketingCtaList } from './marketingCta/MarketingCtaList'; import { FeedItemType } from './cards/common/common'; import { AdGrid } from './cards/ad/AdGrid'; import { AdList } from './cards/ad/AdList'; @@ -28,6 +28,10 @@ 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 { TopSquadsGridCard } from './cards/article/TopSquadsGridCard'; +import { PopularTagsGridCard } from './cards/article/PopularTagsGridCard'; +import type { PopularTagItem } from './cards/article/PopularTagsGridCard'; +import type { TopActiveSquad } from '../hooks/useTopActiveSquads'; import { ShareGrid } from './cards/share/ShareGrid'; import { ShareList } from './cards/share/ShareList'; import { CollectionGrid } from './cards/collection'; @@ -41,26 +45,18 @@ import { ActivePostContextProvider } from '../contexts/ActivePostContext'; import { LogExtraContextProvider } from '../contexts/LogExtraContext'; import { SquadAdList } from './cards/ad/squad/SquadAdList'; import { SquadAdGrid } from './cards/ad/squad/SquadAdGrid'; -import { adLogEvent, feedHighlightsLogEvent, feedLogExtra } from '../lib/feed'; -import { findCreativeForTags } from '../lib/engagementAds'; -import { useEngagementAdsContext } from '../contexts/EngagementAdsContext'; +import { adLogEvent, feedLogExtra } from '../lib/feed'; import { useLogContext } from '../contexts/LogContext'; -import { MarketingCtaVariant } from './marketing/cta/common'; -import { MarketingCtaBriefing } from './marketing/cta/MarketingCtaBriefing'; -import { MarketingCtaYearInReview } from './marketing/cta/MarketingCtaYearInReview'; -import { MarketingCtaVideo } from './marketing/cta/MarketingCtaVideo'; +import { MarketingCtaVariant } from './marketingCta/common'; +import { MarketingCtaBriefing } from './marketingCta/MarketingCtaBriefing'; +import { MarketingCtaYearInReview } from './marketingCta/MarketingCtaYearInReview'; 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'; -import { HighlightGrid } from './cards/highlight/HighlightGrid'; -import { HighlightList } from './cards/highlight/HighlightList'; -import { getHighlightIds, getHighlightIdsKey } from '../graphql/highlights'; export type FeedItemComponentProps = { item: FeedItem; @@ -98,6 +94,11 @@ export type FeedItemComponentProps = { ) => unknown; virtualizedNumCards: number; disableAdRefresh?: boolean; + renderAsTopSquadsCard?: boolean; + topActiveSquads?: TopActiveSquad[]; + topActiveSquadsPending?: boolean; + renderAsPopularTagsCard?: boolean; + popularTags?: PopularTagItem[]; } & Pick & Pick; @@ -105,8 +106,6 @@ export function getFeedItemKey(item: FeedItem, index: number): string { switch (item.type) { case 'post': return item.post.id; - case 'highlight': - return getHighlightIdsKey(item.highlights) || `highlight-${index}`; case 'ad': return `ad-${index}`; default: @@ -126,7 +125,6 @@ const PostTypeToTagCard: Record> = { [PostType.Poll]: PollGrid, [PostType.SocialTwitter]: SocialTwitterGrid, [PostType.Digest]: ArticleGrid, - [PostType.LiveRoom]: LiveRoomPostGrid, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -141,7 +139,6 @@ const PostTypeToTagList: Record> = { [PostType.Poll]: PollList, [PostType.SocialTwitter]: SocialTwitterList, [PostType.Digest]: ArticleList, - [PostType.LiveRoom]: LiveRoomPostList, }; const getPostTypeForCard = (post?: Post): PostType => { @@ -199,7 +196,6 @@ export const withFeedLogExtraContext = ( props: FeedItemComponentProps, ): ReactElement | null => { const { item } = props; - const { creatives } = useEngagementAdsContext(); if ([FeedItemType.Ad, FeedItemType.Post].includes(item?.type)) { return ( @@ -221,17 +217,6 @@ export const withFeedLogExtraContext = ( extraData.referrer_target_type = post?.id ? TargetType.Post : undefined; - - if ( - item.type === FeedItemType.Post && - post?.tags && - creatives.length > 0 - ) { - const creative = findCreativeForTags(creatives, post.tags); - if (creative) { - extraData.gen_id = creative.genId; - } - } } if (isBoostedSquadAd(item)) { @@ -280,6 +265,12 @@ function FeedItemComponent({ onCommentClick, onReadArticleClick, virtualizedNumCards, + disableAdRefresh, + renderAsTopSquadsCard = false, + topActiveSquads, + topActiveSquadsPending = false, + renderAsPopularTagsCard = false, + popularTags, }: FeedItemComponentProps): ReactElement | null { const { logEvent } = useLogContext(); const inViewRef = useLogImpression( @@ -294,54 +285,6 @@ function FeedItemComponent({ const { shouldUseListFeedLayout, shouldUseListMode } = useFeedLayout(); const { boostedBy } = useFeedCardContext(); - - if (item.type === FeedItemType.Highlight) { - const HighlightTag = - shouldUseListFeedLayout || shouldUseListMode - ? HighlightList - : HighlightGrid; - const highlightIds = getHighlightIds(item.highlights); - - return ( - { - logEvent( - feedHighlightsLogEvent(LogEvent.Click, { - columns: virtualizedNumCards, - column, - row, - feedName, - ranking, - action: 'read_all_click', - count: item.highlights.length, - highlightIds, - feedMeta: item.feedMeta, - }), - ); - }} - onHighlightClick={(highlight, position) => { - logEvent( - feedHighlightsLogEvent(LogEvent.Click, { - columns: virtualizedNumCards, - column, - row, - feedName, - ranking, - action: 'highlight_click', - position, - count: item.highlights.length, - clickedHighlight: highlight, - highlightIds, - feedMeta: item.feedMeta, - }), - ); - }} - /> - ); - } - const { PostTag, AdTag, @@ -395,8 +338,35 @@ function FeedItemComponent({ return null; } - return ( - + const isPostItem = item.type === FeedItemType.Post; + const renderTopSquads = renderAsTopSquadsCard && isPostItem; + const renderPopularTags = + renderAsPopularTagsCard && isPostItem && !renderTopSquads; + + let postBody: ReactElement; + if (renderTopSquads) { + postBody = ( + + ); + } else if (renderPopularTags) { + postBody = ( + + ); + } else { + postBody = ( - onPostClick(post, index, row, column, false, event) - } + onPostClick={(post: Post) => onPostClick(post, index, row, column)} onPostAuxClick={(post: Post) => onPostClick(post, index, row, column, true) } @@ -463,6 +431,12 @@ function FeedItemComponent({ > {item.type === FeedItemType.Ad && } + ); + } + + return ( + + {postBody} ); } @@ -479,6 +453,11 @@ function FeedItemComponent({ index={item.index} feedIndex={index} onLinkClick={(ad: Ad) => onAdAction(AdActions.Click, ad)} + onRefresh={ + disableAdRefresh + ? undefined + : (ad: Ad) => onAdAction(AdActions.Refresh, ad) + } /> ); } @@ -493,10 +472,6 @@ function FeedItemComponent({ return ; } - if (item.marketingCta.variant === MarketingCtaVariant.Video) { - return ; - } - return ( void; +}): ReactElement => { + const keyword = tag.slug; + + if (isFollowed) { + return ( +
  • + + + + #{rank} + + + {tag.name} + + + +
  • + ); + } + + return ( +
  • + + + + #{rank} + + + {tag.name} + + + + + +
  • + ); +}; + +export const PopularTagsGridCard = forwardRef(function PopularTagsGridCard( + { + post, + domProps = {}, + children, + tags = POPULAR_TAGS, + }: PopularTagsGridCardProps, + ref: Ref, +): ReactElement { + const { className, style } = domProps; + const { isLoggedIn } = useAuthContext(); + const { feedId, isCustomFeed } = useCustomFeed(); + const { feedSettings } = useFeedSettings({ + feedId: isCustomFeed ? feedId : undefined, + }); + const { onFollowTags } = useTagAndSource({ + origin: Origin.PostTags, + feedId, + shouldInvalidateQueries: false, + }); + + const followedTagSet = useMemo( + () => new Set(feedSettings?.includeTags ?? []), + [feedSettings?.includeTags], + ); + + const onFollowTag = useCallback( + (keyword: string) => { + onFollowTags({ + tags: [keyword], + requireLogin: true, + }); + }, + [onFollowTags], + ); + + const { listRef, visibleTags } = usePopularTagsWrapFit(tags); + + return ( + +
    +
    +

    + Most followed tags +

    +
    +
      + {visibleTags.map((tag, index) => ( + + ))} +
    + {children} +
    +
    + ); +}); diff --git a/packages/shared/src/components/cards/article/TopSquadsGridCard.tsx b/packages/shared/src/components/cards/article/TopSquadsGridCard.tsx new file mode 100644 index 00000000000..63cb87b7075 --- /dev/null +++ b/packages/shared/src/components/cards/article/TopSquadsGridCard.tsx @@ -0,0 +1,236 @@ +import type { MouseEvent, ReactElement, Ref } from 'react'; +import React, { forwardRef, useCallback } from 'react'; +import classNames from 'classnames'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { PostCardProps } from '../common/common'; +import FeedItemContainer from '../common/FeedItemContainer'; +import { anchorDefaultRel } from '../../../lib/strings'; +import type { TopActiveSquad } from '../../../hooks/useTopActiveSquads'; +import type { SourceTooltip, Squad } from '../../../graphql/sources'; +import { SourceType } from '../../../graphql/sources'; +import SourceButton from '../common/SourceButton'; +import { ProfileImageSize } from '../../ProfilePicture'; +import { Button } from '../../buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useJoinSquad, useToastNotification } from '../../../hooks'; +import { labels } from '../../../lib'; +import { AuthTriggers } from '../../../lib/auth'; +import type { SquadStaticData } from '../../../graphql/squads'; +import { RequestKey } from '../../../lib/query'; + +const SKELETON_KEYS = Array.from( + { length: 8 }, + (_, index) => `skeleton-${index}`, +); + +const TOP_ACTIVE_SQUAD_PLACEHOLDER_ID_PREFIX = 'top-active-squad-'; + +interface TopSquadsGridCardProps extends PostCardProps { + squads?: TopActiveSquad[]; + isPending?: boolean; +} + +const formatMembersCount = (count?: number): string | null => { + if (typeof count !== 'number' || count <= 0) { + return null; + } + + if (count >= 1000) { + return `${(count / 1000).toFixed(count >= 10000 ? 0 : 1)}k members`; + } + + return `${count} members`; +}; + +const isPlaceholderTopActiveSquadId = (id: string): boolean => + id.startsWith(TOP_ACTIVE_SQUAD_PLACEHOLDER_ID_PREFIX); + +const toSourceTooltip = (squad: TopActiveSquad): SourceTooltip => ({ + id: squad.id, + name: squad.name, + image: squad.image, + handle: squad.handle, + permalink: squad.permalink, + type: SourceType.Squad, + membersCount: squad.membersCount, +}); + +const SquadRowJoinButton = ({ + squad, +}: { + squad: TopActiveSquad; +}): ReactElement | null => { + const queryClient = useQueryClient(); + const { user, showLogin } = useAuthContext(); + const { displayToast } = useToastNotification(); + + const squadForJoin: Pick = { + id: squad.id, + handle: squad.handle, + }; + + const { mutateAsync: joinSquad, isPending: isJoining } = useMutation({ + mutationFn: useJoinSquad({ squad: squadForJoin }), + onError: () => { + displayToast(labels.error.generic); + }, + onSuccess: (joined) => { + displayToast(`🙌 You joined the Squad ${joined.name}`); + queryClient.setQueryData( + [RequestKey.Squad, 'top-active', squad.handle], + (prev) => { + if (!prev) { + return prev; + } + + return { + ...prev, + currentMember: joined.currentMember, + membersCount: joined.membersCount, + }; + }, + ); + }, + }); + + const onJoinClick = useCallback( + async (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (!user) { + showLogin({ + trigger: AuthTriggers.JoinSquad, + options: { + onLoginSuccess: () => joinSquad(), + onRegistrationSuccess: () => joinSquad(), + }, + }); + + return; + } + + await joinSquad(); + }, + [joinSquad, showLogin, user], + ); + + return ( + + ); +}; + +const SquadRow = ({ + squad, + rank, +}: { + squad: TopActiveSquad; + rank: number; +}): ReactElement => { + const membersLabel = formatMembersCount(squad.membersCount); + const showJoinButton = + !isPlaceholderTopActiveSquadId(squad.id) && !squad.currentMember; + + return ( +
    + + {showJoinButton && } +
    + ); +}; + +export const TopSquadsGridCard = forwardRef(function TopSquadsGridCard( + { + post, + domProps = {}, + children, + squads = [], + isPending = false, + }: TopSquadsGridCardProps, + ref: Ref, +): ReactElement { + const { className, style } = domProps; + + return ( + +
    +
    +

    + Top active squads +

    +

    + Most active public squads over the last 30 days +

    +
    +
    + {isPending && squads.length === 0 + ? SKELETON_KEYS.map((key) => ( +
    + + + +
    + )) + : squads.map((squad, index) => ( + + ))} +
    + {children} +
    +
    + ); +}); diff --git a/packages/shared/src/components/feeds/FeedContainer.tsx b/packages/shared/src/components/feeds/FeedContainer.tsx index dbcac7df244..ae02f61edb6 100644 --- a/packages/shared/src/components/feeds/FeedContainer.tsx +++ b/packages/shared/src/components/feeds/FeedContainer.tsx @@ -46,6 +46,11 @@ export interface FeedContainerProps { feedContainerRef?: React.Ref; showBriefCard?: boolean; disableListFrame?: boolean; + /** + * When true, enable CSS grid `dense` auto-flow so smaller items backfill + * holes left by wide multi-card variants pushed to the next row. + */ + isMultiCardLayout?: boolean; } const listGapClass = 'gap-2'; @@ -132,6 +137,7 @@ export const FeedContainer = ({ feedContainerRef, showBriefCard, disableListFrame = false, + isMultiCardLayout = false, }: FeedContainerProps): ReactElement => { const currentSettings = useContext(FeedContext); const { subject } = useToastNotification(); @@ -325,6 +331,10 @@ export const FeedContainer = ({ isSearch && !shouldUseListFeedLayout && !isAnyExplore && 'mt-8', isHorizontal && 'no-scrollbar snap-x snap-mandatory grid-flow-col overflow-x-scroll scroll-smooth py-2 pt-5', + isMultiCardLayout && + !isList && + !isHorizontal && + 'grid-flow-row-dense', gapClass({ isList, isFeedLayoutList: shouldUseListFeedLayout, diff --git a/packages/shared/src/graphql/squads.ts b/packages/shared/src/graphql/squads.ts index 6a7e00ce3a3..d5f57aac5a5 100644 --- a/packages/shared/src/graphql/squads.ts +++ b/packages/shared/src/graphql/squads.ts @@ -1,6 +1,6 @@ import { gql } from 'graphql-request'; -import { subDays } from 'date-fns'; import { + CURRENT_MEMBER_FRAGMENT, SHARED_POST_INFO_FRAGMENT, SOURCE_BASE_FRAGMENT, SQUAD_BASE_FRAGMENT, @@ -25,7 +25,6 @@ import { RequestKey, StaleTime } from '../lib/query'; import { PrivacyOption } from '../components/squads/settings/SquadPrivacySection'; import type { Author } from './comments'; import { OrganizationMemberRole } from '../features/organizations/types'; -import type { UserShortProfile } from '../lib/user'; type BaseSquadForm = Pick< Squad, @@ -291,44 +290,6 @@ export const SQUAD_ANALYTICS_HISTORY_QUERY = gql` } `; -export const TOP_MEMBERS_BY_SQUAD_QUERY = gql` - query TopMembersBySquad($sourceId: ID!, $since: DateTime!, $limit: Int) { - topMembersBySquad(sourceId: $sourceId, since: $since, limit: $limit) { - ...UserShortInfo - } - } - ${USER_SHORT_INFO_FRAGMENT} -`; - -export interface TopMembersBySquadData { - topMembersBySquad: UserShortProfile[]; -} - -export const MAX_TOP_MEMBERS_BY_SQUAD = 10; - -export const getTopMembersBySquadSince = (): string => { - const since = subDays(new Date(), 30); - since.setUTCHours(0, 0, 0, 0); - - return since.toISOString(); -}; - -export async function getTopMembersBySquad( - sourceId: string, - limit = MAX_TOP_MEMBERS_BY_SQUAD, -): Promise { - const res = await gqlClient.request( - TOP_MEMBERS_BY_SQUAD_QUERY, - { - sourceId, - since: getTopMembersBySquadSince(), - limit, - }, - ); - - return res.topMembersBySquad; -} - export const SQUAD_STATIC_FIELDS_QUERY = gql` query Source($handle: ID!) { source(id: $handle) { @@ -343,8 +304,12 @@ export const SQUAD_STATIC_FIELDS_QUERY = gql` moderationRequired membersCount createdAt + currentMember { + ...CurrentMember + } } } + ${CURRENT_MEMBER_FRAGMENT} `; export type SquadStaticData = Pick< @@ -360,6 +325,7 @@ export type SquadStaticData = Pick< | 'permalink' | 'membersCount' | 'createdAt' + | 'currentMember' >; export const getSquadStaticFields = async ( @@ -526,18 +492,7 @@ export async function getSquad(handle: string): Promise { const res = await gqlClient.request(SQUAD_QUERY, { handle: handle.toLowerCase(), }); - const squad = res.source; - - if (!squad.public || !squad.id) { - return squad; - } - - const topMembers = await getTopMembersBySquad(squad.id); - - return { - ...squad, - topMembers, - }; + return res.source; } export const squadAnalyticsQueryOptions = ({ diff --git a/packages/shared/src/hooks/usePopularTagsWrapFit.ts b/packages/shared/src/hooks/usePopularTagsWrapFit.ts new file mode 100644 index 00000000000..12642f6435a --- /dev/null +++ b/packages/shared/src/hooks/usePopularTagsWrapFit.ts @@ -0,0 +1,159 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import type { RefObject } from 'react'; +import { flushSync } from 'react-dom'; + +/** Upper bound on tags passed to the measurer (matches curated list size). */ +export const POPULAR_TAGS_WRAP_FIT_CAP = 64; + +type ChipBox = { top: number; bottom: number }; + +/** + * `offsetTop` on flex children is relative to `offsetParent` (often a positioned + * ancestor), not the wrapping `ul`. Use rects relative to the list container. + */ +function measureChipBoxes(container: HTMLElement): ChipBox[] { + const c = container.getBoundingClientRect(); + + return Array.from(container.children).map((child) => { + const r = (child as HTMLElement).getBoundingClientRect(); + + return { + top: r.top - c.top, + bottom: r.bottom - c.top, + }; + }); +} + +function groupFlexWrapRows(boxes: ChipBox[]): ChipBox[][] { + return boxes.reduce((rows, box) => { + const topKey = Math.round(box.top); + const prev = rows[rows.length - 1]; + if (!prev || Math.round(prev[0].top) !== topKey) { + rows.push([box]); + } else { + prev.push(box); + } + return rows; + }, []); +} + +function countWrappedChipsThatFit(container: HTMLElement): number { + const available = container.clientHeight; + if (available < 1) { + return 0; + } + + const boxes = measureChipBoxes(container); + if (boxes.length === 0) { + return 0; + } + + const rows = groupFlexWrapRows(boxes); + + const { total } = rows.reduce( + (acc, row) => { + if (acc.stop) { + return acc; + } + const rowBottom = Math.max(...row.map((b) => b.bottom)); + if (rowBottom > available + 0.5) { + return { ...acc, stop: true }; + } + return { total: acc.total + row.length, stop: false }; + }, + { total: 0, stop: false }, + ); + + return total; +} + +/** + * Renders as many tag chips as fit in a flex-wrapped list (no inner scroll). + * Re-measures when candidates or container size change. + */ +export function usePopularTagsWrapFit( + tags: readonly T[], +): { listRef: RefObject; visibleTags: T[] } { + const candidates = useMemo( + () => tags.slice(0, POPULAR_TAGS_WRAP_FIT_CAP), + [tags], + ); + + const listRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(() => candidates.length); + const zeroHeightRetriesRef = useRef(0); + + useLayoutEffect(() => { + zeroHeightRetriesRef.current = 0; + }, [candidates]); + + const recompute = useCallback(() => { + const el = listRef.current; + if (!el) { + return; + } + + if (candidates.length === 0) { + zeroHeightRetriesRef.current = 0; + flushSync(() => { + setVisibleCount(0); + }); + return; + } + + flushSync(() => { + setVisibleCount(candidates.length); + }); + + const available = el.clientHeight; + if (available < 1) { + if (zeroHeightRetriesRef.current < 8) { + zeroHeightRetriesRef.current += 1; + requestAnimationFrame(() => { + recompute(); + }); + } + return; + } + + zeroHeightRetriesRef.current = 0; + + const fit = countWrappedChipsThatFit(el); + const next = Math.max(1, Math.min(fit, candidates.length)); + + if (next !== candidates.length) { + flushSync(() => { + setVisibleCount(next); + }); + } + }, [candidates]); + + useLayoutEffect(() => { + recompute(); + }, [recompute]); + + useLayoutEffect(() => { + const el = listRef.current; + if (!el) { + return undefined; + } + + const ro = new ResizeObserver(() => { + requestAnimationFrame(() => { + recompute(); + }); + }); + + ro.observe(el); + return () => { + ro.disconnect(); + }; + }, [recompute]); + + const visibleTags = useMemo( + () => candidates.slice(0, visibleCount), + [candidates, visibleCount], + ); + + return { listRef, visibleTags }; +} diff --git a/packages/shared/src/hooks/useTopActiveSquads.ts b/packages/shared/src/hooks/useTopActiveSquads.ts new file mode 100644 index 00000000000..01a2215129c --- /dev/null +++ b/packages/shared/src/hooks/useTopActiveSquads.ts @@ -0,0 +1,97 @@ +import { useMemo } from 'react'; +import { useQueries } from '@tanstack/react-query'; +import type { Squad } from '../graphql/sources'; +import type { SquadStaticData } from '../graphql/squads'; +import { getSquadStaticFields } from '../graphql/squads'; +import { RequestKey, StaleTime } from '../lib/query'; + +const TOP_SQUAD_PLACEHOLDER_IMAGE = + 'https://media.daily.dev/image/upload/v1672041320/squads/squad_placeholder.jpg'; + +/** + * Mirrors the curated list rendered in the explore page's + * `Top active squads` strip — the most active public squads over the last + * 30 days. Kept inline because the list is currently FE-curated. + */ +export const TOP_ACTIVE_SQUADS_30D = [ + { name: 'PHP Dev', handle: 'phpdev' }, + { name: 'Machine Learning News', handle: 'mlnews' }, + { name: 'World of Technology', handle: 'thejvmbender' }, + { name: 'Smarter Articles', handle: 'smarterarticles' }, + { name: 'Build With GenAI', handle: 'buildwithgenai' }, + { name: 'Daily Open Source Tools', handle: 'dailyopensourcetools' }, + { name: 'DevOps Daily', handle: 'devopsdaily' }, + { name: 'All Pc Softs', handle: 'allpcsofts' }, + { name: 'Horde', handle: 'horde' }, + { name: 'Grimspin', handle: 'grimspin' }, + { name: 'Devs Together Strong', handle: 'devstogetherstrong' }, + { name: 'Lonely Programmer', handle: 'lonely_programmer' }, + { name: 'Dev World', handle: 'dev_world' }, + { name: 'Just Java', handle: 'justjava' }, + { name: 'Tech GSM Softwares', handle: 'techgsmsoftwares' }, + { name: 'Data Engineering', handle: 'sspdata' }, + { name: 'Zero To Mastery', handle: 'zerotomastery' }, + { name: 'Dev Squad', handle: 'devsquad' }, + { name: 'AI', handle: 'ai' }, + { name: 'Platform & AI', handle: 'platformai' }, +] as const; + +export interface TopActiveSquad { + id: string; + name: string; + handle: string; + permalink: string; + image: string; + membersCount?: number; + currentMember?: Squad['currentMember']; +} + +interface UseTopActiveSquadsParams { + enabled?: boolean; + /** Limit how many of the curated handles to fetch (defaults to all 20). */ + limit?: number; +} + +interface UseTopActiveSquadsResult { + squads: TopActiveSquad[]; + isPending: boolean; +} + +export const useTopActiveSquads = ({ + enabled = true, + limit = TOP_ACTIVE_SQUADS_30D.length, +}: UseTopActiveSquadsParams = {}): UseTopActiveSquadsResult => { + const seeds = useMemo(() => TOP_ACTIVE_SQUADS_30D.slice(0, limit), [limit]); + + const queries = useQueries({ + queries: seeds.map(({ handle }) => ({ + queryKey: [RequestKey.Squad, 'top-active', handle], + queryFn: () => getSquadStaticFields(handle), + enabled, + staleTime: StaleTime.Default, + })), + }); + + const isPending = queries.some((query) => query.isPending); + + const squads = useMemo( + () => + seeds.map(({ name, handle }, index) => { + const data = queries[index]?.data as SquadStaticData | undefined; + + return { + id: data?.id ?? `top-active-squad-${index + 1}-${handle}`, + name: data?.name ?? name, + handle, + permalink: + data?.permalink ?? `https://app.daily.dev/squads/${handle}`, + image: data?.image ?? TOP_SQUAD_PLACEHOLDER_IMAGE, + membersCount: data?.membersCount, + currentMember: data?.currentMember, + }; + }), + [seeds, queries], + ); + + return { squads, isPending }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 22a284deab6..e40486fa420 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -202,3 +202,14 @@ export const featureCompanionDemoWidget = new Feature( ); export const featureFeedTagChips = new Feature('feed_tag_chips', false); + +/** + * Enables variable-size cards (1x2, 2x1, 2x2, 3x2) in My Feed. + * UI-only first; once backend ships `layoutHint` per item, the FE will + * consume it automatically. Defaults on in development for visual QA and off + * in production until rollout is configured in GrowthBook. + */ +export const featureMyFeedMultiCard = new Feature( + 'my_feed_multi_card', + isDevelopment, +); diff --git a/packages/shared/src/lib/feedGridPacker.spec.ts b/packages/shared/src/lib/feedGridPacker.spec.ts new file mode 100644 index 00000000000..7a60621acb0 --- /dev/null +++ b/packages/shared/src/lib/feedGridPacker.spec.ts @@ -0,0 +1,256 @@ +import { packFeedItems } from './feedGridPacker'; +import type { LayoutHint } from './feedLayoutHint'; + +describe('packFeedItems', () => { + it('places all items as 1x1 when no hints are provided', () => { + const hints: LayoutHint[] = ['1x1', '1x1', '1x1', '1x1']; + const placements = packFeedItems({ hints, columns: 2 }); + + expect(placements).toEqual([ + { + row: 0, + column: 0, + rowSpan: 1, + colSpan: 1, + effectiveHint: '1x1', + requestedHint: '1x1', + }, + { + row: 0, + column: 1, + rowSpan: 1, + colSpan: 1, + effectiveHint: '1x1', + requestedHint: '1x1', + }, + { + row: 1, + column: 0, + rowSpan: 1, + colSpan: 1, + effectiveHint: '1x1', + requestedHint: '1x1', + }, + { + row: 1, + column: 1, + rowSpan: 1, + colSpan: 1, + effectiveHint: '1x1', + requestedHint: '1x1', + }, + ]); + }); + + it('honors a requested 2x1 wide card on a 3-column grid', () => { + const hints: LayoutHint[] = ['2x1', '1x1', '1x1', '1x1', '1x1']; + const placements = packFeedItems({ hints, columns: 3 }); + + expect(placements[0]).toMatchObject({ + row: 0, + column: 0, + colSpan: 2, + rowSpan: 1, + effectiveHint: '2x1', + }); + expect(placements[1]).toMatchObject({ + row: 0, + column: 2, + colSpan: 1, + rowSpan: 1, + }); + expect(placements[2]).toMatchObject({ + row: 1, + column: 0, + colSpan: 1, + rowSpan: 1, + }); + expect(placements[3]).toMatchObject({ + row: 1, + column: 1, + colSpan: 1, + rowSpan: 1, + }); + expect(placements[4]).toMatchObject({ + row: 1, + column: 2, + colSpan: 1, + rowSpan: 1, + }); + }); + + it('downgrades 2x1 to 1x1 on a 1-column grid', () => { + const hints: LayoutHint[] = ['2x1', '2x1']; + const placements = packFeedItems({ hints, columns: 1 }); + + placements.forEach((placement) => { + expect(placement.effectiveHint).toBe('1x1'); + expect(placement.colSpan).toBe(1); + expect(placement.rowSpan).toBe(1); + }); + }); + + it('keeps strict feed order when reflowing around large cards', () => { + const hints: LayoutHint[] = ['1x1', '2x1', '1x1', '1x1', '1x1']; + const placements = packFeedItems({ hints, columns: 3 }); + + placements.forEach((placement, index) => { + placements + .slice(0, index) + .forEach((earlier) => expect(earlier.row <= placement.row).toBe(true)); + }); + }); + + it('caps large-card density to 1 per 10 items by default', () => { + const hints: LayoutHint[] = new Array(20).fill('2x1'); + const placements = packFeedItems({ hints, columns: 3 }); + + const largeCount = placements.filter( + (placement) => placement.colSpan * placement.rowSpan > 1, + ).length; + expect(largeCount).toBeLessThanOrEqual(2); + }); + + it('respects custom density configuration', () => { + const hints: LayoutHint[] = new Array(10).fill('2x1'); + const placements = packFeedItems({ + hints, + columns: 3, + largeCardDensity: { maxLarge: 2, perItems: 5 }, + }); + + const largeCount = placements.filter( + (placement) => placement.colSpan * placement.rowSpan > 1, + ).length; + expect(largeCount).toBeLessThanOrEqual(4); + expect(largeCount).toBeGreaterThanOrEqual(2); + }); + + it('throws when columns is less than 1', () => { + expect(() => packFeedItems({ hints: ['1x1'], columns: 0 })).toThrow(); + }); + + it('alternates consecutive 2-wide cards between the left and right edges', () => { + // First 2-wide goes to the left edge (col 0). The next 2-wide should + // start at col 1 so it sits flush with the right edge of a 3-col grid, + // preventing two wide cards from stacking on the same side. + // Density cap is disabled here because the test focuses on placement + // alternation, not density throttling. + const hints: LayoutHint[] = ['2x1', '1x1', '2x1', '1x1']; + const placements = packFeedItems({ + hints, + columns: 3, + largeCardDensity: { maxLarge: hints.length, perItems: 1 }, + }); + + expect(placements[0]).toMatchObject({ row: 0, column: 0, colSpan: 2 }); + expect(placements[2]).toMatchObject({ row: 1, column: 1, colSpan: 2 }); + }); + + it('alternates 2-wide cards across many wide variants', () => { + // Each `2x1` is followed by a single `1x1` so every wide card lands + // on a fresh row and can reach its preferred edge without colliding + // with the previous wide card. + const hints: LayoutHint[] = [ + '2x1', + '1x1', + '2x1', + '1x1', + '2x1', + '1x1', + '2x1', + '1x1', + ]; + const placements = packFeedItems({ + hints, + columns: 3, + largeCardDensity: { maxLarge: hints.length, perItems: 1 }, + }); + + const wideColumns = placements + .filter((placement) => placement.colSpan === 2) + .map((placement) => placement.column); + + expect(wideColumns).toEqual([0, 1, 0, 1]); + }); + + it('keeps the wide card within grid bounds when alternation prefers the right edge', () => { + const hints: LayoutHint[] = ['1x1', '1x1', '2x1', '1x1', '2x1']; + const placements = packFeedItems({ + hints, + columns: 3, + largeCardDensity: { maxLarge: hints.length, perItems: 1 }, + }); + + expect(placements[4]).toMatchObject({ colSpan: 2 }); + expect(placements[4].column + placements[4].colSpan).toBeLessThanOrEqual(3); + }); + + it('backfills holes left when a wide card pushes to the next row', () => { + // Row 0 has two 1x1 cards, then a 2x1 that cannot fit in the remaining + // single column. The packer should place the 2x1 on row 1 and fill the + // hole at (0, 2) with the next 1x1 (dense packing). Without dense fill + // the rendered grid leaves an empty slot before every wide card. + const hints: LayoutHint[] = ['1x1', '1x1', '2x1', '1x1', '1x1']; + const placements = packFeedItems({ hints, columns: 3 }); + + expect(placements[2]).toMatchObject({ row: 1, column: 0, colSpan: 2 }); + expect(placements[3]).toMatchObject({ row: 0, column: 2, colSpan: 1 }); + expect(placements[4]).toMatchObject({ row: 1, column: 2, colSpan: 1 }); + }); + + it('alternates vertical (1x2) cards between the left and right edges', () => { + // First 1x2 lands on the leftmost column (col 0). The next 1x2 should + // prefer the rightmost column (col 3 in a 4-col grid) so the Most + // Upvoted / Best Discussed pair always sits on opposite sides. + const hints: LayoutHint[] = [ + '1x2', + '1x1', + '1x1', + '1x1', + '1x1', + '1x1', + '1x1', + '1x1', + '1x1', + '1x1', + '1x2', + ]; + const placements = packFeedItems({ + hints, + columns: 4, + largeCardDensity: { maxLarge: hints.length, perItems: 1 }, + }); + + const verticalColumns = placements + .filter((placement) => placement.rowSpan > 1) + .map((placement) => placement.column); + + expect(verticalColumns).toEqual([0, 3]); + }); + + it('does not produce overlapping placements', () => { + const hints: LayoutHint[] = [ + '2x1', + '1x1', + '1x2', + '1x1', + '1x1', + '2x1', + '1x1', + '1x1', + ]; + const placements = packFeedItems({ hints, columns: 3 }); + + const cells = new Set(); + placements.forEach(({ row, column, rowSpan, colSpan }) => { + for (let r = row; r < row + rowSpan; r += 1) { + for (let c = column; c < column + colSpan; c += 1) { + const key = `${r}:${c}`; + expect(cells.has(key)).toBe(false); + cells.add(key); + } + } + }); + }); +}); diff --git a/packages/shared/src/lib/feedGridPacker.ts b/packages/shared/src/lib/feedGridPacker.ts new file mode 100644 index 00000000000..2c71c385448 --- /dev/null +++ b/packages/shared/src/lib/feedGridPacker.ts @@ -0,0 +1,261 @@ +import type { LayoutHint } from './feedLayoutHint'; +import { + DEFAULT_LAYOUT_HINT, + LAYOUT_HINT_FALLBACK_CHAIN, + getLayoutHintDimensions, + isLargeLayoutHint, +} from './feedLayoutHint'; + +export interface FeedItemPlacement { + row: number; + column: number; + rowSpan: number; + colSpan: number; + /** Hint actually rendered (after fallback). */ + effectiveHint: LayoutHint; + /** Hint requested by adapter before fallback. */ + requestedHint: LayoutHint; +} + +export interface PackFeedItemsParams { + /** Resolved hint per item, in feed order. */ + hints: LayoutHint[]; + /** Number of grid columns. Must be >= 1. */ + columns: number; + /** + * Maximum number of large cards per N items. + * Defaults to product spec: 1 large per 10 items. + */ + largeCardDensity?: { maxLarge: number; perItems: number }; +} + +const DEFAULT_DENSITY = { maxLarge: 1, perItems: 10 }; + +type OccupiedCells = Map>; + +const isAreaFree = ({ + occupied, + row, + column, + colSpan, + rowSpan, + columns, +}: { + occupied: OccupiedCells; + row: number; + column: number; + colSpan: number; + rowSpan: number; + columns: number; +}): boolean => { + if (column + colSpan > columns) { + return false; + } + + const rowsToCheck = Array.from({ length: rowSpan }, (_, idx) => row + idx); + return rowsToCheck.every((rowIndex) => { + const rowCells = occupied.get(rowIndex); + if (!rowCells) { + return true; + } + const cellsToCheck = Array.from( + { length: colSpan }, + (_, idx) => column + idx, + ); + return cellsToCheck.every((columnIndex) => !rowCells.has(columnIndex)); + }); +}; + +const markArea = ({ + occupied, + row, + column, + colSpan, + rowSpan, +}: { + occupied: OccupiedCells; + row: number; + column: number; + colSpan: number; + rowSpan: number; +}): void => { + Array.from({ length: rowSpan }, (_, idx) => row + idx).forEach((rowIndex) => { + const rowCells = occupied.get(rowIndex) ?? new Set(); + Array.from({ length: colSpan }, (_, idx) => column + idx).forEach( + (columnIndex) => rowCells.add(columnIndex), + ); + occupied.set(rowIndex, rowCells); + }); +}; + +const findFirstFreeSlot = ({ + occupied, + columns, + startRow, + colSpan, + rowSpan, + maxRowsToScan, + preferRightmost = false, +}: { + occupied: OccupiedCells; + columns: number; + startRow: number; + colSpan: number; + rowSpan: number; + maxRowsToScan: number; + /** + * When true, scan column candidates from the rightmost column first. + * Used to alternate 2-wide cards between the left and right edges so + * consecutive wide variants do not stack on the same side of the row. + * Falls back to leftmost columns when the rightmost slot is occupied. + */ + preferRightmost?: boolean; +}): { row: number; column: number } => { + const rowCandidates = Array.from( + { length: maxRowsToScan }, + (_, idx) => startRow + idx, + ); + const columnSlots = columns - colSpan + 1; + const columnCandidates = Array.from({ length: columnSlots }, (_, idx) => + preferRightmost ? columnSlots - 1 - idx : idx, + ); + + let foundSlot: { row: number; column: number } | null = null; + rowCandidates.some((row) => { + const column = columnCandidates.find((candidate) => + isAreaFree({ + occupied, + row, + column: candidate, + colSpan, + rowSpan, + columns, + }), + ); + if (column !== undefined) { + foundSlot = { row, column }; + return true; + } + return false; + }); + + if (!foundSlot) { + throw new Error('findFirstFreeSlot: exhausted scan window without a slot'); + } + + return foundSlot; +}; + +const isRowFull = (occupied: OccupiedCells, row: number, columns: number) => + (occupied.get(row)?.size ?? 0) >= columns; + +/** + * Walks the feed in order and produces grid placements. Reflows around large + * cards while preserving feed order. Falls back to smaller sizes when the + * requested size is structurally impossible (e.g. requested 3 columns on a + * 2-column layout) or when large-card density cap is reached. + * + * The packer is fully deterministic given identical inputs, which keeps SSR + * stable and makes analytics row/column logging reproducible. + */ +export const packFeedItems = ({ + hints, + columns, + largeCardDensity = DEFAULT_DENSITY, +}: PackFeedItemsParams): FeedItemPlacement[] => { + if (columns < 1) { + throw new Error('packFeedItems: columns must be >= 1'); + } + + const occupied: OccupiedCells = new Map(); + const placements: FeedItemPlacement[] = []; + // Generous scan window: at worst we add `hints.length` rows for stacked tall + // cards. Use rowSpan multiplier to be safe. + const maxRowsToScan = Math.max(hints.length, 1) * 4; + let searchStartRow = 0; + let largeCardsPlaced = 0; + // Counts placements that ended up rendering as 2 columns wide (`2x1` / + // `2x2`). Used to alternate consecutive 2-wide cards between the left + // and right edges of the row. + let twoWidePlacements = 0; + // Counts placements that ended up rendering as a vertical (rowSpan > 1) + // card. Used to alternate consecutive vertical cards between the left + // and right edges so the Most Upvoted / Best Discussed pair sits on + // opposite sides of the grid. + let verticalPlacements = 0; + + hints.forEach((requestedHint, index) => { + const fallbackChain = + LAYOUT_HINT_FALLBACK_CHAIN[requestedHint] ?? + LAYOUT_HINT_FALLBACK_CHAIN[DEFAULT_LAYOUT_HINT]; + + const windowIndex = Math.floor(index / largeCardDensity.perItems); + const expectedWindowLarge = + windowIndex * largeCardDensity.maxLarge + largeCardDensity.maxLarge; + const densityExhausted = largeCardsPlaced >= expectedWindowLarge; + + const eligibleCandidates = fallbackChain.filter((candidate) => { + const { colSpan } = getLayoutHintDimensions(candidate); + if (colSpan > columns) { + return false; + } + if (densityExhausted && isLargeLayoutHint(candidate)) { + return false; + } + return true; + }); + + const chosenHint = + eligibleCandidates.length > 0 + ? eligibleCandidates[0] + : DEFAULT_LAYOUT_HINT; + const { colSpan, rowSpan } = getLayoutHintDimensions(chosenHint); + const isTwoWide = colSpan === 2; + const isVertical = rowSpan > 1; + const preferRightmost = + (isTwoWide && twoWidePlacements % 2 === 1) || + (isVertical && verticalPlacements % 2 === 1); + const slot = findFirstFreeSlot({ + occupied, + columns, + startRow: searchStartRow, + colSpan, + rowSpan, + maxRowsToScan, + preferRightmost, + }); + + markArea({ + occupied, + row: slot.row, + column: slot.column, + colSpan, + rowSpan, + }); + + if (isLargeLayoutHint(chosenHint)) { + largeCardsPlaced += 1; + } + if (isTwoWide) { + twoWidePlacements += 1; + } + if (isVertical) { + verticalPlacements += 1; + } + + placements.push({ + row: slot.row, + column: slot.column, + rowSpan, + colSpan, + effectiveHint: chosenHint, + requestedHint, + }); + + while (isRowFull(occupied, searchStartRow, columns)) { + searchStartRow += 1; + } + }); + + return placements; +}; diff --git a/packages/shared/src/lib/feedLayoutHint.spec.ts b/packages/shared/src/lib/feedLayoutHint.spec.ts new file mode 100644 index 00000000000..26e64f7d5e1 --- /dev/null +++ b/packages/shared/src/lib/feedLayoutHint.spec.ts @@ -0,0 +1,119 @@ +import { FeedItemType } from '../components/cards/common/common'; +import { + AD_MAX_LAYOUT_HINT, + DEFAULT_LAYOUT_HINT, + isLayoutHint, + resolveLayoutHint, +} from './feedLayoutHint'; + +describe('isLayoutHint', () => { + it.each(['1x1', '1x2', '2x1'])('recognizes valid hint %s', (hint) => { + expect(isLayoutHint(hint)).toBe(true); + }); + + it.each([null, undefined, '', 'invalid', '4x4', '2x2', '3x2', 5])( + 'rejects invalid hint %s', + (hint) => { + expect(isLayoutHint(hint)).toBe(false); + }, + ); +}); + +describe('resolveLayoutHint', () => { + it('returns default hint when feature is disabled', () => { + expect( + resolveLayoutHint({ + rawHint: '2x1', + itemType: FeedItemType.Post, + isMobile: false, + isDisabled: true, + }), + ).toBe(DEFAULT_LAYOUT_HINT); + }); + + it('returns default hint on mobile regardless of backend hint', () => { + expect( + resolveLayoutHint({ + rawHint: '1x2', + itemType: FeedItemType.Post, + isMobile: true, + isDisabled: false, + }), + ).toBe(DEFAULT_LAYOUT_HINT); + }); + + it('returns default hint for non post/ad items', () => { + expect( + resolveLayoutHint({ + rawHint: '2x1', + itemType: FeedItemType.MarketingCta, + isMobile: false, + isDisabled: false, + }), + ).toBe(DEFAULT_LAYOUT_HINT); + }); + + it('returns default hint when backend value is missing or invalid', () => { + expect( + resolveLayoutHint({ + rawHint: undefined, + itemType: FeedItemType.Post, + isMobile: false, + isDisabled: false, + }), + ).toBe(DEFAULT_LAYOUT_HINT); + + expect( + resolveLayoutHint({ + rawHint: '4x4', + itemType: FeedItemType.Post, + isMobile: false, + isDisabled: false, + }), + ).toBe(DEFAULT_LAYOUT_HINT); + }); + + it('treats unsupported larger hints (2x2, 3x2) as invalid', () => { + expect( + resolveLayoutHint({ + rawHint: '2x2', + itemType: FeedItemType.Post, + isMobile: false, + isDisabled: false, + }), + ).toBe(DEFAULT_LAYOUT_HINT); + + expect( + resolveLayoutHint({ + rawHint: '3x2', + itemType: FeedItemType.Post, + isMobile: false, + isDisabled: false, + }), + ).toBe(DEFAULT_LAYOUT_HINT); + }); + + it('preserves valid post hints', () => { + expect( + resolveLayoutHint({ + rawHint: '2x1', + itemType: FeedItemType.Post, + isMobile: false, + isDisabled: false, + }), + ).toBe('2x1'); + + expect( + resolveLayoutHint({ + rawHint: '1x2', + itemType: FeedItemType.Post, + isMobile: false, + isDisabled: false, + }), + ).toBe('1x2'); + }); + + it('exposes the configured ad cap at 2x1', () => { + expect(AD_MAX_LAYOUT_HINT).toBe('2x1'); + }); +}); diff --git a/packages/shared/src/lib/feedLayoutHint.ts b/packages/shared/src/lib/feedLayoutHint.ts new file mode 100644 index 00000000000..c70ae477080 --- /dev/null +++ b/packages/shared/src/lib/feedLayoutHint.ts @@ -0,0 +1,155 @@ +import type { FeedItem } from '../hooks/useFeed'; +import { FeedItemType } from '../components/cards/common/common'; + +/** + * Allowed multi-card layout sizes for feed items. + * Format is `colSpan x rowSpan`. + * + * NOTE: Backend will eventually emit one of these values per feed item via + * `Post.layoutHint`. Until then, the FE treats all items as `1x1` so behavior + * is unchanged when the field is missing. + * + * Scope is intentionally narrow for now: only `1x1`, `2x1` (horizontal wide + * topic-cluster card) and `1x2` (vertical Most Upvoted card) are supported. + * Larger sizes (`2x2`, `3x2`) are intentionally not allowed yet. + */ +export const LAYOUT_HINT_VALUES = ['1x1', '1x2', '2x1'] as const; + +export type LayoutHint = (typeof LAYOUT_HINT_VALUES)[number]; + +export const DEFAULT_LAYOUT_HINT: LayoutHint = '1x1'; + +export interface LayoutHintDimensions { + colSpan: number; + rowSpan: number; +} + +/** + * Layout dimensions used by the feed grid packer. + */ +const LAYOUT_HINT_DIMENSIONS: Record = { + '1x1': { colSpan: 1, rowSpan: 1 }, + '1x2': { colSpan: 1, rowSpan: 2 }, + '2x1': { colSpan: 2, rowSpan: 1 }, +}; + +/** + * Density / "is large" classification stays semantic — based on the original + * hint area — so the density cap (1 large per 10 items) keeps doing the + * right thing. + */ +const LAYOUT_HINT_AREA: Record = { + '1x1': 1, + '1x2': 2, + '2x1': 2, +}; + +/** Largest size a sponsored / ad item is allowed to occupy. */ +export const AD_MAX_LAYOUT_HINT: LayoutHint = '2x1'; + +/** + * Fallback chain used when the requested size cannot fit the current row. + * 2x1 -> 1x1 and 1x2 -> 1x1 are the only structural fallbacks possible. + */ +export const LAYOUT_HINT_FALLBACK_CHAIN: Record = { + '2x1': ['2x1', '1x1'], + '1x2': ['1x2', '1x1'], + '1x1': ['1x1'], +}; + +export const isLayoutHint = (value: unknown): value is LayoutHint => + typeof value === 'string' && + (LAYOUT_HINT_VALUES as readonly string[]).includes(value); + +export const getLayoutHintDimensions = ( + hint: LayoutHint, +): LayoutHintDimensions => LAYOUT_HINT_DIMENSIONS[hint]; + +export const isLargeLayoutHint = (hint: LayoutHint): boolean => + LAYOUT_HINT_AREA[hint] > 1; + +interface ResolveLayoutHintParams { + rawHint: unknown; + itemType: FeedItemType; + /** When true, force `1x1` (mobile uses 1 column). */ + isMobile: boolean; + /** When true, multi-card layout is disabled entirely (flag off). */ + isDisabled: boolean; +} + +/** + * Resolves the effective layout hint for a feed item with the safety rules + * locked by product: + * - feature flag off / mobile / non-post-or-ad item -> 1x1 + * - invalid backend value -> 1x1 + * - ad items capped at 2x1 + */ +export const resolveLayoutHint = ({ + rawHint, + itemType, + isMobile, + isDisabled, +}: ResolveLayoutHintParams): LayoutHint => { + if (isDisabled || isMobile) { + return DEFAULT_LAYOUT_HINT; + } + + if (itemType !== FeedItemType.Post && itemType !== FeedItemType.Ad) { + return DEFAULT_LAYOUT_HINT; + } + + if (!isLayoutHint(rawHint)) { + return DEFAULT_LAYOUT_HINT; + } + + if ( + itemType === FeedItemType.Ad && + LAYOUT_HINT_AREA[rawHint] > LAYOUT_HINT_AREA[AD_MAX_LAYOUT_HINT] + ) { + return AD_MAX_LAYOUT_HINT; + } + + return rawHint; +}; + +/** + * Reads the raw `layoutHint` from a feed item without coupling to GraphQL + * shape. Backend may attach the hint to either the post or the ad-wrapped + * post, so we look in both spots. + * + * Cast through `Record` because `layoutHint` is not yet in + * the shared GraphQL types; once backend ships, replace with a typed field. + */ +export const getRawLayoutHintFromItem = (item: FeedItem): unknown => { + if (item.type === FeedItemType.Post) { + return (item.post as unknown as Record)?.layoutHint; + } + + if (item.type === FeedItemType.Ad) { + const adPost = (item.ad?.data?.post ?? null) as unknown as Record< + string, + unknown + > | null; + return adPost?.layoutHint; + } + + return undefined; +}; + +/** + * Deterministic dev-only seed used until backend emits `layoutHint`. + * Returns a pre-set hint at fixed intervals so visual QA can verify both + * supported large variants in My Feed. Returns `undefined` for items that + * should remain `1x1`. + * + * index 21 -> 2x1 (horizontal wide Top active squads card) + * index 35 -> 2x1 (horizontal wide Popular tags card) + * pattern repeats every 40 items + */ +const DEV_SEED_PATTERN: Record = { + 21: '2x1', + 35: '2x1', +}; + +export const getDevSeededLayoutHint = (index: number): LayoutHint | undefined => + DEV_SEED_PATTERN[index % 40]; From ac9f868ceee50d858215a57d78a1f1f0fa3d767f Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 21 May 2026 10:37:26 +0300 Subject: [PATCH 2/9] feat(shared): show Happening Now as 1x2 feed card with fitted rows Enable feed v2 highlights in dev, render the highlight card in My Feed, fix stale marketing CTA imports, and distribute row heights via ResizeObserver so headlines fit the tall slot without inner scroll. Co-authored-by: Cursor --- packages/shared/src/components/Feed.tsx | 14 +- .../src/components/FeedItemComponent.tsx | 70 +++++- .../cards/highlight/HighlightCards.spec.tsx | 25 +- .../cards/highlight/HighlightGrid.tsx | 4 +- .../src/components/cards/highlight/common.tsx | 228 +++++++++++++++++- packages/shared/src/graphql/feed.spec.ts | 2 +- packages/shared/src/graphql/feed.ts | 3 +- packages/shared/src/lib/featureManagement.ts | 5 +- .../shared/src/lib/feedLayoutHint.spec.ts | 13 +- packages/shared/src/lib/feedLayoutHint.ts | 12 +- packages/shared/src/styles/utilities.css | 9 + 11 files changed, 347 insertions(+), 38 deletions(-) diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index 52daa159281..63a8985a360 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -52,11 +52,12 @@ import { useProfileCompletionCard } from '../hooks/profile/useProfileCompletionC import type { AllFeedPages } from '../lib/query'; import { OtherFeedPage, RequestKey } from '../lib/query'; -import { MarketingCtaVariant } from './marketingCta/common'; +import { MarketingCtaVariant } from './marketing/cta/common'; import { isNullOrUndefined } from '../lib/func'; import { useSearchResultsLayout } from '../hooks/search/useSearchResultsLayout'; import { SearchResultsLayout } from './search/SearchResults/SearchResultsLayout'; import { acquisitionKey } from './cards/AcquisitionForm/common/common'; +import { FeedItemType } from './cards/common/common'; import type { PostClick } from '../lib/click'; import { useFeedContentPreferenceMutationSubscription } from './feeds/useFeedContentPreferenceMutationSubscription'; @@ -81,7 +82,7 @@ import { getProductsQueryOptions } from '../graphql/njord'; import { useUpdateQuery } from '../hooks/useUpdateQuery'; import { BriefBannerFeed } from './cards/brief/BriefBanner/BriefBannerFeed'; import { ActionType } from '../graphql/actions'; -import { TopHero } from './banners/HeroBottomBanner'; +import { TopHero } from './marketing/banners/HeroBottomBanner'; import { useReadingReminderFeedHero } from '../hooks/notifications/useReadingReminderFeedHero'; import { useTopActiveSquads } from '../hooks/useTopActiveSquads'; @@ -328,6 +329,9 @@ export default function Feed({ const hints = items.map((item, itemIndex) => { const rawHint = getRawLayoutHintFromItem(item) ?? + (item.type === FeedItemType.Highlight && isMultiCardLayoutEnabled + ? '1x2' + : undefined) ?? (isDevelopment && isMultiCardLayoutEnabled ? getDevSeededLayoutHint(itemIndex) : undefined); @@ -347,12 +351,16 @@ export default function Feed({ let wideCount = 0; placements.forEach((placement, index) => { if (placement.colSpan > 1 && placement.rowSpan === 1) { + if (items[index]?.type !== FeedItemType.Post) { + return; + } + map.set(index, sequence[wideCount % sequence.length]); wideCount += 1; } }); return map; - }, [placements]); + }, [placements, items]); const hasTopSquadsSlot = useMemo( () => Array.from(horizontalWideVariantByIndex.values()).some( diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 8a98c561d75..e36129e22d5 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -14,8 +14,8 @@ import { Origin, TargetType } from '../lib/log'; import type { UseVotePost } from '../hooks'; import { useFeedLayout } from '../hooks'; import { CollectionList } from './cards/collection/CollectionList'; -import { MarketingCtaCard } from './marketingCta'; -import { MarketingCtaList } from './marketingCta/MarketingCtaList'; +import { MarketingCtaCard } from './marketing/cta'; +import { MarketingCtaList } from './marketing/cta/MarketingCtaList'; import { FeedItemType } from './cards/common/common'; import { AdGrid } from './cards/ad/AdGrid'; import { AdList } from './cards/ad/AdList'; @@ -45,11 +45,12 @@ import { ActivePostContextProvider } from '../contexts/ActivePostContext'; import { LogExtraContextProvider } from '../contexts/LogExtraContext'; import { SquadAdList } from './cards/ad/squad/SquadAdList'; import { SquadAdGrid } from './cards/ad/squad/SquadAdGrid'; -import { adLogEvent, feedLogExtra } from '../lib/feed'; +import { adLogEvent, feedHighlightsLogEvent, feedLogExtra } from '../lib/feed'; import { useLogContext } from '../contexts/LogContext'; -import { MarketingCtaVariant } from './marketingCta/common'; -import { MarketingCtaBriefing } from './marketingCta/MarketingCtaBriefing'; -import { MarketingCtaYearInReview } from './marketingCta/MarketingCtaYearInReview'; +import { MarketingCtaVariant } from './marketing/cta/common'; +import { MarketingCtaBriefing } from './marketing/cta/MarketingCtaBriefing'; +import { MarketingCtaYearInReview } from './marketing/cta/MarketingCtaYearInReview'; +import { MarketingCtaVideo } from './marketing/cta/MarketingCtaVideo'; import PollGrid from './cards/poll/PollGrid'; import { PollList } from './cards/poll/PollList'; import { SocialTwitterGrid } from './cards/socialTwitter/SocialTwitterGrid'; @@ -57,6 +58,9 @@ import { SocialTwitterList } from './cards/socialTwitter/SocialTwitterList'; import { SignalList } from './cards/common/list/SignalList'; import { OtherFeedPage } from '../lib/query'; import { isSourceSquadOrMachine } from '../graphql/sources'; +import { HighlightGrid } from './cards/highlight/HighlightGrid'; +import { HighlightList } from './cards/highlight/HighlightList'; +import { getHighlightIds, getHighlightIdsKey } from '../graphql/highlights'; export type FeedItemComponentProps = { item: FeedItem; @@ -106,6 +110,8 @@ export function getFeedItemKey(item: FeedItem, index: number): string { switch (item.type) { case 'post': return item.post.id; + case 'highlight': + return getHighlightIdsKey(item.highlights) || `highlight-${index}`; case 'ad': return `ad-${index}`; default: @@ -285,6 +291,54 @@ function FeedItemComponent({ const { shouldUseListFeedLayout, shouldUseListMode } = useFeedLayout(); const { boostedBy } = useFeedCardContext(); + + if (item.type === FeedItemType.Highlight) { + const HighlightTag = + shouldUseListFeedLayout || shouldUseListMode + ? HighlightList + : HighlightGrid; + const highlightIds = getHighlightIds(item.highlights); + + return ( + { + logEvent( + feedHighlightsLogEvent(LogEvent.Click, { + columns: virtualizedNumCards, + column, + row, + feedName, + ranking, + action: 'read_all_click', + count: item.highlights.length, + highlightIds, + feedMeta: item.feedMeta, + }), + ); + }} + onHighlightClick={(highlight, position) => { + logEvent( + feedHighlightsLogEvent(LogEvent.Click, { + columns: virtualizedNumCards, + column, + row, + feedName, + ranking, + action: 'highlight_click', + position, + count: item.highlights.length, + clickedHighlight: highlight, + highlightIds, + feedMeta: item.feedMeta, + }), + ); + }} + /> + ); + } + const { PostTag, AdTag, @@ -472,6 +526,10 @@ function FeedItemComponent({ return ; } + if (item.marketingCta.variant === MarketingCtaVariant.Video) { + return ; + } + return ( ({ const highlights = [ { id: 'highlight-1', - channel: 'agents', + channel: 'vibes', headline: 'The first highlight', highlightedAt: '2026-04-05T09:00:00.000Z', post: { @@ -21,7 +21,7 @@ const highlights = [ }, { id: 'highlight-2', - channel: 'agents', + channel: 'vibes', headline: 'The second highlight', highlightedAt: '2026-04-05T08:00:00.000Z', post: { @@ -32,13 +32,22 @@ const highlights = [ ]; describe('Highlight cards', () => { - it('should render the grid card with highlight links', () => { + it('should render the grid card as a uniform highlight list', () => { render(); expect(screen.getByText('Happening Now')).toBeInTheDocument(); + expect( + screen.getByText('What developers are talking about right now'), + ).toBeInTheDocument(); expect(screen.getByText('The first highlight')).toBeInTheDocument(); expect(screen.getByText('The second highlight')).toBeInTheDocument(); expect(screen.getByText('Read all')).toBeInTheDocument(); + expect(screen.queryByText('Top story')).not.toBeInTheDocument(); + expect(screen.queryByText('Also trending')).not.toBeInTheDocument(); + expect( + screen.queryByText('Curated from community discussions'), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/trending stories/i)).not.toBeInTheDocument(); expect( screen.getByRole('link', { name: /the first highlight/i }), ).toHaveAttribute('href', '/highlights?highlight=highlight-1'); @@ -46,12 +55,12 @@ describe('Highlight cards', () => { 'href', '/highlights?highlight=highlight-1', ); - expect(screen.getByText('The first highlight')).not.toHaveClass( - 'line-clamp-2', - ); expect( - screen.getByRole('link', { name: /the first highlight/i }).parentElement, - ).toHaveClass('no-scrollbar', 'overflow-y-auto'); + screen.getByRole('link', { name: /the first highlight/i }), + ).toHaveClass('overflow-hidden'); + expect( + screen.getByRole('link', { name: /the first highlight/i }), + ).not.toHaveClass('overflow-y-auto', 'overflow-y-scroll'); }); it('should render the list card with highlight links', () => { diff --git a/packages/shared/src/components/cards/highlight/HighlightGrid.tsx b/packages/shared/src/components/cards/highlight/HighlightGrid.tsx index 7186bd286bf..1d492ed3361 100644 --- a/packages/shared/src/components/cards/highlight/HighlightGrid.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightGrid.tsx @@ -12,9 +12,9 @@ export const HighlightGrid = forwardRef(function HighlightGrid( -
    +
    @@ -24,6 +26,95 @@ export const getHighlightsUrl = (highlightId?: string): string => const getHighlightUrl = (highlight: PostHighlight): string => getHighlightsUrl(highlight.id); +/** Vertical padding on each grid row (`py-2`). */ +const GRID_ROW_PADDING_Y_PX = 16; +/** Headline + timestamp block below the title. */ +const GRID_ROW_TIME_BLOCK_PX = 18; +/** Approximate `typo-footnote` line height for clamp math. */ +const GRID_HEADLINE_LINE_HEIGHT_PX = 18; +const GRID_HEADLINE_MAX_LINES = 3; + +const getHeadlineLineClamp = (rowHeightPx: number): number => { + const headlineArea = + rowHeightPx - GRID_ROW_PADDING_Y_PX - GRID_ROW_TIME_BLOCK_PX; + + if (headlineArea <= 0) { + return 1; + } + + return Math.max( + 1, + Math.min( + GRID_HEADLINE_MAX_LINES, + Math.floor(headlineArea / GRID_HEADLINE_LINE_HEIGHT_PX), + ), + ); +}; + +const distributeRowHeights = ( + containerHeightPx: number, + itemCount: number, +): number[] => { + if (itemCount <= 0 || containerHeightPx <= 0) { + return []; + } + + const baseHeight = Math.floor(containerHeightPx / itemCount); + const remainder = containerHeightPx % itemCount; + + return Array.from( + { length: itemCount }, + (_, index) => baseHeight + (index < remainder ? 1 : 0), + ); +}; + +const useHighlightGridRowHeights = ( + itemCount: number, +): { + listRef: React.RefObject; + rowHeights: number[]; + headlineLineClamps: number[]; +} => { + const listRef = useRef(null); + const [rowHeights, setRowHeights] = useState([]); + const [headlineLineClamps, setHeadlineLineClamps] = useState([]); + + useLayoutEffect(() => { + const listElement = listRef.current; + + if (!listElement || itemCount === 0) { + setRowHeights([]); + setHeadlineLineClamps([]); + return undefined; + } + + const updateRowHeights = (): void => { + const containerHeightPx = listElement.clientHeight; + + if (containerHeightPx <= 0) { + return; + } + + const heights = distributeRowHeights(containerHeightPx, itemCount); + setRowHeights(heights); + setHeadlineLineClamps(heights.map(getHeadlineLineClamp)); + }; + + updateRowHeights(); + + if (typeof ResizeObserver === 'undefined') { + return undefined; + } + + const resizeObserver = new ResizeObserver(updateRowHeights); + resizeObserver.observe(listElement); + + return () => resizeObserver.disconnect(); + }, [itemCount]); + + return { listRef, rowHeights, headlineLineClamps }; +}; + export const ReadAllHighlightsFooter = ({ highlightId, onClick, @@ -90,26 +181,137 @@ const HighlightRow = ({ ); }; +const HighlightGridRow = ({ + highlight, + index, + onHighlightClick, + rowHeight, + headlineLineClamp, +}: { + highlight: PostHighlight; + index: number; + onHighlightClick?: (highlight: PostHighlight, position: number) => void; + rowHeight?: number; + headlineLineClamp?: number; +}): ReactElement => ( + + onHighlightClick?.(highlight, index + 1)} + > + + + + {highlight.headline} + + + + + +); + +const HighlightGridCardContent = ({ + highlights, + onHighlightClick, + onReadAllClick, +}: HighlightCardProps): ReactElement => { + const firstHighlight = highlights[0]; + const { listRef, rowHeights, headlineLineClamps } = useHighlightGridRowHeights( + highlights.length, + ); + + return ( +
    +
    +
    +

    + Happening Now +

    +

    + What developers are talking about right now +

    +
    + +
    + +
    + {highlights.map((highlight, index) => ( + + ))} +
    + + +
    + ); +}; + export const HighlightCardContent = ({ highlights, 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'; + if (variant === 'grid') { + return ( + + ); + } + const firstHighlight = highlights[0]; return ( <> -
    +

    -
    +
    {highlights.map((highlight, index) => ( ); diff --git a/packages/shared/src/graphql/feed.spec.ts b/packages/shared/src/graphql/feed.spec.ts index df0460f9fd6..6d317b89ce0 100644 --- a/packages/shared/src/graphql/feed.spec.ts +++ b/packages/shared/src/graphql/feed.spec.ts @@ -17,7 +17,7 @@ describe('normalizeFeedPage', () => { expect(FEED_V2_QUERY).toContain('highlightsLimit: $highlightsLimit'); expect(FEED_V2_QUERY).toContain('... on FeedHighlightsItem'); expect(FEED_V2_QUERY).toContain('...PostHighlightCard'); - expect(FEED_V2_HIGHLIGHTS_LIMIT).toBe(5); + expect(FEED_V2_HIGHLIGHTS_LIMIT).toBe(10); }); it('should add highlight to feedV2 supported types only when enabled', () => { diff --git a/packages/shared/src/graphql/feed.ts b/packages/shared/src/graphql/feed.ts index d657a6effbd..36bf8603a06 100644 --- a/packages/shared/src/graphql/feed.ts +++ b/packages/shared/src/graphql/feed.ts @@ -34,7 +34,8 @@ export const supportedTypesForPrivateSources = [ const joinedTypes = baseFeedSupportedTypes.join('","'); export const SUPPORTED_TYPES = `$supportedTypes: [String!] = ["${joinedTypes}"]`; -export const FEED_V2_HIGHLIGHTS_LIMIT = 5; +/** Matches the highlights page density for tall feed cards (see /highlights). */ +export const FEED_V2_HIGHLIGHTS_LIMIT = 10; export const getFeedV2SupportedTypes = ( shouldSupportHighlights: boolean, diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index e40486fa420..78b744b15f0 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -32,7 +32,10 @@ export const upvotedFeedVersion = new Feature('upvoted_feed_version', 2); export const discussedFeedVersion = new Feature('discussed_feed_version', 2); export const latestFeedVersion = new Feature('latest_feed_version', 2); export const customFeedVersion = new Feature('custom_feed_version', 2); -export const featureFeedV2Highlights = new Feature('feed_v2_highlights', false); +export const featureFeedV2Highlights = new Feature( + 'feed_v2_highlights', + isDevelopment, +); export const featureMajorHeadlinesPush = new Feature( 'major_headlines_push', false, diff --git a/packages/shared/src/lib/feedLayoutHint.spec.ts b/packages/shared/src/lib/feedLayoutHint.spec.ts index 26e64f7d5e1..4e13a1bbb89 100644 --- a/packages/shared/src/lib/feedLayoutHint.spec.ts +++ b/packages/shared/src/lib/feedLayoutHint.spec.ts @@ -42,7 +42,7 @@ describe('resolveLayoutHint', () => { ).toBe(DEFAULT_LAYOUT_HINT); }); - it('returns default hint for non post/ad items', () => { + it('returns default hint for unsupported item types', () => { expect( resolveLayoutHint({ rawHint: '2x1', @@ -53,6 +53,17 @@ describe('resolveLayoutHint', () => { ).toBe(DEFAULT_LAYOUT_HINT); }); + it('allows 1x2 layout for highlight items', () => { + expect( + resolveLayoutHint({ + rawHint: '1x2', + itemType: FeedItemType.Highlight, + isMobile: false, + isDisabled: false, + }), + ).toBe('1x2'); + }); + it('returns default hint when backend value is missing or invalid', () => { expect( resolveLayoutHint({ diff --git a/packages/shared/src/lib/feedLayoutHint.ts b/packages/shared/src/lib/feedLayoutHint.ts index c70ae477080..e57d1e71d6e 100644 --- a/packages/shared/src/lib/feedLayoutHint.ts +++ b/packages/shared/src/lib/feedLayoutHint.ts @@ -80,7 +80,7 @@ interface ResolveLayoutHintParams { /** * Resolves the effective layout hint for a feed item with the safety rules * locked by product: - * - feature flag off / mobile / non-post-or-ad item -> 1x1 + * - feature flag off / mobile / unsupported item type -> 1x1 * - invalid backend value -> 1x1 * - ad items capped at 2x1 */ @@ -94,7 +94,11 @@ export const resolveLayoutHint = ({ return DEFAULT_LAYOUT_HINT; } - if (itemType !== FeedItemType.Post && itemType !== FeedItemType.Ad) { + if ( + itemType !== FeedItemType.Post && + itemType !== FeedItemType.Ad && + itemType !== FeedItemType.Highlight + ) { return DEFAULT_LAYOUT_HINT; } @@ -125,6 +129,10 @@ export const getRawLayoutHintFromItem = (item: FeedItem): unknown => { return (item.post as unknown as Record)?.layoutHint; } + if (item.type === FeedItemType.Highlight) { + return (item as unknown as Record)?.layoutHint; + } + if (item.type === FeedItemType.Ad) { const adPost = (item.ad?.data?.post ?? null) as unknown as Record< string, diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 9b23df6c1ad..6460ec5d693 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -380,6 +380,15 @@ } } +.feed-highlights-accent-dot { + background-image: linear-gradient( + 120deg, + var(--theme-accent-blueCheese-default), + var(--theme-accent-cheese-default), + var(--theme-accent-avocado-default) + ); +} + .feed-highlights-new-item-border-bottom { border-style: solid; border-width: 0 0 0.0625rem; From 079695daa146ede57ef7d9b4e41176f52bad9201 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 21 May 2026 11:12:27 +0300 Subject: [PATCH 3/9] fix(shared): polish Happening Now feed card list styling Drop the header subtitle and row bullets, use production typo-callout for headlines, and remove the unused accent-dot utility. Co-authored-by: Cursor --- .../cards/highlight/HighlightCards.spec.tsx | 4 ++-- .../src/components/cards/highlight/common.tsx | 20 ++++--------------- packages/shared/src/styles/utilities.css | 9 --------- 3 files changed, 6 insertions(+), 27 deletions(-) diff --git a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx index cef851402e0..f1167ba3cb3 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx @@ -37,8 +37,8 @@ describe('Highlight cards', () => { expect(screen.getByText('Happening Now')).toBeInTheDocument(); expect( - screen.getByText('What developers are talking about right now'), - ).toBeInTheDocument(); + screen.queryByText('What developers are talking about right now'), + ).not.toBeInTheDocument(); expect(screen.getByText('The first highlight')).toBeInTheDocument(); expect(screen.getByText('The second highlight')).toBeInTheDocument(); expect(screen.getByText('Read all')).toBeInTheDocument(); diff --git a/packages/shared/src/components/cards/highlight/common.tsx b/packages/shared/src/components/cards/highlight/common.tsx index b04526b64ae..8861e2d559f 100644 --- a/packages/shared/src/components/cards/highlight/common.tsx +++ b/packages/shared/src/components/cards/highlight/common.tsx @@ -16,8 +16,6 @@ export interface HighlightCardProps { export const highlightsTitleGradientClassName = 'feed-highlights-title-gradient'; -export const highlightsAccentDotClassName = 'feed-highlights-accent-dot'; - const HIGHLIGHTS_URL = `${webappUrl}highlights`; export const getHighlightsUrl = (highlightId?: string): string => @@ -30,8 +28,8 @@ const getHighlightUrl = (highlight: PostHighlight): string => const GRID_ROW_PADDING_Y_PX = 16; /** Headline + timestamp block below the title. */ const GRID_ROW_TIME_BLOCK_PX = 18; -/** Approximate `typo-footnote` line height for clamp math. */ -const GRID_HEADLINE_LINE_HEIGHT_PX = 18; +/** Matches `typo-callout` line-height (1.25rem) for clamp math. */ +const GRID_HEADLINE_LINE_HEIGHT_PX = 20; const GRID_HEADLINE_MAX_LINES = 3; const getHeadlineLineClamp = (rowHeightPx: number): number => { @@ -196,22 +194,15 @@ const HighlightGridRow = ({ }): ReactElement => ( onHighlightClick?.(highlight, index + 1)} > - Happening Now -

    - What developers are talking about right now -

    diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 6460ec5d693..9b23df6c1ad 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -380,15 +380,6 @@ } } -.feed-highlights-accent-dot { - background-image: linear-gradient( - 120deg, - var(--theme-accent-blueCheese-default), - var(--theme-accent-cheese-default), - var(--theme-accent-avocado-default) - ); -} - .feed-highlights-new-item-border-bottom { border-style: solid; border-width: 0 0 0.0625rem; From c64f8425f6be9d774ae1621e92f86b69fab709fd Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 21 May 2026 11:30:15 +0300 Subject: [PATCH 4/9] fix(shared): restore Happening Now row dots and two-line headlines Bring back the gradient accent dot with the same animated fill as the header, and clamp grid headlines to two lines for better readability. Co-authored-by: Cursor --- .../src/components/cards/highlight/common.tsx | 70 +++++-------------- packages/shared/src/styles/utilities.css | 13 ++-- 2 files changed, 28 insertions(+), 55 deletions(-) diff --git a/packages/shared/src/components/cards/highlight/common.tsx b/packages/shared/src/components/cards/highlight/common.tsx index 8861e2d559f..526cadb4aef 100644 --- a/packages/shared/src/components/cards/highlight/common.tsx +++ b/packages/shared/src/components/cards/highlight/common.tsx @@ -16,6 +16,8 @@ export interface HighlightCardProps { export const highlightsTitleGradientClassName = 'feed-highlights-title-gradient'; +export const highlightsAccentDotClassName = 'feed-highlights-accent-dot'; + const HIGHLIGHTS_URL = `${webappUrl}highlights`; export const getHighlightsUrl = (highlightId?: string): string => @@ -24,31 +26,6 @@ export const getHighlightsUrl = (highlightId?: string): string => const getHighlightUrl = (highlight: PostHighlight): string => getHighlightsUrl(highlight.id); -/** Vertical padding on each grid row (`py-2`). */ -const GRID_ROW_PADDING_Y_PX = 16; -/** Headline + timestamp block below the title. */ -const GRID_ROW_TIME_BLOCK_PX = 18; -/** Matches `typo-callout` line-height (1.25rem) for clamp math. */ -const GRID_HEADLINE_LINE_HEIGHT_PX = 20; -const GRID_HEADLINE_MAX_LINES = 3; - -const getHeadlineLineClamp = (rowHeightPx: number): number => { - const headlineArea = - rowHeightPx - GRID_ROW_PADDING_Y_PX - GRID_ROW_TIME_BLOCK_PX; - - if (headlineArea <= 0) { - return 1; - } - - return Math.max( - 1, - Math.min( - GRID_HEADLINE_MAX_LINES, - Math.floor(headlineArea / GRID_HEADLINE_LINE_HEIGHT_PX), - ), - ); -}; - const distributeRowHeights = ( containerHeightPx: number, itemCount: number, @@ -71,18 +48,15 @@ const useHighlightGridRowHeights = ( ): { listRef: React.RefObject; rowHeights: number[]; - headlineLineClamps: number[]; } => { const listRef = useRef(null); const [rowHeights, setRowHeights] = useState([]); - const [headlineLineClamps, setHeadlineLineClamps] = useState([]); useLayoutEffect(() => { const listElement = listRef.current; if (!listElement || itemCount === 0) { setRowHeights([]); - setHeadlineLineClamps([]); return undefined; } @@ -95,7 +69,6 @@ const useHighlightGridRowHeights = ( const heights = distributeRowHeights(containerHeightPx, itemCount); setRowHeights(heights); - setHeadlineLineClamps(heights.map(getHeadlineLineClamp)); }; updateRowHeights(); @@ -110,7 +83,7 @@ const useHighlightGridRowHeights = ( return () => resizeObserver.disconnect(); }, [itemCount]); - return { listRef, rowHeights, headlineLineClamps }; + return { listRef, rowHeights }; }; export const ReadAllHighlightsFooter = ({ @@ -184,37 +157,35 @@ const HighlightGridRow = ({ index, onHighlightClick, rowHeight, - headlineLineClamp, }: { highlight: PostHighlight; index: number; onHighlightClick?: (highlight: PostHighlight, position: number) => void; rowHeight?: number; - headlineLineClamp?: number; }): ReactElement => ( onHighlightClick?.(highlight, index + 1)} > + {highlight.headline} @@ -234,9 +205,7 @@ const HighlightGridCardContent = ({ onReadAllClick, }: HighlightCardProps): ReactElement => { const firstHighlight = highlights[0]; - const { listRef, rowHeights, headlineLineClamps } = useHighlightGridRowHeights( - highlights.length, - ); + const { listRef, rowHeights } = useHighlightGridRowHeights(highlights.length); return (
    @@ -265,7 +234,6 @@ const HighlightGridCardContent = ({ index={index} onHighlightClick={onHighlightClick} rowHeight={rowHeights[index]} - headlineLineClamp={headlineLineClamps[index]} /> ))}
    diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 9b23df6c1ad..cf923789f1a 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -359,8 +359,8 @@ } } -.feed-highlights-title-gradient { - color: transparent; +.feed-highlights-title-gradient, +.feed-highlights-accent-dot { background-image: linear-gradient( 120deg, var(--theme-accent-blueCheese-default), @@ -368,13 +368,18 @@ var(--theme-accent-avocado-default) ); background-size: 200% 200%; + animation: feed-highlights-title-gradient-shift 6s ease-in-out infinite; +} + +.feed-highlights-title-gradient { + color: transparent; background-clip: text; -webkit-background-clip: text; - animation: feed-highlights-title-gradient-shift 6s ease-in-out infinite; } @media (prefers-reduced-motion: reduce) { - .feed-highlights-title-gradient { + .feed-highlights-title-gradient, + .feed-highlights-accent-dot { animation: none; background-position: 0% 50%; } From da19c98ad02e266f26e4bd1d5c4273a3596a530d Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 21 May 2026 13:44:02 +0300 Subject: [PATCH 5/9] feat(shared): mute Happening Now rows after they are clicked Persist clicked highlight IDs and switch the accent dot to tertiary fill and the headline to secondary text so read items are visually distinct. Co-authored-by: Cursor --- .../cards/highlight/HighlightCards.spec.tsx | 42 +++++++++++++++ .../src/components/cards/highlight/common.tsx | 54 ++++++++++++++++--- .../shared/src/hooks/useReadHighlights.ts | 36 +++++++++++++ 3 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 packages/shared/src/hooks/useReadHighlights.ts diff --git a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx index f1167ba3cb3..b0a7c23d179 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx @@ -3,11 +3,17 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { HighlightGrid } from './HighlightGrid'; import { HighlightList } from './HighlightList'; +import { useReadHighlights } from '../../../hooks/useReadHighlights'; jest.mock('../../../lib/constants', () => ({ webappUrl: '/', })); +jest.mock('../../../hooks/useReadHighlights'); + +const mockedUseReadHighlights = jest.mocked(useReadHighlights); +const markAsRead = jest.fn(); + const highlights = [ { id: 'highlight-1', @@ -32,6 +38,14 @@ const highlights = [ ]; describe('Highlight cards', () => { + beforeEach(() => { + markAsRead.mockReset(); + mockedUseReadHighlights.mockReturnValue({ + isRead: () => false, + markAsRead, + }); + }); + it('should render the grid card as a uniform highlight list', () => { render(); @@ -90,5 +104,33 @@ describe('Highlight cards', () => { expect(onHighlightClick).toHaveBeenCalledWith(highlights[0], 1); expect(onReadAllClick).toHaveBeenCalledTimes(1); + expect(markAsRead).toHaveBeenCalledWith('highlight-1'); + }); + + it('should show read styling for clicked highlights', () => { + mockedUseReadHighlights.mockReturnValue({ + isRead: (highlightId) => highlightId === 'highlight-1', + markAsRead, + }); + + render(); + + const readLink = screen.getByRole('link', { name: /the first highlight/i }); + const unreadLink = screen.getByRole('link', { + name: /the second highlight/i, + }); + + expect(readLink.querySelector('span[aria-hidden]')).toHaveClass( + 'bg-text-tertiary', + ); + expect(readLink.querySelector('.typo-callout')).toHaveClass( + 'text-text-secondary', + ); + expect(unreadLink.querySelector('span[aria-hidden]')).toHaveClass( + 'feed-highlights-accent-dot', + ); + expect(unreadLink.querySelector('.typo-callout')).toHaveClass( + 'text-text-primary', + ); }); }); diff --git a/packages/shared/src/components/cards/highlight/common.tsx b/packages/shared/src/components/cards/highlight/common.tsx index 526cadb4aef..425befd0f21 100644 --- a/packages/shared/src/components/cards/highlight/common.tsx +++ b/packages/shared/src/components/cards/highlight/common.tsx @@ -3,6 +3,7 @@ import React, { useLayoutEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import type { PostHighlight } from '../../../graphql/highlights'; import { webappUrl } from '../../../lib/constants'; +import { useReadHighlights } from '../../../hooks/useReadHighlights'; import { RelativeTime } from '../../utilities/RelativeTime'; import Link from '../../utilities/Link'; import { HighlightCardOptions } from './HighlightCardOptions'; @@ -26,6 +27,18 @@ export const getHighlightsUrl = (highlightId?: string): string => const getHighlightUrl = (highlight: PostHighlight): string => getHighlightsUrl(highlight.id); +const getHighlightHeadlineClassName = (isRead: boolean): string => + classNames( + 'break-words font-bold typo-callout', + isRead ? 'text-text-secondary' : 'text-text-primary', + ); + +const getHighlightAccentDotClassName = (isRead: boolean): string => + classNames( + 'mt-1.5 size-1.5 shrink-0 rounded-full', + isRead ? 'bg-text-tertiary' : highlightsAccentDotClassName, + ); + const distributeRowHeights = ( containerHeightPx: number, itemCount: number, @@ -127,19 +140,26 @@ const HighlightRow = ({ highlight, index, onHighlightClick, + isRead, + onMarkAsRead, }: { highlight: PostHighlight; index: number; onHighlightClick?: (highlight: PostHighlight, position: number) => void; + isRead: boolean; + onMarkAsRead: (highlightId: string) => void; }): ReactElement => { return (
    onHighlightClick?.(highlight, index + 1)} + onClick={() => { + onMarkAsRead(highlight.id); + onHighlightClick?.(highlight, index + 1); + }} > - + {highlight.headline} void; rowHeight?: number; + isRead: boolean; + onMarkAsRead: (highlightId: string) => void; }): ReactElement => ( onHighlightClick?.(highlight, index + 1)} + onClick={() => { + onMarkAsRead(highlight.id); + onHighlightClick?.(highlight, index + 1); + }} > { const firstHighlight = highlights[0]; const { listRef, rowHeights } = useHighlightGridRowHeights(highlights.length); + const { isRead, markAsRead } = useReadHighlights(); + + const onMarkAsRead = (highlightId: string): void => { + void markAsRead(highlightId); + }; return (
    @@ -234,6 +263,8 @@ const HighlightGridCardContent = ({ index={index} onHighlightClick={onHighlightClick} rowHeight={rowHeights[index]} + isRead={isRead(highlight.id)} + onMarkAsRead={onMarkAsRead} /> ))}
    @@ -264,6 +295,11 @@ export const HighlightCardContent = ({ } const firstHighlight = highlights[0]; + const { isRead, markAsRead } = useReadHighlights(); + + const onMarkAsRead = (highlightId: string): void => { + void markAsRead(highlightId); + }; return ( <> @@ -285,6 +321,8 @@ export const HighlightCardContent = ({ highlight={highlight} index={index} onHighlightClick={onHighlightClick} + isRead={isRead(highlight.id)} + onMarkAsRead={onMarkAsRead} /> ))}
    diff --git a/packages/shared/src/hooks/useReadHighlights.ts b/packages/shared/src/hooks/useReadHighlights.ts new file mode 100644 index 00000000000..822ab562d7d --- /dev/null +++ b/packages/shared/src/hooks/useReadHighlights.ts @@ -0,0 +1,36 @@ +import { useCallback, useMemo } from 'react'; +import usePersistentContext from './usePersistentContext'; + +const READ_HIGHLIGHTS_KEY = 'read_highlights'; + +export function useReadHighlights(): { + isRead: (highlightId: string) => boolean; + markAsRead: (highlightId: string) => Promise; +} { + const [value, setValue] = usePersistentContext( + READ_HIGHLIGHTS_KEY, + [], + undefined, + [], + ); + const readIds = useMemo(() => value ?? [], [value]); + const readSet = useMemo(() => new Set(readIds), [readIds]); + + const isRead = useCallback( + (highlightId: string) => readSet.has(highlightId), + [readSet], + ); + + const markAsRead = useCallback( + async (highlightId: string) => { + if (readSet.has(highlightId)) { + return; + } + + await setValue([...readIds, highlightId]); + }, + [readIds, readSet, setValue], + ); + + return { isRead, markAsRead }; +} From 8250d9f2645a3dfa5b2aa17e80d08a2fa94c51ff Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 21 May 2026 15:02:10 +0300 Subject: [PATCH 6/9] feat(shared): add featured article wide card to My Feed cycle Introduce a 2x1 featured article card with breaking news styling and rotate it with top squads and popular tags in horizontal wide feed slots. Co-authored-by: Cursor --- packages/shared/src/components/Feed.tsx | 31 +- .../src/components/FeedItemComponent.tsx | 54 +++- .../ArticleFeaturedWideGridCard.spec.tsx | 82 ++++++ .../article/ArticleFeaturedWideGridCard.tsx | 266 ++++++++++++++++++ packages/shared/src/lib/feedLayoutHint.ts | 2 + packages/shared/src/styles/utilities.css | 89 ++++++ 6 files changed, 514 insertions(+), 10 deletions(-) create mode 100644 packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx create mode 100644 packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index 63a8985a360..f5a974ecf14 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -8,6 +8,7 @@ import React, { useState, } from 'react'; import dynamic from 'next/dynamic'; +import classNames from 'classnames'; import { useRouter } from 'next/router'; import type { QueryKey } from '@tanstack/react-query'; import type { PostItem, UseFeedOptionalParams } from '../hooks/useFeed'; @@ -344,10 +345,17 @@ export default function Feed({ }); return packFeedItems({ hints, columns: virtualizedNumCards }); }, [items, virtualizedNumCards, isMobile, isMultiCardLayoutEnabled]); - // Horizontal-wide (2x1) slots alternate top-active-squads and popular-tags. + // Horizontal-wide (2x1) slots cycle featured article, top squads, popular tags. const horizontalWideVariantByIndex = useMemo(() => { - const map = new Map(); - const sequence = ['topSquads', 'popularTags'] as const; + const map = new Map< + number, + 'featuredArticle' | 'topSquads' | 'popularTags' + >(); + const sequence = [ + 'featuredArticle', + 'topSquads', + 'popularTags', + ] as const; let wideCount = 0; placements.forEach((placement, index) => { if (placement.colSpan > 1 && placement.rowSpan === 1) { @@ -720,6 +728,8 @@ export default function Feed({ (placement.colSpan > 1 || placement.rowSpan > 1); const horizontalWideVariant = horizontalWideVariantByIndex.get(index); + const isFeaturedArticleSlot = + horizontalWideVariant === 'featuredArticle'; const isTopSquadsSlot = horizontalWideVariant === 'topSquads'; const isPopularTagsSlot = horizontalWideVariant === 'popularTags'; @@ -781,10 +791,16 @@ export default function Feed({ {shouldApplySpan ? (
    *]:h-full` lets the inner card fill the - // assigned grid track(s); removing `max-h-cardLarge` - // is required so vertical spans (`rowSpan > 1`) aren't - // clamped to a single-row card height. - className="flex h-full w-full [&>*]:h-full [&>*]:w-full [&_.max-h-cardLarge]:max-h-none" + // assigned grid track(s). Only vertical spans need + // `max-h-cardLarge` removed so `rowSpan > 1` cards + // are not clamped to a single-row height; horizontal + // wide cards (2x1) keep the standard card height cap so + // a square side image cannot stretch the row. + className={classNames( + 'flex h-full w-full [&>*]:h-full [&>*]:w-full', + placement.rowSpan > 1 && + '[&_.max-h-cardLarge]:max-h-none', + )} style={spanStyle} data-testid="feedItemSpanWrapper" > @@ -810,6 +826,7 @@ export default function Feed({ onReadArticleClick={onReadArticleClick} virtualizedNumCards={virtualizedNumCards} disableAdRefresh={disableAdRefresh} + renderAsFeaturedArticleCard={isFeaturedArticleSlot} renderAsTopSquadsCard={isTopSquadsSlot} topActiveSquads={topActiveSquadsForCard} topActiveSquadsPending={ diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index e36129e22d5..b3094d47854 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -28,6 +28,7 @@ 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 { ArticleFeaturedWideGridCard } from './cards/article/ArticleFeaturedWideGridCard'; import { TopSquadsGridCard } from './cards/article/TopSquadsGridCard'; import { PopularTagsGridCard } from './cards/article/PopularTagsGridCard'; import type { PopularTagItem } from './cards/article/PopularTagsGridCard'; @@ -98,6 +99,7 @@ export type FeedItemComponentProps = { ) => unknown; virtualizedNumCards: number; disableAdRefresh?: boolean; + renderAsFeaturedArticleCard?: boolean; renderAsTopSquadsCard?: boolean; topActiveSquads?: TopActiveSquad[]; topActiveSquadsPending?: boolean; @@ -272,6 +274,7 @@ function FeedItemComponent({ onReadArticleClick, virtualizedNumCards, disableAdRefresh, + renderAsFeaturedArticleCard = false, renderAsTopSquadsCard = false, topActiveSquads, topActiveSquadsPending = false, @@ -393,12 +396,57 @@ function FeedItemComponent({ } const isPostItem = item.type === FeedItemType.Post; - const renderTopSquads = renderAsTopSquadsCard && isPostItem; + const renderFeaturedArticle = renderAsFeaturedArticleCard && isPostItem; + const renderTopSquads = + renderAsTopSquadsCard && isPostItem && !renderFeaturedArticle; const renderPopularTags = - renderAsPopularTagsCard && isPostItem && !renderTopSquads; + renderAsPopularTagsCard && isPostItem && !renderFeaturedArticle && !renderTopSquads; + + const featuredArticleHandlers = { + ref: inViewRef, + post: { ...itemPost }, + 'data-testid': 'postItem', + onUpvoteClick: (post: Post, origin = Origin.Feed) => { + toggleUpvote({ + payload: post, + origin, + opts: { columns, column, row }, + }); + }, + onDownvoteClick: (post: Post, origin = Origin.Feed) => { + toggleDownvote({ + payload: post, + origin, + opts: { columns, column, row }, + }); + }, + onPostClick: (post: Post) => onPostClick(post, index, row, column), + onPostAuxClick: (post: Post) => + onPostClick(post, index, row, column, true), + onReadArticleClick: () => + onReadArticleClick(itemPost, index, row, column), + onShare: (post: Post) => onShare(post, row, column), + onBookmarkClick: (post: Post, origin = Origin.Feed) => { + toggleBookmark({ + post, + origin, + opts: { columns, column, row }, + }); + }, + openNewTab, + onCopyLinkClick: (event: React.MouseEvent, post: Post) => + onCopyLinkClick(event, post, index, row, column), + onCommentClick: (post: Post) => + onCommentClick(post, index, row, column, !!boostedBy), + eagerLoadImage: row === 0 && column === 0, + }; let postBody: ReactElement; - if (renderTopSquads) { + if (renderFeaturedArticle) { + postBody = ( + + ); + } else if (renderTopSquads) { postBody = ( ({ + useRouter: jest.fn(), +})); + +beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useRouter).mockImplementation( + () => + ({ + pathname: '/', + } as unknown as NextRouter), + ); +}); + +const defaultProps: PostCardProps = { + post: { + ...post, + summary: 'A short summary for the featured wide card.', + }, + onPostClick: jest.fn(), + onUpvoteClick: jest.fn(), + onCommentClick: jest.fn(), + onBookmarkClick: jest.fn(), + onShare: jest.fn(), + onCopyLinkClick: jest.fn(), + onReadArticleClick: jest.fn(), + onPostAuxClick: jest.fn(), + onDownvoteClick: jest.fn(), +}; + +const renderComponent = (props: Partial = {}): RenderResult => { + return render( + + + , + ); +}; + +it('renders a larger title, description, engagement bar, and column-width image', async () => { + renderComponent(); + + const title = await screen.findByRole('heading', { level: 3 }); + expect(title).toHaveClass('typo-title1'); + expect(title).not.toHaveClass('line-clamp-2', 'line-clamp-3'); + expect(title).toHaveTextContent(post.title ?? ''); + + expect( + screen.getByText('A short summary for the featured wide card.'), + ).toHaveClass('line-clamp-3'); + + const upvoteButton = screen.getByLabelText('More like this'); + expect(upvoteButton).toBeInTheDocument(); + expect(upvoteButton).toHaveClass('h-8', 'w-8'); + expect(screen.getByText('Read post')).toBeInTheDocument(); + + const image = screen.getByRole('img', { name: post.title }); + expect(image).toHaveClass('object-cover'); + expect(image.parentElement).toHaveClass('h-full', 'min-w-0', 'rounded-r-16'); + expect(image.parentElement?.parentElement).toHaveClass('grid-cols-2'); + + expect(screen.getByText('Breaking news')).toHaveClass( + 'breaking-news-chip-fill', + ); + const chip = screen.getByText('Breaking news'); + expect( + title.compareDocumentPosition(chip) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(chip.closest('.shrink-0')?.nextElementSibling).toHaveClass( + 'overflow-hidden', + ); +}); diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx new file mode 100644 index 00000000000..d8ca64ac6ca --- /dev/null +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx @@ -0,0 +1,266 @@ +import type { ReactElement, Ref } from 'react'; +import React, { forwardRef, useMemo } from 'react'; +import classNames from 'classnames'; +import type { PostCardProps } from '../common/common'; +import { Container } from '../common/common'; +import FeedItemContainer from '../common/FeedItemContainer'; +import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; +import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; +import { usePostFeedback } from '../../../hooks'; +import { isVideoPost, PostType } from '../../../graphql/posts'; +import { PostTagsPanel } from '../../post/block/PostTagsPanel'; +import { + CardSpace, + CardTextContainer, + getPostClassNames, +} from '../common/Card'; +import CardOverlay from '../common/CardOverlay'; +import { Origin } from '../../../lib/log'; +import styles from '../common/Card.module.css'; +import { PostCardHeader } from '../common/PostCardHeader'; +import PostTags from '../common/PostTags'; +import PostMetadata from '../common/PostMetadata'; +import ActionButtons from '../common/ActionButtons'; +import { FeedbackGrid } from './feedback/FeedbackGrid'; +import { ClickbaitShield } from '../common/ClickbaitShield'; +import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; +import { usePostImage } from '../../../hooks/post/usePostImage'; +import { + HIGH_PRIORITY_IMAGE_PROPS, + Image, + ImageType, +} from '../../image/Image'; +import { stripHtmlTags } from '../../../lib/strings'; + +const BREAKING_NEWS_CHIP_LABEL = 'Breaking news'; +const breakingNewsChipFillClassName = 'breaking-news-chip-fill'; +const breakingNewsChipGlowClassName = 'breaking-news-chip-glow'; + +const BreakingNewsChip = ({ + className, +}: { + className?: string; +}): ReactElement => ( + + + + {BREAKING_NEWS_CHIP_LABEL} + + +); + +export const ArticleFeaturedWideGridCard = forwardRef( + function ArticleFeaturedWideGridCard( + { + post, + onPostClick, + onPostAuxClick, + onUpvoteClick, + onDownvoteClick, + onCommentClick, + onBookmarkClick, + onCopyLinkClick, + openNewTab, + children, + onReadArticleClick, + domProps = {}, + eagerLoadImage = false, + }: PostCardProps, + ref: Ref, + ): ReactElement { + const { className, style } = domProps; + const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); + const { data } = useBlockPostPanel(post); + const onPostCardClick = () => onPostClick?.(post); + const onPostCardAuxClick = () => onPostAuxClick?.(post); + const { pinnedAt, trending } = post; + const { showFeedback } = usePostFeedback({ post }); + const { title } = useSmartTitle(post); + const isVideoType = isVideoPost(post); + const image = usePostImage(post); + const isTweetPost = + post.type === PostType.SocialTwitter || + post.sharedPost?.type === PostType.SocialTwitter; + const shouldUseSharedPostContent = + post.subType === 'repost' && !post.contentHtml?.trim(); + const description = useMemo(() => { + if (isTweetPost) { + const primaryTweetContent = + stripHtmlTags(post.contentHtml ?? '').trim() || + post.title?.trim() || + ''; + const sharedTweetContent = + stripHtmlTags(post.sharedPost?.contentHtml ?? '').trim() || + post.sharedPost?.title?.trim() || + ''; + return shouldUseSharedPostContent + ? sharedTweetContent || primaryTweetContent || '' + : primaryTweetContent || sharedTweetContent || ''; + } + + return post.summary?.trim() || post.sharedPost?.summary?.trim() || ''; + }, [ + isTweetPost, + shouldUseSharedPostContent, + post.contentHtml, + post.title, + post.sharedPost?.title, + post.sharedPost?.contentHtml, + post.sharedPost?.summary, + post.summary, + ]); + + if (isHidden) { + return ( + + {hiddenPanel} + + ); + } + + if (data?.showTagsPanel && (post.tags?.length ?? 0) > 0) { + return ( + + ); + } + + return ( + + + {showFeedback && ( + onUpvoteClick?.(post, Origin.FeedbackCard)} + onDownvoteClick={() => onDownvoteClick?.(post, Origin.FeedbackCard)} + isVideoType={isVideoType} + /> + )} + +
    +
    + + +

    + {title} +

    + {!showFeedback && ( + <> +
    + {post.clickbaitTitleDetected && ( + + )} + + +
    + + + )} + {!showFeedback && description ? ( +

    + {description} +

    + ) : null} +
    + {!showFeedback && ( + + + + + )} +
    + {!showFeedback && image ? ( +
    + {post.title} +
    + ) : null} +
    + {children} +
    + ); + }, +); diff --git a/packages/shared/src/lib/feedLayoutHint.ts b/packages/shared/src/lib/feedLayoutHint.ts index e57d1e71d6e..53b5fb2471f 100644 --- a/packages/shared/src/lib/feedLayoutHint.ts +++ b/packages/shared/src/lib/feedLayoutHint.ts @@ -150,11 +150,13 @@ export const getRawLayoutHintFromItem = (item: FeedItem): unknown => { * supported large variants in My Feed. Returns `undefined` for items that * should remain `1x1`. * + * index 7 -> 2x1 (horizontal wide featured article card) * index 21 -> 2x1 (horizontal wide Top active squads card) * index 35 -> 2x1 (horizontal wide Popular tags card) * pattern repeats every 40 items */ const DEV_SEED_PATTERN: Record = { + 7: '2x1', 21: '2x1', 35: '2x1', }; diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index cf923789f1a..d4c92d07e11 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -385,6 +385,95 @@ } } +@keyframes breaking-news-chip-mesh { + 0% { + background-position: 100% 0%, 80% 100%, 50% 100%, 0% 50%; + } + + 50% { + background-position: 0% 100%, 15% 0%, 50% 0%, 100% 50%; + } + + 100% { + background-position: 100% 0%, 80% 100%, 50% 100%, 0% 50%; + } +} + +@keyframes breaking-news-chip-glow-mesh { + 0% { + background-position: 100% 50%, 50% 0%; + } + + 50% { + background-position: 0% 50%, 50% 100%; + } + + 100% { + background-position: 100% 50%, 50% 0%; + } +} + +.breaking-news-chip-fill { + background-color: var(--theme-accent-ketchup-default); + background-image: radial-gradient( + circle at 85% 25%, + var(--theme-accent-bun-default) 0%, + transparent 52% + ), + radial-gradient( + circle at 70% 90%, + var(--theme-accent-bacon-default) 0%, + transparent 54% + ), + radial-gradient( + circle at 55% 115%, + var(--theme-accent-ketchup-bolder) 0%, + transparent 58% + ), + linear-gradient( + 120deg, + var(--theme-accent-ketchup-default), + var(--theme-accent-bacon-default), + var(--theme-accent-bun-default), + var(--theme-accent-ketchup-bolder) + ); + background-size: 220% 220%, 230% 230%, 260% 260%, 320% 320%; + background-position: 100% 0%, 80% 100%, 50% 100%, 0% 50%; + animation: breaking-news-chip-mesh 4.5s ease-in-out infinite; +} + +.breaking-news-chip-glow { + background-color: var(--theme-accent-ketchup-default); + background-image: radial-gradient( + circle at 75% 50%, + var(--theme-accent-bun-default) 0%, + transparent 62% + ), + radial-gradient( + circle at 50% 50%, + var(--theme-accent-bacon-default) 0%, + transparent 68% + ); + background-size: 180% 180%, 210% 210%; + background-position: 100% 50%, 50% 0%; + animation: breaking-news-chip-glow-mesh 6s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .breaking-news-chip-fill, + .breaking-news-chip-glow { + animation: none; + } + + .breaking-news-chip-fill { + background-position: 100% 0%, 80% 100%, 50% 100%, 0% 50%; + } + + .breaking-news-chip-glow { + background-position: 100% 50%, 50% 0%; + } +} + .feed-highlights-new-item-border-bottom { border-style: solid; border-width: 0 0 0.0625rem; From c24e8831a9a495737942a6b55d41588af233db0d Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 21 May 2026 15:12:41 +0300 Subject: [PATCH 7/9] fix(shared): address Claude PR review feedback for wide feed cards Align popular tags copy, reduce squad query fan-out, cap read highlights storage, simplify wide-card variant wiring, and fix hydration/lint issues in highlight cards. Co-authored-by: Cursor --- packages/shared/src/components/Feed.tsx | 29 +++++------ .../src/components/FeedItemComponent.tsx | 29 +++++------ .../ArticleFeaturedWideGridCard.spec.tsx | 6 +-- .../cards/article/PopularTagsGridCard.tsx | 6 +-- .../cards/article/TopSquadsGridCard.tsx | 4 +- .../src/components/cards/highlight/common.tsx | 49 ++++++++++++------- .../shared/src/hooks/usePopularTagsWrapFit.ts | 14 +----- .../shared/src/hooks/useReadHighlights.ts | 3 +- .../shared/src/hooks/useTopActiveSquads.ts | 7 ++- packages/shared/src/lib/featureManagement.ts | 2 +- 10 files changed, 72 insertions(+), 77 deletions(-) diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index f5a974ecf14..5ad3d13c05c 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -351,11 +351,7 @@ export default function Feed({ number, 'featuredArticle' | 'topSquads' | 'popularTags' >(); - const sequence = [ - 'featuredArticle', - 'topSquads', - 'popularTags', - ] as const; + const sequence = ['featuredArticle', 'topSquads', 'popularTags'] as const; let wideCount = 0; placements.forEach((placement, index) => { if (placement.colSpan > 1 && placement.rowSpan === 1) { @@ -728,14 +724,10 @@ export default function Feed({ (placement.colSpan > 1 || placement.rowSpan > 1); const horizontalWideVariant = horizontalWideVariantByIndex.get(index); - const isFeaturedArticleSlot = - horizontalWideVariant === 'featuredArticle'; - const isTopSquadsSlot = horizontalWideVariant === 'topSquads'; - const isPopularTagsSlot = - horizontalWideVariant === 'popularTags'; - const topActiveSquadsForCard = isTopSquadsSlot - ? topActiveSquads - : undefined; + const topActiveSquadsForCard = + horizontalWideVariant === 'topSquads' + ? topActiveSquads + : undefined; // The packer chooses an exact column and row for every spanned // card. Pin both axes so CSS Grid `dense` flow honours the // planned placement instead of re-packing from left-to-right. @@ -744,7 +736,9 @@ export default function Feed({ // free column rather than the rightmost slot the packer picked. const spanStyle = shouldApplySpan ? { - gridColumn: `${placement.column + 1} / span ${placement.colSpan}`, + gridColumn: `${placement.column + 1} / span ${ + placement.colSpan + }`, gridRow: `${placement.row + 1} / span ${placement.rowSpan}`, } : undefined; @@ -826,13 +820,12 @@ export default function Feed({ onReadArticleClick={onReadArticleClick} virtualizedNumCards={virtualizedNumCards} disableAdRefresh={disableAdRefresh} - renderAsFeaturedArticleCard={isFeaturedArticleSlot} - renderAsTopSquadsCard={isTopSquadsSlot} + horizontalWideVariant={horizontalWideVariant} topActiveSquads={topActiveSquadsForCard} topActiveSquadsPending={ - isTopSquadsSlot && isTopActiveSquadsPending + horizontalWideVariant === 'topSquads' && + isTopActiveSquadsPending } - renderAsPopularTagsCard={isPopularTagsSlot} />
    ) : ( diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index b3094d47854..8f9c374f825 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -63,6 +63,11 @@ import { HighlightGrid } from './cards/highlight/HighlightGrid'; import { HighlightList } from './cards/highlight/HighlightList'; import { getHighlightIds, getHighlightIdsKey } from '../graphql/highlights'; +export type HorizontalWideFeedVariant = + | 'featuredArticle' + | 'topSquads' + | 'popularTags'; + export type FeedItemComponentProps = { item: FeedItem; index: number; @@ -99,11 +104,9 @@ export type FeedItemComponentProps = { ) => unknown; virtualizedNumCards: number; disableAdRefresh?: boolean; - renderAsFeaturedArticleCard?: boolean; - renderAsTopSquadsCard?: boolean; + horizontalWideVariant?: HorizontalWideFeedVariant; topActiveSquads?: TopActiveSquad[]; topActiveSquadsPending?: boolean; - renderAsPopularTagsCard?: boolean; popularTags?: PopularTagItem[]; } & Pick & Pick; @@ -274,11 +277,9 @@ function FeedItemComponent({ onReadArticleClick, virtualizedNumCards, disableAdRefresh, - renderAsFeaturedArticleCard = false, - renderAsTopSquadsCard = false, + horizontalWideVariant, topActiveSquads, topActiveSquadsPending = false, - renderAsPopularTagsCard = false, popularTags, }: FeedItemComponentProps): ReactElement | null { const { logEvent } = useLogContext(); @@ -396,11 +397,7 @@ function FeedItemComponent({ } const isPostItem = item.type === FeedItemType.Post; - const renderFeaturedArticle = renderAsFeaturedArticleCard && isPostItem; - const renderTopSquads = - renderAsTopSquadsCard && isPostItem && !renderFeaturedArticle; - const renderPopularTags = - renderAsPopularTagsCard && isPostItem && !renderFeaturedArticle && !renderTopSquads; + const wideVariant = isPostItem ? horizontalWideVariant : undefined; const featuredArticleHandlers = { ref: inViewRef, @@ -442,11 +439,9 @@ function FeedItemComponent({ }; let postBody: ReactElement; - if (renderFeaturedArticle) { - postBody = ( - - ); - } else if (renderTopSquads) { + if (wideVariant === 'featuredArticle') { + postBody = ; + } else if (wideVariant === 'topSquads') { postBody = ( ); - } else if (renderPopularTags) { + } else if (wideVariant === 'popularTags') { postBody = (

    - Most followed tags + Popular tags

      {visibleTags.map((tag, index) => ( diff --git a/packages/shared/src/components/cards/article/TopSquadsGridCard.tsx b/packages/shared/src/components/cards/article/TopSquadsGridCard.tsx index 63cb87b7075..2634004d3e0 100644 --- a/packages/shared/src/components/cards/article/TopSquadsGridCard.tsx +++ b/packages/shared/src/components/cards/article/TopSquadsGridCard.tsx @@ -37,7 +37,9 @@ const formatMembersCount = (count?: number): string | null => { } if (count >= 1000) { - return `${(count / 1000).toFixed(count >= 10000 ? 0 : 1)}k members`; + const formatted = (count / 1000).toFixed(1).replace(/\.0$/, ''); + + return `${formatted}k members`; } return `${count} members`; diff --git a/packages/shared/src/components/cards/highlight/common.tsx b/packages/shared/src/components/cards/highlight/common.tsx index 425befd0f21..943b6c00e35 100644 --- a/packages/shared/src/components/cards/highlight/common.tsx +++ b/packages/shared/src/components/cards/highlight/common.tsx @@ -197,10 +197,7 @@ const HighlightGridRow = ({ onHighlightClick?.(highlight, index + 1); }} > - + { - void markAsRead(highlightId); + Promise.resolve(markAsRead(highlightId)).catch(() => undefined); }; return ( @@ -278,27 +275,16 @@ const HighlightGridCardContent = ({ ); }; -export const HighlightCardContent = ({ +const HighlightListCardContent = ({ highlights, onHighlightClick, onReadAllClick, - variant, -}: HighlightCardProps & { variant: 'grid' | 'list' }): ReactElement => { - if (variant === 'grid') { - return ( - - ); - } - +}: HighlightCardProps): ReactElement => { const firstHighlight = highlights[0]; const { isRead, markAsRead } = useReadHighlights(); const onMarkAsRead = (highlightId: string): void => { - void markAsRead(highlightId); + Promise.resolve(markAsRead(highlightId)).catch(() => undefined); }; return ( @@ -334,3 +320,28 @@ export const HighlightCardContent = ({ ); }; + +export const HighlightCardContent = ({ + highlights, + onHighlightClick, + onReadAllClick, + variant, +}: HighlightCardProps & { variant: 'grid' | 'list' }): ReactElement => { + if (variant === 'grid') { + return ( + + ); + } + + return ( + + ); +}; diff --git a/packages/shared/src/hooks/usePopularTagsWrapFit.ts b/packages/shared/src/hooks/usePopularTagsWrapFit.ts index 12642f6435a..f4a6389ec91 100644 --- a/packages/shared/src/hooks/usePopularTagsWrapFit.ts +++ b/packages/shared/src/hooks/usePopularTagsWrapFit.ts @@ -80,11 +80,10 @@ export function usePopularTagsWrapFit( ); const listRef = useRef(null); - const [visibleCount, setVisibleCount] = useState(() => candidates.length); - const zeroHeightRetriesRef = useRef(0); + const [visibleCount, setVisibleCount] = useState(1); useLayoutEffect(() => { - zeroHeightRetriesRef.current = 0; + setVisibleCount(1); }, [candidates]); const recompute = useCallback(() => { @@ -94,7 +93,6 @@ export function usePopularTagsWrapFit( } if (candidates.length === 0) { - zeroHeightRetriesRef.current = 0; flushSync(() => { setVisibleCount(0); }); @@ -107,17 +105,9 @@ export function usePopularTagsWrapFit( const available = el.clientHeight; if (available < 1) { - if (zeroHeightRetriesRef.current < 8) { - zeroHeightRetriesRef.current += 1; - requestAnimationFrame(() => { - recompute(); - }); - } return; } - zeroHeightRetriesRef.current = 0; - const fit = countWrappedChipsThatFit(el); const next = Math.max(1, Math.min(fit, candidates.length)); diff --git a/packages/shared/src/hooks/useReadHighlights.ts b/packages/shared/src/hooks/useReadHighlights.ts index 822ab562d7d..eff7c3943fa 100644 --- a/packages/shared/src/hooks/useReadHighlights.ts +++ b/packages/shared/src/hooks/useReadHighlights.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react'; import usePersistentContext from './usePersistentContext'; const READ_HIGHLIGHTS_KEY = 'read_highlights'; +const READ_HIGHLIGHTS_MAX = 500; export function useReadHighlights(): { isRead: (highlightId: string) => boolean; @@ -27,7 +28,7 @@ export function useReadHighlights(): { return; } - await setValue([...readIds, highlightId]); + await setValue([...readIds, highlightId].slice(-READ_HIGHLIGHTS_MAX)); }, [readIds, readSet, setValue], ); diff --git a/packages/shared/src/hooks/useTopActiveSquads.ts b/packages/shared/src/hooks/useTopActiveSquads.ts index 01a2215129c..ac6a78eb5d5 100644 --- a/packages/shared/src/hooks/useTopActiveSquads.ts +++ b/packages/shared/src/hooks/useTopActiveSquads.ts @@ -36,6 +36,9 @@ export const TOP_ACTIVE_SQUADS_30D = [ { name: 'Platform & AI', handle: 'platformai' }, ] as const; +/** Matches the skeleton/initial viewport in `TopSquadsGridCard`. */ +export const TOP_ACTIVE_SQUADS_CARD_LIMIT = 8; + export interface TopActiveSquad { id: string; name: string; @@ -48,7 +51,7 @@ export interface TopActiveSquad { interface UseTopActiveSquadsParams { enabled?: boolean; - /** Limit how many of the curated handles to fetch (defaults to all 20). */ + /** Limit how many of the curated handles to fetch. */ limit?: number; } @@ -59,7 +62,7 @@ interface UseTopActiveSquadsResult { export const useTopActiveSquads = ({ enabled = true, - limit = TOP_ACTIVE_SQUADS_30D.length, + limit = TOP_ACTIVE_SQUADS_CARD_LIMIT, }: UseTopActiveSquadsParams = {}): UseTopActiveSquadsResult => { const seeds = useMemo(() => TOP_ACTIVE_SQUADS_30D.slice(0, limit), [limit]); diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 78b744b15f0..d796062cb46 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -207,7 +207,7 @@ export const featureCompanionDemoWidget = new Feature( export const featureFeedTagChips = new Feature('feed_tag_chips', false); /** - * Enables variable-size cards (1x2, 2x1, 2x2, 3x2) in My Feed. + * Enables variable-size cards (1x1, 1x2, 2x1) in My Feed. * UI-only first; once backend ships `layoutHint` per item, the FE will * consume it automatically. Defaults on in development for visual QA and off * in production until rollout is configured in GrowthBook. From 74ab49b9cab9f39044d8117ecc2b3ac849d46df4 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 21 May 2026 15:42:48 +0300 Subject: [PATCH 8/9] fix(shared): restore Feed topContent prop dropped during merge Re-adds the topContent prop on Feed so MainFeedLayout can render explore tag chips above the feed. Without it Vercel's Next.js build fails type checking because MainFeedLayout passes a prop Feed no longer declares. Co-authored-by: Cursor --- packages/shared/src/components/Feed.tsx | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index daad5d2bb2f..43117140c28 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -114,6 +114,7 @@ export interface FeedProps isHorizontal?: boolean; feedContainerRef?: React.Ref; disableListFrame?: boolean; + topContent?: ReactNode; } interface RankVariables { @@ -210,6 +211,7 @@ export default function Feed({ isHorizontal = false, feedContainerRef, disableListFrame = false, + topContent: topContentProp, }: FeedProps): ReactElement { const origin = Origin.Feed; const { logEvent } = useLogContext(); @@ -668,15 +670,17 @@ export default function Feed({ const containerProps = isSearchPageLaptop ? {} : { - topContent: shouldShowTopHero ? ( - onEnableHero(NotificationCtaPlacement.TopHero)} - onClose={() => onDismissHero(NotificationCtaPlacement.TopHero)} - /> - ) : undefined, + topContent: + topContentProp ?? + (shouldShowTopHero ? ( + onEnableHero(NotificationCtaPlacement.TopHero)} + onClose={() => onDismissHero(NotificationCtaPlacement.TopHero)} + /> + ) : undefined), header, inlineHeader, className, From f75425f3ced3933e7b31fdadae6146f75c7296be Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 24 May 2026 10:15:54 +0300 Subject: [PATCH 9/9] feat(shared): add 3x1 and 4x1 featured article wide card variants Extend the horizontal-wide featured card to support 3x1 and 4x1 column spans. Text stays one column wide while the image stretches across the remaining columns. Dev seeds place all three sizes (2x1, 3x1, 4x1) in separate density windows so all variants are visible in My Feed at once. Headline is capped at 4 lines with ellipsis. Co-authored-by: Cursor --- packages/shared/src/components/Feed.tsx | 20 ++++++- .../src/components/FeedItemComponent.tsx | 10 +++- .../ArticleFeaturedWideGridCard.spec.tsx | 21 ++++++- .../article/ArticleFeaturedWideGridCard.tsx | 31 ++++++++-- .../shared/src/lib/feedGridPacker.spec.ts | 33 +++++++++++ packages/shared/src/lib/feedGridPacker.ts | 16 +++--- .../shared/src/lib/feedLayoutHint.spec.ts | 27 ++++++++- packages/shared/src/lib/feedLayoutHint.ts | 56 +++++++++++++------ 8 files changed, 176 insertions(+), 38 deletions(-) diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index 43117140c28..85b6e16d0a2 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -73,6 +73,7 @@ import { } from '../lib/featureManagement'; import { getDevSeededLayoutHint, + getDevSeededWideVariant, getRawLayoutHintFromItem, resolveLayoutHint, } from '../lib/feedLayoutHint'; @@ -348,7 +349,8 @@ export default function Feed({ }); return packFeedItems({ hints, columns: virtualizedNumCards }); }, [items, virtualizedNumCards, isMobile, isMultiCardLayoutEnabled]); - // Horizontal-wide (2x1) slots cycle featured article, top squads, popular tags. + // Horizontal-wide slots cycle featured article, top squads, popular tags. + // In dev mode, respect the seeded variant so 3x1/4x1 stays as featuredArticle. const horizontalWideVariantByIndex = useMemo(() => { const map = new Map< number, @@ -362,12 +364,17 @@ export default function Feed({ return; } - map.set(index, sequence[wideCount % sequence.length]); + const devVariant = + isDevelopment && isMultiCardLayoutEnabled + ? getDevSeededWideVariant(index) + : undefined; + + map.set(index, devVariant ?? sequence[wideCount % sequence.length]); wideCount += 1; } }); return map; - }, [placements, items]); + }, [placements, items, isMultiCardLayoutEnabled]); const hasTopSquadsSlot = useMemo( () => Array.from(horizontalWideVariantByIndex.values()).some( @@ -826,6 +833,13 @@ export default function Feed({ virtualizedNumCards={virtualizedNumCards} disableAdRefresh={disableAdRefresh} horizontalWideVariant={horizontalWideVariant} + horizontalWideColSpan={ + horizontalWideVariant === 'featuredArticle' && + placement.colSpan >= 2 && + placement.colSpan <= 4 + ? (placement.colSpan as 2 | 3 | 4) + : undefined + } topActiveSquads={topActiveSquadsForCard} topActiveSquadsPending={ horizontalWideVariant === 'topSquads' && diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 72348584f3d..af568ce315a 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -29,6 +29,7 @@ import type { PostClick } from '../lib/click'; import { ArticleList } from './cards/article/ArticleList'; import { ArticleGrid } from './cards/article/ArticleGrid'; import { ArticleFeaturedWideGridCard } from './cards/article/ArticleFeaturedWideGridCard'; +import type { FeaturedWideColSpan } from './cards/article/ArticleFeaturedWideGridCard'; import { LiveRoomPostGrid } from './cards/liveRoom/LiveRoomPostGrid'; import { LiveRoomPostList } from './cards/liveRoom/LiveRoomPostList'; import { TopSquadsGridCard } from './cards/article/TopSquadsGridCard'; @@ -107,6 +108,7 @@ export type FeedItemComponentProps = { virtualizedNumCards: number; disableAdRefresh?: boolean; horizontalWideVariant?: HorizontalWideFeedVariant; + horizontalWideColSpan?: FeaturedWideColSpan; topActiveSquads?: TopActiveSquad[]; topActiveSquadsPending?: boolean; popularTags?: PopularTagItem[]; @@ -281,6 +283,7 @@ function FeedItemComponent({ onReadArticleClick, virtualizedNumCards, horizontalWideVariant, + horizontalWideColSpan = 2, topActiveSquads, topActiveSquadsPending = false, popularTags, @@ -443,7 +446,12 @@ function FeedItemComponent({ let postBody: ReactElement; if (wideVariant === 'featuredArticle') { - postBody = ; + postBody = ( + + ); } else if (wideVariant === 'topSquads') { postBody = ( = {}): RenderResult => { +const renderComponent = ( + props: Partial = {}, +): RenderResult => { return render( @@ -53,6 +55,7 @@ it('renders a larger title, description, engagement bar, and column-width image' const title = await screen.findByRole('heading', { level: 3 }); expect(title).toHaveClass('typo-title1'); expect(title).not.toHaveClass('line-clamp-2', 'line-clamp-3'); + expect(title).toHaveClass('line-clamp-4'); expect(title).toHaveTextContent(post.title ?? ''); expect( @@ -66,7 +69,7 @@ it('renders a larger title, description, engagement bar, and column-width image' const image = screen.getByRole('img', { name: post.title }); expect(image).toHaveClass('object-cover'); - expect(image.parentElement).toHaveClass('h-full', 'min-w-0', 'rounded-r-16'); + expect(image.parentElement).toHaveClass('h-full', 'min-w-0', 'rounded-r-16', 'col-span-1'); expect(image.parentElement?.parentElement).toHaveClass('grid-cols-2'); expect(screen.getByText('Breaking news')).toHaveClass( @@ -80,3 +83,17 @@ it('renders a larger title, description, engagement bar, and column-width image' 'overflow-hidden', ); }); + +it.each([ + [3, 'grid-cols-3', 'col-span-2'], + [4, 'grid-cols-4', 'col-span-3'], +] as const)( + 'keeps the text column at one grid column width for %sx1 cards', + async (wideColSpan, expectedGrid, expectedImageSpan) => { + renderComponent({ wideColSpan }); + + const image = await screen.findByRole('img', { name: post.title }); + expect(image.parentElement).toHaveClass(expectedImageSpan); + expect(image.parentElement?.parentElement).toHaveClass('grid', expectedGrid); + }, +); diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx index a795d3b49e1..564482e4d18 100644 --- a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx @@ -32,6 +32,22 @@ const BREAKING_NEWS_CHIP_LABEL = 'Breaking news'; const breakingNewsChipFillClassName = 'breaking-news-chip-fill'; const breakingNewsChipGlowClassName = 'breaking-news-chip-glow'; +export type FeaturedWideColSpan = 2 | 3 | 4; + +/** Inner grid class mirroring the feed column count the card spans. */ +const INNER_GRID_COLS: Record = { + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4', +}; + +/** Image takes every column except the first (text) column. */ +const IMAGE_COL_SPAN: Record = { + 2: 'col-span-1', + 3: 'col-span-2', + 4: 'col-span-3', +}; + const BreakingNewsChip = ({ className, }: { @@ -77,7 +93,8 @@ export const ArticleFeaturedWideGridCard = forwardRef( onReadArticleClick, domProps = {}, eagerLoadImage = false, - }: PostCardProps, + wideColSpan = 2, + }: PostCardProps & { wideColSpan?: FeaturedWideColSpan }, ref: Ref, ): ReactElement { const { className, style } = domProps; @@ -184,7 +201,8 @@ export const ArticleFeaturedWideGridCard = forwardRef(
      -

      +

      {title}

      {!showFeedback && ( @@ -244,7 +262,12 @@ export const ArticleFeaturedWideGridCard = forwardRef( )}
      {!showFeedback && image ? ( -
      +
      {post.title} { expect(() => packFeedItems({ hints: ['1x1'], columns: 0 })).toThrow(); }); + it('honors a requested 3x1 wide card on a 4-column grid', () => { + const hints: LayoutHint[] = ['3x1', '1x1', '1x1', '1x1']; + const placements = packFeedItems({ + hints, + columns: 4, + largeCardDensity: { maxLarge: hints.length, perItems: 1 }, + }); + + expect(placements[0]).toMatchObject({ colSpan: 3, rowSpan: 1, effectiveHint: '3x1' }); + }); + + it('downgrades 4x1 to 3x1 on a 3-column grid', () => { + const hints: LayoutHint[] = ['4x1', '1x1', '1x1']; + const placements = packFeedItems({ + hints, + columns: 3, + largeCardDensity: { maxLarge: hints.length, perItems: 1 }, + }); + + expect(placements[0]).toMatchObject({ colSpan: 3, rowSpan: 1, effectiveHint: '3x1', requestedHint: '4x1' }); + }); + + it('downgrades 4x1 to 2x1 on a 2-column grid', () => { + const hints: LayoutHint[] = ['4x1', '1x1']; + const placements = packFeedItems({ + hints, + columns: 2, + largeCardDensity: { maxLarge: hints.length, perItems: 1 }, + }); + + expect(placements[0]).toMatchObject({ colSpan: 2, effectiveHint: '2x1', requestedHint: '4x1' }); + }); + it('alternates consecutive 2-wide cards between the left and right edges', () => { // First 2-wide goes to the left edge (col 0). The next 2-wide should // start at col 1 so it sits flush with the right edge of a 3-col grid, diff --git a/packages/shared/src/lib/feedGridPacker.ts b/packages/shared/src/lib/feedGridPacker.ts index 2c71c385448..2b21b50b471 100644 --- a/packages/shared/src/lib/feedGridPacker.ts +++ b/packages/shared/src/lib/feedGridPacker.ts @@ -174,10 +174,10 @@ export const packFeedItems = ({ const maxRowsToScan = Math.max(hints.length, 1) * 4; let searchStartRow = 0; let largeCardsPlaced = 0; - // Counts placements that ended up rendering as 2 columns wide (`2x1` / - // `2x2`). Used to alternate consecutive 2-wide cards between the left - // and right edges of the row. - let twoWidePlacements = 0; + // Counts placements that ended up rendering as horizontally wide (colSpan > 1, + // rowSpan === 1). Used to alternate consecutive wide cards between the left + // and right edges so they don't always stack on the same side. + let horizontalWidePlacements = 0; // Counts placements that ended up rendering as a vertical (rowSpan > 1) // card. Used to alternate consecutive vertical cards between the left // and right edges so the Most Upvoted / Best Discussed pair sits on @@ -210,10 +210,10 @@ export const packFeedItems = ({ ? eligibleCandidates[0] : DEFAULT_LAYOUT_HINT; const { colSpan, rowSpan } = getLayoutHintDimensions(chosenHint); - const isTwoWide = colSpan === 2; + const isHorizontallyWide = colSpan > 1 && rowSpan === 1; const isVertical = rowSpan > 1; const preferRightmost = - (isTwoWide && twoWidePlacements % 2 === 1) || + (isHorizontallyWide && horizontalWidePlacements % 2 === 1) || (isVertical && verticalPlacements % 2 === 1); const slot = findFirstFreeSlot({ occupied, @@ -236,8 +236,8 @@ export const packFeedItems = ({ if (isLargeLayoutHint(chosenHint)) { largeCardsPlaced += 1; } - if (isTwoWide) { - twoWidePlacements += 1; + if (isHorizontallyWide) { + horizontalWidePlacements += 1; } if (isVertical) { verticalPlacements += 1; diff --git a/packages/shared/src/lib/feedLayoutHint.spec.ts b/packages/shared/src/lib/feedLayoutHint.spec.ts index 4e13a1bbb89..5fb5cc4fe42 100644 --- a/packages/shared/src/lib/feedLayoutHint.spec.ts +++ b/packages/shared/src/lib/feedLayoutHint.spec.ts @@ -7,9 +7,12 @@ import { } from './feedLayoutHint'; describe('isLayoutHint', () => { - it.each(['1x1', '1x2', '2x1'])('recognizes valid hint %s', (hint) => { - expect(isLayoutHint(hint)).toBe(true); - }); + it.each(['1x1', '1x2', '2x1', '3x1', '4x1'])( + 'recognizes valid hint %s', + (hint) => { + expect(isLayoutHint(hint)).toBe(true); + }, + ); it.each([null, undefined, '', 'invalid', '4x4', '2x2', '3x2', 5])( 'rejects invalid hint %s', @@ -114,6 +117,24 @@ describe('resolveLayoutHint', () => { }), ).toBe('2x1'); + expect( + resolveLayoutHint({ + rawHint: '3x1', + itemType: FeedItemType.Post, + isMobile: false, + isDisabled: false, + }), + ).toBe('3x1'); + + expect( + resolveLayoutHint({ + rawHint: '4x1', + itemType: FeedItemType.Post, + isMobile: false, + isDisabled: false, + }), + ).toBe('4x1'); + expect( resolveLayoutHint({ rawHint: '1x2', diff --git a/packages/shared/src/lib/feedLayoutHint.ts b/packages/shared/src/lib/feedLayoutHint.ts index 53b5fb2471f..1b9d3ba39f9 100644 --- a/packages/shared/src/lib/feedLayoutHint.ts +++ b/packages/shared/src/lib/feedLayoutHint.ts @@ -9,11 +9,10 @@ import { FeedItemType } from '../components/cards/common/common'; * `Post.layoutHint`. Until then, the FE treats all items as `1x1` so behavior * is unchanged when the field is missing. * - * Scope is intentionally narrow for now: only `1x1`, `2x1` (horizontal wide - * topic-cluster card) and `1x2` (vertical Most Upvoted card) are supported. - * Larger sizes (`2x2`, `3x2`) are intentionally not allowed yet. + * Supported sizes: `1x1`, `2x1`, `3x1`, `4x1` (horizontal wide cards) and + * `1x2` (vertical Happening Now card). Sizes `2x2`, `3x2` are not allowed yet. */ -export const LAYOUT_HINT_VALUES = ['1x1', '1x2', '2x1'] as const; +export const LAYOUT_HINT_VALUES = ['1x1', '1x2', '2x1', '3x1', '4x1'] as const; export type LayoutHint = (typeof LAYOUT_HINT_VALUES)[number]; @@ -31,6 +30,8 @@ const LAYOUT_HINT_DIMENSIONS: Record = { '1x1': { colSpan: 1, rowSpan: 1 }, '1x2': { colSpan: 1, rowSpan: 2 }, '2x1': { colSpan: 2, rowSpan: 1 }, + '3x1': { colSpan: 3, rowSpan: 1 }, + '4x1': { colSpan: 4, rowSpan: 1 }, }; /** @@ -42,6 +43,8 @@ const LAYOUT_HINT_AREA: Record = { '1x1': 1, '1x2': 2, '2x1': 2, + '3x1': 3, + '4x1': 4, }; /** Largest size a sponsored / ad item is allowed to occupy. */ @@ -49,9 +52,11 @@ export const AD_MAX_LAYOUT_HINT: LayoutHint = '2x1'; /** * Fallback chain used when the requested size cannot fit the current row. - * 2x1 -> 1x1 and 1x2 -> 1x1 are the only structural fallbacks possible. + * Wider hints downgrade to the next narrower size before falling back to 1x1. */ export const LAYOUT_HINT_FALLBACK_CHAIN: Record = { + '4x1': ['4x1', '3x1', '2x1', '1x1'], + '3x1': ['3x1', '2x1', '1x1'], '2x1': ['2x1', '1x1'], '1x2': ['1x2', '1x1'], '1x1': ['1x1'], @@ -146,20 +151,37 @@ export const getRawLayoutHintFromItem = (item: FeedItem): unknown => { /** * Deterministic dev-only seed used until backend emits `layoutHint`. - * Returns a pre-set hint at fixed intervals so visual QA can verify both - * supported large variants in My Feed. Returns `undefined` for items that - * should remain `1x1`. + * Each slot sits in its own 10-item density window so the packer's + * "1 large per 10 items" cap does not downgrade any of them. * - * index 7 -> 2x1 (horizontal wide featured article card) - * index 21 -> 2x1 (horizontal wide Top active squads card) - * index 35 -> 2x1 (horizontal wide Popular tags card) - * pattern repeats every 40 items + * index 7 -> 2x1 featured article + * index 14 -> 3x1 featured article (falls back to 2x1 on < 3 columns) + * index 21 -> 4x1 featured article (falls back to 3x1/2x1 on < 4 columns) + * index 32 -> 2x1 Top active squads + * index 42 -> 2x1 Popular tags + * pattern repeats every 50 items */ -const DEV_SEED_PATTERN: Record = { - 7: '2x1', - 21: '2x1', - 35: '2x1', +export type DevSeededWideVariant = 'featuredArticle' | 'topSquads' | 'popularTags'; + +interface DevSeedSlot { + hint: LayoutHint; + variant: DevSeededWideVariant; +} + +const DEV_SEED_PATTERN: Record = { + 7: { hint: '2x1', variant: 'featuredArticle' }, + 14: { hint: '3x1', variant: 'featuredArticle' }, + 21: { hint: '4x1', variant: 'featuredArticle' }, + 32: { hint: '2x1', variant: 'topSquads' }, + 42: { hint: '2x1', variant: 'popularTags' }, }; +const DEV_SEED_CYCLE = 50; + export const getDevSeededLayoutHint = (index: number): LayoutHint | undefined => - DEV_SEED_PATTERN[index % 40]; + DEV_SEED_PATTERN[index % DEV_SEED_CYCLE]?.hint; + +export const getDevSeededWideVariant = ( + index: number, +): DevSeededWideVariant | undefined => + DEV_SEED_PATTERN[index % DEV_SEED_CYCLE]?.variant;