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..3b83838928 --- /dev/null +++ b/packages/shared/src/components/post/focus/CommunitySentiment.tsx @@ -0,0 +1,333 @@ +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 { RelativeTime } from '../../utilities/RelativeTime'; +import { largeNumberFormat } from '../../../lib/numberFormat'; +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[]; +} + +/** A discussion thread the take is aggregated from — raw per-platform counts, + * separate from the `bySource` narrative lean. */ +export interface CommunitySentimentDiscussion { + /** Machine-friendly platform id, e.g. "hackernews" | "lobsters". */ + provider: string; + /** Link to the discussion thread. */ + url: string; + /** Upvotes/points on that platform. */ + points: number; + /** Comment count on that platform. */ + commentsCount: number; +} + +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[]; + /** Raw per-platform discussion links/counts backing the take. */ + discussions?: CommunitySentimentDiscussion[]; + /** When the take was last (re)generated — distinct from the post's own + * `updatedAt`, which reflects the whole post row. */ + updatedAt?: string; +} + +/** Structured engagement counts for a highlight, exactly as the API returns + * them — apps formats these into the display strings `SentimentHighlight` + * expects (e.g. `{ points: 214 }` => "214 points"). */ +export interface SentimentHighlightMetrics { + /** Upvotes/points on the source platform (HN, Lobsters). */ + points?: number; + /** Reply/comment count. */ + replies?: number; + /** Likes/favorites — X-only; unused for HN/Lobsters in v1. */ + likes?: number; +} + +/** Raw highlight shape returned by the API: same as `SentimentHighlight` but + * with structured (not yet formatted) metrics. */ +export type CommunitySentimentHighlightPost = Omit< + SentimentHighlight, + 'metrics' +> & { + /** Nullable on the wire — the API omits metrics when the source had none. */ + metrics?: SentimentHighlightMetrics | null; +}; + +/** + * Wire shape of `Post.communitySentiment`: the same contract as + * `CommunitySentimentData`, but highlight metrics arrive structured rather + * than as formatted display strings. `mapCommunitySentimentPost` below + * converts one into the other — the only real work in wiring up real data. + */ +export type CommunitySentimentPost = Omit< + CommunitySentimentData, + 'highlights' +> & { + highlights: CommunitySentimentHighlightPost[]; +}; + +const HIGHLIGHT_METRIC_LABELS: Array<{ + key: keyof SentimentHighlightMetrics; + label: string; +}> = [ + { key: 'points', label: 'points' }, + { key: 'replies', label: 'comments' }, + { key: 'likes', label: 'likes' }, +]; + +/** `largeNumberFormat` formats "2.4K"; the design uses a lowercase "k". */ +const formatMetricCount = (value: number): string => + largeNumberFormat(value)?.toLowerCase() ?? `${value}`; + +const formatHighlightMetrics = (metrics: SentimentHighlightMetrics): string[] => + HIGHLIGHT_METRIC_LABELS.reduce((acc, { key, label }) => { + const value = metrics[key]; + if (typeof value === 'number') { + acc.push(`${formatMetricCount(value)} ${label}`); + } + return acc; + }, []); + +/** + * Maps the API's wire shape (structured highlight metrics) to the display + * shape the components in this file render. + */ +export const mapCommunitySentimentPost = ( + data: CommunitySentimentPost, +): CommunitySentimentData => ({ + ...data, + highlights: data.highlights.map((highlight) => ({ + ...highlight, + metrics: highlight.metrics ? formatHighlightMetrics(highlight.metrics) : [], + })), +}); + +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 { + /** `null`/`undefined` render nothing — a post without a take has no widget. */ + data?: CommunitySentimentData | null; + 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, + onSeeBreakdown, + className, +}: CommunitySentimentProps): ReactElement | null => { + const [isExpanded, setIsExpanded] = useState(false); + + if (!data) { + return null; + } + + const { breakdown, tldr, postCount, discussions, updatedAt } = data; + + // `postCount` is the number of source threads (e.g. one HN + one Lobsters + // thread = 2), which undersells the analysis — the take is distilled from + // every comment in those threads, so lead with the comment total instead. + const totalComments = + discussions?.reduce( + (total, discussion) => total + discussion.commentsCount, + 0, + ) ?? 0; + const stat = + totalComments > 0 + ? { value: totalComments, label: 'comments' } + : { value: postCount, label: 'discussions' }; + + const toggle = () => { + setIsExpanded((prev) => !prev); + onSeeBreakdown?.(); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggle(); + } + }; + + return ( +
+
+ {/* Stacked on mobile (the stat gets its own line), inline and + right-aligned from tablet up. */} +
+ + + + Community take + + {updatedAt && ( + <> + · + + Updated + + + )} + + + + {formatMetricCount(stat.value)} + + + {stat.label} + + +
+ + + {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..005a338ef5 --- /dev/null +++ b/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx @@ -0,0 +1,288 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { OpenLinkIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import { anchorDefaultRel, capitalize } from '../../../lib/strings'; +import { largeNumberFormat } from '../../../lib/numberFormat'; +import type { + CommunitySentimentData, + CommunitySentimentDiscussion, + SourceLean, + SourceSentiment, +} from './CommunitySentiment'; + +// The take payload references communities loosely — the LLM may emit a +// provider id ("hackernews") or a friendly name ("Hacker News"). Normalize +// everything to the discussion payload's provider id so display labels, +// badges and raw counts all resolve from the same key. +const PROVIDER_ALIASES: Record = { + hn: 'hackernews', + twitter: 'x', +}; + +const normalizeProvider = (source: string): string => { + const key = source.toLowerCase().replace(/[^a-z0-9]/g, ''); + return PROVIDER_ALIASES[key] ?? key; +}; + +// 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 PROVIDER_META: Record< + string, + { label: string; badge: { glyph: string; color?: string } } +> = { + hackernews: { label: 'Hacker News', badge: { glyph: 'Y', color: '#FF6600' } }, + lobsters: { label: 'Lobsters', badge: { glyph: 'L', color: '#A6291F' } }, + x: { label: 'X', badge: { glyph: '𝕏' } }, +}; + +const providerLabel = (source: string): string => + PROVIDER_META[normalizeProvider(source)]?.label ?? capitalize(source); + +const formatDiscussionCount = (value: number): string => + largeNumberFormat(value)?.toLowerCase() ?? `${value}`; + +const SourceBadge = ({ + source, + className, +}: { + source: string; + className: string; +}): ReactElement | null => { + const badge = PROVIDER_META[normalizeProvider(source)]?.badge; + if (!badge) { + return null; + } + return ( + + {badge.glyph} + + ); +}; + +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 SourceRow = ({ + source, + lean, + note, + url, + discussion, +}: SourceSentiment & { + discussion?: CommunitySentimentDiscussion; +}): ReactElement => { + const chip = LEAN_CHIP[lean]; + const label = providerLabel(source); + // The narrative row may not carry its own url; the raw discussion entry for + // the same provider is the same thread, so use it as the link fallback. + const linkUrl = url ?? discussion?.url; + + const content = ( + <> + +
+
+ + {label} + + + {chip.label} + + {discussion && ( + + {formatDiscussionCount(discussion.points)} points ·{' '} + {formatDiscussionCount(discussion.commentsCount)} comments + + )} +
+ + {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 (linkUrl) { + return ( + + {content} + + ); + } + + return
{content}
; +}; + +/** + * 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). + */ +export const CommunitySentimentBreakdown = ({ + data, +}: { + data: CommunitySentimentData; +}): ReactElement => { + const { pros, cons, bySource, discussions } = data; + + const discussionByProvider = new Map( + discussions?.map((discussion) => [ + normalizeProvider(discussion.provider), + discussion, + ]), + ); + + return ( +
+ {(pros.length > 0 || cons.length > 0) && ( +
+ {pros.length > 0 && ( + + )} + {cons.length > 0 && ( + + )} +
+ )} + + {bySource.length > 0 && ( +
+ By community +
+ {bySource.map((item) => ( + + ))} +
+
+ )} +
+ ); +}; diff --git a/packages/shared/src/components/post/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx index 9d0706c024..eb2a4bd4be 100644 --- a/packages/shared/src/components/post/focus/PostFocusCard.tsx +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -36,7 +36,12 @@ import { PostTagList } from '../tags/PostTagList'; import { TruncateText } from '../../utilities'; import { combinedClicks } from '../../../lib/click'; import { useFeature } from '../../GrowthBookProvider'; -import { feature } from '../../../lib/featureManagement'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { + feature, + featureCommunitySentiment, +} from '../../../lib/featureManagement'; +import { isDevelopment } from '../../../lib/constants'; import { SourceStrip } from '../reader/SourceStrip'; import Link from '../../utilities/Link'; import HoverCard from '../../cards/common/HoverCard'; @@ -51,6 +56,10 @@ import { PostMenuOptions } from '../PostMenuOptions'; import { FocusCardActionBar } from './FocusCardActionBar'; import { PostDiscussionPanel } from './PostDiscussionPanel'; import { CollectionSources } from './CollectionSources'; +import { + CommunitySentiment, + mapCommunitySentimentPost, +} from './CommunitySentiment'; const PostCodeSnippets = dynamic(() => import(/* webpackChunkName: "postCodeSnippets" */ '../PostCodeSnippets').then( @@ -250,6 +259,25 @@ 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); const focusCommentRef = useRef<() => void>(() => {}); const discussionRef = useRef(null); // The video is a small floating preview on tablet/desktop and expands to the @@ -539,6 +567,10 @@ export const PostFocusCard = ({ + {showCommunitySentiment && ( + + )} + onShowUpvoted(post.id, upvotes)} diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index 6951a303da..f476cc471c 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -460,6 +460,44 @@ export const SHARED_POST_INFO_FRAGMENT = gql` } numPollVotes endsAt + communitySentiment { + breakdown { + positive + mixed + critical + } + tldr + postCount + sources + pros + cons + bySource { + source + lean + note + url + } + hottestDebate + openQuestions + highlights { + quote + author + source + url + metrics { + points + replies + likes + } + } + discussions { + provider + url + points + commentsCount + } + updatedAt + } } ${PRIVILEGED_MEMBERS_FRAGMENT} ${SOURCE_BASE_FRAGMENT} diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index 7611db71cb..e5e9b0858c 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -23,6 +23,7 @@ import { FEED_POST_CONNECTION_FRAGMENT } from './feed'; import { getPostByIdKey, RequestKey, StaleTime } from '../lib/query'; import type { LiveRoomPost } from './liveRooms'; import type { PostHero } from './types'; +import type { CommunitySentimentPost } from '../components/post/focus/CommunitySentiment'; export const ACCEPTED_TYPES = 'image/png,image/jpeg,image/webp,image/avif'; export const acceptedTypesList = ACCEPTED_TYPES.split(','); @@ -303,6 +304,10 @@ export interface Post { liveRoom?: LiveRoomPost | null; analytics?: Partial>; hero?: PostHero | null; + /** LLM-generated digest of what the developer community outside daily.dev + * (HN, Lobsters) thinks about this post. `null` when no take exists yet — + * gated by the `community_sentiment` experiment (see PostFocusCard). */ + communitySentiment?: CommunitySentimentPost | null; } export type RelatedPost = Pick< diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 1be284e118..8ca5104a8e 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -39,6 +39,17 @@ export const featurePostPageHighlights = new Feature( false, ); export const featurePostRedesign = new Feature('post_redesign', false); +// Experiment: community takes — an LLM-generated digest of what the developer +// community on HN/Lobsters thinks about a post. Control hides the surface, +// treatment shows it. Enrollment is conditional on the post actually having a +// take (see PostFocusCard's `shouldEvaluate`), so exposure is only logged when +// there's something to show — take-less posts never dilute the split. Backend +// generation is unconditional; this flag gates rendering only, so flipping it +// needs no data backfill. Default MUST stay `false` — see the rule below. +export const featureCommunitySentiment = new Feature( + 'community_sentiment', + false, +); // @ts-expect-error stale feature without default export const plusTakeoverContent = new Feature<{