Private
Public Access
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user