1import {
2 memo,
3 useCallback,
4 useEffect,
5 useRef,
6 useState,
7 type ReactNode,
8 type KeyboardEvent,
9} from "react";
10import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react";
11import { Image as ImageGlyph, Text as TextGlyph } from "lucide";
12import { Button } from "../ui/button";
13import { MorphIcon } from "~/components/MorphIcon";
14import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
15import type { ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview";
16import { resolveExternalWebLinkHost } from "./externalLinkContextMenu";
17import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls";
18import { OpenMediaLink } from "../media/OpenMediaLink";
19import { MediaActions, type MediaActionSource } from "../media/MediaActions";
20import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
21import { isContextMenuOpen } from "../../contextMenuFallback";
22import {
23 SnapShotAccessibilityData,
24 SnapShotContentsButton,
25 snapShotAccessibilityDetails,
26} from "./SnapShotAttachmentDetails";
27import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
28import { ZoomableImage, type ZoomableImageHandle } from "./ZoomableImage";
29import { composerFloatingLayerProps } from "./composerEventScope";
30
31interface ExpandedImageDialogProps {
32 preview: ExpandedImagePreview;
33 onClose: () => void;
34}
35
36const EXPANDED_MEDIA_STATE_CLASS_NAME =
37 "flex aspect-auto h-48 min-h-0 w-[min(var(--media-width),32rem)] flex-col items-center justify-center gap-3 rounded-lg border border-border/70 bg-black p-6 text-center text-sm text-white shadow-2xl";
38
39function ExpandedMediaFailure({ children }: { children: ReactNode }) {
40 return (
41 <div role="alert" className={EXPANDED_MEDIA_STATE_CLASS_NAME}>
42 {children}
43 </div>
44 );
45}
46
47function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) {
48 const asset = item.actionsSource?.asset;
49 const assetUrl = useAssetUrlState(asset?.environmentId ?? null, asset?.resource ?? null);
50 const refreshAssetUrl = useAssetUrlRefresh(asset?.environmentId ?? null, asset?.resource ?? null);
51 const src = asset
52 ? assetUrl._tag === "Success"
53 ? assetUrl.url + (item.srcFragment ?? "")
54 : null
55 : item.src;
56 return (
57 <MediaVideoPlayer
58 src={src}
59 label={item.name}
60 sourceFailed={assetUrl._tag === "Failure"}
61 originalUrl={item.originalUrl}
62 preload="metadata"
63 autoPlay={item.autoPlay ?? true}
64 className="block max-h-[var(--media-height)] max-w-[var(--media-width)] text-center"
65 videoClassName="aspect-auto max-h-[var(--media-height)] w-auto max-w-[var(--media-width)] rounded-lg border border-border/70 shadow-2xl"
66 stateClassName={EXPANDED_MEDIA_STATE_CLASS_NAME}
67 onRetry={asset ? refreshAssetUrl : undefined}
68 actionsSource={item.actionsSource}
69 />
70 );
71}
72
73export const ExpandedImageDialog = memo(function ExpandedImageDialog({
74 preview,
75 onClose,
76}: ExpandedImageDialogProps) {
77 const [imageOffset, setImageOffset] = useState(0);
78 const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null);
79 const [accessibilityDetailsSrc, setAccessibilityDetailsSrc] = useState<string | null>(null);
80 const zoomableImageRef = useRef<ZoomableImageHandle>(null);
81 const [returnFocusTarget] = useState(() =>
82 document.activeElement instanceof HTMLElement ? document.activeElement : null,
83 );
84 const closeButtonRef = useRef<HTMLButtonElement>(null);
85 // The offset accumulates without bound, so wrap it into range in both directions:
86 // JavaScript `%` keeps the sign of the dividend, and a negative index blanks the dialog.
87 const imageCount = preview.images.length;
88 const index =
89 imageCount > 0 ? (((preview.index + imageOffset) % imageCount) + imageCount) % imageCount : 0;
90 const item = preview.images[index];
91 const source: MediaActionSource = item?.actionsSource ?? {
92 kind: item?.type === "video" ? "video" : "image",
93 name: item?.name ?? "Media",
94 src: item?.src ?? null,
95 };
96 const openFile = source.onOpenFile;
97 const actionsSource: MediaActionSource = openFile
98 ? {
99 ...source,
100 onOpenFile: () => {
101 openFile();
102 onClose();
103 },
104 }
105 : source;
106
107 const navigateImage = useCallback((direction: -1 | 1) => {
108 setImageOffset((current) => current + direction);
109 }, []);
110
111 // The element that opened the preview gets focus back on close. Without
112 // this a close button click leaves focus on the unmounted dialog, and the
113 // composer that owned the opener reads that as a blur and rests.
114 const openerRef = useRef<Element | null>(null);
115 useEffect(() => {
116 openerRef.current = document.activeElement;
117 return () => {
118 const opener = openerRef.current;
119 if (opener instanceof HTMLElement && opener.isConnected) {
120 opener.focus({ preventScroll: true });
121 }
122 };
123 }, []);
124
125 const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
126 if (event.defaultPrevented || isContextMenuOpen() || event.target instanceof HTMLVideoElement)
127 return;
128 if (zoomableImageRef.current?.pan(event.key)) {
129 event.preventDefault();
130 event.stopPropagation();
131 return;
132 }
133 if (preview.images.length <= 1) return;
134 if (event.key === "ArrowLeft") {
135 event.preventDefault();
136 event.stopPropagation();
137 navigateImage(-1);
138 return;
139 }
140 if (event.key !== "ArrowRight") return;
141 event.preventDefault();
142 event.stopPropagation();
143 navigateImage(1);
144 };
145
146 useEffect(() => {
147 const onEscape = (event: globalThis.KeyboardEvent) => {
148 if (event.key !== "Escape" || isContextMenuOpen()) return;
149 event.preventDefault();
150 event.stopPropagation();
151 onClose();
152 };
153 window.addEventListener("keydown", onEscape, { capture: true });
154 return () => window.removeEventListener("keydown", onEscape, { capture: true });
155 }, [onClose]);
156
157 if (!item) return null;
158 const mediaLabel = item.type === "video" ? "video" : "image";
159 const openOriginalLink =
160 item.originalUrl && resolveExternalWebLinkHost(item.originalUrl) !== null ? (
161 <OpenMediaLink originalUrl={item.originalUrl} />
162 ) : null;
163 const accessibilityDetails = item.source ? snapShotAccessibilityDetails(item.source) : undefined;
164 const showingAccessibilityDetails =
165 Boolean(accessibilityDetails) && accessibilityDetailsSrc === item.src;
166 const contentsLabel = showingAccessibilityDetails
167 ? "Show screenshot"
168 : accessibilityDetails?.format === "json"
169 ? "Show accessibility JSON"
170 : "Show extracted text";
171
172 return (
173 <Dialog
174 open
175 onOpenChange={(open) => {
176 if (!open) onClose();
177 }}
178 >
179 <DialogPopup
180 {...composerFloatingLayerProps}
181 variant="media"
182 showCloseButton={false}
183 bottomStickOnMobile={false}
184 className="row-start-1 h-full max-h-[92vh] w-[92vw] max-w-[92vw] items-center justify-center overflow-visible [--media-width:92vw] [--media-height:min(86vh,calc(100vh-160px))] sm:[--media-width:calc(92vw-96px)]"
185 onKeyDown={onKeyDown}
186 initialFocus={closeButtonRef}
187 finalFocus={() => returnFocusTarget}
188 onClick={(event) => {
189 if (event.target === event.currentTarget) onClose();
190 }}
191 >
192 <DialogTitle className="sr-only">Expanded {mediaLabel} preview</DialogTitle>
193 {/* The stage keeps a fixed size, so the close button stays put whatever the media or caption. */}
194 <Button
195 type="button"
196 ref={closeButtonRef}
197 size="icon-sm"
198 variant="media-close"
199 className="absolute right-0 top-0 z-20"
200 onClick={onClose}
201 aria-label={`Close ${mediaLabel} preview`}
202 >
203 <XIcon />
204 </Button>
205 {preview.images.length > 1 && (
206 <Button
207 type="button"
208 size="icon"
209 variant="media-navigation"
210 className="left-0 top-auto bottom-0 translate-y-0 sm:top-1/2 sm:bottom-auto sm:-translate-y-1/2"
211 aria-label="Previous media"
212 onClick={() => navigateImage(-1)}
213 >
214 <ChevronLeftIcon className="size-5" />
215 </Button>
216 )}
217 <MediaActions source={actionsSource}>
218 <div className="relative isolate z-10 flex max-h-full max-w-[var(--media-width)] flex-col items-center">
219 {item.type === "video" ? (
220 <ExpandedVideo key={index} item={item} />
221 ) : showingAccessibilityDetails ? (
222 accessibilityDetails ? (
223 <SnapShotAccessibilityData
224 details={accessibilityDetails}
225 className="h-[min(var(--media-height),40rem)] w-[min(var(--media-width),42rem)] transition-opacity duration-140 ease-out starting:opacity-0 rounded-lg border border-border/70 bg-background p-4 text-xs leading-5 shadow-2xl motion-reduce:transition-none"
226 />
227 ) : null
228 ) : item.src === null || failedImageSrc === item.src ? (
229 <ExpandedMediaFailure>
230 <p>
231 {openOriginalLink
232 ? "This image could not be loaded."
233 : "Image unavailable. The file may have been moved or deleted."}
234 </p>
235 {openOriginalLink}
236 </ExpandedMediaFailure>
237 ) : (
238 <ZoomableImage
239 ref={zoomableImageRef}
240 key={`${index}:${item.src}`}
241 src={item.src}
242 name={item.name}
243 onError={() => setFailedImageSrc(item.src)}
244 />
245 )}
246 <div className="mt-2 flex max-w-[var(--media-width)] items-center justify-center gap-1.5 text-xs text-white/80 max-sm:px-10">
247 <span className="truncate" aria-live="polite" aria-atomic="true">
248 {item.name}
249 {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}
250 </span>
251 {accessibilityDetails && item.source ? (
252 <Tooltip>
253 <TooltipTrigger
254 render={
255 <Button
256 aria-label={contentsLabel}
257 aria-pressed={showingAccessibilityDetails}
258 onClick={() =>
259 setAccessibilityDetailsSrc(showingAccessibilityDetails ? null : item.src)
260 }
261 size="icon-micro"
262 variant="overlay"
263 />
264 }
265 >
266 <MorphIcon
267 className="size-3"
268 aria-hidden="true"
269 icon={showingAccessibilityDetails ? ImageGlyph : TextGlyph}
270 />
271 </TooltipTrigger>
272 <TooltipPopup side="top">{contentsLabel}</TooltipPopup>
273 </Tooltip>
274 ) : item.source ? (
275 <SnapShotContentsButton source={item.source} side="top" />
276 ) : null}
277 </div>
278 </div>
279 </MediaActions>
280 {preview.images.length > 1 && (
281 <Button
282 type="button"
283 size="icon"
284 variant="media-navigation"
285 className="right-0 top-auto bottom-0 translate-y-0 sm:top-1/2 sm:bottom-auto sm:-translate-y-1/2"
286 aria-label="Next media"
287 onClick={() => navigateImage(1)}
288 >
289 <ChevronRightIcon className="size-5" />
290 </Button>
291 )}
292 </DialogPopup>
293 </Dialog>
294 );
295});