Files
llink/js/mobile/src/features/stream-view/TextParticleView.tsx
T

236 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 315s). 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>
);
}