diff --git a/packages/shared/src/components/post/PostPageFeed.tsx b/packages/shared/src/components/post/PostPageFeed.tsx new file mode 100644 index 00000000000..67b615917e5 --- /dev/null +++ b/packages/shared/src/components/post/PostPageFeed.tsx @@ -0,0 +1,61 @@ +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, OtherFeedPage } from '../../lib/query'; + +/** + * The post page route forces list layout (feedName === OtherFeedPage.Post in + * 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); + 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(OtherFeedPage.PostPageFeed, user); + + 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/hooks/useFeedLayout.ts b/packages/shared/src/hooks/useFeedLayout.ts index 00214ee236e..5cd4a244dfe 100644 --- a/packages/shared/src/hooks/useFeedLayout.ts +++ b/packages/shared/src/hooks/useFeedLayout.ts @@ -71,6 +71,7 @@ export const FeedLayoutMobileFeedPages = new Set([ OtherFeedPage.Welcome, OtherFeedPage.Following, OtherFeedPage.AgentsVibes, + OtherFeedPage.PostPageFeed, ]); export const UserProfileFeedPages = new Set([ diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 7e3e196cba6..9542f0e471d 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -36,6 +36,7 @@ export const featurePostPageHighlights = new Feature( 'post_page_highlights', false, ); +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/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]', } diff --git a/packages/webapp/__tests__/PostPage.tsx b/packages/webapp/__tests__/PostPage.tsx index c53565aedf3..87181d147d7 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'; @@ -90,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 }; }, })); @@ -245,6 +251,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 +296,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 && }