From 4d7b4a282709aa5f8d2ec773c69bce33f902357e Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 14:51:47 +0300 Subject: [PATCH 1/3] feat(post): add "For you" feed below comments on the post page Adds a personalized discovery feed under the comments on the article post page to keep readers exploring. Logged-in users get the personalized feedV2; anonymous users get the popular anonymous feed. The feed renders as a grid by overriding the post route's forced list layout (feedName -> Popular, insaneMode off, FeedLayoutProvider). Gated behind the new `post_page_feed` flag (default on for now; flip to off after review) and limited to standard reading post types (article, video, share, freeform, welcome, collection). First of three PRs split out of #6130. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/post/PostPageFeed.tsx | 65 +++++++++++++++++++ .../shared/src/hooks/post/usePostPageFeed.ts | 30 +++++++++ packages/shared/src/lib/featureManagement.ts | 2 + packages/webapp/__tests__/PostPage.tsx | 56 ++++++++++++++-- packages/webapp/pages/posts/[id]/index.tsx | 4 ++ 5 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 packages/shared/src/components/post/PostPageFeed.tsx create mode 100644 packages/shared/src/hooks/post/usePostPageFeed.ts diff --git a/packages/shared/src/components/post/PostPageFeed.tsx b/packages/shared/src/components/post/PostPageFeed.tsx new file mode 100644 index 00000000000..dff1acae489 --- /dev/null +++ b/packages/shared/src/components/post/PostPageFeed.tsx @@ -0,0 +1,65 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useContext, useMemo } from 'react'; +import Feed from '../Feed'; +import SettingsContext from '../../contexts/SettingsContext'; +import { FeedLayoutProvider } from '../../contexts/FeedContext'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { ActiveFeedNameContext } from '../../contexts/ActiveFeedNameContext'; +import { ANONYMOUS_FEED_QUERY, FEED_V2_QUERY } from '../../graphql/feed'; +import { generateQueryKey } from '../../lib/query'; +import { SharedFeedPage } from '../utilities/common'; + +/** + * The post page route forces list layout (feedName === OtherFeedPage.Post in + * useFeedLayout). Overriding the feed name to a grid-capable page, disabling + * insaneMode, and supplying the feed sizing provider keeps this nested feed on + * the grid-card path on laptop while still collapsing to a list on mobile. + */ +const FeedGridScope = ({ children }: { children: ReactNode }): ReactElement => { + const settings = useContext(SettingsContext); + const settingsValue = useMemo( + () => ({ ...settings, insaneMode: false }), + [settings], + ); + + return ( + + + {children} + + + ); +}; + +export const PostPageFeed = (): ReactElement => { + const { user } = useAuthContext(); + const query = user ? FEED_V2_QUERY : ANONYMOUS_FEED_QUERY; + const feedQueryKey = generateQueryKey( + SharedFeedPage.Popular, + user, + 'post-page-feed', + ); + + return ( +
+
+

For you

+

+ More from daily.dev, picked for you +

