Play video attachments inline, with an expand option (#65)

A video file previously rendered as a generic downloadable file card,
same as any other attachment. The file-serve endpoint forces
Content-Disposition: attachment for every upload as an XSS mitigation
(a same-origin-served .html/.svg executing script), which also meant a
<video> tag pointed at it couldn't play -- the browser would just try
to download it.

Carve out a strict, server-side allowlist (video/mp4, video/webm,
video/ogg -- deliberately not "every video/* type") that skips the
forced download, the same reasoning MessageImage's own endpoint
already relies on: these are content types a browser only ever
interprets as media, never as something that could execute script.
Anything else, including other video formats like .mov, still forces
a download exactly as before.

On the frontend, a video attachment with one of those content types
renders as an inline <video controls> instead of the generic file
card, with a hover-revealed expand button that calls the browser's
native Fullscreen API on the video element directly rather than
building a second lightbox component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 19:08:25 -06:00
co-authored by Claude Sonnet 5
parent 89d609f584
commit 5ec79e652f
5 changed files with 150 additions and 2 deletions
+38
View File
@@ -62,6 +62,44 @@
margin-bottom: 4px;
}
.message-video-wrap {
position: relative;
display: inline-block;
max-width: min(320px, 100%);
margin-bottom: 4px;
}
.message-video {
display: block;
width: 100%;
max-height: 240px;
border-radius: var(--radius);
border: 1px solid var(--ds-border);
background: var(--ds-void);
}
.message-video-expand {
position: absolute;
top: 6px;
right: 6px;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
background: rgba(0, 0, 0, 0.6);
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.message-video-wrap:hover .message-video-expand,
.message-video-expand:focus-visible {
opacity: 1;
}
.message-file-attachment {
display: inline-flex;
align-items: center;
+47 -1
View File
@@ -60,6 +60,49 @@ function FileAttachmentCard({ file, roomId, onPreview }: FileAttachmentCardProps
)
}
// #65: kept in sync with backend/app/storage.py's INLINE_SAFE_VIDEO_
// CONTENT_TYPES -- the server only ever serves these particular content
// types without a forced download, so a <video> tag pointed at anything
// else would just show a broken player instead of playing (or, worse,
// trigger a download the moment the browser tries to fetch it).
const PLAYABLE_VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm', 'video/ogg'])
interface VideoAttachmentProps {
file: MessageFileInfo
roomId: string
}
// Plays inline via the browser's own <video controls> (no custom overlay
// needed for play/pause/volume/seek) -- the one thing it doesn't give a
// small inline player is an obvious way to go bigger, so this adds an
// explicit expand button on top calling the standard Fullscreen API
// directly on the video element, rather than building a whole second
// lightbox component just to re-embed the same <video>.
function VideoAttachment({ file, roomId }: VideoAttachmentProps) {
const videoRef = useRef<HTMLVideoElement>(null)
return (
<div className="message-video-wrap">
<video ref={videoRef} src={getRoomFileUrl(roomId, file.id)} controls className="message-video" />
<button
type="button"
className="message-video-expand"
onClick={() => videoRef.current?.requestFullscreen()}
aria-label="Expand video"
>
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M7 3H3v4M13 3h4v4M3 13v4h4M17 13v4h-4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
)
}
interface MessageListProps {
roomId: string
messages: (Message | ChatMessageEnvelope)[]
@@ -215,7 +258,10 @@ export function MessageList({
onClick={() => setLightboxSrc(getRoomImageUrl(roomId, msg.image_id!))}
/>
)}
{msg.file && (
{msg.file && PLAYABLE_VIDEO_CONTENT_TYPES.has(msg.file.content_type) && (
<VideoAttachment file={msg.file} roomId={roomId} />
)}
{msg.file && !PLAYABLE_VIDEO_CONTENT_TYPES.has(msg.file.content_type) && (
<FileAttachmentCard
file={msg.file}
roomId={roomId}