Add inline preview for markdown and plain-text file attachments

Clicking a .md/.txt attachment now opens a modal instead of downloading,
with an explicit download button still available inside it. Markdown
renders through the same XSS-safe renderer used for chat messages;
plain text renders as literal escaped content via <pre>.

No backend change needed: the preview content is read via fetch(), which
is unaffected by the Content-Disposition: attachment header the file-serve
endpoint always sends (that header only steers the browser's own
navigation/embed rendering, not a script-initiated body read) -- so the
existing download-forcing security behavior from #13 stays intact.
Non-previewable types (PDF, etc.) are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 21:30:29 -06:00
co-authored by Claude Sonnet 5
parent 14dc340174
commit d7e777cbd8
4 changed files with 273 additions and 36 deletions
@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react'
import Markdown from 'markdown-to-jsx'
import { getRoomFileUrl } from '../api/rooms'
import { useEscapeKey } from '../hooks/useEscapeKey'
import type { MessageFileInfo } from '../types'
import { MARKDOWN_OPTIONS } from './MessageContent'
import './FilePreviewModal.css'
export type PreviewKind = 'markdown' | 'text'
// Deliberately extension-based, not content_type-based: the browser-supplied
// content_type for less-common extensions like .md is inconsistent (often
// reported as empty or application/octet-stream), so it isn't reliable
// enough to gate what gets parsed as markdown vs. shown as literal text.
export function getPreviewKind(filename: string): PreviewKind | null {
const lower = filename.toLowerCase()
if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'markdown'
if (lower.endsWith('.txt')) return 'text'
return null
}
interface FilePreviewModalProps {
roomId: string
file: MessageFileInfo
kind: PreviewKind
onClose: () => void
}
export function FilePreviewModal({ roomId, file, kind, onClose }: FilePreviewModalProps) {
useEscapeKey(onClose)
const [content, setContent] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const fileUrl = getRoomFileUrl(roomId, file.id)
useEffect(() => {
let cancelled = false
// A plain fetch() read is unaffected by the Content-Disposition:
// attachment header the file-serve endpoint always sends -- that header
// only steers the browser's own navigation/embed rendering, not a
// script-initiated read of the response body. So no separate
// "inline"-flavored endpoint is needed just to preview text.
fetch(fileUrl, { credentials: 'include' })
.then((res) => {
if (!res.ok) throw new Error(`Failed to load file (${res.status})`)
return res.text()
})
.then((text) => {
if (!cancelled) setContent(text)
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load file')
})
return () => {
cancelled = true
}
}, [fileUrl])
return (
<div className="file-preview-overlay" onClick={onClose}>
<div className="file-preview-modal" onClick={(e) => e.stopPropagation()}>
<div className="file-preview-header">
<span className="file-preview-filename">{file.filename}</span>
<div className="file-preview-actions">
<a href={fileUrl} download={file.filename} className="file-preview-download" aria-label="Download">
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M10 3v10m0 0-4-4m4 4 4-4M4 16h12"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
<button type="button" className="file-preview-close" onClick={onClose} aria-label="Close">
×
</button>
</div>
</div>
<div className="file-preview-body">
{error && <p className="file-preview-error">{error}</p>}
{!error && content === null && <p className="file-preview-loading">Loading</p>}
{!error && content !== null && kind === 'markdown' && (
<div className="message-text file-preview-markdown">
<Markdown options={MARKDOWN_OPTIONS}>{content}</Markdown>
</div>
)}
{!error && content !== null && kind === 'text' && <pre className="file-preview-text">{content}</pre>}
</div>
</div>
</div>
)
}