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:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -46,10 +46,10 @@ 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,
|
||||||
@@ -57,9 +57,8 @@ export function MessageContent({ content }: MessageContentProps) {
|
|||||||
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>
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user