feat: use inline markdown editor #227

Merged
talksik merged 10 commits from claude/brave-curie-Btskb into main 2026-05-30 18:10:49 +00:00
10 changed files with 1636 additions and 492 deletions
+3 -6
View File
@@ -1,8 +1,8 @@
{ {
"name": "Flowy.llink", "name": "Flowy.llink",
"productName": "Flowy.llink", "productName": "Flowy.llink",
"version": "1.4.1", "version": "1.4.0",
"description": "Flowy.llink is a team communication app for teams", "description": "Flowy.llink is a video messaging app for teams",
"main": ".vite/build/main.js", "main": ".vite/build/main.js",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -58,6 +58,7 @@
"dependencies": { "dependencies": {
"@livekit/components-react": "^2.9.20", "@livekit/components-react": "^2.9.20",
"@livekit/components-styles": "^1.2.0", "@livekit/components-styles": "^1.2.0",
"@milkdown/crepe": "^7.21.1",
"@sentry/electron": "^7.11.0", "@sentry/electron": "^7.11.0",
"@sentry/react": "^10.54.0", "@sentry/react": "^10.54.0",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
@@ -65,7 +66,6 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"firebase": "^12.10.0", "firebase": "^12.10.0",
"highlight.js": "^11.11.1",
"livekit-client": "^2.18.0", "livekit-client": "^2.18.0",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -73,11 +73,8 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-error-boundary": "^6.1.1", "react-error-boundary": "^6.1.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
"react-use": "^17.6.0", "react-use": "^17.6.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"shadcn": "^3.8.5", "shadcn": "^3.8.5",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "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 { AttachmentStrip } from "@/features/compose/attachment-strip";
import type { PendingAttachment } from "@/features/compose/attachment-strip"; import type { PendingAttachment } from "@/features/compose/attachment-strip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import ReactMarkdown from "react-markdown"; import { MarkdownEditor } from "@/features/compose/markdown-editor";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github-dark.css";
export interface TextEditorAttachmentProps { export interface TextEditorAttachmentProps {
attachments: PendingAttachment[]; attachments: PendingAttachment[];
@@ -43,66 +40,6 @@ function getImmersiveTextStyle(length: number) {
return { size: "text-2xl", weight: "font-normal" }; 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({ export function TextEditor({
textContent, textContent,
onTextChange, onTextChange,
@@ -112,7 +49,6 @@ export function TextEditor({
attachmentProps, attachmentProps,
}: TextEditorProps) { }: TextEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const [previewMode, setPreviewMode] = useState(false);
const [forceCardMode, setForceCardMode] = useState(false); const [forceCardMode, setForceCardMode] = useState(false);
const [debouncedText, setDebouncedText] = useState(textContent); const [debouncedText, setDebouncedText] = useState(textContent);
@@ -127,33 +63,35 @@ export function TextEditor({
const immersive = const immersive =
textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode; 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(() => { useEffect(() => {
if (!previewMode) { if (!immersive) return;
const t = setTimeout(() => { const t = setTimeout(() => {
const el = textareaRef.current; const el = textareaRef.current;
if (el) { if (el) {
el.focus(); el.focus();
el.selectionStart = el.selectionEnd = el.value.length; el.selectionStart = el.selectionEnd = el.value.length;
} }
}, 0); }, 0);
return () => clearTimeout(t); return () => clearTimeout(t);
} }, [immersive]);
}, [previewMode, forceCardMode, immersive]);
useEffect(() => {
textareaRef.current?.focus();
}, []);
// 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( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
onCancel(); onCancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
if (textContent.trim()) onSubmit(); if (textContent.trim()) onSubmit();
} else if (e.key === "m" && (e.metaKey || e.ctrlKey)) { } else if (e.key === "m" && (e.metaKey || e.ctrlKey)) {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
setForceCardMode(true); setForceCardMode(true);
} }
}, },
@@ -250,51 +188,22 @@ export function TextEditor({
isDragging && "ring-2 ring-inset ring-white/30", isDragging && "ring-2 ring-inset ring-white/30",
)} )}
{...dropZoneProps} {...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="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">
<div className="mb-3 flex shrink-0 items-center justify-end gap-1"> {/* No padding here: the editor's own scroll box hosts the slash menu,
<button so we pad inside the editor (ProseMirror) instead. That keeps the
type="button" menu's clipping bounds the full card rather than the inset box. */}
onClick={() => setPreviewMode(false)} <div className="min-h-0 flex-1">
className={cn( <MarkdownEditor
"rounded-md px-3 py-1 text-xs font-medium transition-colors", value={textContent}
!previewMode onChange={onTextChange}
? "bg-white/15 text-white" placeholder="Hit / to see options"
: "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> </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 && ( {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} {strip}
</div> </div>
)} )}
@@ -14,10 +14,7 @@ import { ParticleAttachments } from "@/features/particles/particle-attachments";
import { TextEditOverlay } from "@/features/particles/text-edit-overlay"; import { TextEditOverlay } from "@/features/particles/text-edit-overlay";
import { RelativeTimestamp } from "@/components/relative-timestamp"; import { RelativeTimestamp } from "@/components/relative-timestamp";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import ReactMarkdown from "react-markdown"; import { MarkdownEditor } from "@/features/compose/markdown-editor";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github-dark.css";
type TextParticle = Extract<Particle, { type: "text" }>; type TextParticle = Extract<Particle, { type: "text" }>;
@@ -60,66 +57,6 @@ function hasMarkdownFormatting(content: string): boolean {
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(content); 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[] }) { function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
return ( return (
<div className="flex flex-wrap gap-3"> <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="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 <div
className={cn( 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]:w-2",
"[&::-webkit-scrollbar]:p-2",
"[&::-webkit-scrollbar-track]:bg-transparent", "[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:rounded-full", "[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-white/30", "[&::-webkit-scrollbar-thumb]:bg-white/30",
"[&::-webkit-scrollbar-thumb]:hover:bg-white/50", "[&::-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} />} {hasLinks && <LinkPreviews entries={linkPreviews} />}
+3 -1
View File
@@ -10,5 +10,7 @@ export interface LinkMetadata {
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g; const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
export function extractUrls(text: string): string[] { 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": "[email protected]", "packageManager": "[email protected]",
"dependencies": { "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-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.0.14", "@react-navigation/native": "^7.0.14",
"@react-navigation/native-stack": "^7.2.0", "@react-navigation/native-stack": "^7.2.0",
@@ -30,16 +34,13 @@
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-video": "~3.0.10", "expo-video": "~3.0.10",
"firebase": "^12.10.0", "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", "livekit-client": "^2.15.2",
"lucide-react-native": "^0.575.0", "lucide-react-native": "^0.575.0",
"nativewind": "^4.1.23", "nativewind": "^4.1.23",
"react": "19.1.0", "react": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-marked": "^8.1.0",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef, type ReactNode } from "react";
import { ScrollView, Text, View } from "react-native"; 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 type { Particle } from "@/api/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { RelativeTimestamp } from "@/components/RelativeTimestamp"; import { RelativeTimestamp } from "@/components/RelativeTimestamp";
@@ -37,6 +38,105 @@ function getImmersiveStyle(length: number) {
return { className: "text-2xl font-normal 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({ export function TextParticleView({
particle, particle,
paused, paused,
@@ -48,6 +148,11 @@ export function TextParticleView({
const durationS = computeReadDuration(content); const durationS = computeReadDuration(content);
const elapsedRef = useRef(0); const elapsedRef = useRef(0);
const safe = useStreamSafeArea(); const safe = useStreamSafeArea();
const markdownNodes = useMarkdown(withTaskCheckboxes(content), {
renderer: MARKDOWN_RENDERER,
theme: MARKDOWN_THEME,
styles: MARKDOWN_STYLES,
});
const editedLabel = editedAt ? ( const editedLabel = editedAt ? (
<View className="mt-3 items-center"> <View className="mt-3 items-center">
@@ -79,8 +184,10 @@ export function TextParticleView({
return () => clearInterval(interval); return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]); }, [paused, durationS, onEnded, onProgress, particle.id]);
// Immersive (short, plain): centered, large type — feels like a lock-screen note. // Immersive (short, plain): centered, large type — feels like a lock-screen
if (content.length < IMMERSIVE_CHAR_LIMIT) { // 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); const style = getImmersiveStyle(content.length);
return ( return (
<View <View
@@ -100,10 +207,12 @@ export function TextParticleView({
); );
} }
// Long text: scrollable card so the reader can pace themselves; the // Long text or markdown: scrollable card so the reader can pace themselves;
// duration timer keeps ticking either way, which is intentional — // the duration timer keeps ticking either way, which is intentional — long
// long messages should still auto-advance at the 15s cap. Padding is // messages should still auto-advance at the 15s cap. Markdown is rendered
// pulled from the StreamSafeArea so the card never slips under chrome. // 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 ( return (
<View <View
className="flex-1 items-center justify-center px-6" className="flex-1 items-center justify-center px-6"
@@ -118,7 +227,7 @@ export function TextParticleView({
showsVerticalScrollIndicator showsVerticalScrollIndicator
indicatorStyle="white" indicatorStyle="white"
> >
<Text className="text-white text-lg leading-relaxed">{content}</Text> {markdownNodes}
{editedLabel} {editedLabel}
</ScrollView> </ScrollView>
</View> </View>
+48
View File
@@ -1700,6 +1700,16 @@
"@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14" "@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]": "@livekit/[email protected]":
version "0.12.13" version "0.12.13"
resolved "https://registry.yarnpkg.com/@livekit/components-core/-/components-core-0.12.13.tgz#83d935719453c6831086daffebfc434137ea6497" 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" resolved "https://registry.yarnpkg.com/getenv/-/getenv-2.0.0.tgz#b1698c7b0f29588f4577d06c42c73a5b475c69e0"
integrity sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ== 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: glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2" version "5.1.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" 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: dependencies:
lru-cache "^10.0.1" 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: http-errors@~2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b"
@@ -4680,6 +4700,11 @@ [email protected]:
dependencies: dependencies:
tmpl "1.0.5" 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: marky@^1.2.2:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/marky/-/marky-1.3.0.tgz#422b63b0baf65022f02eda61a238eccdbbc14997" 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" 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== 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: react-native-reanimated@~4.1.1:
version "4.1.7" version "4.1.7"
resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz#b4e8524503a1b6ec1b5a40c460ee807a6a9fd2cf" 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" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== 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: tailwind-merge@^3.5.0:
version "3.5.0" version "3.5.0"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz#06502f4496ba15151445d97d916a26564d50d1ca" resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz#06502f4496ba15151445d97d916a26564d50d1ca"