Use inline WYSIWYG markdown editor for long text messages

Desktop: replace the Write/Preview tab toggle in the compose card with
MDXEditor, an inline WYSIWYG that renders markdown as you type (Obsidian/
Notion feel) and round-trips plain markdown, matching how particle content
is stored. Immersive mode is unchanged — short, plain notes still get the
centered large-type textarea, and ⌘+M still drops into the rich editor.
Shortcut handling moves to the capture phase so ⌘+Enter / Esc win over the
editor's own key handling.

Mobile: render markdown on the display side via react-native-marked's
useMarkdown hook (no FlatList, so it nests cleanly in the existing
ScrollView). Mirrors desktop's immersive-vs-card logic: short plain notes
stay centered; anything with markdown renders formatted instead of showing
raw syntax. Composer stays plain text.

https://claude.ai/code/session_019DU6V5z6Nr4Vu7b1fBDnq4
This commit is contained in:
Claude
2026-05-28 23:32:57 +00:00
parent 3bfc15945d
commit 47c173f472
8 changed files with 1413 additions and 146 deletions
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react";
import { ScrollView, Text, View } from "react-native";
import { useMarkdown, type MarkedStyles } from "react-native-marked";
import type { Particle } from "@/api/types";
import { cn } from "@/lib/utils";
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
@@ -37,6 +38,32 @@ function getImmersiveStyle(length: number) {
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**`.
function hasMarkdownFormatting(content: string): boolean {
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.
const MARKDOWN_THEME = {
colors: {
text: "#ffffff",
link: "#60a5fa",
code: "rgba(255,255,255,0.1)",
border: "rgba(255,255,255,0.2)",
},
};
const MARKDOWN_STYLES: MarkedStyles = {
text: { color: "#ffffff", fontSize: 18, lineHeight: 28 },
codespan: { color: "#ffffff" },
};
export function TextParticleView({
particle,
paused,
@@ -48,6 +75,10 @@ export function TextParticleView({
const durationS = computeReadDuration(content);
const elapsedRef = useRef(0);
const safe = useStreamSafeArea();
const markdownNodes = useMarkdown(content, {
theme: MARKDOWN_THEME,
styles: MARKDOWN_STYLES,
});
const editedLabel = editedAt ? (
<View className="mt-3 items-center">
@@ -79,8 +110,10 @@ export function TextParticleView({
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
// Immersive (short, plain): centered, large type — feels like a lock-screen note.
if (content.length < IMMERSIVE_CHAR_LIMIT) {
// 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
@@ -100,10 +133,12 @@ export function TextParticleView({
);
}
// Long text: 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. Padding is
// pulled from the StreamSafeArea so the card never slips under chrome.
// 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"
@@ -118,7 +153,7 @@ export function TextParticleView({
showsVerticalScrollIndicator
indicatorStyle="white"
>
<Text className="text-white text-lg leading-relaxed">{content}</Text>
{markdownNodes}
{editedLabel}
</ScrollView>
</View>