diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index e65f589..028a62f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
+ "markdown-to-jsx": "^9.10.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
@@ -4557,6 +4558,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/markdown-to-jsx": {
+ "version": "9.10.2",
+ "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.2.tgz",
+ "integrity": "sha512-iR9GadlIox0q1uXnpqdxpF02Vb1WDmZ/QIXWjBR5htzjUEEhyIWbM65LuVKavdwJaVo4q95C/F2OOyNjWe91ig==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "react": ">= 16.0.0",
+ "solid-js": ">=1.0.0",
+ "vue": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "solid-js": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ }
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 9c36f66..17b0cd2 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
+ "markdown-to-jsx": "^9.10.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
diff --git a/frontend/src/components/MessageContent.tsx b/frontend/src/components/MessageContent.tsx
new file mode 100644
index 0000000..6a21c34
--- /dev/null
+++ b/frontend/src/components/MessageContent.tsx
@@ -0,0 +1,65 @@
+import Markdown from 'markdown-to-jsx'
+
+interface MessageContentProps {
+ content: string
+}
+
+interface MarkdownImageLinkProps {
+ src?: string
+ alt?: string
+ title?: string
+}
+
+// Markdown image embeds (``) degrade to a link instead of an
+// -- the app already has a first-class image-attachment upload
+// (MessageImage), and a second, unmoderated remote-image path would both
+// duplicate that and leak the viewer's IP/UA to arbitrary URLs.
+function MarkdownImageLink({ src, alt, title }: MarkdownImageLinkProps) {
+ if (!src) return null
+ return (
+
+ {alt || src}
+
+ )
+}
+
+// CommonMark treats a single newline as a soft break (rendered as a space),
+// not a visible line break -- only a trailing double-space or blank line
+// produces one. The Composer's Shift+Enter has always inserted a plain
+// newline expecting a visible line break, so without this, existing
+// multi-line messages would silently collapse onto one line once markdown
+// parsing is introduced. This restores that behavior by appending a hard-
+// break marker to single newlines, while leaving fenced code blocks (where
+// trailing whitespace shouldn't be added) and blank-line paragraph breaks
+// untouched.
+function preserveLineBreaks(text: string): string {
+ const lines = text.split('\n')
+ let inFence = false
+ return lines
+ .map((line, i) => {
+ if (/^\s*```/.test(line)) inFence = !inFence
+ const isLast = i === lines.length - 1
+ const nextIsBlank = !isLast && lines[i + 1] === ''
+ if (inFence || isLast || nextIsBlank || line === '') return line
+ return line + ' '
+ })
+ .join('\n')
+}
+
+export function MessageContent({ content }: MessageContentProps) {
+ return (
+