diff --git a/packages/shared/src/components/post/focus/CommunitySentiment.tsx b/packages/shared/src/components/post/focus/CommunitySentiment.tsx new file mode 100644 index 0000000000..333f3126b0 --- /dev/null +++ b/packages/shared/src/components/post/focus/CommunitySentiment.tsx @@ -0,0 +1,299 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../typography/Typography'; +import { DiscussIcon, ArrowIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import { CommunitySentimentBreakdown } from './CommunitySentimentBreakdown'; + +export interface SentimentBreakdown { + /** Share of the community that is positive (0-100). */ + positive: number; + /** Share that is mixed/neutral (0-100). */ + mixed: number; + /** Share that is critical/skeptical (0-100). */ + critical: number; +} + +export type SourceLean = 'positive' | 'mixed' | 'skeptical' | 'heated'; + +export interface SourceSentiment { + /** External community name, e.g. "Hacker News". */ + source: string; + /** How that community leans overall. */ + lean: SourceLean; + /** One-line characterization of the conversation there. */ + note: string; + /** Link to the actual discussion, when it lives in one place (HN/Lobsters). */ + url?: string; +} + +export interface SentimentHighlight { + /** Verbatim, punchy quote worth reading. */ + quote: string; + /** Author handle or username. */ + author: string; + /** Where it was said. */ + source: string; + /** Link to the original post/comment. */ + url: string; + /** Engagement metrics for the platform, e.g. ["2.4k likes", "180 replies"]. */ + metrics: string[]; +} + +export interface CommunitySentimentData { + /** How opinion splits across positive / mixed / skeptical — the surface metric. */ + breakdown: SentimentBreakdown; + /** One-to-two sentence plain-English summary of what the community thinks. */ + tldr: string; + /** Total discussions the take is aggregated from. */ + postCount: number; + /** External communities the take is drawn from. */ + sources: string[]; + /** Layer 2 — the case for (coalition). */ + pros: string[]; + /** Layer 2 — the pushback (opposition). */ + cons: string[]; + /** Layer 2 — how each external community leans. */ + bySource: SourceSentiment[]; + /** Layer 2 — the single biggest flashpoint, if the take is divisive. */ + hottestDebate: string; + /** Layer 2 — what the community is still asking. */ + openQuestions: string[]; + /** Layer 3 — a few verbatim receipts worth reading. */ + highlights: SentimentHighlight[]; +} + +/** + * Layer 1 + 2 mock data. Replaced by a real aggregation query once the design + * lands; kept as a typed default so wiring live data later is a one-line swap. + */ +export const SAMPLE_COMMUNITY_SENTIMENT: CommunitySentimentData = { + breakdown: { positive: 57, mixed: 27, critical: 16 }, + tldr: `Developers love the idea of collapsing their stack onto Postgres to cut ops overhead and complexity, but a vocal group warns that Redis, Kafka, and Elasticsearch exist for a reason once you hit real scale.`, + postCount: 410, + sources: ['X', 'Hacker News', 'Lobsters'], + pros: [ + 'One database to run, back up and monitor — a real ops win for small teams', + 'Extensions like pgvector, pg_search, PGMQ and TimescaleDB are genuinely capable now', + 'Transactions and joins across data that used to live in separate systems', + ], + cons: [ + 'Purpose-built tools still win at scale — Kafka throughput, ES relevance, Redis latency', + 'Overloading one primary turns it into a single point of failure and contention', + 'Extensions bring their own upgrade and operational pain', + ], + bySource: [ + { + source: 'Hacker News', + lean: 'heated', + note: `Classic "just use Postgres" vs "know your scale" flame war`, + url: 'https://news.ycombinator.com/item?id=39135501', + }, + { + source: 'X', + lean: 'positive', + note: 'Devs cheering the stack simplification and cost savings', + }, + { + source: 'Lobsters', + lean: 'skeptical', + note: `More measured — "it works until it doesn't; depends on scale"`, + url: 'https://lobste.rs/s/vtfkqh', + }, + ], + hottestDebate: `Is consolidating onto Postgres a smart simplification, or just deferring the scaling pain you'll pay for later?`, + openQuestions: [ + 'At what scale does the single-Postgres approach actually break down?', + 'Does pgvector / pg_search hold up against dedicated Elasticsearch on large corpora?', + 'Is the migration cost worth the ops savings for an existing stack?', + ], + highlights: [ + { + quote: `Every service I've replaced with plain Postgres is one less thing paging me at 3am.`, + author: '@maya_builds', + source: 'X', + url: 'https://x.com/maya_builds/status/1750000000000000000', + metrics: ['2.4k likes', '180 replies', '410 reposts'], + }, + { + quote: `"Just use Postgres" is great advice, right up until your one primary is on fire and everything is down at once.`, + author: 'throwaway_42', + source: 'Hacker News', + url: 'https://news.ycombinator.com/item?id=39135777', + metrics: ['214 points', '96 comments'], + }, + { + quote: `pgvector is genuinely good now, but for serious search at scale, Elasticsearch still eats it for lunch.`, + author: 'pg_curious', + source: 'Lobsters', + url: 'https://lobste.rs/s/vtfkqh', + metrics: ['38 votes', '22 comments'], + }, + { + quote: `We moved our job queue from Redis to PGMQ and honestly haven't thought about it since.`, + author: '@kirsten_dev', + source: 'X', + url: 'https://x.com/kirsten_dev/status/1750000000000000001', + metrics: ['1.2k likes', '88 replies'], + }, + { + quote: `The "one database" crowd forgets that backups, failover and vacuum tuning get scarier as that single DB grows.`, + author: 'dba_ghost', + source: 'Hacker News', + url: 'https://news.ycombinator.com/item?id=39135888', + metrics: ['156 points', '74 comments'], + }, + { + quote: `TimescaleDB replacing our metrics stack was the single best infra decision we made last year.`, + author: 'metrics_maxi', + source: 'Lobsters', + url: 'https://lobste.rs/s/wq2m4p', + metrics: ['29 votes', '15 comments'], + }, + ], +}; + +const BREAKDOWN_SEGMENTS: Array<{ + key: keyof SentimentBreakdown; + label: string; + color: string; +}> = [ + { key: 'positive', label: 'positive', color: 'bg-accent-avocado-default' }, + { key: 'mixed', label: 'mixed', color: 'bg-accent-bun-default' }, + { key: 'critical', label: 'skeptical', color: 'bg-accent-ketchup-default' }, +]; + +interface CommunitySentimentProps { + data?: CommunitySentimentData; + onSeeBreakdown?: () => void; + className?: string; +} + +/** + * Community Sentiment — a light, at-a-glance read on what the developer community + * outside daily.dev thinks about a post. Layer 1 (surface) is a plain-English + * TL;DR + a breakdown bar; "Deep dive" expands the modular Layer 2 blocks in place. + */ +export const CommunitySentiment = ({ + data = SAMPLE_COMMUNITY_SENTIMENT, + onSeeBreakdown, + className, +}: CommunitySentimentProps): ReactElement => { + const { breakdown, tldr, postCount } = data; + const [isExpanded, setIsExpanded] = useState(false); + + const toggle = () => { + setIsExpanded((prev) => !prev); + onSeeBreakdown?.(); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggle(); + } + }; + + return ( +
+
+
+ + + + Community take + + + + + {postCount} + + + discussions + + +
+ + + {tldr} + + +
+
+ {BREAKDOWN_SEGMENTS.map(({ key, color }) => ( + + ))} +
+
+ {BREAKDOWN_SEGMENTS.map(({ key, label, color }) => ( + + + + {breakdown[key]}% {label} + + + ))} + + {isExpanded ? 'Show less' : 'Deep dive'} + + +
+
+
+ + {isExpanded && ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx b/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx new file mode 100644 index 0000000000..fc1eda958a --- /dev/null +++ b/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx @@ -0,0 +1,360 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { HotIcon, OpenLinkIcon, ArrowIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import { anchorDefaultRel } from '../../../lib/strings'; +import type { + CommunitySentimentData, + SentimentHighlight, + SourceLean, + SourceSentiment, +} from './CommunitySentiment'; + +// Authentic-enough source marks: HN's real logo is an orange "Y" square, X the +// glyph, Lobsters a red square. `color` omitted => theme-adaptive mono badge. +const SOURCE_BADGE: Record = { + X: { label: '𝕏' }, + 'Hacker News': { label: 'Y', color: '#FF6600' }, + Lobsters: { label: 'L', color: '#A6291F' }, +}; + +const SourceBadge = ({ + source, + className, +}: { + source: string; + className: string; +}): ReactElement | null => { + const badge = SOURCE_BADGE[source]; + if (!badge) { + return null; + } + return ( + + {badge.label} + + ); +}; + +const LEAN_CHIP: Record = { + positive: { + label: 'Positive', + className: 'bg-accent-avocado-flat text-accent-avocado-default', + }, + mixed: { + label: 'Mixed', + className: 'bg-accent-bun-flat text-accent-bun-default', + }, + skeptical: { + label: 'Skeptical', + className: 'bg-accent-water-flat text-accent-water-default', + }, + heated: { + label: 'Heated', + className: 'bg-accent-ketchup-flat text-accent-ketchup-default', + }, +}; + +const BlockTitle = ({ children }: { children: string }): ReactElement => ( + + {children} + +); + +const ArgumentList = ({ + title, + items, + tone, +}: { + title: string; + items: string[]; + tone: 'pro' | 'con'; +}): ReactElement => ( +
+ + {title} + +
    + {items.map((item) => ( +
  • + + + {item} + +
  • + ))} +
+
+); + +const Flashpoint = ({ text }: { text: string }): ReactElement => ( +
+ + + +
+ + The big debate + + + {text} + +
+
+); + +const SourceRow = ({ + source, + lean, + note, + url, +}: SourceSentiment): ReactElement => { + const chip = LEAN_CHIP[lean]; + + const content = ( + <> + +
+
+ + {source} + + + {chip.label} + +
+ + {note} + +
+ {/* Always reserve the link slot so rows without a link stay aligned. */} + + + + + ); + + const rowClassName = '-mx-2 flex gap-2.5 rounded-10 px-2 py-1.5'; + + if (url) { + return ( + + {content} + + ); + } + + return
{content}
; +}; + +const HighlightCard = ({ + quote, + author, + source, + url, + metrics, +}: SentimentHighlight): ReactElement => ( + + + “{quote}” + +
+ +
+ + {author} + + + · {metrics.join(' · ')} + +
+ + + +
+
+); + +/** + * Community Sentiment — Layer 2. A modular set of blocks revealed by "Deep dive". + * Each block only renders when it has content, so the layer composes itself to + * the item's sentiment shape (a calm, consensus item shows fewer blocks than a + * divisive one). + */ +const INITIAL_HIGHLIGHTS = 3; + +export const CommunitySentimentBreakdown = ({ + data, +}: { + data: CommunitySentimentData; +}): ReactElement => { + const { pros, cons, bySource, hottestDebate, openQuestions, highlights } = + data; + const [showAllHighlights, setShowAllHighlights] = useState(false); + + const visibleHighlights = showAllHighlights + ? highlights + : highlights.slice(0, INITIAL_HIGHLIGHTS); + const hasMoreHighlights = + !showAllHighlights && highlights.length > INITIAL_HIGHLIGHTS; + + return ( +
+ {(pros.length > 0 || cons.length > 0) && ( +
+ {pros.length > 0 && ( + + )} + {cons.length > 0 && ( + + )} +
+ )} + + {hottestDebate && } + + {openQuestions.length > 0 && ( +
+ Open questions +
    + {openQuestions.map((question) => ( +
  • + + ? + + + {question} + +
  • + ))} +
+
+ )} + + {bySource.length > 0 && ( +
+ By community +
+ {bySource.map((item) => ( + + ))} +
+
+ )} + + {highlights.length > 0 && ( +
+ Top picks +
+ {visibleHighlights.map((item) => ( + + ))} +
+ {hasMoreHighlights && ( + + )} +
+ )} +
+ ); +}; diff --git a/packages/shared/src/components/post/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx index 9180f39988..2f2cc5316a 100644 --- a/packages/shared/src/components/post/focus/PostFocusCard.tsx +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -35,7 +35,10 @@ import { PostTagList } from '../tags/PostTagList'; import { TruncateText } from '../../utilities'; import { combinedClicks } from '../../../lib/click'; import { useFeature } from '../../GrowthBookProvider'; -import { feature } from '../../../lib/featureManagement'; +import { + feature, + featureCommunitySentiment, +} from '../../../lib/featureManagement'; import { SourceStrip } from '../reader/SourceStrip'; import Link from '../../utilities/Link'; import HoverCard from '../../cards/common/HoverCard'; @@ -50,6 +53,7 @@ import { PostMenuOptions } from '../PostMenuOptions'; import { FocusCardActionBar } from './FocusCardActionBar'; import { PostDiscussionPanel } from './PostDiscussionPanel'; import { CollectionSources } from './CollectionSources'; +import { CommunitySentiment } from './CommunitySentiment'; const PostCodeSnippets = dynamic(() => import(/* webpackChunkName: "postCodeSnippets" */ '../PostCodeSnippets').then( @@ -242,6 +246,9 @@ export const PostFocusCard = ({ const { isReaderEnabled } = useReaderModalEligibility(); const isReaderVariant = isReaderEnabled && post.type === PostType.Article; const showCodeSnippets = useFeature(feature.showCodeSnippets); + const communitySentimentEnabled = useFeature(featureCommunitySentiment); + // Only on the full post page, not the preview modal (which passes `onClose`). + const showCommunitySentiment = !onClose && communitySentimentEnabled; const focusCommentRef = useRef<() => void>(() => {}); const discussionRef = useRef(null); // The video is a small floating preview on tablet/desktop and expands to the @@ -526,6 +533,8 @@ export const PostFocusCard = ({ + {showCommunitySentiment && } + onShowUpvoted(post.id, upvotes)} diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 350fefb60b..75c6687000 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -38,6 +38,10 @@ export const featurePostPageHighlights = new Feature( false, ); export const featurePostRedesign = new Feature('post_redesign', false); +export const featureCommunitySentiment = new Feature( + 'community_sentiment', + isDevelopment, +); // @ts-expect-error stale feature without default export const plusTakeoverContent = new Feature<{