diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index 55fffced7b..85b6e16d0a 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'; @@ -57,6 +58,7 @@ 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'; @@ -67,8 +69,16 @@ import { briefCardFeedFeature, briefFeedEntrypointPage, featureFeedAdTemplate, + featureMyFeedMultiCard, } from '../lib/featureManagement'; -import { useNewD1ExperienceFeature } from '../hooks/useNewD1ExperienceFeature'; +import { + getDevSeededLayoutHint, + getDevSeededWideVariant, + 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'; @@ -76,8 +86,7 @@ import { BriefBannerFeed } from './cards/brief/BriefBanner/BriefBannerFeed'; import { ActionType } from '../graphql/actions'; import { TopHero } from './marketing/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'), @@ -147,13 +156,6 @@ const SocialTwitterPostModal = dynamic( ), ); -const ReaderPostModal = dynamic( - () => - import( - /* webpackChunkName: "readerPostModal" */ './modals/ReaderPostModal' - ), -); - const BriefCardFeed = dynamic( () => import( @@ -232,8 +234,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 +264,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 +317,75 @@ 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) ?? + (item.type === FeedItemType.Highlight && isMultiCardLayoutEnabled + ? '1x2' + : undefined) ?? + (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 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, + 'featuredArticle' | 'topSquads' | 'popularTags' + >(); + const sequence = ['featuredArticle', 'topSquads', 'popularTags'] as const; + let wideCount = 0; + placements.forEach((placement, index) => { + if (placement.colSpan > 1 && placement.rowSpan === 1) { + if (items[index]?.type !== FeedItemType.Post) { + return; + } + + const devVariant = + isDevelopment && isMultiCardLayoutEnabled + ? getDevSeededWideVariant(index) + : undefined; + + map.set(index, devVariant ?? sequence[wideCount % sequence.length]); + wideCount += 1; + } + }); + return map; + }, [placements, items, isMultiCardLayoutEnabled]); + 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 +402,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 +577,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 +609,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 +644,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 ; } @@ -692,6 +698,7 @@ export default function Feed({ feedContainerRef, showBriefCard, disableListFrame, + isMultiCardLayout: isMultiCardLayoutEnabled, }; return ( @@ -716,87 +723,172 @@ 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 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. + // 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). 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" + > + = 2 && + placement.colSpan <= 4 + ? (placement.colSpan as 2 | 3 | 4) + : undefined + } + topActiveSquads={topActiveSquadsForCard} + topActiveSquadsPending={ + horizontalWideVariant === 'topSquads' && + isTopActiveSquadsPending + } + /> +
+ ) : ( + -
- )} - -
- ))} + )} + + ); + })} {!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 594ae9b9e8..af568ce315 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -28,6 +28,14 @@ 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 type { FeaturedWideColSpan } from './cards/article/ArticleFeaturedWideGridCard'; +import { LiveRoomPostGrid } from './cards/liveRoom/LiveRoomPostGrid'; +import { LiveRoomPostList } from './cards/liveRoom/LiveRoomPostList'; +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'; @@ -42,8 +50,6 @@ 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 { useLogContext } from '../contexts/LogContext'; import { MarketingCtaVariant } from './marketing/cta/common'; import { MarketingCtaBriefing } from './marketing/cta/MarketingCtaBriefing'; @@ -53,8 +59,6 @@ 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'; @@ -62,6 +66,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; @@ -98,6 +107,11 @@ export type FeedItemComponentProps = { ) => unknown; virtualizedNumCards: number; disableAdRefresh?: boolean; + horizontalWideVariant?: HorizontalWideFeedVariant; + horizontalWideColSpan?: FeaturedWideColSpan; + topActiveSquads?: TopActiveSquad[]; + topActiveSquadsPending?: boolean; + popularTags?: PopularTagItem[]; } & Pick & Pick; @@ -199,7 +213,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 +234,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 +282,11 @@ function FeedItemComponent({ onCommentClick, onReadArticleClick, virtualizedNumCards, + horizontalWideVariant, + horizontalWideColSpan = 2, + topActiveSquads, + topActiveSquadsPending = false, + popularTags, }: FeedItemComponentProps): ReactElement | null { const { logEvent } = useLogContext(); const inViewRef = useLogImpression( @@ -395,8 +402,79 @@ function FeedItemComponent({ return null; } - return ( - + const isPostItem = item.type === FeedItemType.Post; + const wideVariant = isPostItem ? horizontalWideVariant : undefined; + + 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 (wideVariant === 'featuredArticle') { + postBody = ( + + ); + } else if (wideVariant === 'topSquads') { + postBody = ( + + ); + } else if (wideVariant === 'popularTags') { + 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 +539,12 @@ function FeedItemComponent({ > {item.type === FeedItemType.Ad && } + ); + } + + return ( + + {postBody} ); } diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx new file mode 100644 index 0000000000..51456bf750 --- /dev/null +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import post from '../../../../__tests__/fixture/post'; +import type { PostCardProps } from '../common/common'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { ArticleFeaturedWideGridCard } from './ArticleFeaturedWideGridCard'; + +jest.mock('next/router', () => ({ + 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).toHaveClass('line-clamp-4'); + 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', 'col-span-1'); + 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)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(chip.closest('.shrink-0')?.nextElementSibling).toHaveClass( + '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 new file mode 100644 index 0000000000..564482e4d1 --- /dev/null +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx @@ -0,0 +1,285 @@ +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'; + +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, +}: { + 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, + wideColSpan = 2, + }: PostCardProps & { wideColSpan?: FeaturedWideColSpan }, + 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/components/cards/article/PopularTagsGridCard.tsx b/packages/shared/src/components/cards/article/PopularTagsGridCard.tsx new file mode 100644 index 0000000000..b5fbe960f7 --- /dev/null +++ b/packages/shared/src/components/cards/article/PopularTagsGridCard.tsx @@ -0,0 +1,231 @@ +import type { ReactElement, Ref } from 'react'; +import React, { forwardRef, useCallback, useMemo } from 'react'; +import classNames from 'classnames'; +import type { PostCardProps } from '../common/common'; +import FeedItemContainer from '../common/FeedItemContainer'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useCustomFeed } from '../../../hooks/feed/useCustomFeed'; +import useFeedSettings from '../../../hooks/useFeedSettings'; +import { usePopularTagsWrapFit } from '../../../hooks/usePopularTagsWrapFit'; +import useTagAndSource from '../../../hooks/useTagAndSource'; +import { getTagPageLink } from '../../../lib/links'; +import { Origin } from '../../../lib/log'; +import Link from '../../utilities/Link'; +import { Button, ButtonSize } from '../../buttons/Button'; +import { PlusIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import { Tooltip } from '../../tooltip/Tooltip'; + +export interface PopularTagItem { + name: string; + slug: string; +} + +/** + * Mirrors the curated `Popular tags` strip on the explore page. Static for + * now — kept inline because no per-tag API call is required. + */ +export const POPULAR_TAGS: PopularTagItem[] = [ + { name: 'AI', slug: 'ai' }, + { name: 'Webdev', slug: 'webdev' }, + { name: 'Backend', slug: 'backend' }, + { name: 'Databases', slug: 'databases' }, + { name: 'Career', slug: 'career' }, + { name: 'Golang', slug: 'golang' }, + { name: 'Rust', slug: 'rust' }, + { name: 'Open source', slug: 'open-source' }, + { name: 'Testing', slug: 'testing' }, + { name: 'PHP', slug: 'php' }, + { name: 'Java', slug: 'java' }, + { name: 'Python', slug: 'python' }, + { name: 'JavaScript', slug: 'javascript' }, + { name: 'TypeScript', slug: 'typescript' }, + { name: 'DevOps', slug: 'devops' }, + { name: 'Security', slug: 'security' }, + { name: 'Cloud', slug: 'cloud' }, + { name: 'Kubernetes', slug: 'kubernetes' }, + { name: 'Next.js', slug: 'nextjs' }, + { name: 'React', slug: 'react' }, + { name: 'Vue', slug: 'vue' }, + { name: 'Angular', slug: 'angular' }, + { name: 'Svelte', slug: 'svelte' }, + { name: 'Node.js', slug: 'nodejs' }, + { name: 'Docker', slug: 'docker' }, + { name: 'AWS', slug: 'aws' }, + { name: 'GCP', slug: 'gcp' }, + { name: 'Azure', slug: 'azure' }, + { name: 'Linux', slug: 'linux' }, + { name: 'Git', slug: 'git' }, + { name: 'GraphQL', slug: 'graphql' }, + { name: 'Postgres', slug: 'postgres' }, + { name: 'MongoDB', slug: 'mongodb' }, + { name: 'Redis', slug: 'redis' }, + { name: 'Microservices', slug: 'microservices' }, + { name: 'Machine Learning', slug: 'machine-learning' }, + { name: 'Data Science', slug: 'data-science' }, + { name: 'Productivity', slug: 'productivity' }, + { name: 'Startup', slug: 'startup' }, + { name: 'Tutorial', slug: 'tutorial' }, +]; + +interface PopularTagsGridCardProps extends PostCardProps { + tags?: PopularTagItem[]; +} + +const popularTagChipBaseClass = + 'focus-visible-outline inline-flex shrink-0 items-center gap-2 rounded-12 border border-border-subtlest-tertiary transition-colors'; + +const PopularTagChip = ({ + tag, + rank, + isFollowed, + onFollowTag, +}: { + tag: PopularTagItem; + rank: number; + isFollowed: boolean; + onFollowTag: (keyword: string) => 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 ( + +
    +
    +

    + Popular tags +

    +
    +
      } + aria-label="Popular tags" + className="flex min-h-0 flex-1 list-none flex-wrap content-start gap-2 overflow-hidden p-0" + > + {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 0000000000..2634004d3e --- /dev/null +++ b/packages/shared/src/components/cards/article/TopSquadsGridCard.tsx @@ -0,0 +1,238 @@ +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) { + const formatted = (count / 1000).toFixed(1).replace(/\.0$/, ''); + + return `${formatted}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/cards/highlight/HighlightCards.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx index 605cadf9dd..b0a7c23d17 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx @@ -3,15 +3,21 @@ 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', - channel: 'agents', + channel: 'vibes', headline: 'The first highlight', highlightedAt: '2026-04-05T09:00:00.000Z', post: { @@ -21,7 +27,7 @@ const highlights = [ }, { id: 'highlight-2', - channel: 'agents', + channel: 'vibes', headline: 'The second highlight', highlightedAt: '2026-04-05T08:00:00.000Z', post: { @@ -32,13 +38,30 @@ const highlights = [ ]; describe('Highlight cards', () => { - it('should render the grid card with highlight links', () => { + beforeEach(() => { + markAsRead.mockReset(); + mockedUseReadHighlights.mockReturnValue({ + isRead: () => false, + markAsRead, + }); + }); + + it('should render the grid card as a uniform highlight list', () => { render(); expect(screen.getByText('Happening Now')).toBeInTheDocument(); + expect( + 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(); + 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 +69,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', () => { @@ -81,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/HighlightGrid.tsx b/packages/shared/src/components/cards/highlight/HighlightGrid.tsx index 7186bd286b..1d492ed336 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 +27,78 @@ 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, +): 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[]; +} => { + const listRef = useRef(null); + const [rowHeights, setRowHeights] = useState([]); + + useLayoutEffect(() => { + const listElement = listRef.current; + + if (!listElement || itemCount === 0) { + setRowHeights([]); + return undefined; + } + + const updateRowHeights = (): void => { + const containerHeightPx = listElement.clientHeight; + + if (containerHeightPx <= 0) { + return; + } + + const heights = distributeRowHeights(containerHeightPx, itemCount); + setRowHeights(heights); + }; + + updateRowHeights(); + + if (typeof ResizeObserver === 'undefined') { + return undefined; + } + + const resizeObserver = new ResizeObserver(updateRowHeights); + resizeObserver.observe(listElement); + + return () => resizeObserver.disconnect(); + }, [itemCount]); + + return { listRef, rowHeights }; +}; + export const ReadAllHighlightsFooter = ({ highlightId, onClick, @@ -65,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 => ( + + { + onMarkAsRead(highlight.id); + onHighlightClick?.(highlight, index + 1); + }} + > + + + + {highlight.headline} + + + + + +); + +const HighlightGridCardContent = ({ 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'; +}: HighlightCardProps): ReactElement => { + const firstHighlight = highlights[0]; + const { listRef, rowHeights } = useHighlightGridRowHeights(highlights.length); + const { isRead, markAsRead } = useReadHighlights(); + + const onMarkAsRead = (highlightId: string): void => { + Promise.resolve(markAsRead(highlightId)).catch(() => undefined); + }; + + return ( +
    +
    +
    +

    + Happening Now +

    +
    + +
    + +
    + {highlights.map((highlight, index) => ( + + ))} +
    + + +
    + ); +}; + +const HighlightListCardContent = ({ + highlights, + onHighlightClick, + onReadAllClick, +}: HighlightCardProps): ReactElement => { const firstHighlight = highlights[0]; + const { isRead, markAsRead } = useReadHighlights(); + + const onMarkAsRead = (highlightId: string): void => { + Promise.resolve(markAsRead(highlightId)).catch(() => undefined); + }; return ( <> -
    +

    -
    +
    {highlights.map((highlight, index) => ( ))}
    ); }; + +export const HighlightCardContent = ({ + highlights, + onHighlightClick, + onReadAllClick, + variant, +}: HighlightCardProps & { variant: 'grid' | 'list' }): ReactElement => { + if (variant === 'grid') { + return ( + + ); + } + + return ( + + ); +}; diff --git a/packages/shared/src/components/feeds/FeedContainer.tsx b/packages/shared/src/components/feeds/FeedContainer.tsx index dbcac7df24..ae02f61edb 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/feed.spec.ts b/packages/shared/src/graphql/feed.spec.ts index df0460f9fd..6d317b89ce 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 d657a6effb..36bf8603a0 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/graphql/squads.ts b/packages/shared/src/graphql/squads.ts index 6a7e00ce3a..cfbd1e56a3 100644 --- a/packages/shared/src/graphql/squads.ts +++ b/packages/shared/src/graphql/squads.ts @@ -7,6 +7,7 @@ import { USER_AUTHOR_FRAGMENT, USER_BASIC_INFO, USER_SHORT_INFO_FRAGMENT, + CURRENT_MEMBER_FRAGMENT, } from './fragments'; import type { Connection } from './common'; import { gqlClient } from './common'; @@ -343,8 +344,12 @@ export const SQUAD_STATIC_FIELDS_QUERY = gql` moderationRequired membersCount createdAt + currentMember { + ...CurrentMember + } } } + ${CURRENT_MEMBER_FRAGMENT} `; export type SquadStaticData = Pick< @@ -360,6 +365,7 @@ export type SquadStaticData = Pick< | 'permalink' | 'membersCount' | 'createdAt' + | 'currentMember' >; export const getSquadStaticFields = async ( diff --git a/packages/shared/src/hooks/usePopularTagsWrapFit.ts b/packages/shared/src/hooks/usePopularTagsWrapFit.ts new file mode 100644 index 0000000000..f4a6389ec9 --- /dev/null +++ b/packages/shared/src/hooks/usePopularTagsWrapFit.ts @@ -0,0 +1,149 @@ +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(1); + + useLayoutEffect(() => { + setVisibleCount(1); + }, [candidates]); + + const recompute = useCallback(() => { + const el = listRef.current; + if (!el) { + return; + } + + if (candidates.length === 0) { + flushSync(() => { + setVisibleCount(0); + }); + return; + } + + flushSync(() => { + setVisibleCount(candidates.length); + }); + + const available = el.clientHeight; + if (available < 1) { + return; + } + + 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/useReadHighlights.ts b/packages/shared/src/hooks/useReadHighlights.ts new file mode 100644 index 0000000000..eff7c3943f --- /dev/null +++ b/packages/shared/src/hooks/useReadHighlights.ts @@ -0,0 +1,37 @@ +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; + 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].slice(-READ_HIGHLIGHTS_MAX)); + }, + [readIds, readSet, setValue], + ); + + return { isRead, markAsRead }; +} diff --git a/packages/shared/src/hooks/useTopActiveSquads.ts b/packages/shared/src/hooks/useTopActiveSquads.ts new file mode 100644 index 0000000000..ac6a78eb5d --- /dev/null +++ b/packages/shared/src/hooks/useTopActiveSquads.ts @@ -0,0 +1,100 @@ +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; + +/** Matches the skeleton/initial viewport in `TopSquadsGridCard`. */ +export const TOP_ACTIVE_SQUADS_CARD_LIMIT = 8; + +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. */ + limit?: number; +} + +interface UseTopActiveSquadsResult { + squads: TopActiveSquad[]; + isPending: boolean; +} + +export const useTopActiveSquads = ({ + enabled = true, + limit = TOP_ACTIVE_SQUADS_CARD_LIMIT, +}: 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 11a2946d5a..584ac9314e 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, @@ -203,4 +206,15 @@ export const featureCompanionDemoWidget = new Feature( export const featureFeedTagChips = new Feature('feed_tag_chips', false); +/** + * 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. + */ +export const featureMyFeedMultiCard = new Feature( + 'my_feed_multi_card', + isDevelopment, +); + export const featureEngagementBarV2 = new Feature('engagement_bar_v2', false); diff --git a/packages/shared/src/lib/feedGridPacker.spec.ts b/packages/shared/src/lib/feedGridPacker.spec.ts new file mode 100644 index 0000000000..363ef0bc8e --- /dev/null +++ b/packages/shared/src/lib/feedGridPacker.spec.ts @@ -0,0 +1,289 @@ +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('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, + // 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 0000000000..2b21b50b47 --- /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 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 + // 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 isHorizontallyWide = colSpan > 1 && rowSpan === 1; + const isVertical = rowSpan > 1; + const preferRightmost = + (isHorizontallyWide && horizontalWidePlacements % 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 (isHorizontallyWide) { + horizontalWidePlacements += 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 0000000000..5fb5cc4fe4 --- /dev/null +++ b/packages/shared/src/lib/feedLayoutHint.spec.ts @@ -0,0 +1,151 @@ +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', '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', + (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 unsupported item types', () => { + expect( + resolveLayoutHint({ + rawHint: '2x1', + itemType: FeedItemType.MarketingCta, + isMobile: false, + isDisabled: false, + }), + ).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({ + 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: '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', + 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 0000000000..1b9d3ba39f --- /dev/null +++ b/packages/shared/src/lib/feedLayoutHint.ts @@ -0,0 +1,187 @@ +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. + * + * 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', '3x1', '4x1'] 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 }, + '3x1': { colSpan: 3, rowSpan: 1 }, + '4x1': { colSpan: 4, 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, + '3x1': 3, + '4x1': 4, +}; + +/** 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. + * 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'], +}; + +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 / unsupported item type -> 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 && + itemType !== FeedItemType.Highlight + ) { + 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.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, + unknown + > | null; + return adPost?.layoutHint; + } + + return undefined; +}; + +/** + * Deterministic dev-only seed used until backend emits `layoutHint`. + * 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 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 + */ +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 % DEV_SEED_CYCLE]?.hint; + +export const getDevSeededWideVariant = ( + index: number, +): DevSeededWideVariant | undefined => + DEV_SEED_PATTERN[index % DEV_SEED_CYCLE]?.variant; diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 9b23df6c1a..d4c92d07e1 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,18 +368,112 @@ 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%; } } +@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;