authorgravatar for chris@xenforo.comChris Deeming <chris@xenforo.com> 2026-09-14 17:28:32+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 09:28:32-07:00
logec5ede5e6a1f2f9d564a5834462f47d1b7e5f0ec
treeeadf771c41e4654f6f3882634cb87e42e94f05fd
parent05e3bcbc65d43adc70c900f176bec6c095b3b833
signature Signed by PGP key B5690EEEBB952194

fix(web): open video attachment thumbnails in the viewer (#11734)


3 files changed, 32 insertions(+), 8 deletions(-)

apps/web/src/components/chat/MessagesTimeline.test.tsx-1
...@@ -791,7 +791,6 @@ describe("MessagesTimeline", () => {...@@ -791,7 +791,6 @@ describe("MessagesTimeline", () => {
791791
792 expect(markup).toContain("<video");792 expect(markup).toContain("<video");
793 expect(markup).toContain('aria-label="demo.mp4"');793 expect(markup).toContain('aria-label="demo.mp4"');
794 expect(markup).toContain('controls=""');
795 expect(markup).not.toContain("Expand demo.mp4");794 expect(markup).not.toContain("Expand demo.mp4");
796 });795 });
797796
apps/web/src/components/chat/MessagesTimeline.tsx+4
...@@ -1522,6 +1522,10 @@ function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) {...@@ -1522,6 +1522,10 @@ function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) {
1522 }1522 }
1523 label={file.name}1523 label={file.name}
1524 preload="visible"1524 preload="visible"
1525 onOpen={() => {
1526 const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file);
1527 if (preview) ctx.onImageExpand(preview);
1528 }}
1525 className="block aspect-[4/3] w-full"1529 className="block aspect-[4/3] w-full"
1526 videoClassName="aspect-auto size-full rounded-lg border border-border/80"1530 videoClassName="aspect-auto size-full rounded-lg border border-border/80"
1527 stateClassName="aspect-auto min-h-full rounded-lg border border-border/80 bg-black text-white"1531 stateClassName="aspect-auto min-h-full rounded-lg border border-border/80 bg-black text-white"
apps/web/src/components/media/MediaVideoPlayer.tsx+28-7
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { RotateCwIcon, TriangleAlertIcon } from "lucide-react";1import { PlayIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react";
2import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";2import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
33
4import { cn } from "../../lib/utils";4import { cn } from "../../lib/utils";
...@@ -15,6 +15,8 @@ interface MediaVideoPlayerProps {...@@ -15,6 +15,8 @@ interface MediaVideoPlayerProps {
15 readonly revision?: string | null | undefined;15 readonly revision?: string | null | undefined;
16 readonly preload?: "visible" | "metadata" | undefined;16 readonly preload?: "visible" | "metadata" | undefined;
17 readonly autoPlay?: boolean | undefined;17 readonly autoPlay?: boolean | undefined;
18 /** Presents a still thumbnail whose full surface opens the video in a viewer. */
19 readonly onOpen?: (() => void) | undefined;
18 readonly className?: string | undefined;20 readonly className?: string | undefined;
19 readonly videoClassName?: string | undefined;21 readonly videoClassName?: string | undefined;
20 /** Styles the loading and failure panels, which otherwise assume an inline light surface. */22 /** Styles the loading and failure panels, which otherwise assume an inline light surface. */
...@@ -34,6 +36,7 @@ export function MediaVideoPlayer({...@@ -34,6 +36,7 @@ export function MediaVideoPlayer({
34 revision = null,36 revision = null,
35 preload = "visible",37 preload = "visible",
36 autoPlay = false,38 autoPlay = false,
39 onOpen,
37 className,40 className,
38 videoClassName,41 videoClassName,
39 stateClassName,42 stateClassName,
...@@ -131,7 +134,7 @@ export function MediaVideoPlayer({...@@ -131,7 +134,7 @@ export function MediaVideoPlayer({
131 style={style}134 style={style}
132 data-markdown-copy={copyMarkdown}135 data-markdown-copy={copyMarkdown}
133 >136 >
134 {failed ? (137 {failed && !onOpen ? (
135 <span138 <span
136 role="alert"139 role="alert"
137 className={cn(140 className={cn(
...@@ -160,17 +163,23 @@ export function MediaVideoPlayer({...@@ -160,17 +163,23 @@ export function MediaVideoPlayer({
160 <OpenMediaLink originalUrl={originalUrl} src={latestSrc ?? src} fileName={label} />163 <OpenMediaLink originalUrl={originalUrl} src={latestSrc ?? src} fileName={label} />
161 </span>164 </span>
162 </span>165 </span>
163 ) : src !== null ? (166 ) : src !== null && !failed ? (
164 <video167 <video
165 key={loadAttempt}168 key={loadAttempt}
166 ref={videoRef}169 ref={videoRef}
167 src={src}170 src={src}
168 aria-label={label || "Video preview"}171 aria-label={label || "Video preview"}
169 autoPlay={autoPlay}172 aria-hidden={onOpen ? true : undefined}
170 controls173 autoPlay={onOpen ? false : autoPlay}
174 controls={!onOpen}
175 muted={onOpen ? true : undefined}
171 playsInline176 playsInline
172 preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"}177 preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"}
173 className={cn("aspect-video max-h-full w-full bg-black object-contain", videoClassName)}178 className={cn(
179 "aspect-video max-h-full w-full bg-black object-contain",
180 onOpen && "pointer-events-none",
181 videoClassName,
182 )}
174 style={style}183 style={style}
175 onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)}184 onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)}
176 onPlay={() => setPlaybackSource({ src, revision: sourceRevision })}185 onPlay={() => setPlaybackSource({ src, revision: sourceRevision })}
...@@ -184,11 +193,23 @@ export function MediaVideoPlayer({...@@ -184,11 +193,23 @@ export function MediaVideoPlayer({
184 ) : (193 ) : (
185 <span194 <span
186 role="status"195 role="status"
187 aria-label="Loading video"196 aria-label={failed ? "Video preview unavailable" : "Loading video"}
188 className={cn("block aspect-video w-full rounded-lg bg-muted/60", stateClassName)}197 className={cn("block aspect-video w-full rounded-lg bg-muted/60", stateClassName)}
189 style={style}198 style={style}
190 />199 />
191 )}200 )}
201 {onOpen ? (
202 <button
203 type="button"
204 aria-label={label ? `Play ${label}` : "Play video"}
205 onClick={onOpen}
206 className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
207 >
208 <span className="flex size-8 items-center justify-center rounded-full bg-black/50 text-white">
209 <PlayIcon aria-hidden className="size-4 fill-current" />
210 </span>
211 </button>
212 ) : null}
192 </span>213 </span>
193 );214 );
194 return actionsSource ? <MediaActions source={actionsSource}>{player}</MediaActions> : player;215 return actionsSource ? <MediaActions source={actionsSource}>{player}</MediaActions> : player;