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 (`![alt](url)`) 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 ( + + {preserveLineBreaks(content)} + + ) +} diff --git a/frontend/src/components/MessageList.css b/frontend/src/components/MessageList.css index 33cbdd3..065540e 100644 --- a/frontend/src/components/MessageList.css +++ b/frontend/src/components/MessageList.css @@ -66,10 +66,146 @@ font-size: 0.88rem; line-height: 1.45; color: var(--ds-text); - white-space: pre-wrap; word-break: break-word; } +.message-text p { + margin: 0 0 0.4em; +} + +.message-text p:last-child { + margin-bottom: 0; +} + +.message-text code { + background: var(--ds-surface-2); + padding: 1px 5px; + border-radius: 5px; + font-family: var(--mono); + font-size: 0.85em; +} + +.message-text pre { + background: var(--ds-surface-2); + padding: 8px 10px; + border-radius: 8px; + overflow-x: auto; + margin: 0.3em 0; +} + +.message-text pre code { + background: none; + padding: 0; + font-size: 0.82em; +} + +.message-text blockquote { + border-left: 3px solid var(--ds-border); + margin: 0.3em 0; + padding: 0 0.7em; + color: var(--ds-muted); +} + +.message-text ul, +.message-text ol { + margin: 0.2em 0; + padding-left: 1.3em; +} + +.message-text a { + color: var(--ds-accent); + word-break: break-all; +} + +.message-text h1, +.message-text h2, +.message-text h3, +.message-text h4, +.message-text h5, +.message-text h6 { + font-weight: 700; + margin: 0.3em 0; + line-height: 1.3; +} + +/* Capped well below normal HTML heading sizes so a heading in chat doesn't + dwarf the message row, but each level still steps down from the last so + the hierarchy stays visible. */ +.message-text h1 { + font-size: 1.3em; +} + +.message-text h2 { + font-size: 1.2em; +} + +.message-text h3 { + font-size: 1.1em; +} + +.message-text h4 { + font-size: 1.02em; +} + +.message-text h5 { + font-size: 0.95em; +} + +.message-text h6 { + font-size: 0.88em; + color: var(--ds-muted); +} + +.message-text table { + border-collapse: collapse; + margin: 0.3em 0; + font-size: 0.85em; + max-width: 100%; + overflow-x: auto; +} + +.message-text th, +.message-text td { + border: 1px solid var(--ds-border); + padding: 4px 8px; + text-align: left; +} + +.message-text th { + background: var(--ds-surface-2); + font-weight: 700; +} + +.message-text mark { + background: color-mix(in srgb, var(--ds-accent) 35%, transparent); + color: var(--ds-text); + padding: 0 2px; + border-radius: 3px; +} + +.message-text li:has(> input[type='checkbox']) { + list-style: none; + margin-left: -1.3em; +} + +.message-text input[type='checkbox'] { + accent-color: var(--ds-accent); + margin-right: 4px; + vertical-align: middle; +} + +.message-text footer { + margin-top: 0.4em; + padding-top: 0.3em; + border-top: 1px solid var(--ds-border); + font-size: 0.8em; + color: var(--ds-muted); +} + +.message-text footer div { + margin: 2px 0; +} + .message-edited { font-style: italic; color: var(--ds-muted); @@ -161,4 +297,5 @@ color: var(--ds-text); font-family: var(--sans); width: 100%; + resize: vertical; } diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 3300b40..b1b9d66 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -5,6 +5,7 @@ import { avatarUrlFor, displayNameFor, senderColorIndex } from '../lib/messageGr import type { ChatMessageEnvelope, Message, RoomMember } from '../types' import { EmojiPicker } from './EmojiPicker' import { ImageLightbox } from './ImageLightbox' +import { MessageContent } from './MessageContent' import { UserAvatar } from './UserAvatar' import './MessageList.css' @@ -77,13 +78,17 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess )} {editing ? ( - setDraft(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter') commitEdit(msg.id) + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + commitEdit(msg.id) + } if (e.key === 'Escape') setEditingId(null) }} onBlur={() => commitEdit(msg.id)} @@ -100,7 +105,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess )} {msg.content && (
- {msg.content} + {msg.edited_at && (edited)}
)}