feat: replace markdown preview with inline markdown editor #224

Merged
talksik merged 10 commits from claude/brave-curie-Btskb into master 2026-05-30 18:08:40 +00:00
10 changed files with 1636 additions and 492 deletions
+3 -6
View File
@@ -1,8 +1,8 @@
{
"name": "Flowy.llink",
"productName": "Flowy.llink",
"version": "1.4.1",
"description": "Flowy.llink is a team communication app for teams",
"version": "1.4.0",
"description": "Flowy.llink is a video messaging app for teams",
"main": ".vite/build/main.js",
"private": true,
"scripts": {
@@ -58,6 +58,7 @@
"dependencies": {
"@livekit/components-react": "^2.9.20",
"@livekit/components-styles": "^1.2.0",
"@milkdown/crepe": "^7.21.1",
"@sentry/electron": "^7.11.0",
"@sentry/react": "^10.54.0",
"@tanstack/react-query": "^5.90.21",
@@ -65,7 +66,6 @@
"clsx": "^2.1.1",
"electron-squirrel-startup": "^1.0.1",
"firebase": "^12.10.0",
"highlight.js": "^11.11.1",
"livekit-client": "^2.18.0",
"lucide-react": "^0.575.0",
"next-themes": "^0.4.6",
@@ -73,11 +73,8 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-error-boundary": "^6.1.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"react-use": "^17.6.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"shadcn": "^3.8.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
@@ -0,0 +1,77 @@
/* Theme Milkdown Crepe to blend into the app's translucent "glass" surfaces.
* Crepe's frame-dark theme is a grayscale palette driven by --crepe-* custom
* properties; we override those to white-on-transparent with a blue accent so
* the editor (and the read-only display, which uses the same engine) sits on
* top of the existing card instead of painting its own opaque background.
*
* The floating menus (slash menu, selection toolbar, link tooltip) are portaled
* to <body>, OUTSIDE .milkdown — so the variables must be declared on those
* selectors too, otherwise they fall back to transparent and the menu is
* unreadable over the content behind it. */
.llink-crepe .milkdown,
.milkdown-slash-menu,
.milkdown-toolbar,
.milkdown-link-edit,
.milkdown-link-preview,
.milkdown-block-handle {
--crepe-color-background: transparent;
--crepe-color-on-background: rgb(255 255 255 / 0.92);
/* Surface backs code blocks, tables, and the floating menus — keep it
* (near-)opaque so menus read clearly over content. */
--crepe-color-surface: rgb(24 24 28 / 0.96);
--crepe-color-surface-low: rgb(42 42 50 / 0.96);
--crepe-color-on-surface: #ffffff;
--crepe-color-on-surface-variant: rgb(255 255 255 / 0.65);
--crepe-color-outline: rgb(255 255 255 / 0.2);
--crepe-color-primary: #60a5fa;
--crepe-color-secondary: rgb(96 165 250 / 0.25);
--crepe-color-on-secondary: #ffffff;
--crepe-color-inverse: #ffffff;
--crepe-color-on-inverse: #0b0b0b;
--crepe-color-inline-code: #fca5a5;
--crepe-color-inline-area: rgb(255 255 255 / 0.1);
--crepe-color-error: #f87171;
--crepe-color-hover: rgb(255 255 255 / 0.08);
--crepe-color-selected: rgb(96 165 250 / 0.25);
/* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */
--crepe-font-default: inherit;
--crepe-font-title: inherit;
--crepe-font-code: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
}
/* Glassy floating menus over content. */
.milkdown-slash-menu,
.milkdown-toolbar,
.milkdown-link-edit,
.milkdown-link-preview {
backdrop-filter: blur(12px);
}
.llink-crepe .milkdown {
background: transparent;
box-shadow: none;
}
.llink-crepe .milkdown .ProseMirror {
padding: 0;
outline: none;
}
/* Editing context: fill the compose card and scroll internally so a long
* message stays inside the card rather than growing the whole overlay. */
.llink-crepe--fill,
.llink-crepe--fill .milkdown {
height: 100%;
}
.llink-crepe--fill .milkdown {
overflow-y: auto;
}
/* Pad the content (not the card) so the slash menu — which Crepe appends to
* .milkdown — can use the full card width/height before clipping. */
.llink-crepe--fill .milkdown .ProseMirror {
min-height: 100%;
padding: 1.25rem;
}
@@ -0,0 +1,103 @@
import { useEffect, useRef } from "react";
import { Crepe } from "@milkdown/crepe";
import "@milkdown/crepe/theme/common/style.css";
import "@milkdown/crepe/theme/frame-dark.css";
import "./markdown-editor.css";
import { cn } from "@/lib/utils";
interface MarkdownEditorProps {
/** Initial markdown. The editor owns its content after mount; edits flow out
* through `onChange`, so this is only read when the editor is (re)created. */
value: string;
onChange?: (markdown: string) => void;
placeholder?: string;
/** Render the same engine read-only, for displaying a message. */
readOnly?: boolean;
autoFocus?: boolean;
className?: string;
}
/**
* Single markdown engine used for both composing and displaying messages
* (Milkdown Crepe). Editing is inline/WYSIWYG (Obsidian/Notion feel) with full
* GFM — headings, lists, task lists, tables, code blocks, quotes, links — and
* read-only mode renders the exact same way, so write and read never drift.
*/
export function MarkdownEditor({
value,
onChange,
placeholder,
readOnly = false,
autoFocus = true,
className,
}: MarkdownEditorProps) {
const rootRef = useRef<HTMLDivElement>(null);
// Keep the latest onChange without forcing the editor to be recreated.
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
let cancelled = false;
let created: Crepe | null = null;
const crepe = new Crepe({
root,
defaultValue: value,
features: {
[Crepe.Feature.CodeMirror]: true,
[Crepe.Feature.ListItem]: true,
[Crepe.Feature.Table]: true,
[Crepe.Feature.LinkTooltip]: !readOnly,
[Crepe.Feature.Cursor]: !readOnly,
[Crepe.Feature.BlockEdit]: !readOnly,
[Crepe.Feature.Toolbar]: !readOnly,
[Crepe.Feature.Placeholder]: !readOnly,
[Crepe.Feature.ImageBlock]: false,
[Crepe.Feature.Latex]: false,
[Crepe.Feature.TopBar]: false,
[Crepe.Feature.AI]: false,
},
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: placeholder ?? "" },
},
});
crepe.setReadonly(readOnly);
if (!readOnly) {
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown) => {
onChangeRef.current?.(markdown);
});
});
}
crepe.create().then(() => {
if (cancelled) {
crepe.destroy();
return;
}
created = crepe;
if (autoFocus && !readOnly) {
root.querySelector<HTMLElement>(".ProseMirror")?.focus();
}
});
return () => {
cancelled = true;
created?.destroy();
};
// `value` is the initial content only; recreate when the mode flips.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readOnly]);
return (
<div
ref={rootRef}
className={cn("llink-crepe", !readOnly && "llink-crepe--fill", className)}
/>
);
}
+30 -121
View File
@@ -6,10 +6,7 @@ import { useAllLinkMetadata } from "@/hooks/use-link-metadata";
import { AttachmentStrip } from "@/features/compose/attachment-strip";
import type { PendingAttachment } from "@/features/compose/attachment-strip";
import { Button } from "@/components/ui/button";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github-dark.css";
import { MarkdownEditor } from "@/features/compose/markdown-editor";
export interface TextEditorAttachmentProps {
attachments: PendingAttachment[];
@@ -43,66 +40,6 @@ function getImmersiveTextStyle(length: number) {
return { size: "text-2xl", weight: "font-normal" };
}
const markdownComponents: React.ComponentProps<typeof ReactMarkdown>["components"] = {
h1: ({ children }) => <h1 className="mb-3 text-3xl font-bold text-white">{children}</h1>,
h2: ({ children }) => <h2 className="mb-2 text-2xl font-semibold text-white">{children}</h2>,
h3: ({ children }) => <h3 className="mb-2 text-xl font-semibold text-white">{children}</h3>,
h4: ({ children }) => <h4 className="mb-1 text-lg font-medium text-white">{children}</h4>,
h5: ({ children }) => <h5 className="mb-1 text-base font-medium text-white">{children}</h5>,
h6: ({ children }) => <h6 className="mb-1 text-sm font-medium text-white">{children}</h6>,
p: ({ children }) => <p className="mb-3 leading-relaxed text-white last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
em: ({ children }) => <em className="italic text-white">{children}</em>,
a: ({ href, children }) => (
<a href={href} className="text-blue-400 underline" target="_blank" rel="noreferrer">
{children}
</a>
),
code: ({ className, children, ...props }) => {
const isBlock = className?.startsWith("language-");
if (isBlock) {
return (
<code className={cn(className, "text-sm")} {...props}>
{children}
</code>
);
}
return (
<code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-sm text-white" {...props}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="mb-3 overflow-x-auto rounded-lg bg-black/40 p-4 text-sm last:mb-0">
{children}
</pre>
),
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 text-white last:mb-0">{children}</ul>,
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
li: ({ children }) => <li className="mb-1 leading-relaxed">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
{children}
</blockquote>
),
hr: () => <hr className="my-4 border-white/10" />,
};
function MarkdownPreview({ content }: { content: string }) {
return (
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
);
}
export function TextEditor({
textContent,
onTextChange,
@@ -112,7 +49,6 @@ export function TextEditor({
attachmentProps,
}: TextEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [previewMode, setPreviewMode] = useState(false);
const [forceCardMode, setForceCardMode] = useState(false);
const [debouncedText, setDebouncedText] = useState(textContent);
@@ -127,33 +63,35 @@ export function TextEditor({
const immersive =
textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode;
// Keep the immersive textarea focused with the caret at the end when we
// (re)enter it. The card-mode editor manages its own focus.
useEffect(() => {
if (!previewMode) {
const t = setTimeout(() => {
const el = textareaRef.current;
if (el) {
el.focus();
el.selectionStart = el.selectionEnd = el.value.length;
}
}, 0);
return () => clearTimeout(t);
}
}, [previewMode, forceCardMode, immersive]);
useEffect(() => {
textareaRef.current?.focus();
}, []);
if (!immersive) return;
const t = setTimeout(() => {
const el = textareaRef.current;
if (el) {
el.focus();
el.selectionStart = el.selectionEnd = el.value.length;
}
}, 0);
return () => clearTimeout(t);
}, [immersive]);
// Attached in the capture phase so our shortcuts win before the card-mode
// editor's own key handling (e.g. ⌘+Enter must submit, not insert a break).
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onCancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
if (textContent.trim()) onSubmit();
} else if (e.key === "m" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
setForceCardMode(true);
}
},
@@ -250,51 +188,22 @@ export function TextEditor({
isDragging && "ring-2 ring-inset ring-white/30",
)}
{...dropZoneProps}
onKeyDown={handleKeyDown}
onKeyDownCapture={handleKeyDown}
>
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-xl">
<div className="mb-3 flex shrink-0 items-center justify-end gap-1">
<button
type="button"
onClick={() => setPreviewMode(false)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
!previewMode
? "bg-white/15 text-white"
: "text-white/40 hover:text-white/60",
)}
>
Write
</button>
<button
type="button"
onClick={() => setPreviewMode(true)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
previewMode
? "bg-white/15 text-white"
: "text-white/40 hover:text-white/60",
)}
>
Preview
</button>
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 flex-col overflow-hidden rounded border border-white/10 bg-white/5 backdrop-blur-xl">
{/* No padding here: the editor's own scroll box hosts the slash menu,
so we pad inside the editor (ProseMirror) instead. That keeps the
menu's clipping bounds the full card rather than the inset box. */}
<div className="min-h-0 flex-1">
<MarkdownEditor
value={textContent}
onChange={onTextChange}
placeholder="Hit / to see options"
/>
</div>
{previewMode ? (
<MarkdownPreview content={textContent} />
) : (
<textarea
ref={textareaRef}
value={textContent}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (markdown supported)"
className="min-h-0 flex-1 resize-none border-none bg-transparent font-mono text-sm leading-relaxed text-white placeholder-white/40 outline-none"
/>
)}
{hasEnrichments && strip && (
<div className="shrink-0 border-t border-white/10 pt-3">
<div className="shrink-0 border-t border-white/10 px-5 py-3">
{strip}
</div>
)}
@@ -14,10 +14,7 @@ import { ParticleAttachments } from "@/features/particles/particle-attachments";
import { TextEditOverlay } from "@/features/particles/text-edit-overlay";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { useAuthStore } from "@/stores/auth-store";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github-dark.css";
import { MarkdownEditor } from "@/features/compose/markdown-editor";
type TextParticle = Extract<Particle, { type: "text" }>;
@@ -60,66 +57,6 @@ function hasMarkdownFormatting(content: string): boolean {
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(content);
}
const markdownComponents: React.ComponentProps<typeof ReactMarkdown>["components"] = {
h1: ({ children }) => <h1 className="mb-3 text-3xl font-bold text-white">{children}</h1>,
h2: ({ children }) => <h2 className="mb-2 text-2xl font-semibold text-white">{children}</h2>,
h3: ({ children }) => <h3 className="mb-2 text-xl font-semibold text-white">{children}</h3>,
h4: ({ children }) => <h4 className="mb-1 text-lg font-medium text-white">{children}</h4>,
h5: ({ children }) => <h5 className="mb-1 text-base font-medium text-white">{children}</h5>,
h6: ({ children }) => <h6 className="mb-1 text-sm font-medium text-white">{children}</h6>,
p: ({ children }) => <p className="mb-3 leading-relaxed text-white last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
em: ({ children }) => <em className="italic text-white">{children}</em>,
a: ({ href, children }) => (
<a href={href} className="text-blue-400 underline" target="_blank" rel="noreferrer">
{children}
</a>
),
code: ({ className, children, ...props }) => {
const isBlock = className?.startsWith("language-");
if (isBlock) {
return (
<code className={cn(className, "text-sm")} {...props}>
{children}
</code>
);
}
return (
<code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-sm text-white" {...props}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="mb-3 overflow-x-auto rounded-lg bg-black/40 p-4 text-sm last:mb-0">
{children}
</pre>
),
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 text-white last:mb-0">{children}</ul>,
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
li: ({ children }) => <li className="mb-1 leading-relaxed">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
{children}
</blockquote>
),
hr: () => <hr className="my-4 border-white/10" />,
};
function MarkdownContent({ content, className }: { content: string; className?: string }) {
return (
<div className={cn("break-words", className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
);
}
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
return (
<div className="flex flex-wrap gap-3">
@@ -258,15 +195,16 @@ export function TextParticleView({
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div
className={cn(
"flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded-2xl bg-white/10 p-6 backdrop-blur-md",
"flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded bg-white/10 p-6 backdrop-blur-md",
"[&::-webkit-scrollbar]:w-2",
"[&::-webkit-scrollbar]:p-2",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-white/30",
"[&::-webkit-scrollbar-thumb]:hover:bg-white/50",
)}
>
<MarkdownContent content={content} className="select-text cursor-text pb-3" />
<MarkdownEditor value={content} readOnly className="select-text pb-3" />
{hasLinks && <LinkPreviews entries={linkPreviews} />}
+3 -1
View File
@@ -10,5 +10,7 @@ export interface LinkMetadata {
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
export function extractUrls(text: string): string[] {
return Array.from(text.matchAll(URL_REGEX), (m) => m[0]);
// Dedupe: a URL repeated in the text (e.g. a `[url](url)` markdown link)
// should only yield a single preview card.
return Array.from(new Set(Array.from(text.matchAll(URL_REGEX), (m) => m[0])));
}
+1245 -285
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -13,6 +13,10 @@
},
"packageManager": "yarn@1.22.22",
"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 { 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";
@@ -37,6 +38,105 @@ 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**`. 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,
@@ -48,6 +148,11 @@ export function TextParticleView({
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">
@@ -79,8 +184,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 +207,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 +227,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/counter-style@2.0.2":
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/react-native-li@2.3.1":
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/components-core@0.12.13":
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==
github-slugger@2.0.0:
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"
html-entities@2.6.0:
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 @@ makeerror@1.0.12:
dependencies:
tmpl "1.0.5"
marked@18.0.3:
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"
react-native-reanimated-table@0.0.2:
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==
svg-parser@2.0.4:
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"