diff --git a/packages/shared/src/components/post/PostEngagements.tsx b/packages/shared/src/components/post/PostEngagements.tsx index b3797e253c..76615c505f 100644 --- a/packages/shared/src/components/post/PostEngagements.tsx +++ b/packages/shared/src/components/post/PostEngagements.tsx @@ -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'), @@ -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); @@ -162,6 +173,7 @@ function PostEngagements({ CommentInputOrModal={CommentInputOrModal} /> {!isPlus && } + {showChatter && } ( + + {initial} + +); + +const CommentRow = ({ comment }: { comment: ChatterComment }): ReactElement => { + const hasUpvotes = !!comment.upvotes && comment.upvotes > 0; + const hasReplies = !!comment.replies && comment.replies > 0; + + return ( +
+ +
+
+ + {comment.author} + + {comment.handle && ( + + {comment.handle} + + )} + + · {comment.timeAgo} + +
+ + {comment.text} + + {(hasUpvotes || hasReplies) && ( +
+ {hasUpvotes && ( + + {' '} + {largeNumberFormat(comment.upvotes)} + + )} + {hasReplies && ( + + {' '} + {largeNumberFormat(comment.replies)} + + )} +
+ )} +
+
+ ); +}; + +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 ( +
+ + +
+ + {visual.mark} + +
+ + {source.heading} + + + {source.subtitle} + +
+
+ {source.stats.map((stat) => ( + + {stat.value} {stat.label} + + ))} +
+
+ +
+ + AI summary + + + {source.mood} {source.summary} + +
+ + + + {isOpen && ( +
+ {source.comments.map((comment) => ( + + ))} +
+ )} + + +
+ ); +}; diff --git a/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx b/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx new file mode 100644 index 0000000000..272538d930 --- /dev/null +++ b/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx @@ -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(); + +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(); + }); +}); diff --git a/packages/shared/src/features/chatter/components/ChatterSection.tsx b/packages/shared/src/features/chatter/components/ChatterSection.tsx new file mode 100644 index 0000000000..d77acebd1b --- /dev/null +++ b/packages/shared/src/features/chatter/components/ChatterSection.tsx @@ -0,0 +1,145 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { ChatterPlatform } from '../types'; +import { platformVisuals } from '../platforms'; +import { useArticleChatter } from '../hooks/useArticleChatter'; +import { ChatterPlatformCard } from './ChatterPlatformCard'; +import { CommunityPulse } from './CommunityPulse'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { AiIcon, ArrowIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { ElementPlaceholder } from '../../../components/ElementPlaceholder'; +import { useToggle } from '../../../hooks/useToggle'; + +type ChatterFilter = ChatterPlatform | 'all'; + +const filters: { value: ChatterFilter; label: string }[] = [ + { value: 'all', label: 'All sources' }, + { value: ChatterPlatform.X, label: platformVisuals[ChatterPlatform.X].label }, + { + value: ChatterPlatform.HackerNews, + label: platformVisuals[ChatterPlatform.HackerNews].label, + }, +]; + +const LoadingSkeleton = (): ReactElement => ( +
+
+ + +
+ + + +
+); + +interface ChatterSectionProps { + post: Post; + className?: string; +} + +export const ChatterSection = ({ + post, + className, +}: ChatterSectionProps): ReactElement => { + const { chatter, isLoading } = useArticleChatter(post); + const [filter, setFilter] = useState('all'); + const [isRawOpen, toggleRawOpen] = useToggle(false); + + const sources = + chatter?.sources.filter( + (source) => filter === 'all' || source.platform === filter, + ) ?? []; + + return ( +
+
+ + + +
+ + The Chatter — how the wider dev community reacted + + + An AI read of the conversation across X & Hacker News — not just the + raw comments. + +
+
+ + {isLoading || !chatter ? ( + + ) : ( + <> + + +
+ + + {isRawOpen && ( + <> +
+ {filters.map((item) => ( + + ))} +
+ {sources.map((source) => ( + + ))} + + )} +
+ + )} + + + Summaries are AI-generated from public posts and may be imperfect. + Always open the source thread for full context. + +
+ ); +}; diff --git a/packages/shared/src/features/chatter/components/CommunityPulse.tsx b/packages/shared/src/features/chatter/components/CommunityPulse.tsx new file mode 100644 index 0000000000..521372a62f --- /dev/null +++ b/packages/shared/src/features/chatter/components/CommunityPulse.tsx @@ -0,0 +1,309 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { + CommunityMomentum, + CommunityPulse as CommunityPulseData, + CommunityStance, + ControversyLevel, + HighlightKind, + SentimentSplit, +} from '../types'; +import { platformVisuals } from '../platforms'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { + ArrowIcon, + SparkleIcon, + TrendingIcon, +} from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { useToggle } from '../../../hooks/useToggle'; +import { largeNumberFormat } from '../../../lib/numberFormat'; + +const stanceDot: Record = { + positive: 'bg-status-success', + divided: 'bg-status-warning', + critical: 'bg-status-error', + mixed: 'bg-status-warning', +}; + +const controversyText: Record = { + calm: 'text-status-success', + lively: 'text-status-warning', + heated: 'text-status-error', +}; + +const momentumLabel: Record = { + rising: 'Rising', + steady: 'Steady', + cooling: 'Cooling', +}; + +const highlightMeta: Record< + HighlightKind, + { label: string; dot: string; text: string } +> = { + 'the-bull-case': { + label: 'The bull case', + dot: 'bg-status-success', + text: 'text-status-success', + }, + 'the-skeptic': { + label: 'The skeptic', + dot: 'bg-status-error', + text: 'text-status-error', + }, + 'the-tell': { + label: 'The tell', + dot: 'bg-accent-cabbage-default', + text: 'text-action-plus-default', + }, + 'the-receipt': { + label: 'The receipt', + dot: 'bg-accent-salt-default', + text: 'text-text-secondary', + }, +}; + +const leanDot = (split: SentimentSplit): string => { + const net = split.agree - split.disagree; + if (net >= 10) { + return 'bg-status-success'; + } + if (net <= -10) { + return 'bg-status-error'; + } + return 'bg-status-warning'; +}; + +const Section = ({ children }: { children: ReactNode }): ReactElement => ( +
+ {children} +
+); + +const SectionLabel = ({ children }: { children: ReactNode }): ReactElement => ( + + {children} + +); + +const SentimentBar = ({ split }: { split: SentimentSplit }): ReactElement => ( +
+ + + +
+); + +const Details = ({ pulse }: { pulse: CommunityPulseData }): ReactElement => ( + <> +
+ Highlights +
+ {pulse.highlights.map((highlight) => { + const meta = highlightMeta[highlight.kind]; + return ( +
+ + + + {meta.label} + + + + “{highlight.text}” + + + — {highlight.who} + +
+ ); + })} +
+
+ +
+ How each platform leans +
+ {pulse.perPlatform.map((item) => { + const visual = platformVisuals[item.platform]; + return ( +
+ + {visual.mark} + + + {visual.label} + + + {item.read} + + +
+ ); + })} +
+
+ +
+ Where it splits +
    + {pulse.contention.map((item) => ( +
  • + + + {item} + +
  • + ))} +
+
+ +
+
+ + Bottom line +
+ + {pulse.bottomLine} + +
+ +); + +interface CommunityPulseProps { + pulse: CommunityPulseData; +} + +export const CommunityPulse = ({ + pulse, +}: CommunityPulseProps): ReactElement => { + const [showDetails, toggleDetails] = useToggle(false); + + return ( +
+ {/* Glance: the whole answer in one view */} +
+ + {pulse.verdict} + + +
+ +
+ + + {pulse.overallSplit.agree}% agree + + + + {pulse.overallSplit.mixed}% mixed + + + + {pulse.overallSplit.disagree}% disagree + +
+
+ +
+ + + + {pulse.stance} + + + + {pulse.controversyLevel} + + + {' '} + {momentumLabel[pulse.momentum]} + + {largeNumberFormat(pulse.totalVoices)} voices +
+
+ + + + {showDetails &&
} +
+ ); +}; diff --git a/packages/shared/src/features/chatter/hooks/useArticleChatter.ts b/packages/shared/src/features/chatter/hooks/useArticleChatter.ts new file mode 100644 index 0000000000..d3fe1bd522 --- /dev/null +++ b/packages/shared/src/features/chatter/hooks/useArticleChatter.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import type { Post } from '../../../graphql/posts'; +import type { ArticleChatter } from '../types'; +import { getMockChatter } from '../mockData'; + +interface UseArticleChatter { + chatter?: ArticleChatter; + isLoading: boolean; +} + +/** + * POC: returns mocked cross-platform chatter after a short simulated fetch so + * the loading state is exercised. Swap the timeout for a real query (keyed off + * the post permalink / yggdrasilId) when the backend exists. + */ +export const useArticleChatter = (post: Post): UseArticleChatter => { + const [chatter, setChatter] = useState(); + + useEffect(() => { + setChatter(undefined); + const timeout = setTimeout(() => setChatter(getMockChatter()), 500); + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [post.id]); + + return { chatter, isLoading: !chatter }; +}; diff --git a/packages/shared/src/features/chatter/mockData.ts b/packages/shared/src/features/chatter/mockData.ts new file mode 100644 index 0000000000..506e8f6b9c --- /dev/null +++ b/packages/shared/src/features/chatter/mockData.ts @@ -0,0 +1,125 @@ +import type { ArticleChatter } from './types'; +import { ChatterPlatform } from './types'; + +// POC: real captured community-pulse dataset for "GLM 5.2 and the coming AI +// margin collapse" (Martin Alderson). HN exact-URL match (#1, 508 points) + X +// amplification. Replace this with a live query keyed off the post's permalink +// / yggdrasilId when the data source is ready. +export const getMockChatter = (): ArticleChatter => ({ + pulse: { + verdict: + 'X is calling the top (“the collapse is here”); HN is calling BS on the framing while quietly hunting the cheap plans.', + stance: 'divided', + controversyLevel: 'heated', + totalVoices: 900, + momentum: 'rising', + overallSplit: { agree: 48, mixed: 30, disagree: 22 }, + perPlatform: [ + { + platform: ChatterPlatform.HackerNews, + read: 'Skeptical of the hype, deep in the weeds', + split: { agree: 40, mixed: 33, disagree: 27 }, + }, + { + platform: ChatterPlatform.X, + read: 'Declaring victory for open weights', + split: { agree: 66, mixed: 22, disagree: 12 }, + }, + ], + contention: [ + 'Is GLM 5.2 actually frontier, or just cheap? (HN says not close to Opus; X says GPT-5-class)', + '“The collapse is here” vs. “this has been AI news every single day”', + 'Are frontier labs doomed, or does speed (Cerebras) become the new moat?', + ], + highlights: [ + { + kind: 'the-bull-case', + text: 'The AI margin collapse has already started. GLM 5.2 just dropped, a 7MB embedding model runs fully in your browser. 2026 is the year API access stops being a moat.', + who: '@alsamahi (X)', + }, + { + kind: 'the-skeptic', + text: '“The least understood shift in AI economics” — then proceeds to talk about something that’s in the AI news every day. By no measure is GLM 5.2 as good as Opus.', + who: 'budsniffer952 (HN)', + }, + { + kind: 'the-tell', + text: 'Somehow no one talks about LLM speed. — The whole top thread then pivots from cost to Cerebras and tokens/sec: speed may be the next moat.', + who: 'copperx → tough → LoganDark (HN)', + }, + { + kind: 'the-receipt', + text: 'I switched to the yearly Cline pass because it was too cheap haha — $6 a month, planning to mostly run DeepSeek v4 flash.', + who: 'zackify (HN)', + }, + ], + bottomLine: + 'The thesis isn’t controversial — cheap open weights squeeze margins, everyone knows it. The fault line is the read: X treats GLM 5.2 as the moment API access stopped being a moat, while HN’s most-engaged replies think the post oversells a slow, already-obvious trend and quietly redirects the debate to the thing nobody benchmarks — raw inference speed.', + }, + sources: [ + { + platform: ChatterPlatform.HackerNews, + heading: 'Hacker News · #1 on the front page', + subtitle: 'exact URL match · 508 points', + stats: [ + { label: 'points', value: '508' }, + { label: 'comments', value: '307' }, + ], + mood: 'HN is skeptical of the framing.', + moodTone: 'split', + summary: + 'Deep in the weeds and unconvinced by the headline — the most-engaged replies argue the post oversells an already-obvious trend, then pivot the debate from cost to raw inference speed (Cerebras, tokens/sec).', + sourceUrl: 'https://news.ycombinator.com/item?id=48809877', + sourceLabel: 'Open discussion on Hacker News', + defaultOpen: true, + comments: [ + { + id: 'hn1', + author: 'budsniffer952', + avatar: '#ff6600', + timeAgo: 'recent', + text: '“The least understood upcoming shift in AI economics” — then proceeds to talk about something that’s in the AI news every single day. By no measure is GLM 5.2 as good as Opus. Yes, open models pressure margins… eventually.', + }, + { + id: 'hn2', + author: 'copperx', + avatar: '#ff8a3d', + timeAgo: 'recent', + text: 'Somehow no one talks about LLM speed. [the thread pivots to Cerebras and tokens/sec — speed may be the next moat]', + }, + { + id: 'hn3', + author: 'zackify', + avatar: '#9a5a2a', + timeAgo: 'recent', + text: 'I switched to the yearly Cline pass because it was too cheap haha — $6 a month, planning to mostly run DeepSeek v4 flash.', + }, + ], + }, + { + platform: ChatterPlatform.X, + heading: 'X · widely shared', + subtitle: 'mostly bullish', + stats: [ + { label: 'sentiment', value: '66% pos' }, + { label: 'framing', value: 'collapse is here' }, + ], + mood: 'X is declaring victory for open weights.', + moodTone: 'mixed', + summary: + 'Reframed as a milestone: GLM 5.2 is “the moment API access stops being a moat.” Amplified widely with little pushback.', + sourceUrl: 'https://x.com/search?q=AI%20margin%20collapse%20GLM', + sourceLabel: 'See posts on X', + comments: [ + { + id: 'x1', + author: 'alsamahi', + handle: '@alsamahi', + avatar: 'linear-gradient(135deg,#1d9bf0,#0f6fb8)', + timeAgo: 'recent', + text: 'The AI margin collapse has already started. GLM 5.2 just dropped, a 7MB embedding model runs fully in your browser. 2026 is the year API access stops being a moat.', + }, + ], + }, + ], +}); diff --git a/packages/shared/src/features/chatter/platforms.tsx b/packages/shared/src/features/chatter/platforms.tsx new file mode 100644 index 0000000000..12d3189640 --- /dev/null +++ b/packages/shared/src/features/chatter/platforms.tsx @@ -0,0 +1,67 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { ChatterPlatform } from './types'; + +export interface PlatformVisual { + label: string; + // The card's left stripe + icon chip color. X uses a theme-aware token class + // (black-on-light / white-on-dark) since its brand is monochrome; HN and + // Reddit use raw brand hex — deliberately not design-system tokens, because + // they must match each platform's real identity. + chipClassName?: string; + chipColor?: string; + markClassName?: string; + mark: ReactElement; +} + +const XMark = ( + + + +); + +const HackerNewsMark = ( + + Y + +); + +const RedditMark = ( + + + + + + +); + +export const platformVisuals: Record = { + [ChatterPlatform.X]: { + label: 'X', + chipClassName: 'bg-text-primary', + markClassName: 'text-background-default', + mark: XMark, + }, + [ChatterPlatform.HackerNews]: { + label: 'Hacker News', + chipColor: '#ff6600', + mark: HackerNewsMark, + }, + [ChatterPlatform.Reddit]: { + label: 'Reddit', + chipColor: '#ff4500', + mark: RedditMark, + }, +}; diff --git a/packages/shared/src/features/chatter/types.ts b/packages/shared/src/features/chatter/types.ts new file mode 100644 index 0000000000..5e143f77ed --- /dev/null +++ b/packages/shared/src/features/chatter/types.ts @@ -0,0 +1,91 @@ +export enum ChatterPlatform { + X = 'x', + HackerNews = 'hackernews', + Reddit = 'reddit', +} + +export type ChatterMoodTone = 'hot' | 'split' | 'mixed' | 'positive'; + +export interface ChatterComment { + id: string; + author: string; + handle?: string; + // Placeholder avatar background (CSS color/gradient) until real avatars exist. + avatar: string; + timeAgo: string; + text: string; + upvotes?: number; + replies?: number; +} + +export interface ChatterStat { + label: string; + value: string; +} + +export interface ChatterSource { + platform: ChatterPlatform; + heading: string; + subtitle: string; + stats: ChatterStat[]; + /** Short bold lead for the AI summary, e.g. "X is on fire." */ + mood: string; + moodTone: ChatterMoodTone; + /** The AI-generated TLDR body. */ + summary: string; + sourceUrl: string; + sourceLabel: string; + comments: ChatterComment[]; + /** Whether the comment list starts expanded. */ + defaultOpen?: boolean; +} + +export type CommunityStance = 'positive' | 'divided' | 'critical' | 'mixed'; +export type ControversyLevel = 'calm' | 'lively' | 'heated'; +export type CommunityMomentum = 'rising' | 'steady' | 'cooling'; + +export interface SentimentSplit { + agree: number; + mixed: number; + disagree: number; +} + +export interface PlatformSentiment { + platform: ChatterPlatform; + /** One-word read of the platform's mood, e.g. "Skeptical". */ + read: string; + split: SentimentSplit; +} + +export type HighlightKind = + | 'the-bull-case' + | 'the-skeptic' + | 'the-tell' + | 'the-receipt'; + +export interface CommunityHighlight { + kind: HighlightKind; + text: string; + who: string; +} + +export interface CommunityPulse { + verdict: string; + stance: CommunityStance; + controversyLevel: ControversyLevel; + totalVoices: number; + momentum: CommunityMomentum; + overallSplit: SentimentSplit; + perPlatform: PlatformSentiment[]; + /** The fault lines the community splits on. */ + contention: string[]; + /** The standout quotes, tagged by their role in the conversation. */ + highlights: CommunityHighlight[]; + /** One-sentence synthesized takeaway. */ + bottomLine: string; +} + +export interface ArticleChatter { + pulse: CommunityPulse; + sources: ChatterSource[]; +} diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index d53abbdb31..f3edac6e82 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -39,6 +39,8 @@ export const featurePostPageHighlights = new Feature( ); export const featurePostRedesign = new Feature('post_redesign', false); +export const featureArticleChatter = new Feature('article_chatter', false); + // @ts-expect-error stale feature without default export const plusTakeoverContent = new Feature<{ title: string;