236 lines
8.9 KiB
TypeScript
236 lines
8.9 KiB
TypeScript
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<Particle, { type: "text" }>;
|
||
|
||
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 ? (
|
||
<View className="mt-3 items-center">
|
||
<Text className="text-white/40 text-xs">
|
||
edited <RelativeTimestamp date={editedAt} />
|
||
</Text>
|
||
</View>
|
||
) : 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 (
|
||
<View
|
||
className="flex-1 items-center justify-center px-8"
|
||
style={{
|
||
paddingTop: safe.top + 16,
|
||
paddingBottom: safe.bottom + 16,
|
||
}}
|
||
>
|
||
<Text
|
||
className={cn("text-white text-center max-w-xl", style.className)}
|
||
>
|
||
{content}
|
||
</Text>
|
||
{editedLabel}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<View
|
||
className="flex-1 items-center justify-center px-6"
|
||
style={{
|
||
paddingTop: safe.top + 16,
|
||
paddingBottom: safe.bottom + 16,
|
||
}}
|
||
>
|
||
<ScrollView
|
||
className="max-h-full w-full max-w-xl rounded-2xl bg-white/10"
|
||
contentContainerClassName="px-5 py-5"
|
||
showsVerticalScrollIndicator
|
||
indicatorStyle="white"
|
||
>
|
||
{markdownNodes}
|
||
{editedLabel}
|
||
</ScrollView>
|
||
</View>
|
||
);
|
||
}
|