import { 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 { getImmersiveTextStyle } from '@/lib/immersive-text'; import { hasMarkdownFormatting } from '@/lib/markdown'; import { LinkPreviewCard, LinkPreviewCardFallback, 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 { useFixedDwell } from '@/hooks/use-fixed-dwell'; 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 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 LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) { return (
{entries.map((entry) => (
{entry.isLoading ? ( ) : entry.metadata ? ( ) : ( )}
))}
); } 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, ); useFixedDwell({ id: particle.id, durationS, paused, onEnded, onProgress, }); // 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}
); }