diff --git a/packages/shared/src/components/post/SocialTwitterPostContent.spec.tsx b/packages/shared/src/components/post/SocialTwitterPostContent.spec.tsx new file mode 100644 index 0000000000..9a5a975a67 --- /dev/null +++ b/packages/shared/src/components/post/SocialTwitterPostContent.spec.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; +import { render, screen } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import postFixture from '../../../__tests__/fixture/post'; +import type { Post } from '../../graphql/posts'; +import { PostType } from '../../graphql/posts'; +import type { CommunitySentimentPost } from './focus/CommunitySentiment'; +import { SocialTwitterPostContentRaw } from './SocialTwitterPostContent'; +import { Origin } from '../../lib/log'; +import { featureCommunitySentiment } from '../../lib/featureManagement'; + +// `PostSourceInfo` and `SquadPostWidgets` pull in their own data-fetching +// hooks (follow status, squad membership, share widgets, further reading) +// that are unrelated to the community sentiment gating under test here. +jest.mock('./PostSourceInfo', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('./SquadPostWidgets', () => ({ + SquadPostWidgets: () => null, +})); +// `BasePostContent` renders `children` then the (heavy, comments-fetching) +// `PostEngagements` — stub it down to just its children so the assertions +// below cover exactly where the sentiment block is slotted in: after the +// tweet body, ahead of the (unmounted-here) comments/discussion section. +jest.mock('./BasePostContent', () => ({ + BasePostContent: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), +})); + +const communitySentiment: CommunitySentimentPost = { + breakdown: { positive: 60, mixed: 30, critical: 10 }, + tldr: 'Developers are largely positive about this tweet.', + postCount: 2, + sources: ['Hacker News'], + pros: [], + cons: [], + bySource: [], + openQuestions: [], + highlights: [], + discussions: [ + { + provider: 'hackernews', + url: 'https://news.ycombinator.com/item?id=1', + points: 10, + commentsCount: 5, + }, + ], +}; + +const tweetPost: Post = { + ...postFixture, + type: PostType.SocialTwitter, + subType: 'thread', + title: 'A tweet about testing', + contentHtml: undefined, + content: undefined, +}; + +interface RenderOptions { + isPostPage?: boolean; + onClose?: () => void; + /** Override the (always-`false`-by-default) experiment flag. */ + flagEnabled?: boolean; +} + +const renderComponent = ( + post: Post, + { isPostPage = true, onClose, flagEnabled = false }: RenderOptions = {}, +) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const gb = new GrowthBook(); + gb.setFeatures({ + [featureCommunitySentiment.id]: { defaultValue: flagEnabled }, + }); + + return render( + + + , + ); +}; + +describe('SocialTwitterPostContent - community sentiment', () => { + it('renders the sentiment block when the post has a take and the flag is on', () => { + renderComponent( + { ...tweetPost, communitySentiment }, + { flagEnabled: true }, + ); + + expect( + screen.getByText('Developers are largely positive about this tweet.'), + ).toBeInTheDocument(); + }); + + it('does not render without a take, even with the flag on', () => { + renderComponent( + { ...tweetPost, communitySentiment: null }, + { flagEnabled: true }, + ); + + expect( + screen.queryByText('Developers are largely positive about this tweet.'), + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText('What the community thinks'), + ).not.toBeInTheDocument(); + }); + + it('does not render with a take when the flag is off', () => { + renderComponent( + { ...tweetPost, communitySentiment }, + { flagEnabled: false }, + ); + + expect( + screen.queryByText('Developers are largely positive about this tweet.'), + ).not.toBeInTheDocument(); + }); + + it('does not render in the preview modal, even with a take and the flag on', () => { + renderComponent( + { ...tweetPost, communitySentiment }, + { flagEnabled: true, isPostPage: false, onClose: jest.fn() }, + ); + + expect( + screen.queryByText('Developers are largely positive about this tweet.'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/post/SocialTwitterPostContent.tsx b/packages/shared/src/components/post/SocialTwitterPostContent.tsx index 6d6f8a9191..11de02ea7e 100644 --- a/packages/shared/src/components/post/SocialTwitterPostContent.tsx +++ b/packages/shared/src/components/post/SocialTwitterPostContent.tsx @@ -31,12 +31,14 @@ import { getSocialTwitterMetadataLabel, } from '../cards/socialTwitter/socialTwitterHelpers'; import { Separator } from '../cards/common/common'; +import { CommunitySentiment } from './focus/CommunitySentiment'; +import { useCommunitySentiment } from './focus/useCommunitySentiment'; type SocialTwitterPostContentRawProps = Omit & { post: Post; }; -function SocialTwitterPostContentRaw({ +export function SocialTwitterPostContentRaw({ post, isFallback, shouldOnboardAuthor, @@ -104,6 +106,9 @@ function SocialTwitterPostContentRaw({ !post.content?.trim(); const metadataLabel = getSocialTwitterMetadataLabel(); const socialTextDirectionProps = getSocialTextDirectionProps(post.language); + // Only on the full post page, not the preview modal. + const { data: communitySentimentData, show: showCommunitySentiment } = + useCommunitySentiment(post, { isFullPage: !!isPostPage }); return ( )} + {showCommunitySentiment && ( + + )} import(/* webpackChunkName: "postCodeSnippets" */ '../PostCodeSnippets').then( @@ -259,25 +252,10 @@ export const PostFocusCard = ({ const { isReaderEnabled } = useReaderModalEligibility(); const isReaderVariant = isReaderEnabled && post.type === PostType.Article; const showCodeSnippets = useFeature(feature.showCodeSnippets); - const communitySentimentData = article.communitySentiment - ? mapCommunitySentimentPost(article.communitySentiment) - : undefined; - // Conditional enrollment: only evaluate (and log exposure for) the - // community_sentiment experiment on posts that actually have a take, so - // take-less posts don't dilute the treatment/control split. Backend keeps - // generating the take for every eligible post regardless of this flag. - const { value: communitySentimentEnabled } = useConditionalFeature({ - feature: featureCommunitySentiment, - shouldEvaluate: !!communitySentimentData, - }); // Only on the full post page, not the preview modal (which passes - // `onClose`), and only when the post actually has a take. `isDevelopment` - // lets the surface be previewed locally without flipping the committed - // (always-`false`) flag default. - const showCommunitySentiment = - !onClose && - !!communitySentimentData && - (communitySentimentEnabled || isDevelopment); + // `onClose`). + const { data: communitySentimentData, show: showCommunitySentiment } = + useCommunitySentiment(article, { isFullPage: !onClose }); const focusCommentRef = useRef<() => void>(() => {}); const discussionRef = useRef(null); // The video is a small floating preview on tablet/desktop and expands to the diff --git a/packages/shared/src/components/post/focus/useCommunitySentiment.ts b/packages/shared/src/components/post/focus/useCommunitySentiment.ts new file mode 100644 index 0000000000..05302c766f --- /dev/null +++ b/packages/shared/src/components/post/focus/useCommunitySentiment.ts @@ -0,0 +1,43 @@ +import type { Post } from '../../../graphql/posts'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { featureCommunitySentiment } from '../../../lib/featureManagement'; +import { isDevelopment } from '../../../lib/constants'; +import type { CommunitySentimentData } from './CommunitySentiment'; +import { mapCommunitySentimentPost } from './CommunitySentiment'; + +interface UseCommunitySentimentOptions { + /** True on the full post page; false in a feed preview modal — the take + * never renders there regardless of the take/flag state. */ + isFullPage: boolean; +} + +interface UseCommunitySentiment { + data?: CommunitySentimentData; + /** Whether the surface should actually render. */ + show: boolean; +} + +/** + * Shared gating for the Community Sentiment surface: maps the wire shape, + * conditionally enrolls in the `community_sentiment` experiment (only for + * posts that actually have a take, so take-less posts don't dilute the + * treatment/control split — the backend keeps generating takes regardless of + * this flag), and resolves whether the surface should render. + */ +export const useCommunitySentiment = ( + post: Pick | undefined, + { isFullPage }: UseCommunitySentimentOptions, +): UseCommunitySentiment => { + const data = post?.communitySentiment + ? mapCommunitySentimentPost(post.communitySentiment) + : undefined; + const { value: isEnabled } = useConditionalFeature({ + feature: featureCommunitySentiment, + shouldEvaluate: !!data, + }); + // `isDevelopment` lets the surface be previewed locally without flipping + // the committed (always-`false`) flag default. + const show = isFullPage && !!data && (isEnabled || isDevelopment); + + return { data, show }; +};