Replace video expand's Fullscreen API with a lightbox (#65)

requestFullscreen() silently did nothing in the Electron desktop build
(it worked fine in a regular browser). Swap it for a VideoLightbox
component mirroring the existing ImageLightbox overlay, which has no
such platform dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 19:17:22 -06:00
co-authored by Claude Sonnet 5
parent 5ec79e652f
commit 08a36248a0
3 changed files with 87 additions and 9 deletions
+29 -9
View File
@@ -10,6 +10,7 @@ import { ImageLightbox } from './ImageLightbox'
import { LinkPreviewCard } from './LinkPreviewCard'
import { MessageContent } from './MessageContent'
import { UserAvatar } from './UserAvatar'
import { VideoLightbox } from './VideoLightbox'
import './MessageList.css'
export function FileAttachmentIcon() {
@@ -70,23 +71,25 @@ const PLAYABLE_VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm', 'video/
interface VideoAttachmentProps {
file: MessageFileInfo
roomId: string
onExpand: () => void
}
// 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)
// small inline player is an obvious way to go bigger. The expand button
// opens a VideoLightbox (matching how images already expand) rather than
// calling the Fullscreen API directly on the video element -- that API is
// unreliable in embedded/packaged contexts (e.g. the Electron desktop
// build), where a rejected requestFullscreen() promise just does nothing
// with no visible error.
function VideoAttachment({ file, roomId, onExpand }: VideoAttachmentProps) {
return (
<div className="message-video-wrap">
<video ref={videoRef} src={getRoomFileUrl(roomId, file.id)} controls className="message-video" />
<video src={getRoomFileUrl(roomId, file.id)} controls className="message-video" />
<button
type="button"
className="message-video-expand"
onClick={() => videoRef.current?.requestFullscreen()}
onClick={onExpand}
aria-label="Expand video"
>
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" aria-hidden="true">
@@ -133,6 +136,7 @@ export function MessageList({
const [editingId, setEditingId] = useState<string | null>(null)
const [draft, setDraft] = useState('')
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
const [videoLightbox, setVideoLightbox] = useState<{ src: string; filename: string } | null>(null)
const [reactingId, setReactingId] = useState<string | null>(null)
const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
@@ -259,7 +263,16 @@ export function MessageList({
/>
)}
{msg.file && PLAYABLE_VIDEO_CONTENT_TYPES.has(msg.file.content_type) && (
<VideoAttachment file={msg.file} roomId={roomId} />
<VideoAttachment
file={msg.file}
roomId={roomId}
onExpand={() =>
setVideoLightbox({
src: getRoomFileUrl(roomId, msg.file!.id),
filename: msg.file!.filename,
})
}
/>
)}
{msg.file && !PLAYABLE_VIDEO_CONTENT_TYPES.has(msg.file.content_type) && (
<FileAttachmentCard
@@ -362,6 +375,13 @@ export function MessageList({
})}
<div ref={bottomRef} />
{lightboxSrc && <ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}
{videoLightbox && (
<VideoLightbox
src={videoLightbox.src}
filename={videoLightbox.filename}
onClose={() => setVideoLightbox(null)}
/>
)}
{previewFile && (
<FilePreviewModal
roomId={roomId}
@@ -0,0 +1,6 @@
.video-lightbox-video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: var(--radius);
}
+52
View File
@@ -0,0 +1,52 @@
import { useEscapeKey } from '../hooks/useEscapeKey'
import './ImageLightbox.css'
import './VideoLightbox.css'
interface VideoLightboxProps {
src: string
filename: string
onClose: () => void
}
// #65 follow-up: requestFullscreen() on the inline <video> looked right in
// testing but silently did nothing in production -- the Fullscreen API can
// reject for reasons that don't show up as a visible error (permission
// policy, a standalone/installed PWA window disallowing it entirely), and
// nothing was catching that rejection. A lightbox has no such dependency --
// it's the same "expand" ImageLightbox already gives images, reusing its
// overlay/action-bar chrome directly (see the shared classNames below).
export function VideoLightbox({ src, filename, onClose }: VideoLightboxProps) {
useEscapeKey(onClose)
return (
<div className="image-lightbox" onClick={onClose}>
<div className="image-lightbox-actions" onClick={(e) => e.stopPropagation()}>
<a href={src} download={filename} className="image-lightbox-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="image-lightbox-close" onClick={onClose} aria-label="Close">
×
</button>
</div>
{/* stopPropagation -- without it, clicking the video to play/pause
(or seek, or hit any native control) also bubbles up to the
overlay's onClose, closing the lightbox on the very interaction
it exists to allow. */}
<video
src={src}
controls
autoPlay
className="video-lightbox-video"
onClick={(e) => e.stopPropagation()}
/>
</div>
)
}