+
+ + + +
+ ); +}; diff --git a/packages/shared/src/hooks/post/usePostPageFeed.ts b/packages/shared/src/hooks/post/usePostPageFeed.ts new file mode 100644 index 00000000000..eb648f5c278 --- /dev/null +++ b/packages/shared/src/hooks/post/usePostPageFeed.ts @@ -0,0 +1,30 @@ +import type { Post } from '../../graphql/posts'; +import { PostType } from '../../graphql/posts'; +import { useConditionalFeature } from '../useConditionalFeature'; +import { featurePostPageFeed } from '../../lib/featureManagement'; + +// Standard reading post types that render the "For you" feed below the +// comments. Specialized types (poll, brief, social, digest) keep their +// dedicated layouts and are intentionally excluded. +const eligiblePostTypes = new Set([ + PostType.Article, + PostType.VideoYouTube, + PostType.Share, + PostType.Freeform, + PostType.Welcome, + PostType.Collection, +]); + +interface UsePostPageFeed { + isEligible: boolean; +} + +export const usePostPageFeed = (post?: Post): UsePostPageFeed => { + const isTypeEligible = !!post && eligiblePostTypes.has(post.type); + const { value: isFlagOn } = useConditionalFeature({ + feature: featurePostPageFeed, + shouldEvaluate: isTypeEligible, + }); + + return { isEligible: isTypeEligible && isFlagOn }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 7e3e196cba6..85ef04cb955 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -36,6 +36,8 @@ export const featurePostPageHighlights = new Feature( 'post_page_highlights', false, ); +// TODO: flip default to `false` after author review of the post-page "For you" feed. +export const featurePostPageFeed = new Feature('post_page_feed', true); // @ts-expect-error stale feature without default export const plusTakeoverContent = new Feature<{ diff --git a/packages/webapp/__tests__/PostPage.tsx b/packages/webapp/__tests__/PostPage.tsx index c53565aedf3..8e46d06ea20 100644 --- a/packages/webapp/__tests__/PostPage.tsx +++ b/packages/webapp/__tests__/PostPage.tsx @@ -5,6 +5,7 @@ import { fireEvent, queryByText, render, + renderHook, screen, waitFor, } from '@testing-library/react'; @@ -49,6 +50,7 @@ import { TestBootProvider } from '@dailydotdev/shared/__tests__/helpers/boot'; import * as hooks from '@dailydotdev/shared/src/hooks/useViewSize'; import { UserVoteEntity } from '@dailydotdev/shared/src/hooks'; import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import { usePostPageFeed } from '@dailydotdev/shared/src/hooks/post/usePostPageFeed'; import type { Props } from '../pages/posts/[id]'; import { PostPage } from '../pages/posts/[id]'; import { getSeoDescription } from '../components/PostSEOSchema'; @@ -245,6 +247,23 @@ const mockCompleteActionMutation = (action: ActionType): void => { .reply(200, { data: { _: true } }); }; +// The post page "For you" feed (PostPageFeed) fires FeedV2/AnonymousFeed with +// layout-dependent variables, so match on the operation name rather than exact +// variables. Persisted to also cover any pagination/retries. +const mockPostPageFeedQuery = (): void => { + nock('http://localhost:3000') + .persist() + .post('/graphql', (body: { query?: string }) => + Boolean( + body.query?.includes('query FeedV2(') || + body.query?.includes('query AnonymousFeed('), + ), + ) + .reply(200, { + data: { page: { pageInfo: { hasNextPage: false }, edges: [] } }, + }); +}; + let client: QueryClient; const logEvent = jest.fn(); @@ -273,6 +292,7 @@ function renderPost( ]; defaultMocks.forEach(mockGraphQL); + mockPostPageFeedQuery(); return render( { it('should log page view on initial load', async () => { renderPost(); await screen.findAllByText('Towards Data Science'); - expect(logEvent).toBeCalledTimes(1); - expect(logEvent).toBeCalledWith( - expect.objectContaining({ - event_name: 'article page view', - }), + const pageViewCalls = logEvent.mock.calls.filter( + ([event]) => event?.event_name === 'article page view', + ); + expect(pageViewCalls).toHaveLength(1); + }); +}); + +describe('post page feed', () => { + it('should render the For you feed below an eligible article', async () => { + renderPost(); + expect( + await screen.findByRole('heading', { name: 'For you' }), + ).toBeInTheDocument(); + }); + + it('should render the For you feed for anonymous users', async () => { + renderPost({}, [createPostMock(), createCommentsMock()], undefined); + expect( + await screen.findByRole('heading', { name: 'For you' }), + ).toBeInTheDocument(); + }); + + it('should be eligible for standard reading post types', () => { + const { result } = renderHook(() => usePostPageFeed(defaultPost as Post)); + expect(result.current.isEligible).toBe(true); + }); + + it('should not be eligible for specialized post types', () => { + const { result } = renderHook(() => + usePostPageFeed({ ...(defaultPost as Post), type: PostType.Brief }), ); + expect(result.current.isEligible).toBe(false); }); }); diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index fce81b9431d..c8e9b47c552 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -48,6 +48,8 @@ import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; import useDebounceFn from '@dailydotdev/shared/src/hooks/useDebounceFn'; import { useEngagementAdsContext } from '@dailydotdev/shared/src/contexts/EngagementAdsContext'; import { CompanionDemoWidget } from '@dailydotdev/shared/src/components/post/CompanionDemoWidget'; +import { PostPageFeed } from '@dailydotdev/shared/src/components/post/PostPageFeed'; +import { usePostPageFeed } from '@dailydotdev/shared/src/hooks/post/usePostPageFeed'; import { getPageSeoTitles } from '../../../components/layouts/utils'; import { getLayout } from '../../../components/layouts/MainLayout'; import FooterNavBarLayout from '../../../components/layouts/FooterNavBarLayout'; @@ -194,6 +196,7 @@ export const PostPage = ({ retry: false, }, }); + const { isEligible: showPostPageFeed } = usePostPageFeed(post); const featureTheme = useFeatureTheme(); const containerClass = classNames( 'mb-16 min-h-page max-w-[69.25rem] tablet:mb-8 laptop:mb-0 laptop:pb-6 laptopL:pb-0', @@ -290,6 +293,7 @@ export const PostPage = ({ /> {shouldShowAuthBanner && isLaptop && } + {showPostPageFeed && } From 113ec9ae6e13b7a476fbc4a7d6274ede559fbbd9 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 15:16:19 +0300 Subject: [PATCH 2/3] chore(post): default post_page_feed flag to off Ship the post-page "For you" feed disabled by default; it is enabled via the post_page_feed feature flag. Tests enable the flag explicitly to exercise the visible behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/shared/src/lib/featureManagement.ts | 3 +-- packages/webapp/__tests__/PostPage.tsx | 4 ++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 85ef04cb955..9542f0e471d 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -36,8 +36,7 @@ export const featurePostPageHighlights = new Feature( 'post_page_highlights', false, ); -// TODO: flip default to `false` after author review of the post-page "For you" feed. -export const featurePostPageFeed = new Feature('post_page_feed', true); +export const featurePostPageFeed = new Feature('post_page_feed', false); // @ts-expect-error stale feature without default export const plusTakeoverContent = new Feature<{ diff --git a/packages/webapp/__tests__/PostPage.tsx b/packages/webapp/__tests__/PostPage.tsx index 8e46d06ea20..87181d147d7 100644 --- a/packages/webapp/__tests__/PostPage.tsx +++ b/packages/webapp/__tests__/PostPage.tsx @@ -92,6 +92,10 @@ jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ if (args?.feature?.id === 'reader_modal') { return { value: false, isLoading: false }; } + // Exercise the post-page feed with the flag enabled; its default is off. + if (args?.feature?.id === 'post_page_feed') { + return { value: true, isLoading: false }; + } return { value: args?.feature?.defaultValue, isLoading: false }; }, })); From 35b8f2ed963c6225b9541f1353006e7e9fea6fa5 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 15:21:57 +0300 Subject: [PATCH 3/3] fix(post): give the post-page feed its own feed identity for analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feed below comments reused SharedFeedPage.Popular, so its impressions, clicks, votes and FinishFeed/FeedEmpty events were attributed to the real Popular feed — polluting its metrics and making the gated feature impossible to measure. Introduce OtherFeedPage.PostPageFeed, register it in FeedLayoutMobileFeedPages (grid on laptop, list on mobile preserved), and use it as the feed name and query key so all events are attributable to this surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/post/PostPageFeed.tsx | 20 ++++++++----------- packages/shared/src/hooks/useFeedLayout.ts | 1 + packages/shared/src/lib/query.ts | 1 + 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/components/post/PostPageFeed.tsx b/packages/shared/src/components/post/PostPageFeed.tsx index dff1acae489..67b615917e5 100644 --- a/packages/shared/src/components/post/PostPageFeed.tsx +++ b/packages/shared/src/components/post/PostPageFeed.tsx @@ -6,14 +6,14 @@ import { FeedLayoutProvider } from '../../contexts/FeedContext'; import { useAuthContext } from '../../contexts/AuthContext'; import { ActiveFeedNameContext } from '../../contexts/ActiveFeedNameContext'; import { ANONYMOUS_FEED_QUERY, FEED_V2_QUERY } from '../../graphql/feed'; -import { generateQueryKey } from '../../lib/query'; -import { SharedFeedPage } from '../utilities/common'; +import { generateQueryKey, OtherFeedPage } from '../../lib/query'; /** * The post page route forces list layout (feedName === OtherFeedPage.Post in - * useFeedLayout). Overriding the feed name to a grid-capable page, disabling - * insaneMode, and supplying the feed sizing provider keeps this nested feed on - * the grid-card path on laptop while still collapsing to a list on mobile. + * useFeedLayout). Overriding the feed name to PostPageFeed (registered in + * FeedLayoutMobileFeedPages), disabling insaneMode, and supplying the feed + * sizing provider keeps this nested feed on the grid-card path on laptop while + * still collapsing to a list on mobile. */ const FeedGridScope = ({ children }: { children: ReactNode }): ReactElement => { const settings = useContext(SettingsContext); @@ -24,7 +24,7 @@ const FeedGridScope = ({ children }: { children: ReactNode }): ReactElement => { return ( {children} @@ -36,11 +36,7 @@ const FeedGridScope = ({ children }: { children: ReactNode }): ReactElement => { export const PostPageFeed = (): ReactElement => { const { user } = useAuthContext(); const query = user ? FEED_V2_QUERY : ANONYMOUS_FEED_QUERY; - const feedQueryKey = generateQueryKey( - SharedFeedPage.Popular, - user, - 'post-page-feed', - ); + const feedQueryKey = generateQueryKey(OtherFeedPage.PostPageFeed, user); return (
@@ -52,7 +48,7 @@ export const PostPageFeed = (): ReactElement => { ([ OtherFeedPage.Welcome, OtherFeedPage.Following, OtherFeedPage.AgentsVibes, + OtherFeedPage.PostPageFeed, ]); export const UserProfileFeedPages = new Set([ diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 454ed4e222d..ef4c14aa241 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -71,6 +71,7 @@ export enum OtherFeedPage { Discussed = 'discussed', Following = 'following', Post = 'posts[id]', + PostPageFeed = 'post-page-feed', AgentsVibes = 'agents-vibes', ExploreTag = 'explore[tag]', }