import { useEffect, useRef, type ReactNode } from "react"; import { Platform, ScrollView, Text, View, type ViewStyle } from "react-native"; import { Renderer, useMarkdown, type MarkedStyles } from "react-native-marked"; import type { Particle } from "@/api/types"; import { cn } from "@/lib/utils"; 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, ); } // react-native-marked doesn't render GFM task-list checkboxes (marked strips // the `[ ]`/`[x]` into token flags the parser ignores), so a write/read drift // shows up as bullets with no box. Swap the marker for a checkbox glyph before // parsing — read-only, matching desktop's bullet-free checkboxes. const TASK_ITEM_RE = /^(\s*)[-*+] \[([ xX])\] /gm; function withTaskCheckboxes(markdown: string): string { return markdown.replace( TASK_ITEM_RE, (_match, indent: string, mark: string) => `${indent}${mark === " " ? "☐" : "☑"} `, ); } // Mirror the desktop Crepe palette (markdown-editor.css `--crepe-*`) so a // message reads the same on both surfaces: white-on-transparent text, a blue // accent, pink inline code, and a near-opaque dark surface behind code blocks // and tables. Defined at module scope so the references stay stable — // `useMarkdown` re-parses only when these or the content change. // // Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe // uses CodeMirror; react-native-marked only exposes the language tag). They // render as plain monospace on the dark surface, which is acceptable for v1. const TEXT_COLOR = "rgba(255,255,255,0.92)"; const ACCENT = "#60a5fa"; const SURFACE = "rgba(24,24,28,0.96)"; const OUTLINE = "rgba(255,255,255,0.2)"; const MONO = Platform.OS === "ios" ? "Menlo" : "monospace"; const MARKDOWN_THEME = { colors: { text: TEXT_COLOR, link: ACCENT, code: SURFACE, border: OUTLINE, }, }; const MARKDOWN_STYLES: MarkedStyles = { text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 }, li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 }, strong: { fontWeight: "700" }, em: { fontStyle: "italic" }, strikethrough: { textDecorationLine: "line-through", color: "rgba(255,255,255,0.6)", }, // fontStyle "normal" cancels react-native-marked's italic-by-default for // links and inline code (desktop renders neither italic). link: { color: ACCENT, fontStyle: "normal" }, // borderBottomWidth 0 removes the library's default heading underline rule, // which desktop's headings don't have. h1: { color: "#ffffff", fontSize: 28, lineHeight: 34, fontWeight: "700", marginTop: 8, marginBottom: 8, borderBottomWidth: 0 }, h2: { color: "#ffffff", fontSize: 24, lineHeight: 30, fontWeight: "700", marginTop: 8, marginBottom: 6, borderBottomWidth: 0 }, h3: { color: "#ffffff", fontSize: 20, lineHeight: 26, fontWeight: "600", marginTop: 6, marginBottom: 4 }, h4: { color: "#ffffff", fontSize: 18, lineHeight: 24, fontWeight: "600", marginTop: 6, marginBottom: 4 }, h5: { color: "#ffffff", fontSize: 16, lineHeight: 22, fontWeight: "600", marginTop: 4, marginBottom: 2 }, h6: { color: "rgba(255,255,255,0.7)", fontSize: 15, lineHeight: 20, fontWeight: "600", marginTop: 4, marginBottom: 2 }, codespan: { color: "#fca5a5", fontFamily: MONO, fontStyle: "normal", backgroundColor: "rgba(255,255,255,0.1)" }, code: { backgroundColor: SURFACE, borderColor: OUTLINE, borderWidth: 1, borderRadius: 8, padding: 12, marginVertical: 6 }, blockquote: { borderLeftWidth: 3, borderLeftColor: OUTLINE, paddingLeft: 12, marginVertical: 6, opacity: 0.85 }, // hr is left to the library default, which already draws a 1px rule in the // themed border color (OUTLINE). table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 }, tableRow: { borderColor: OUTLINE }, tableCell: { borderColor: OUTLINE, padding: 8 }, }; // react-native-marked feeds fenced code blocks the `em` (italic, proportional) // text style, so out of the box code renders italic in the body font. Override // `code` to apply a monospace, non-italic style instead — matching desktop's // code blocks. Instantiated once at module scope to keep the reference stable // for `useMarkdown`'s memoization. const CODE_TEXT_STYLE = { color: TEXT_COLOR, fontFamily: MONO, fontSize: 15, lineHeight: 22, }; class MarkdownRenderer extends Renderer { code(text: string, language?: string, containerStyle?: ViewStyle): ReactNode { return super.code(text, language, containerStyle, CODE_TEXT_STYLE); } } const MARKDOWN_RENDERER = new MarkdownRenderer(); 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 markdownNodes = useMarkdown(withTaskCheckboxes(content), { renderer: MARKDOWN_RENDERER, theme: MARKDOWN_THEME, styles: MARKDOWN_STYLES, }); 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 ( {markdownNodes} {editedLabel} ); }