Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions packages/shared/src/components/post/SocialTwitterPostContent.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TestBootProvider client={client} gb={gb}>
<SocialTwitterPostContentRaw
post={post}
origin={Origin.ArticlePage}
isPostPage={isPostPage}
onClose={onClose}
/>
</TestBootProvider>,
);
};

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();
});
});
13 changes: 12 additions & 1 deletion packages/shared/src/components/post/SocialTwitterPostContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PostContentProps, 'post'> & {
post: Post;
};

function SocialTwitterPostContentRaw({
export function SocialTwitterPostContentRaw({
post,
isFallback,
shouldOnboardAuthor,
Expand Down Expand Up @@ -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 (
<PostContentContainer
Expand Down Expand Up @@ -226,6 +231,12 @@ function SocialTwitterPostContentRaw({
showImage
/>
)}
{showCommunitySentiment && (
<CommunitySentiment
data={communitySentimentData}
className="mb-6"
/>
)}
</BasePostContent>
</div>
<SquadPostWidgets
Expand Down
34 changes: 6 additions & 28 deletions packages/shared/src/components/post/focus/PostFocusCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,7 @@ import { PostTagList } from '../tags/PostTagList';
import { TruncateText } from '../../utilities';
import { combinedClicks } from '../../../lib/click';
import { useFeature } from '../../GrowthBookProvider';
import { useConditionalFeature } from '../../../hooks/useConditionalFeature';
import {
feature,
featureCommunitySentiment,
} from '../../../lib/featureManagement';
import { isDevelopment } from '../../../lib/constants';
import { feature } from '../../../lib/featureManagement';
import { SourceStrip } from '../reader/SourceStrip';
import Link from '../../utilities/Link';
import HoverCard from '../../cards/common/HoverCard';
Expand All @@ -56,10 +51,8 @@ import { PostMenuOptions } from '../PostMenuOptions';
import { FocusCardActionBar } from './FocusCardActionBar';
import { PostDiscussionPanel } from './PostDiscussionPanel';
import { CollectionSources } from './CollectionSources';
import {
CommunitySentiment,
mapCommunitySentimentPost,
} from './CommunitySentiment';
import { CommunitySentiment } from './CommunitySentiment';
import { useCommunitySentiment } from './useCommunitySentiment';

const PostCodeSnippets = dynamic(() =>
import(/* webpackChunkName: "postCodeSnippets" */ '../PostCodeSnippets').then(
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
// The video is a small floating preview on tablet/desktop and expands to the
Expand Down
43 changes: 43 additions & 0 deletions packages/shared/src/components/post/focus/useCommunitySentiment.ts
Original file line number Diff line number Diff line change
@@ -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<Post, 'communitySentiment'> | 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 };
};
Loading