Skip to content

Commit e961f1e

Browse files
authored
feat: add feed v2 highlight cards (#5836)
1 parent 4fe54da commit e961f1e

46 files changed

Lines changed: 2783 additions & 159 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/shared/src/components/Feed.spec.tsx

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,146 @@ describe('Feed logged in', () => {
364364
expect(await screen.findAllByTestId('postItem')).not.toHaveLength(0);
365365
});
366366

367+
it('should render feedV2 highlight items as cards', async () => {
368+
renderComponent(
369+
[
370+
{
371+
request: {
372+
query: FEED_V2_QUERY,
373+
variables,
374+
},
375+
result: {
376+
data: {
377+
page: {
378+
pageInfo: defaultFeedPage.pageInfo,
379+
edges: [
380+
{
381+
node: {
382+
__typename: 'FeedHighlightsItem',
383+
feedMeta: null,
384+
highlights: [
385+
{
386+
id: 'highlight-1',
387+
channel: 'agents',
388+
headline: 'The first highlight',
389+
highlightedAt: '2026-04-05T09:00:00.000Z',
390+
post: {
391+
id: defaultFeedPage.edges[0].node.id,
392+
commentsPermalink:
393+
defaultFeedPage.edges[0].node.commentsPermalink,
394+
},
395+
},
396+
{
397+
id: 'highlight-2',
398+
channel: 'agents',
399+
headline: 'The second highlight',
400+
highlightedAt: '2026-04-05T08:00:00.000Z',
401+
post: {
402+
id: defaultFeedPage.edges[1].node.id,
403+
commentsPermalink:
404+
defaultFeedPage.edges[1].node.commentsPermalink,
405+
},
406+
},
407+
],
408+
},
409+
},
410+
{
411+
node: {
412+
__typename: 'FeedPostItem',
413+
post: defaultFeedPage.edges[0].node,
414+
feedMeta: defaultFeedPage.edges[0].node.feedMeta ?? null,
415+
},
416+
},
417+
],
418+
},
419+
},
420+
},
421+
},
422+
],
423+
defaultUser,
424+
SharedFeedPage.MyFeed,
425+
FEED_V2_QUERY,
426+
);
427+
428+
await waitForNock();
429+
expect(await screen.findByText('Happening Now')).toBeInTheDocument();
430+
expect(screen.getByText('The first highlight')).toBeInTheDocument();
431+
expect(screen.getByLabelText('Read all highlights')).toBeInTheDocument();
432+
});
433+
434+
it('should keep feedV2 highlights in the response order', async () => {
435+
renderComponent(
436+
[
437+
{
438+
request: {
439+
query: FEED_V2_QUERY,
440+
variables,
441+
},
442+
result: {
443+
data: {
444+
page: {
445+
pageInfo: defaultFeedPage.pageInfo,
446+
edges: [
447+
{
448+
node: {
449+
__typename: 'FeedPostItem',
450+
post: defaultFeedPage.edges[0].node,
451+
feedMeta: defaultFeedPage.edges[0].node.feedMeta ?? null,
452+
},
453+
},
454+
{
455+
node: {
456+
__typename: 'FeedPostItem',
457+
post: defaultFeedPage.edges[1].node,
458+
feedMeta: defaultFeedPage.edges[1].node.feedMeta ?? null,
459+
},
460+
},
461+
{
462+
node: {
463+
__typename: 'FeedHighlightsItem',
464+
feedMeta: null,
465+
highlights: [
466+
{
467+
id: 'highlight-1',
468+
channel: 'agents',
469+
headline: 'The first highlight',
470+
highlightedAt: '2026-04-05T09:00:00.000Z',
471+
post: {
472+
id: defaultFeedPage.edges[0].node.id,
473+
commentsPermalink:
474+
defaultFeedPage.edges[0].node.commentsPermalink,
475+
},
476+
},
477+
],
478+
},
479+
},
480+
{
481+
node: {
482+
__typename: 'FeedPostItem',
483+
post: defaultFeedPage.edges[2].node,
484+
feedMeta: defaultFeedPage.edges[2].node.feedMeta ?? null,
485+
},
486+
},
487+
],
488+
},
489+
},
490+
},
491+
},
492+
],
493+
defaultUser,
494+
SharedFeedPage.MyFeed,
495+
FEED_V2_QUERY,
496+
);
497+
498+
await waitForNock();
499+
500+
const orderedItems = await screen.findAllByTestId(/postItem|highlightItem/);
501+
502+
expect(
503+
orderedItems.map((item) => item.getAttribute('data-testid')),
504+
).toEqual(['postItem', 'postItem', 'highlightItem', 'postItem']);
505+
});
506+
367507
it('should send upvote mutation', async () => {
368508
let mutationCalled = false;
369509
renderComponent([

packages/shared/src/components/FeedItemComponent.tsx

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ReactElement } from 'react';
22
import React from 'react';
3+
import { useQueryClient } from '@tanstack/react-query';
34
import type { AdSquadItem, FeedItem } from '../hooks/useFeed';
45
import { isBoostedPostAd, isBoostedSquadAd } from '../hooks/useFeed';
56
import { PlaceholderGrid } from './cards/placeholder/PlaceholderGrid';
@@ -10,7 +11,7 @@ import { isSocialTwitterPost, PostType } from '../graphql/posts';
1011
import type { LoggedUser } from '../lib/user';
1112
import useLogImpression from '../hooks/feed/useLogImpression';
1213
import type { FeedPostClick } from '../hooks/feed/useFeedOnPostClick';
13-
import { Origin, TargetType } from '../lib/log';
14+
import { LogEvent, Origin, TargetType } from '../lib/log';
1415
import type { UseVotePost } from '../hooks';
1516
import { useFeedLayout } from '../hooks';
1617
import { CollectionList } from './cards/collection/CollectionList';
@@ -41,7 +42,7 @@ import { ActivePostContextProvider } from '../contexts/ActivePostContext';
4142
import { LogExtraContextProvider } from '../contexts/LogExtraContext';
4243
import { SquadAdList } from './cards/ad/squad/SquadAdList';
4344
import { SquadAdGrid } from './cards/ad/squad/SquadAdGrid';
44-
import { adLogEvent, feedLogExtra } from '../lib/feed';
45+
import { adLogEvent, feedHighlightsLogEvent, feedLogExtra } from '../lib/feed';
4546
import { useLogContext } from '../contexts/LogContext';
4647
import { MarketingCtaVariant } from './marketingCta/common';
4748
import { MarketingCtaBriefing } from './marketingCta/MarketingCtaBriefing';
@@ -53,6 +54,15 @@ import { SocialTwitterList } from './cards/socialTwitter/SocialTwitterList';
5354
import { SignalList } from './cards/common/list/SignalList';
5455
import { OtherFeedPage } from '../lib/query';
5556
import { isSourceSquadOrMachine } from '../graphql/sources';
57+
import { HighlightGrid } from './cards/highlight/HighlightGrid';
58+
import { HighlightList } from './cards/highlight/HighlightList';
59+
import {
60+
getHighlightIdsKey,
61+
getHighlightIds,
62+
MAJOR_HEADLINES_MAX_FIRST,
63+
majorHeadlinesQueryOptions,
64+
} from '../graphql/highlights';
65+
import { HighlightPostModal } from './modals/HighlightPostModal';
5666

5767
export type FeedItemComponentProps = {
5868
item: FeedItem;
@@ -97,6 +107,8 @@ export function getFeedItemKey(item: FeedItem, index: number): string {
97107
switch (item.type) {
98108
case 'post':
99109
return item.post.id;
110+
case 'highlight':
111+
return getHighlightIdsKey(item.highlights) || `highlight-${index}`;
100112
case 'ad':
101113
return `ad-${index}`;
102114
default:
@@ -259,6 +271,10 @@ function FeedItemComponent({
259271
disableAdRefresh,
260272
}: FeedItemComponentProps): ReactElement | null {
261273
const { logEvent } = useLogContext();
274+
const queryClient = useQueryClient();
275+
const [selectedHighlightId, setSelectedHighlightId] = React.useState<
276+
string | null
277+
>(null);
262278
const inViewRef = useLogImpression(
263279
item,
264280
index,
@@ -271,6 +287,100 @@ function FeedItemComponent({
271287

272288
const { shouldUseListFeedLayout, shouldUseListMode } = useFeedLayout();
273289
const { boostedBy } = useFeedCardContext();
290+
291+
if (item.type === FeedItemType.Highlight) {
292+
const HighlightTag =
293+
shouldUseListFeedLayout || shouldUseListMode
294+
? HighlightList
295+
: HighlightGrid;
296+
const highlightIds = getHighlightIds(item.highlights);
297+
const openHighlightModal = (highlightId: string): void => {
298+
queryClient
299+
.fetchQuery(
300+
majorHeadlinesQueryOptions({ first: MAJOR_HEADLINES_MAX_FIRST }),
301+
)
302+
.catch(() => undefined)
303+
.finally(() => setSelectedHighlightId(highlightId));
304+
};
305+
306+
return (
307+
<>
308+
<HighlightTag
309+
ref={inViewRef}
310+
highlights={item.highlights}
311+
onReadAllClick={() => {
312+
const [firstHighlight] = item.highlights;
313+
314+
if (!firstHighlight) {
315+
return;
316+
}
317+
318+
logEvent(
319+
feedHighlightsLogEvent(LogEvent.Click, {
320+
columns: virtualizedNumCards,
321+
column,
322+
row,
323+
feedName,
324+
ranking,
325+
action: 'read_all_click',
326+
count: item.highlights.length,
327+
highlightIds,
328+
feedMeta: item.feedMeta,
329+
}),
330+
);
331+
332+
openHighlightModal(firstHighlight.id);
333+
}}
334+
onHighlightClick={(highlight, position) => {
335+
logEvent(
336+
feedHighlightsLogEvent(LogEvent.Click, {
337+
columns: virtualizedNumCards,
338+
column,
339+
row,
340+
feedName,
341+
ranking,
342+
action: 'highlight_click',
343+
position,
344+
count: item.highlights.length,
345+
clickedHighlight: highlight,
346+
highlightIds,
347+
feedMeta: item.feedMeta,
348+
}),
349+
);
350+
351+
openHighlightModal(highlight.id);
352+
}}
353+
/>
354+
<HighlightPostModal
355+
isOpen={!!selectedHighlightId}
356+
selectedHighlightId={selectedHighlightId}
357+
highlights={item.highlights}
358+
onRequestClose={() => setSelectedHighlightId(null)}
359+
onHighlightClick={(highlight, position, modalHighlights) => {
360+
logEvent(
361+
feedHighlightsLogEvent(LogEvent.Click, {
362+
columns: virtualizedNumCards,
363+
column,
364+
row,
365+
feedName,
366+
ranking,
367+
action: 'modal_highlight_click',
368+
position,
369+
count: modalHighlights.length,
370+
clickedHighlight: highlight,
371+
highlightIds: getHighlightIds(modalHighlights),
372+
feedMeta: item.feedMeta,
373+
}),
374+
);
375+
}}
376+
onSelectHighlight={(highlight) => {
377+
setSelectedHighlightId(highlight.id);
378+
}}
379+
/>
380+
</>
381+
);
382+
}
383+
274384
const {
275385
PostTag,
276386
AdTag,

packages/shared/src/components/MainFeedLayout.tsx

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@ import AuthContext from '../contexts/AuthContext';
1717
import type { LoggedUser } from '../lib/user';
1818
import { SharedFeedPage } from './utilities';
1919
import {
20+
FEED_V2_HIGHLIGHTS_LIMIT,
2021
ANONYMOUS_FEED_QUERY,
2122
CUSTOM_FEED_QUERY,
2223
FEED_V2_QUERY,
2324
FOLLOWING_FEED_QUERY,
2425
MOST_DISCUSSED_FEED_QUERY,
2526
MOST_UPVOTED_FEED_QUERY,
2627
SEARCH_POSTS_QUERY,
28+
getFeedV2SupportedTypes,
2729
} from '../graphql/feed';
2830
import { generateQueryKey, OtherFeedPage, RequestKey } from '../lib/query';
2931
import SettingsContext from '../contexts/SettingsContext';
@@ -50,6 +52,7 @@ import {
5052
customFeedVersion,
5153
discussedFeedVersion,
5254
feature,
55+
featureFeedV2Highlights,
5356
followingFeedVersion,
5457
latestFeedVersion,
5558
popularFeedVersion,
@@ -292,6 +295,16 @@ export default function MainFeedLayout({
292295
feature: customFeedVersion,
293296
shouldEvaluate: feedName === SharedFeedPage.Custom,
294297
});
298+
const shouldEvaluateFeedV2Highlights =
299+
!!user &&
300+
((feedName === SharedFeedPage.MyFeed && !isCustomDefaultFeed) ||
301+
feedName === SharedFeedPage.Search ||
302+
(feedName === SharedFeedPage.CustomForm &&
303+
router.query?.slugOrId === user?.id));
304+
const { value: isFeedV2HighlightsEnabled } = useConditionalFeature({
305+
feature: featureFeedV2Highlights,
306+
shouldEvaluate: shouldEvaluateFeedV2Highlights,
307+
});
295308
const {
296309
isNoAi,
297310
isNoAiAvailable,
@@ -354,17 +367,27 @@ export default function MainFeedLayout({
354367
};
355368
}
356369

370+
const query = getQueryBasedOnLogin(
371+
tokenRefreshed,
372+
user ?? null,
373+
dynamicFeedConfig?.query || feedConfig.query,
374+
dynamicFeedConfig?.queryIfLogged || feedConfig.queryIfLogged || null,
375+
);
376+
const shouldRequestFeedV2Highlights =
377+
query === FEED_V2_QUERY && isFeedV2HighlightsEnabled;
378+
357379
return {
358380
requestKey: feedConfig.requestKey,
359-
query: getQueryBasedOnLogin(
360-
tokenRefreshed,
361-
user ?? null,
362-
dynamicFeedConfig?.query || feedConfig.query,
363-
dynamicFeedConfig?.queryIfLogged || feedConfig.queryIfLogged || null,
364-
),
381+
query,
365382
variables: {
366383
...feedConfig.variables,
367384
...dynamicFeedConfig?.variables,
385+
...(shouldRequestFeedV2Highlights
386+
? {
387+
supportedTypes: getFeedV2SupportedTypes(true),
388+
highlightsLimit: FEED_V2_HIGHLIGHTS_LIMIT,
389+
}
390+
: {}),
368391
...(shouldEvaluateNoAi && isNoAi ? { noAi: true } : {}),
369392
version:
370393
isDevelopment && !isProductionAPI
@@ -386,6 +409,7 @@ export default function MainFeedLayout({
386409
customFeedV,
387410
tokenRefreshed,
388411
feedVersion,
412+
isFeedV2HighlightsEnabled,
389413
isNoAi,
390414
shouldEvaluateNoAi,
391415
]);

packages/shared/src/components/cards/common/common.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export const generateTitleClamp = ({
8181

8282
export enum FeedItemType {
8383
'Post' = 'post',
84+
Highlight = 'highlight',
8485
'Ad' = 'ad',
8586
PlusEntry = 'plusEntry',
8687
Placeholder = 'placeholder',

0 commit comments

Comments
 (0)