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
+64 -20
View File
@@ -2,8 +2,9 @@ import { useEffect, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext'
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 { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
import { ImageLightbox } from './ImageLightbox'
import { MessageContent } from './MessageContent'
import { UserAvatar } from './UserAvatar'
@@ -15,6 +16,54 @@ function formatFileSize(bytes: number): string {
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 {
roomId: string
messages: (Message | ChatMessageEnvelope)[]
@@ -30,6 +79,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
const [draft, setDraft] = useState('')
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
const [reactingId, setReactingId] = useState<string | null>(null)
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
function displayNameForUserId(userId: string): string {
const member = members.find((m) => m.user_id === userId)
@@ -110,25 +160,11 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
/>
)}
{msg.file && (
<a
href={getRoomFileUrl(roomId, msg.file.id)}
download={msg.file.filename}
className="message-file-attachment"
>
<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>
<FileAttachmentCard
file={msg.file}
roomId={roomId}
onPreview={() => setPreviewFile(msg.file!)}
/>
)}
{msg.content && (
<div className="message-text">
@@ -198,6 +234,14 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
})}
<div ref={bottomRef} />
{lightboxSrc && <ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}
{previewFile && (
<FilePreviewModal
roomId={roomId}
file={previewFile}
kind={getPreviewKind(previewFile.filename) ?? 'text'}
onClose={() => setPreviewFile(null)}
/>
)}
</div>
)
}