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,101 @@
.file-preview-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-4);
z-index: 100;
}
.file-preview-modal {
background: var(--ds-surface);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
width: min(700px, 100%);
max-height: min(80vh, 800px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.file-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--ds-border);
flex: none;
}
.file-preview-filename {
font-size: 0.86rem;
font-weight: 600;
color: var(--ds-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-preview-actions {
display: flex;
align-items: center;
gap: 4px;
flex: none;
}
.file-preview-download,
.file-preview-close {
width: 28px;
height: 28px;
border-radius: var(--radius);
background: transparent;
border: 1px solid var(--ds-border);
color: var(--ds-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.file-preview-close {
font-size: 1rem;
line-height: 1;
}
.file-preview-download:hover,
.file-preview-close:hover {
color: var(--ds-text);
border-color: var(--ds-accent);
}
.file-preview-body {
padding: var(--sp-4);
overflow: auto;
}
.file-preview-loading,
.file-preview-error {
font-size: 0.86rem;
color: var(--ds-muted);
margin: 0;
}
.file-preview-error {
color: var(--ds-danger, #e5484d);
}
.file-preview-text {
font-family: var(--mono);
font-size: 0.82rem;
color: var(--ds-text);
white-space: pre-wrap;
word-break: break-word;
margin: 0;
}
.file-preview-markdown {
font-size: 0.9rem;
}
@@ -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>
)
}
+15 -16
View File
@@ -46,20 +46,19 @@ function preserveLineBreaks(text: string): string {
.join('\n') .join('\n')
} }
export function MessageContent({ content }: MessageContentProps) { // Shared with FilePreviewModal so both render paths carry the exact same
return ( // XSS mitigation (disableParsingRawHTML) -- duplicating this object would
<Markdown // risk the two drifting out of sync if one gets edited later.
options={{ export const MARKDOWN_OPTIONS = {
// The core XSS mitigation: raw HTML in message content is escaped // The core XSS mitigation: raw HTML in message content is escaped
// and printed literally instead of being parsed into elements. // and printed literally instead of being parsed into elements.
disableParsingRawHTML: true, disableParsingRawHTML: true,
overrides: { overrides: {
a: { props: { target: '_blank', rel: 'noopener noreferrer' } }, a: { props: { target: '_blank', rel: 'noopener noreferrer' } },
img: { component: MarkdownImageLink }, img: { component: MarkdownImageLink },
}, },
}} }
>
{preserveLineBreaks(content)} export function MessageContent({ content }: MessageContentProps) {
</Markdown> return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(content)}</Markdown>
)
} }
+64 -20
View File
@@ -2,8 +2,9 @@ import { useEffect, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms' import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { avatarUrlFor, displayNameFor, senderColorIndex } from '../lib/messageGrouping' import { avatarUrlFor, displayNameFor, senderColorIndex } from '../lib/messageGrouping'
import type { ChatMessageEnvelope, Message, RoomMember } from '../types' import type { ChatMessageEnvelope, Message, MessageFileInfo, RoomMember } from '../types'
import { EmojiPicker } from './EmojiPicker' import { EmojiPicker } from './EmojiPicker'
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
import { ImageLightbox } from './ImageLightbox' import { ImageLightbox } from './ImageLightbox'
import { MessageContent } from './MessageContent' import { MessageContent } from './MessageContent'
import { UserAvatar } from './UserAvatar' import { UserAvatar } from './UserAvatar'
@@ -15,6 +16,54 @@ function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
} }
function FileAttachmentIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M6 2.5h6l4 4V16a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 5 16V4A1.5 1.5 0 0 1 6 2.5Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
<path d="M12 2.5V6a1 1 0 0 0 1 1h3.5" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" />
</svg>
)
}
interface FileAttachmentCardProps {
file: MessageFileInfo
roomId: string
onPreview: () => void
}
// Previewable files (markdown/text) open a modal on click, with a small
// explicit download icon alongside; everything else keeps the original
// click-to-download behavior unchanged.
function FileAttachmentCard({ file, roomId, onPreview }: FileAttachmentCardProps) {
const info = (
<span className="message-file-info">
<span className="message-file-name">{file.filename}</span>
<span className="message-file-size">{formatFileSize(file.size_bytes)}</span>
</span>
)
if (getPreviewKind(file.filename)) {
return (
<button type="button" className="message-file-attachment" onClick={onPreview}>
<FileAttachmentIcon />
{info}
</button>
)
}
return (
<a href={getRoomFileUrl(roomId, file.id)} download={file.filename} className="message-file-attachment">
<FileAttachmentIcon />
{info}
</a>
)
}
interface MessageListProps { interface MessageListProps {
roomId: string roomId: string
messages: (Message | ChatMessageEnvelope)[] messages: (Message | ChatMessageEnvelope)[]
@@ -30,6 +79,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
const [draft, setDraft] = useState('') const [draft, setDraft] = useState('')
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null) const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
const [reactingId, setReactingId] = useState<string | null>(null) const [reactingId, setReactingId] = useState<string | null>(null)
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
function displayNameForUserId(userId: string): string { function displayNameForUserId(userId: string): string {
const member = members.find((m) => m.user_id === userId) const member = members.find((m) => m.user_id === userId)
@@ -110,25 +160,11 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
/> />
)} )}
{msg.file && ( {msg.file && (
<a <FileAttachmentCard
href={getRoomFileUrl(roomId, msg.file.id)} file={msg.file}
download={msg.file.filename} roomId={roomId}
className="message-file-attachment" onPreview={() => setPreviewFile(msg.file!)}
> />
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M6 2.5h6l4 4V16a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 5 16V4A1.5 1.5 0 0 1 6 2.5Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
<path d="M12 2.5V6a1 1 0 0 0 1 1h3.5" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" />
</svg>
<span className="message-file-info">
<span className="message-file-name">{msg.file.filename}</span>
<span className="message-file-size">{formatFileSize(msg.file.size_bytes)}</span>
</span>
</a>
)} )}
{msg.content && ( {msg.content && (
<div className="message-text"> <div className="message-text">
@@ -198,6 +234,14 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
})} })}
<div ref={bottomRef} /> <div ref={bottomRef} />
{lightboxSrc && <ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />} {lightboxSrc && <ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}
{previewFile && (
<FilePreviewModal
roomId={roomId}
file={previewFile}
kind={getPreviewKind(previewFile.filename) ?? 'text'}
onClose={() => setPreviewFile(null)}
/>
)}
</div> </div>
) )
} }