Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions packages/shared/src/components/Feed.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,64 @@ describe('Feed logged in', () => {
).toEqual(['postItem', 'postItem', 'highlightItem', 'postItem']);
});

it('should drop feedV2 highlights when the surface shows them itself', async () => {
renderComponent(
[
{
request: {
query: FEED_V2_QUERY,
variables,
},
result: {
data: {
page: {
pageInfo: defaultFeedPage.pageInfo,
edges: [
{
node: {
__typename: 'FeedPostItem',
post: defaultFeedPage.edges[0].node,
feedMeta: defaultFeedPage.edges[0].node.feedMeta ?? null,
},
},
{
node: {
__typename: 'FeedHighlightsItem',
feedMeta: null,
highlights: [
{
id: 'highlight-1',
channel: 'agents',
headline: 'The first highlight',
highlightedAt: '2026-04-05T09:00:00.000Z',
post: {
id: defaultFeedPage.edges[0].node.id,
commentsPermalink:
defaultFeedPage.edges[0].node.commentsPermalink,
},
},
],
},
},
],
},
},
},
},
],
defaultUser,
SharedFeedPage.MyFeed,
FEED_V2_QUERY,
{ disableHighlightCards: true },
);

await waitForNock();

expect(await screen.findByTestId('postItem')).toBeInTheDocument();
expect(screen.queryByTestId('highlightItem')).not.toBeInTheDocument();
expect(screen.queryByText('Happening Now')).not.toBeInTheDocument();
});

