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:
@@ -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",
|
||||||
|
"@mdxeditor/editor": "^4.0.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",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* Blend MDXEditor into the compose glass card.
|
||||||
|
We lean on MDXEditor's bundled `dark-theme` for typography (heading sizes,
|
||||||
|
list markers, code styling) and only override the chrome: transparent
|
||||||
|
background, no border, and padding/colors tuned to the surrounding card. */
|
||||||
|
|
||||||
|
.llink-mdxeditor {
|
||||||
|
--baseBg: transparent;
|
||||||
|
--basePageBg: transparent;
|
||||||
|
background: transparent;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llink-mdxeditor [class*="_editorRoot_"] {
|
||||||
|
background: transparent;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llink-mdx-content {
|
||||||
|
padding: 0 !important;
|
||||||
|
outline: none;
|
||||||
|
height: 100%;
|
||||||
|
color: rgb(255 255 255 / 0.92);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llink-mdx-content :where(h1, h2, h3, h4, h5, h6) {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llink-mdx-content a {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
MDXEditor,
|
||||||
|
type MDXEditorMethods,
|
||||||
|
headingsPlugin,
|
||||||
|
listsPlugin,
|
||||||
|
quotePlugin,
|
||||||
|
thematicBreakPlugin,
|
||||||
|
linkPlugin,
|
||||||
|
codeBlockPlugin,
|
||||||
|
codeMirrorPlugin,
|
||||||
|
markdownShortcutPlugin,
|
||||||
|
} from "@mdxeditor/editor";
|
||||||
|
import "@mdxeditor/editor/style.css";
|
||||||
|
import "./markdown-editor.css";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Languages offered for fenced code blocks. Kept short — this is a message
|
||||||
|
// composer, not a code editor — but enough to cover what people usually paste.
|
||||||
|
const CODE_BLOCK_LANGUAGES = {
|
||||||
|
"": "Plain text",
|
||||||
|
text: "Plain text",
|
||||||
|
bash: "Shell",
|
||||||
|
json: "JSON",
|
||||||
|
js: "JavaScript",
|
||||||
|
ts: "TypeScript",
|
||||||
|
tsx: "TSX",
|
||||||
|
py: "Python",
|
||||||
|
go: "Go",
|
||||||
|
rust: "Rust",
|
||||||
|
css: "CSS",
|
||||||
|
html: "HTML",
|
||||||
|
sql: "SQL",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MarkdownEditorProps {
|
||||||
|
/** Initial markdown. MDXEditor is the source of truth once mounted; changes
|
||||||
|
* flow out through `onChange`, so this is only read on mount. */
|
||||||
|
value: string;
|
||||||
|
onChange: (markdown: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline WYSIWYG markdown editor (Obsidian/Notion feel): headings, lists,
|
||||||
|
* emphasis, links, and code blocks render as you type via MDXEditor's
|
||||||
|
* markdown shortcuts — no separate preview pane. Reads and writes plain
|
||||||
|
* markdown, matching how particle content is stored.
|
||||||
|
*/
|
||||||
|
export function MarkdownEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
autoFocus = true,
|
||||||
|
}: MarkdownEditorProps) {
|
||||||
|
const ref = useRef<MDXEditorMethods>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoFocus) return;
|
||||||
|
// Defer to the next tick so the editor is mounted before we focus it.
|
||||||
|
const t = setTimeout(() => ref.current?.focus(), 0);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [autoFocus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MDXEditor
|
||||||
|
ref={ref}
|
||||||
|
markdown={value}
|
||||||
|
onChange={onChange}
|
||||||
|
// Defensive: core handles HTML and degrades unknown syntax to text, so
|
||||||
|
// this is rare — surface it rather than failing silently.
|
||||||
|
onError={({ source, error }) =>
|
||||||
|
console.warn("MarkdownEditor parse issue:", error, source)
|
||||||
|
}
|
||||||
|
placeholder={placeholder}
|
||||||
|
contentEditableClassName="llink-mdx-content"
|
||||||
|
className={cn("dark-theme llink-mdxeditor", className)}
|
||||||
|
plugins={[
|
||||||
|
headingsPlugin(),
|
||||||
|
listsPlugin(),
|
||||||
|
quotePlugin(),
|
||||||
|
thematicBreakPlugin(),
|
||||||
|
linkPlugin(),
|
||||||
|
codeBlockPlugin({ defaultCodeBlockLanguage: "" }),
|
||||||
|
codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }),
|
||||||
|
markdownShortcutPlugin(),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,48 +188,16 @@ 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-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">
|
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
<button
|
<MarkdownEditor
|
||||||
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>
|
|
||||||
|
|
||||||
{previewMode ? (
|
|
||||||
<MarkdownPreview content={textContent} />
|
|
||||||
) : (
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={textContent}
|
value={textContent}
|
||||||
onChange={(e) => onTextChange(e.target.value)}
|
onChange={onTextChange}
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Type a message... (markdown supported)"
|
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"
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{hasEnrichments && strip && (
|
{hasEnrichments && strip && (
|
||||||
<div className="shrink-0 border-t border-white/10 pt-3">
|
<div className="shrink-0 border-t border-white/10 pt-3">
|
||||||
|
|||||||
+1168
-18
File diff suppressed because it is too large
Load Diff
@@ -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 } from "react";
|
||||||
import { ScrollView, Text, View } from "react-native";
|
import { ScrollView, Text, View } from "react-native";
|
||||||
|
import { 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,32 @@ 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**`.
|
||||||
|
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({
|
export function TextParticleView({
|
||||||
particle,
|
particle,
|
||||||
paused,
|
paused,
|
||||||
@@ -48,6 +75,10 @@ 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(content, {
|
||||||
|
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 +110,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 +133,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 +153,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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user