From ac7336592ab029ce9c730c027fbc5c07e8594dca Mon Sep 17 00:00:00 2001 From: talksik Date: Wed, 8 Apr 2026 09:38:21 -0700 Subject: [PATCH] feat: support markdown formatting and rendering Resolves #110 Goes the extra mile and supports markdown for sending code and formatted text, almost document-like. It feels more like GitHub's comments and text areas. --- js/package.json | 4 + js/src/features/compose/text-compose-step.tsx | 138 ++- .../features/particles/text-particle-view.tsx | 78 +- js/yarn.lock | 877 +++++++++++++++++- 4 files changed, 1076 insertions(+), 21 deletions(-) diff --git a/js/package.json b/js/package.json index 5e600af..4dd5c9c 100644 --- a/js/package.json +++ b/js/package.json @@ -56,14 +56,18 @@ "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", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", + "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", diff --git a/js/src/features/compose/text-compose-step.tsx b/js/src/features/compose/text-compose-step.tsx index 582516a..16ddf5c 100644 --- a/js/src/features/compose/text-compose-step.tsx +++ b/js/src/features/compose/text-compose-step.tsx @@ -5,6 +5,10 @@ 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"; interface TextComposeStepProps { textContent: string; @@ -31,6 +35,66 @@ function getImmersiveTextStyle(length: number) { return { size: "text-2xl", weight: "font-normal" }; } +const markdownComponents: React.ComponentProps["components"] = { + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>

{children}

, + h5: ({ children }) =>
{children}
, + h6: ({ children }) =>
{children}
, + p: ({ children }) =>

{children}

, + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + a: ({ href, children }) => ( + + {children} + + ), + code: ({ className, children, ...props }) => { + const isBlock = className?.startsWith("language-"); + if (isBlock) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + pre: ({ children }) => ( +
+      {children}
+    
+ ), + ul: ({ children }) => , + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) => ( +
    + {children} +
    + ), + hr: () =>
    , +}; + +function MarkdownPreview({ content }: { content: string }) { + return ( +
    + + {content} + +
    + ); +} + export function TextComposeStep({ textContent, onTextChange, @@ -43,6 +107,8 @@ export function TextComposeStep({ dropZoneProps, }: TextComposeStepProps) { const textareaRef = useRef(null); + const [previewMode, setPreviewMode] = useState(false); + const [forceCardMode, setForceCardMode] = useState(false); // Debounce URL detection to avoid fetching on every keystroke const [debouncedText, setDebouncedText] = useState(textContent); @@ -52,6 +118,14 @@ export function TextComposeStep({ }, [textContent]); const linkPreviews = useAllLinkMetadata(debouncedText); + useEffect(() => { + if (!previewMode) { + // Small timeout so the textarea is mounted before focusing + const t = setTimeout(() => textareaRef.current?.focus(), 0); + return () => clearTimeout(t); + } + }, [previewMode, forceCardMode]); + useEffect(() => { textareaRef.current?.focus(); }, []); @@ -64,13 +138,16 @@ export function TextComposeStep({ } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (textContent.trim()) onAdvance(); + } else if (e.key === "m" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setForceCardMode(true); } }, [onCancel, onAdvance, textContent], ); const hasEnrichments = attachments.length > 0 || linkPreviews.length > 0; - const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments; + const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode; const strip = ( {" "} next + {immersive && ( + + + ⌘+M + {" "} + markdown + + )}