it('should send upvote mutation', async () => {
let mutationCalled = false;
renderComponent([
Expand Down Expand Up @@ -1889,6 +1947,7 @@ interface HighlightLayoutRenderParams {
briefBannerPage?: number;
staticAd?: { ad: Ad; index: number };
disableAds?: boolean;
skipFirstAd?: boolean;
user?: LoggedUser;
isHorizontal?: boolean;
feedName?: AllFeedPages;
Expand All @@ -1906,6 +1965,7 @@ const renderWithHighlightLayout = ({
briefBannerPage,
staticAd,
disableAds,
skipFirstAd,
user = defaultUser,
isHorizontal,
feedName = SharedFeedPage.MyFeed,
Expand Down Expand Up @@ -2012,6 +2072,7 @@ const renderWithHighlightLayout = ({
variables={variables}
staticAd={staticAd}
disableAds={disableAds}
skipFirstAd={skipFirstAd}
isHorizontal={isHorizontal}
/>
</FeedContext.Provider>
Expand Down Expand Up @@ -2096,6 +2157,38 @@ describe('Feed ad cadence with highlight cards', () => {
expect(order.slice(2).every((t) => t === 'postItem')).toBe(true);
});

// The survivor keeps index 12: the dropped ad no longer occupies a cell
// against the cadence, so the next slot comes due one post later.
it('drops the first ad slot when the surface shows one above the feed', async () => {
const posts = Array.from({ length: 20 }, (_, i) => buildPost(`p${i}`));

renderWithHighlightLayout({
posts,
highlightEnabled: false,
skipFirstAd: true,
});

const order = await getFeedItemTestIds();
const adIndices = order
.map((type, index) => (type === 'adItem' ? index : -1))
.filter((index) => index >= 0);

expect(adIndices).toEqual([12]);
});

it('keeps both ad slots when nothing is shown above the feed', async () => {
const posts = Array.from({ length: 20 }, (_, i) => buildPost(`p${i}`));

renderWithHighlightLayout({ posts, highlightEnabled: false });

const order = await getFeedItemTestIds(2);
const adIndices = order
.map((type, index) => (type === 'adItem' ? index : -1))
.filter((index) => index >= 0);

expect(adIndices).toEqual([4, 12]);
});

// Same fixture, flag off: layout disabled → wide card collapses to 1 cell
// (every item contributes 1 to visualCellsSoFar). Ad falls at the original
// 4th position.
Expand Down
17 changes: 16 additions & 1 deletion packages/shared/src/components/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ export interface FeedProps<T>
showSearch?: boolean;
actionButtons?: ReactNode;
disableAds?: boolean;
/** The surface shows the highlights itself, so keep them out of the grid. */
disableHighlightCards?: boolean;
/** The surface owns the top slot, so the feed must not render or measure its own hero. */
disableTopHero?: boolean;
/** The surface shows an ad above the feed, so drop the grid's first one. */
skipFirstAd?: boolean;
/** The surface leads with a featured card, so keep wide ones out of row one. */
deferWideCards?: boolean;
staticAd?: { ad: Ad; index: number };
disableAdRefresh?: boolean;
allowFetchMore?: boolean;
Expand Down Expand Up @@ -211,6 +219,10 @@ export default function Feed<T>({
shortcuts,
actionButtons,
disableAds,
disableHighlightCards,
disableTopHero,
skipFirstAd,
deferWideCards,
staticAd,
disableAdRefresh = false,
allowFetchMore,
Expand Down Expand Up @@ -371,11 +383,14 @@ export default function Feed<T>({
isBriefBannerEligible: !user?.isPlus && isMyFeed,
engagementStripEligible: !isHorizontal && isEngagementAdFeed(feedName),
firstSlotOffset: Number(eligibleFirstSlotCard !== null),
disableTopHero: isV2,
disableTopHero: isV2 || disableTopHero,
isHorizontal,
excludePinnedPosts,
settings: {
disableAds,
disableHighlightCards,
skipFirstAd,
deferWideCards,
staticAd,
adPostLength: isSquadFeed ? 2 : undefined,
feedName,
Expand Down
45 changes: 4 additions & 41 deletions packages/shared/src/components/FeedItemComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,18 @@ import { LogEvent, Origin, TargetType } from '../lib/log';
import type { SearchLogExtra } from '../lib/searchLog';
import type { UseVotePost } from '../hooks';
import { useFeedLayout } from '../hooks';
import { CollectionList } from './cards/collection/CollectionList';
import { FeedItemType } from './cards/common/common';
import { AdGrid } from './cards/ad/AdGrid';
import { AdList } from './cards/ad/AdList';
import { SignalAdList } from './cards/ad/SignalAdList';
import type { AdCardProps } from './cards/ad/common/common';
import { FreeformGrid } from './cards/Freeform/FreeformGrid';
import { FreeformList } from './cards/Freeform/FreeformList';
import type { PostClick } from '../lib/click';
import { ArticleList } from './cards/article/ArticleList';
import { ArticleGrid } from './cards/article/ArticleGrid';
import { PostTypeToGridCard } from './cards/common/gridCards';
import type { FeaturedWideColSpan } from './cards/common/featuredWide';
import { PostTypeToWideCard } from './cards/common/wideCards';
import { ShareGrid } from './cards/share/ShareGrid';
import { ShareList } from './cards/share/ShareList';
import { CollectionGrid } from './cards/collection';
import { PostTypeToListCard } from './cards/common/listCards';
import type { UseBookmarkPost } from '../hooks/useBookmarkPost';
import { AdActions } from '../lib/ads';
import { useFeedCardContext } from '../features/posts/FeedCardContext';
Expand All @@ -38,7 +34,6 @@ import { AdMeasurement } from './cards/ad/common/AdMeasurement';
import { AdViewability } from './cards/ad/common/AdViewability';
import type { ViewabilityData } from '../features/monetization/viewability';
import { viewabilityLogExtra } from '../features/monetization/viewability';
import { BriefCard } from './cards/brief/BriefCard/BriefCard';
import { ActivePostContextProvider } from '../contexts/ActivePostContext';
import { LogExtraContextProvider } from '../contexts/LogExtraContext';
import { SquadAdList } from './cards/ad/squad/SquadAdList';
Expand All @@ -50,10 +45,6 @@ import {
} from '../lib/engagementAds';
import { useEngagementAdsContext } from '../contexts/EngagementAdsContext';
import { useLogContext } from '../contexts/LogContext';
import PollGrid from './cards/poll/PollGrid';
import { PollList } from './cards/poll/PollList';
import { SocialTwitterGrid } from './cards/socialTwitter/SocialTwitterGrid';
import { SocialTwitterList } from './cards/socialTwitter/SocialTwitterList';
import { SignalList } from './cards/common/list/SignalList';
import { OtherFeedPage } from '../lib/query';
import { isSourceSquadOrMachine } from '../graphql/sources';
Expand Down Expand Up @@ -121,34 +112,6 @@ export function getFeedItemKey(item: FeedItem, index: number): string {
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const PostTypeToTagCard: Record<PostType, React.ComponentType<any>> = {
[PostType.Article]: ArticleGrid,
[PostType.Share]: ShareGrid,
[PostType.Welcome]: FreeformGrid,
[PostType.Freeform]: FreeformGrid,
[PostType.VideoYouTube]: ArticleGrid,
[PostType.Collection]: CollectionGrid,
[PostType.Brief]: BriefCard,
[PostType.Poll]: PollGrid,
[PostType.SocialTwitter]: SocialTwitterGrid,
[PostType.Digest]: ArticleGrid,
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const PostTypeToTagList: Record<PostType, React.ComponentType<any>> = {
[PostType.Article]: ArticleList,
[PostType.Share]: ShareList,
[PostType.Welcome]: FreeformList,
[PostType.Freeform]: FreeformList,
[PostType.VideoYouTube]: ArticleList,
[PostType.Collection]: CollectionList,
[PostType.Brief]: BriefCard,
[PostType.Poll]: PollList,
[PostType.SocialTwitter]: SocialTwitterList,
[PostType.Digest]: ArticleList,
};

const getPostTypeForCard = (post?: Post): PostType => {
if (!post) {
return PostType.Article;
Expand Down Expand Up @@ -176,7 +139,7 @@ const getTags = ({
}: GetTagsProps) => {
const useListCards = isListFeedLayout || shouldUseListMode;
const isSignalFeed = feedName === OtherFeedPage.AgentsVibes;
const listPostTag = isSignalFeed ? SignalList : PostTypeToTagList[postType];
const listPostTag = isSignalFeed ? SignalList : PostTypeToListCard[postType];
const listPlaceholderTag = isSignalFeed
? SignalPlaceholderList
: PlaceholderList;
Expand All @@ -185,7 +148,7 @@ const getTags = ({
return {
PostTag: useListCards
? listPostTag ?? ArticleList
: PostTypeToTagCard[postType] ?? ArticleGrid,
: PostTypeToGridCard[postType] ?? ArticleGrid,
AdTag: useListCards ? listAdTag : AdGrid,
SquadAdTag: useListCards ? SquadAdList : SquadAdGrid,
PlaceholderTag: useListCards ? listPlaceholderTag : PlaceholderGrid,
Expand Down
75 changes: 62 additions & 13 deletions packages/shared/src/components/MainFeedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,22 @@ import {
useViewSize,
ViewSize,
} from '../hooks';
import { feedNameToHeading } from './feeds/FeedContainer';
import { feedNameToHeading, v2FeedSideInsetClass } from './feeds/FeedContainer';
import { pageHeaderClassName } from './layout/PageHeader';
import {
customFeedVersion,
discussedFeedVersion,
feature,
featureFeedChips,
featureFeedHero,
FeedChipsVariant,
followingFeedVersion,
latestFeedVersion,
popularFeedVersion,
upvotedFeedVersion,
} from '../lib/featureManagement';
import type { FeedContainerProps } from './feeds';
import { FeedHero } from './feeds/hero/FeedHero';
import { getFeedName } from '../lib/feed';
import CommentFeed from './CommentFeed';
import { COMMENT_FEED_QUERY } from '../graphql/comments';
Expand Down Expand Up @@ -385,6 +387,18 @@ export default function MainFeedLayout({
[showExploreChips, exploreCategories, feeds, isV2],
);

const isMainFeedPage =
feedName === SharedFeedPage.MyFeed || feedName === SharedFeedPage.Popular;
const { value: isFeedHeroEnabled } = useConditionalFeature({
feature: featureFeedHero,
shouldEvaluate: isMainFeedPage,
});
// The hero reports back rather than being asked: it only has a placement once
// its column exists and an ad has come back for it, and it renders nothing at
// all until its headlines resolve.
const [isHeroAdVisible, setIsHeroAdVisible] = useState(false);
const [isHeroRendered, setIsHeroRendered] = useState(false);

const { isSearchPageLaptop } = useSearchResultsLayout();

const config = useMemo(() => {
Expand Down Expand Up @@ -791,6 +805,43 @@ export default function MainFeedLayout({
}
return '';
}, [customFeedsData, feedName, router.query.slugOrId]);
const chipsTopContent =
(isExploreTag || shouldUseListFeedLayout) && chipsNode ? (
<div
className={classNames('mb-8 w-full', shouldUseListFeedLayout && 'mt-8')}
>
{chipsNode}
</div>
) : undefined;
// The hero is a sibling of the v2 grid, so it repeats the grid's inset and
// card border rules. No bottom margin from `tablet` up, where the grid
// already opens with that inset; mobile keeps one as the only separator.
const isV2Grid = isV2 && !shouldUseListFeedLayout;
const heroClassName = classNames(
'w-full tablet:pt-6',
isV2Grid
? classNames(
v2FeedSideInsetClass,
'mb-8 tablet:mb-0',
'[&_article:hover]:!border-border-subtlest-tertiary [&_article]:!border-border-subtlest-quaternary',
)
: 'mb-8',
);
// Left undefined when the hero is off so `Feed` keeps its own top slot.
const topContent = isFeedHeroEnabled ? (
Comment thread
tsahimatsliah marked this conversation as resolved.
<>
<FeedHero
feedName={feedName}
className={heroClassName}
onAdVisibleChange={setIsHeroAdVisible}
onRenderedChange={setIsHeroRendered}
/>
{chipsTopContent}
</>
) : (
chipsTopContent
);

const v2ActionButtons = feedProps?.actionButtons;
const showFeedV2PageHeader =
isV2 &&
Expand Down Expand Up @@ -861,18 +912,16 @@ export default function MainFeedLayout({
<Feed
{...feedProps}
shortcuts={shortcuts}
topContent={
(isExploreTag || shouldUseListFeedLayout) && chipsNode ? (
<div
className={classNames(
'mb-8 w-full',
shouldUseListFeedLayout && 'mt-8',
)}
>
{chipsNode}
</div>
) : undefined
}
topContent={topContent}
// The flag, not the hero's render: this placement logs an
// impression, so it has to be suppressed from the first paint
// rather than flickering in and out as the hero resolves.
disableTopHero={isFeedHeroEnabled}
// The render, so a hero that finds no headlines hands the
// highlights card and row one back to the grid.
disableHighlightCards={isHeroRendered}
skipFirstAd={isHeroAdVisible}
deferWideCards={isHeroRendered}
className={classNames(!isFinder && feedGutter)}
/>
)
Expand Down
Loading
Loading