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 };
+};