import { useEffect, useRef } from 'react'; import { ScrollView, Text, View } from 'react-native'; import type { Particle } from '@/api/types'; import { cn } from '@/lib/utils'; import { MarkdownBody } from '@/components/MarkdownBody'; import { RelativeTimestamp } from '@/components/RelativeTimestamp'; import { useStreamSafeArea } from './stream-safe-area'; type TextParticle = Extract; interface TextParticleViewProps { particle: TextParticle; paused: boolean; onEnded: () => void; onProgress: (ratio: number) => void; } // Mirrors desktop's read-duration math (chars/min ≈ 1000, plus +2s per // link/attachment, clamped 3–15s). Mobile v1 has no attachments and we // don't extract link previews mid-render, so the formula collapses to // a length-only base. const CHARS_PER_MINUTE = 1000; const MIN_DURATION_S = 3; const MAX_DURATION_S = 15; const TICK_MS = 100; const IMMERSIVE_CHAR_LIMIT = 120; function computeReadDuration(text: string): number { const base = (text.length / CHARS_PER_MINUTE) * 60; return Math.min(Math.max(base, MIN_DURATION_S), MAX_DURATION_S); } function getImmersiveStyle(length: number) { if (length < 30) return { className: 'text-5xl font-semibold leading-tight' }; if (length < 70) return { className: 'text-3xl font-semibold leading-snug' }; return { className: 'text-2xl font-normal leading-snug' }; } // Mirrors desktop's text-particle-view: short plain notes get the immersive // centered treatment; anything with markdown syntax renders formatted instead // of showing raw `**asterisks**`. Covers the full GFM set desktop's Crepe // engine handles — including ~~strikethrough~~ and tables — so short formatted // messages drop to the rendered card rather than showing raw syntax. function hasMarkdownFormatting(content: string): boolean { return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|~~|^>|\|.*\|/m.test( content, ); } export function TextParticleView({ particle, paused, onEnded, onProgress, }: TextParticleViewProps) { const content = particle.properties.content; const editedAt = particle.properties.edited_at; const durationS = computeReadDuration(content); const elapsedRef = useRef(0); const safe = useStreamSafeArea(); const editedLabel = editedAt ? ( edited ) : null; // Reset when the particle changes. useEffect(() => { elapsedRef.current = 0; onProgress(0); }, [particle.id, onProgress]); 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]); // Immersive (short, plain): centered, large type — feels like a lock-screen // note. Short messages that contain markdown fall through to the rendered // card so formatting isn't shown as raw syntax. if ( content.length < IMMERSIVE_CHAR_LIMIT && !hasMarkdownFormatting(content) ) { const style = getImmersiveStyle(content.length); return ( {content} {editedLabel} ); } // Long text or markdown: scrollable card so the reader can pace themselves; // the duration timer keeps ticking either way, which is intentional — long // messages should still auto-advance at the 15s cap. Markdown is rendered // via the useMarkdown hook (not the FlatList-based component) so its blocks // nest cleanly inside this ScrollView. Padding is pulled from the // StreamSafeArea so the card never slips under chrome. return ( {editedLabel} ); }