From d7e777cbd8ac69d86a73f2017dcdf86bfe04118e Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Sat, 15 Aug 2026 21:30:29 -0600 Subject: [PATCH] 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
.

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 
---
 frontend/src/components/FilePreviewModal.css | 101 +++++++++++++++++++
 frontend/src/components/FilePreviewModal.tsx |  93 +++++++++++++++++
 frontend/src/components/MessageContent.tsx   |  31 +++---
 frontend/src/components/MessageList.tsx      |  84 +++++++++++----
 4 files changed, 273 insertions(+), 36 deletions(-)
 create mode 100644 frontend/src/components/FilePreviewModal.css
 create mode 100644 frontend/src/components/FilePreviewModal.tsx

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)} + /> + )}
) }