diff --git a/frontend/src/components/FilePreviewModal.css b/frontend/src/components/FilePreviewModal.css new file mode 100644 index 0000000..b678426 --- /dev/null +++ b/frontend/src/components/FilePreviewModal.css @@ -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; +} diff --git a/frontend/src/components/FilePreviewModal.tsx b/frontend/src/components/FilePreviewModal.tsx new file mode 100644 index 0000000..daa500a --- /dev/null +++ b/frontend/src/components/FilePreviewModal.tsx @@ -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(null) + const [error, setError] = useState(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 ( +
+
e.stopPropagation()}> +
+ {file.filename} +
+ + + + +
+
+
+ {error &&

{error}

} + {!error && content === null &&

Loading…

} + {!error && content !== null && kind === 'markdown' && ( +
+ {content} +
+ )} + {!error && content !== null && kind === 'text' &&
{content}
} +
+
+
+ ) +} diff --git a/frontend/src/components/MessageContent.tsx b/frontend/src/components/MessageContent.tsx index 6a21c34..939e19c 100644 --- a/frontend/src/components/MessageContent.tsx +++ b/frontend/src/components/MessageContent.tsx @@ -46,20 +46,19 @@ function preserveLineBreaks(text: string): string { .join('\n') } -export function MessageContent({ content }: MessageContentProps) { - return ( - - {preserveLineBreaks(content)} - - ) +// Shared with FilePreviewModal so both render paths carry the exact same +// XSS mitigation (disableParsingRawHTML) -- duplicating this object would +// risk the two drifting out of sync if one gets edited later. +export const MARKDOWN_OPTIONS = { + // The core XSS mitigation: raw HTML in message content is escaped + // and printed literally instead of being parsed into elements. + disableParsingRawHTML: true, + overrides: { + a: { props: { target: '_blank', rel: 'noopener noreferrer' } }, + img: { component: MarkdownImageLink }, + }, +} + +export function MessageContent({ content }: MessageContentProps) { + return {preserveLineBreaks(content)} } diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index bcc979d..7d37733 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -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 ( + + ) +} + +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 = ( + + {file.filename} + {formatFileSize(file.size_bytes)} + + ) + + if (getPreviewKind(file.filename)) { + return ( + + ) + } + + return ( + + + {info} + + ) +} + 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(null) const [reactingId, setReactingId] = useState(null) + const [previewFile, setPreviewFile] = useState(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 && ( - - - - {msg.file.filename} - {formatFileSize(msg.file.size_bytes)} - - + setPreviewFile(msg.file!)} + /> )} {msg.content && (
@@ -198,6 +234,14 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess })}
{lightboxSrc && setLightboxSrc(null)} />} + {previewFile && ( + setPreviewFile(null)} + /> + )}
) }