From a29917c8c1d0754badfc9fc7a1c5976477d043b4 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 24 Jul 2026 13:36:04 +0200 Subject: [PATCH 1/2] feat: show community sentiment on social twitter post pages The backend now generates community takes for tweet posts (X replies as a discussion provider, yggdrasil#711), but tweets render via the dedicated SocialTwitterPostContent layout which never showed the take. Embeds the CommunitySentiment surface there, after the tweet body and before the comments, with the exact PostFocusCard gating: conditional experiment enrollment only when a take exists, full post page only (isPostPage, the layout's equivalent of the focus card's !onClose), isDevelopment preview escape hatch. The shared gating now lives in a useCommunitySentiment hook consumed by both surfaces (behavior unchanged on the focus card). Co-Authored-By: Claude Fable 5 --- .../post/SocialTwitterPostContent.spec.tsx | 141 ++++++++++++++++++ .../post/SocialTwitterPostContent.tsx | 13 +- .../components/post/focus/PostFocusCard.tsx | 34 +---- .../post/focus/useCommunitySentiment.ts | 43 ++++++ 4 files changed, 202 insertions(+), 29 deletions(-) create mode 100644 packages/shared/src/components/post/SocialTwitterPostContent.spec.tsx create mode 100644 packages/shared/src/components/post/focus/useCommunitySentiment.ts 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 00000000000..9a5a975a67d --- /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 6d6f8a91910..11de02ea7e2 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 00000000000..05302c766fa --- /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 }; +}; From 7602a57d1e847e13d6cad2f82138e52e15e2257d Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 24 Jul 2026 13:45:35 +0200 Subject: [PATCH 2/2] chore: retrigger vercel preview deployment