Private
Public Access
Add markdown rendering for chat messages (issue #14)
Renders message content with markdown-to-jsx: bold/italic/strikethrough, inline code, fenced code blocks, blockquotes, lists (incl. nested and task lists), tables, footnotes, headings, and highlight (==text==). Raw HTML in message content is parsed to escaped literal text rather than rendered (disableParsingRawHTML), which is the XSS mitigation for this being user-generated content -- verified against both <img onerror> and <script> probes. Markdown image embeds degrade to a link instead of an <img>, since the app already has a first-class image upload and a second silent remote-image-embed path would duplicate it and leak the viewer's IP to arbitrary URLs. The inline message-edit control is upgraded from a single-line <input> to a <textarea> so multi-line markdown can actually be edited without losing newlines, mirroring the Composer's Enter-sends/Shift+Enter- newlines convention. Since CommonMark treats a single newline as a soft break (collapses to a space) rather than a visible line break, added a small code-fence- aware preprocessor that converts single newlines to hard breaks -- without it, existing multi-line messages sent via the Composer's Shift+Enter would silently collapse onto one line. Also fixes heading levels rendering at an identical capped size (should still step down by level, just capped lower than default), and adds CSS for markdown constructs the library already parsed but hadn't been styled for the dark theme: table borders, highlight/mark color, task-list checkbox accent, and footnote divider.
This commit is contained in:
Generated
+29
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
// <img> -- 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 (
|
||||
<a href={src} target="_blank" rel="noopener noreferrer" title={title}>
|
||||
{alt || src}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Markdown
|
||||
options={{
|
||||
// The core XSS mitigation: raw HTML in message content is escaped
|
||||
// and printed literally instead of being parsed into elements.
|
||||
disableParsingRawHTML: true,
|
||||
overrides: {
|
||||
a: { props: { target: '_blank', rel: 'noopener noreferrer' } },
|
||||
img: { component: MarkdownImageLink },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{preserveLineBreaks(content)}
|
||||
</Markdown>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
)}
|
||||
{editing ? (
|
||||
<input
|
||||
<textarea
|
||||
autoFocus
|
||||
rows={Math.min(10, draft.split('\n').length)}
|
||||
className="message-edit-input"
|
||||
value={draft}
|
||||
onChange={(e) => 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 && (
|
||||
<div className="message-text">
|
||||
{msg.content}
|
||||
<MessageContent content={msg.content} />
|
||||
{msg.edited_at && <span className="message-edited"> (edited)</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user