import { useEffect, useRef, useState } from 'react'; import { Pencil } from 'lucide-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 { TextEditOverlay } from '@/features/particles/text-edit-overlay'; import { RelativeTimestamp } from '@/components/relative-timestamp'; import { useAuthStore } from '@/stores/auth-store'; import { MarkdownEditor } from '@/features/compose/markdown-editor'; 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, ); } 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 userId = useAuthStore((s) => s.user?.id); const isCreator = !!userId && userId === particle.created_by_human_id; const [isEditing, setIsEditing] = useState(false); 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() === ''; const editButton = isCreator && !isEditing && ( ); const editedLabel = particle.properties.edited_at && ( edited ); const editOverlay = isEditing && ( setIsEditing(false)} /> ); // Mode 1: bare URLs only — show link cards centered if (linksOnly && !hasAttachments) { return (
{editedLabel && (
{editedLabel}
)} {editButton} {editOverlay}
); } // 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}

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