From 3293147351068ced737c857258a434e12c3fbeab Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Sat, 30 May 2026 11:06:06 -0700 Subject: [PATCH] mobile: render markdown text consistent with desktop --- .../features/stream-view/TextParticleView.tsx | 104 +++++++++++++++--- 1 file changed, 89 insertions(+), 15 deletions(-) diff --git a/js/mobile/src/features/stream-view/TextParticleView.tsx b/js/mobile/src/features/stream-view/TextParticleView.tsx index 378aee4..8a528dc 100644 --- a/js/mobile/src/features/stream-view/TextParticleView.tsx +++ b/js/mobile/src/features/stream-view/TextParticleView.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef } from "react"; -import { ScrollView, Text, View } from "react-native"; -import { useMarkdown, type MarkedStyles } from "react-native-marked"; +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"; @@ -40,30 +40,103 @@ function getImmersiveStyle(length: number) { // 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**`. +// 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( + return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|~~|^>|\|.*\|/m.test( content, ); } -// Dark theme + base typography for rendered markdown. Defined at module scope -// so the references stay stable — `useMarkdown` re-parses only when these or -// the content change. +// 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: "#ffffff", - link: "#60a5fa", - code: "rgba(255,255,255,0.1)", - border: "rgba(255,255,255,0.2)", + text: TEXT_COLOR, + link: ACCENT, + code: SURFACE, + border: OUTLINE, }, }; const MARKDOWN_STYLES: MarkedStyles = { - text: { color: "#ffffff", fontSize: 18, lineHeight: 28 }, - codespan: { color: "#ffffff" }, + 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, @@ -75,7 +148,8 @@ export function TextParticleView({ const durationS = computeReadDuration(content); const elapsedRef = useRef(0); const safe = useStreamSafeArea(); - const markdownNodes = useMarkdown(content, { + const markdownNodes = useMarkdown(withTaskCheckboxes(content), { + renderer: MARKDOWN_RENDERER, theme: MARKDOWN_THEME, styles: MARKDOWN_STYLES, });