import { useEffect, useRef } from "react"; import type { Particle } from "@/api/types"; import type { ParticlePath } from "@/lib/particle-path"; import { cn } from "@/lib/utils"; import { useAllLinkMetadata, type LinkPreviewEntry } from "@/hooks/use-link-metadata"; import { extractUrls } from "@/lib/link-metadata"; import { LinkPreviewCard, LinkPreviewCardSkeleton, } from "@/components/link-preview-card"; import { useParticleAttachments } from "@/hooks/use-particle-attachments"; import { ParticleAttachments } from "@/features/particles/particle-attachments"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeHighlight from "rehype-highlight"; import "highlight.js/styles/github-dark.css"; type TextParticle = Extract; interface TextParticleViewProps { particle: TextParticle; streamPath: ParticlePath; paused: boolean; onEnded: () => void; onProgress?: (ratio: number) => void; } // Characters per minute (~1000 cpm ≈ 200 wpm at ~5 chars/word) const CHARS_PER_MINUTE = 1000; const MIN_DURATION_S = 3; const MAX_DURATION_S = 15; const TICK_MS = 100; const EXTRA_S_PER_LINK = 2; const EXTRA_S_PER_ATTACHMENT = 2; // Below this threshold: immersive centered display const IMMERSIVE_CHAR_LIMIT = 120; function computeReadDuration( text: string, linkCount: number, attachmentCount: number, ): number { const base = (text.length / CHARS_PER_MINUTE) * 60; const extra = linkCount * EXTRA_S_PER_LINK + attachmentCount * EXTRA_S_PER_ATTACHMENT; return Math.min(Math.max(base + extra, MIN_DURATION_S), MAX_DURATION_S); } function getImmersiveTextStyle(length: number) { if (length < 30) return { size: "text-5xl", weight: "font-semibold" }; if (length < 70) return { size: "text-3xl", weight: "font-semibold" }; return { size: "text-2xl", weight: "font-normal" }; } function hasMarkdownFormatting(content: string): boolean { return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(content); } const markdownComponents: React.ComponentProps["components"] = { h1: ({ children }) =>

{children}

, h2: ({ children }) =>

{children}

, h3: ({ children }) =>

{children}

, h4: ({ children }) =>

{children}

, h5: ({ children }) =>
{children}
, h6: ({ children }) =>
{children}
, p: ({ children }) =>

{children}

, strong: ({ children }) => {children}, em: ({ children }) => {children}, a: ({ href, children }) => ( {children} ), code: ({ className, children, ...props }) => { const isBlock = className?.startsWith("language-"); if (isBlock) { return ( {children} ); } return ( {children} ); }, pre: ({ children }) => (
      {children}
    
), ul: ({ children }) => , ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , blockquote: ({ children }) => (
    {children}
    ), hr: () =>
    , }; function MarkdownContent({ content, className }: { content: string; className?: string }) { return (
    {content}
    ); } function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) { return (
    {entries.map((entry) => (
    {entry.isLoading ? ( ) : entry.metadata ? ( ) : null}
    ))}
    ); } export function TextParticleView({ particle, streamPath, paused, onEnded, onProgress, }: TextParticleViewProps) { const content = particle.properties.content; const linkPreviews = useAllLinkMetadata(content); const { attachments } = useParticleAttachments(streamPath, particle.id); const urls = extractUrls(content); const hasLinks = urls.length > 0; const hasAttachments = attachments.length > 0; const hasEnrichments = hasLinks || hasAttachments; const durationS = computeReadDuration(content, urls.length, attachments.length); const elapsedRef = useRef(0); // Reset elapsed when particle changes useEffect(() => { elapsedRef.current = 0; }, [particle.id]); useEffect(() => { if (paused) return; const interval = setInterval(() => { elapsedRef.current += TICK_MS / 1000; const ratio = Math.min(elapsedRef.current / durationS, 1); onProgress?.(ratio); if (ratio >= 1) { clearInterval(interval); onEnded(); } }, TICK_MS); return () => clearInterval(interval); }, [paused, durationS, onEnded, onProgress, particle.id]); // Content is just bare URLs with no surrounding text const contentTrimmed = content.trim(); const linksOnly = hasLinks && urls.every((url) => contentTrimmed.includes(url)) && contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, "").trim() === ""; // Mode 1: bare URLs only — show link cards centered if (linksOnly && !hasAttachments) { return (
    ); } // Mode 2: short plain text, no enrichments — immersive centered display if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !hasMarkdownFormatting(content)) { const style = getImmersiveTextStyle(content.length); return (

    {content}

    ); } // Mode 3: card layout return (
    {hasLinks && } {hasAttachments && }
    ); }