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", () => {
791791
792792 expect(markup).toContain("<video");
793793 expect(markup).toContain('aria-label="demo.mp4"');
794 expect(markup).toContain('controls=""');
795794 expect(markup).not.toContain("Expand demo.mp4");
796795 });
797796
apps/web/src/components/chat/MessagesTimeline.tsx+4
......@@ -1522,6 +1522,10 @@ function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) {
15221522 }
15231523 label={file.name}
15241524 preload="visible"
1525 onOpen={() => {
1526 const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file);
1527 if (preview) ctx.onImageExpand(preview);
1528 }}
15251529 className="block aspect-[4/3] w-full"
15261530 videoClassName="aspect-auto size-full rounded-lg border border-border/80"
15271531 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 @@
1import { RotateCwIcon, TriangleAlertIcon } from "lucide-react";
1import { PlayIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react";
22import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
33
44import { cn } from "../../lib/utils";
......@@ -15,6 +15,8 @@ interface MediaVideoPlayerProps {
1515 readonly revision?: string | null | undefined;
1616 readonly preload?: "visible" | "metadata" | undefined;
1717 readonly autoPlay?: boolean | undefined;
18 /** Presents a still thumbnail whose full surface opens the video in a viewer. */
19 readonly onOpen?: (() => void) | undefined;
1820 readonly className?: string | undefined;
1921 readonly videoClassName?: string | undefined;
2022 /** Styles the loading and failure panels, which otherwise assume an inline light surface. */
......@@ -34,6 +36,7 @@ export function MediaVideoPlayer({
3436 revision = null,
3537 preload = "visible",
3638 autoPlay = false,
39 onOpen,
3740 className,
3841 videoClassName,
3942 stateClassName,
......@@ -131,7 +134,7 @@ export function MediaVideoPlayer({
131134 style={style}
132135 data-markdown-copy={copyMarkdown}
133136 >
134 {failed ? (
137 {failed && !onOpen ? (
135138 <span
136139 role="alert"
137140 className={cn(
......@@ -160,17 +163,23 @@ export function MediaVideoPlayer({
160163 <OpenMediaLink originalUrl={originalUrl} src={latestSrc ?? src} fileName={label} />
161164 </span>
162165 </span>
163 ) : src !== null ? (
166 ) : src !== null && !failed ? (
164167 <video
165168 key={loadAttempt}
166169 ref={videoRef}
167170 src={src}
168171 aria-label={label || "Video preview"}
169 autoPlay={autoPlay}
170 controls
172 aria-hidden={onOpen ? true : undefined}
173 autoPlay={onOpen ? false : autoPlay}
174 controls={!onOpen}
175 muted={onOpen ? true : undefined}
171176 playsInline
172177 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 )}
174183 style={style}
175184 onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)}
176185 onPlay={() => setPlaybackSource({ src, revision: sourceRevision })}
......@@ -184,11 +193,23 @@ export function MediaVideoPlayer({
184193 ) : (
185194 <span
186195 role="status"
187 aria-label="Loading video"
196 aria-label={failed ? "Video preview unavailable" : "Loading video"}
188197 className={cn("block aspect-video w-full rounded-lg bg-muted/60", stateClassName)}
189198 style={style}
190199 />
191200 )}
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}
192213 </span>
193214 );
194215 return actionsSource ? <MediaActions source={actionsSource}>{player}</MediaActions> : player;