Skip to content
12 changes: 12 additions & 0 deletions packages/shared/src/components/post/PostEngagements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import { usePlusSubscription } from '../../hooks/usePlusSubscription';
import SocialBar from '../cards/socials/SocialBar';
import { PostContentReminder } from './common/PostContentReminder';
import { useSettingsContext } from '../../contexts/SettingsContext';
import { useConditionalFeature } from '../../hooks/useConditionalFeature';
import { featureArticleChatter } from '../../lib/featureManagement';
import { isDevelopment } from '../../lib/constants';
import { ChatterSection } from '../../features/chatter/components/ChatterSection';

const AuthorOnboarding = dynamic(
() => import(/* webpackChunkName: "authorOnboarding" */ './AuthorOnboarding'),
Expand Down Expand Up @@ -77,6 +81,13 @@ function PostEngagements({
false,
);
const [linkClicked, setLinkClicked] = useState(false);
const { value: chatterFlag } = useConditionalFeature({
feature: featureArticleChatter,
shouldEvaluate: true,
});
// Local-only preview; the committed flag default stays false so it never
// ships to prod until GrowthBook ramps it.
const showChatter = chatterFlag || isDevelopment;

const handleLinkClick = () => {
setLinkClicked(true);
Expand Down Expand Up @@ -162,6 +173,7 @@ function PostEngagements({
CommentInputOrModal={CommentInputOrModal}
/>
{!isPlus && <AdAsComment postId={post.id} />}
{showChatter && <ChatterSection post={post} className="my-6" />}
<PostComments
post={post}
sortBy={sortBy}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import type { CSSProperties, ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
import type { ChatterComment, ChatterSource } from '../types';
import { platformVisuals } from '../platforms';
import {
Typography,
TypographyColor,
TypographyTag,
TypographyType,
} from '../../../components/typography/Typography';
import {
AiIcon,
ArrowIcon,
OpenLinkIcon,
UpvoteIcon,
DiscussIcon,
} from '../../../components/icons';
import { IconSize } from '../../../components/Icon';
import { useToggle } from '../../../hooks/useToggle';
import { largeNumberFormat } from '../../../lib/numberFormat';

const Avatar = ({
background,
initial,
}: {
background: string;
initial: string;
}): ReactElement => (
<span
className="flex size-8 shrink-0 items-center justify-center rounded-10 font-bold text-white typo-footnote"
style={{ background }}
aria-hidden
>
{initial}
</span>
);

const CommentRow = ({ comment }: { comment: ChatterComment }): ReactElement => {
const hasUpvotes = !!comment.upvotes && comment.upvotes > 0;
const hasReplies = !!comment.replies && comment.replies > 0;

return (
<div className="flex gap-3 border-t border-border-subtlest-tertiary py-3 first:border-t-0">
<Avatar
background={comment.avatar}
initial={comment.author.replace(/[@u/]/g, '').charAt(0).toUpperCase()}
/>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex flex-wrap items-center gap-x-2">
<Typography
type={TypographyType.Subhead}
bold
tag={TypographyTag.Span}
>
{comment.author}
</Typography>
{comment.handle && (
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Tertiary}
tag={TypographyTag.Span}
>
{comment.handle}
</Typography>
)}
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Tertiary}
tag={TypographyTag.Span}
>
· {comment.timeAgo}
</Typography>
</div>
<Typography type={TypographyType.Callout} className="break-words">
{comment.text}
</Typography>
{(hasUpvotes || hasReplies) && (
<div className="mt-2 flex items-center gap-4 text-text-tertiary typo-footnote">
{hasUpvotes && (
<span className="flex items-center gap-1">
<UpvoteIcon size={IconSize.XSmall} />{' '}
{largeNumberFormat(comment.upvotes)}
</span>
)}
{hasReplies && (
<span className="flex items-center gap-1">
<DiscussIcon size={IconSize.XSmall} />{' '}
{largeNumberFormat(comment.replies)}
</span>
)}
</div>
)}
</div>
</div>
);
};

interface ChatterPlatformCardProps {
source: ChatterSource;
}

export const ChatterPlatformCard = ({
source,
}: ChatterPlatformCardProps): ReactElement => {
const visual = platformVisuals[source.platform];
const [isOpen, toggleOpen] = useToggle(source.defaultOpen ?? false);
const chipStyle: CSSProperties | undefined = visual.chipColor
? { background: visual.chipColor }
: undefined;

return (
<div className="relative min-w-0 overflow-hidden rounded-16 border border-border-subtlest-tertiary bg-surface-float">
<span
className={classNames(
'absolute inset-y-0 left-0 w-[3px]',
visual.chipClassName,
)}
style={chipStyle}
aria-hidden
/>

<div className="flex items-center gap-3 py-3 pl-5 pr-4">
<span
className={classNames(
'flex size-8 shrink-0 items-center justify-center rounded-10',
visual.chipClassName,
visual.markClassName,
)}
style={chipStyle}
>
{visual.mark}
</span>
<div className="min-w-0 flex-1">
<Typography type={TypographyType.Callout} bold className="truncate">
{source.heading}
</Typography>
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Tertiary}
className="truncate"
>
{source.subtitle}
</Typography>
</div>
<div className="hidden shrink-0 gap-1.5 tablet:flex">
{source.stats.map((stat) => (
<span
key={stat.label}
className="rounded-8 border border-border-subtlest-tertiary bg-background-subtle px-2 py-1 font-bold text-text-secondary typo-caption1"
>
{stat.value} {stat.label}
</span>
))}
</div>
</div>

<div className="pb-3 pl-5 pr-4">
<span className="mb-1.5 flex items-center gap-1 font-bold uppercase tracking-wider text-action-plus-default typo-caption1">
<AiIcon size={IconSize.XSmall} /> AI summary
</span>
<Typography type={TypographyType.Callout}>
<b>{source.mood}</b> {source.summary}
</Typography>
</div>

<button
type="button"
onClick={() => toggleOpen()}
aria-expanded={isOpen}
className="flex w-full items-center justify-between border-t border-border-subtlest-tertiary py-3 pl-5 pr-4 font-bold text-text-secondary typo-callout hover:bg-surface-hover"
>
{isOpen ? 'Hide' : `Show top ${source.comments.length}`}
<ArrowIcon
size={IconSize.Small}
className={isOpen ? undefined : 'rotate-180'}
/>
</button>

{isOpen && (
<div className="flex flex-col pb-2 pl-5 pr-4">
{source.comments.map((comment) => (
<CommentRow key={comment.id} comment={comment} />
))}
</div>
)}

<div className="border-t border-border-subtlest-tertiary py-3 pl-5 pr-4">
<a
href={source.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="flex w-fit items-center gap-1.5 font-bold text-text-link typo-footnote hover:underline"
>
{source.sourceLabel}
<OpenLinkIcon size={IconSize.XSmall} />
</a>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import type { Post } from '../../../graphql/posts';
import { ChatterSection } from './ChatterSection';

const post = { id: 'p1', permalink: 'https://example.com/article' } as Post;

const renderComponent = () => render(<ChatterSection post={post} />);

const waitForGlance = () =>
waitFor(() =>
expect(screen.getByText(/X is calling the top/i)).toBeInTheDocument(),
);

describe('ChatterSection', () => {
it('shows a glanceable pulse and hides the breakdown by default', async () => {
renderComponent();
await waitForGlance();

// The glance: verdict + sentiment + stance, all visible at once.
expect(screen.getByText(/48% agree/i)).toBeInTheDocument();
expect(screen.getByText('divided')).toBeInTheDocument();

// The breakdown is collapsed until asked for.
expect(screen.queryByText('Highlights')).not.toBeInTheDocument();

fireEvent.click(
screen.getByRole('button', { name: /Show the full breakdown/i }),
);

expect(screen.getByText('Highlights')).toBeInTheDocument();
expect(screen.getByText('The bull case')).toBeInTheDocument();
expect(screen.getByText('The skeptic')).toBeInTheDocument();
expect(screen.getByText('Bottom line')).toBeInTheDocument();
expect(
screen.getByText('Declaring victory for open weights'),
).toBeInTheDocument();
});

it('keeps raw platform threads collapsed until requested', async () => {
renderComponent();
await waitForGlance();

expect(screen.queryByText(/X · widely shared/i)).not.toBeInTheDocument();

fireEvent.click(
screen.getByRole('button', { name: /See the raw discussion/i }),
);

expect(screen.getByText(/X · widely shared/i)).toBeInTheDocument();
expect(
screen.getByText(/Hacker News · #1 on the front page/i),
).toBeInTheDocument();
expect(screen.getByText('zackify')).toBeInTheDocument();
});

it('filters raw sources by platform', async () => {
renderComponent();
await waitForGlance();
fireEvent.click(
screen.getByRole('button', { name: /See the raw discussion/i }),
);

fireEvent.click(screen.getByRole('button', { name: 'Hacker News' }));

expect(screen.queryByText(/X · widely shared/i)).not.toBeInTheDocument();
expect(
screen.getByText(/Hacker News · #1 on the front page/i),
).toBeInTheDocument();
});
});
Loading
Loading