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
+5 -4
View File
@@ -13,6 +13,10 @@
},
"packageManager": "[email protected]",
"dependencies": {
"@config-plugins/react-native-webrtc": "^12.0.0",
"@livekit/react-native": "^2.7.5",
"@livekit/react-native-expo-plugin": "^1.0.2",
"@livekit/react-native-webrtc": "^144.1.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.0.14",
"@react-navigation/native-stack": "^7.2.0",
@@ -30,16 +34,13 @@
"expo-status-bar": "~3.0.9",
"expo-video": "~3.0.10",
"firebase": "^12.10.0",
"@config-plugins/react-native-webrtc": "^12.0.0",
"@livekit/react-native": "^2.7.5",
"@livekit/react-native-expo-plugin": "^1.0.2",
"@livekit/react-native-webrtc": "^144.1.0",
"livekit-client": "^2.15.2",
"lucide-react-native": "^0.575.0",
"nativewind": "^4.1.23",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-marked": "^8.1.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
@@ -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>
+48
View File
@@ -1700,6 +1700,16 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@jsamr/[email protected]":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@jsamr/counter-style/-/counter-style-2.0.2.tgz#6f08cfa98e1f0416dc1d7f2d8ac38a8cdb004c5d"
integrity sha512-2mXudGVtSzVxWEA7B9jZLKjoXUeUFYDDtFrQoC0IFX9/Dszz4t1vZOmafi3JSw/FxD+udMQ+4TAFR8Qs0J3URQ==
"@jsamr/[email protected]":
version "2.3.1"
resolved "https://registry.yarnpkg.com/@jsamr/react-native-li/-/react-native-li-2.3.1.tgz#12a5b5f6e3971cec77b96bee58104eed0ae9314a"
integrity sha512-Qbo4NEj48SQ4k8FZJHFE2fgZDKTWaUGmVxcIQh3msg5JezLdTMMHuRRDYctfdHI6L0FZGObmEv3haWbIvmol8w==
"@livekit/[email protected]":
version "0.12.13"
resolved "https://registry.yarnpkg.com/@livekit/components-core/-/components-core-0.12.13.tgz#83d935719453c6831086daffebfc434137ea6497"
@@ -3748,6 +3758,11 @@ getenv@^2.0.0:
resolved "https://registry.yarnpkg.com/getenv/-/getenv-2.0.0.tgz#b1698c7b0f29588f4577d06c42c73a5b475c69e0"
integrity sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==
[email protected]:
version "2.0.0"
resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-2.0.0.tgz#52cf2f9279a21eb6c59dd385b410f0c0adda8f1a"
integrity sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==
glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
@@ -3899,6 +3914,11 @@ hosted-git-info@^7.0.0:
dependencies:
lru-cache "^10.0.1"
[email protected]:
version "2.6.0"
resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.6.0.tgz#7c64f1ea3b36818ccae3d3fb48b6974208e984f8"
integrity sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==
http-errors@~2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b"
@@ -4680,6 +4700,11 @@ [email protected]:
dependencies:
tmpl "1.0.5"
[email protected]:
version "18.0.3"
resolved "https://registry.yarnpkg.com/marked/-/marked-18.0.3.tgz#278b5ba89f1c7ccbaf0422f3ee8955928489220b"
integrity sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==
marky@^1.2.2:
version "1.3.0"
resolved "https://registry.yarnpkg.com/marky/-/marky-1.3.0.tgz#422b63b0baf65022f02eda61a238eccdbbc14997"
@@ -5753,6 +5778,24 @@ react-native-is-edge-to-edge@^1.2.1:
resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz#feb9a6a8faf0874298947edd556e5af22044e139"
integrity sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==
react-native-marked@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/react-native-marked/-/react-native-marked-8.1.0.tgz#7428ca7356aa4bee15f35ca17c52cf3b1823bc4b"
integrity sha512-nNsA0YZ73EvlZzSODms253gnZBYqxr4j3Qqf38NYAzmdVxULZMHB7qmt8yiYwtdEZ2IsTDxexlHckyhwLfjTlg==
dependencies:
"@jsamr/counter-style" "2.0.2"
"@jsamr/react-native-li" "2.3.1"
github-slugger "2.0.0"
html-entities "2.6.0"
marked "18.0.3"
react-native-reanimated-table "0.0.2"
svg-parser "2.0.4"
[email protected]:
version "0.0.2"
resolved "https://registry.yarnpkg.com/react-native-reanimated-table/-/react-native-reanimated-table-0.0.2.tgz#015392dbc12fb03fd872d5a7eea9bc84c4f3a685"
integrity sha512-OeuqfU1AFEmHNTJlEOLWrV78JgAXnM0/ZrCm0Ab+9e5nwYJ+xab/UFXkNKz3Gyf08ZfLSNzwMQRjt3eZWPWoGA==
react-native-reanimated@~4.1.1:
version "4.1.7"
resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz#b4e8524503a1b6ec1b5a40c460ee807a6a9fd2cf"
@@ -6457,6 +6500,11 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
[email protected]:
version "2.0.4"
resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5"
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==
tailwind-merge@^3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz#06502f4496ba15151445d97d916a26564d50d1ca"