1import type { SnapShotSource } from "@t3tools/contracts";
2
3import type { ComposerFileAttachment } from "../../composerDraftStore";
4import { type ChatFileAttachment, type ChatImageAttachment, isVideoAttachment } from "../../types";
5import type {
6 AssetCreateUrlResult,
7 AssetResource,
8 EnvironmentId,
9 ScopedThreadRef,
10} from "@t3tools/contracts";
11import { videoMimeType } from "@t3tools/shared/video";
12import { resolveMediaSource } from "@t3tools/client-runtime/media-source";
13import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
14import {
15 squashAtomCommandFailure,
16 type AtomCommandResult,
17} from "@t3tools/client-runtime/state/runtime";
18import { resolveExternalWebLinkHost } from "./externalLinkContextMenu";
19import type { MediaActionSource } from "../media/MediaActions";
20import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent";
21
22export interface ExpandedImageItem {
23 /** A loadable URL, or null when the dialog must mint one from `asset` first. */
24 src: string | null;
25 name: string;
26 type?: "video";
27 source?: SnapShotSource;
28 autoPlay?: boolean;
29 /** Authored remote destination to open when embedding fails, never a generated asset URL. */
30 originalUrl?: string;
31 srcFragment?: string;
32 actionsSource?: MediaActionSource;
33}
34
35export interface ExpandedImagePreview {
36 images: ExpandedImageItem[];
37 index: number;
38}
39
40/** Wraps navigation in either direction, including offsets beyond a complete cycle. */
41export function wrapExpandedImageIndex(index: number, imageCount: number): number {
42 return imageCount > 0 ? ((index % imageCount) + imageCount) % imageCount : 0;
43}
44
45/** Resolves a chat media reference on its owning environment, without downloading its bytes. */
46export async function resolveMarkdownMediaPreview(input: {
47 source: string;
48 resolvedFilePath?: string | undefined;
49 cwd?: string | undefined;
50 threadRef?: ScopedThreadRef | undefined;
51 httpBaseUrl?: string | undefined;
52 onOpenFile?: ((relativePath: string) => void) | undefined;
53 createAssetUrl: (input: {
54 environmentId: EnvironmentId;
55 input: { resource: AssetResource };
56 }) => Promise<AtomCommandResult<AssetCreateUrlResult, unknown>>;
57}): Promise<ExpandedImagePreview | null> {
58 const media = resolveMediaSource(input.source, {
59 threadId: input.threadRef?.threadId,
60 workspaceRoot: input.cwd,
61 resolvedFilePath: input.resolvedFilePath,
62 });
63 if (media === null) return null;
64 const { kind, name, reference } = media;
65 const relativePath = reference?.kind === "file" ? reference.relativePath : undefined;
66
67 let src: string;
68 let asset: MediaActionSource["asset"];
69 if (media.access === "direct") {
70 src = resolveProtocolRelativeMediaUrl(media.uri);
71 } else {
72 if (media.access === "unavailable" || !input.threadRef || !input.httpBaseUrl) {
73 throw new Error("Reconnect to this environment and open the media again.");
74 }
75 asset = { environmentId: input.threadRef.environmentId, resource: media.resource };
76 const result = await input.createAssetUrl({
77 environmentId: asset.environmentId,
78 input: { resource: asset.resource },
79 });
80 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
81 const assetUrl = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl);
82 if (assetUrl === null) throw new Error("The environment returned an invalid media URL.");
83 src = assetUrl + media.srcFragment;
84 }
85 return {
86 images: [
87 {
88 src,
89 name,
90 ...(kind === "video" ? { type: "video", autoPlay: false } : {}),
91 ...(media.access === "direct" && resolveExternalWebLinkHost(media.uri) !== null
92 ? { originalUrl: media.uri }
93 : {}),
94 ...(media.srcFragment ? { srcFragment: media.srcFragment } : {}),
95 actionsSource: {
96 kind,
97 name,
98 src,
99 ...(reference ? { reference } : {}),
100 ...(asset ? { asset } : {}),
101 ...(relativePath && input.onOpenFile
102 ? { onOpenFile: () => input.onOpenFile?.(relativePath) }
103 : {}),
104 },
105 },
106 ],
107 index: 0,
108 };
109}
110
111export function buildAttachmentVideoAsset(
112 environmentId: EnvironmentId,
113 attachment: ChatFileAttachment,
114): NonNullable<MediaActionSource["asset"]> {
115 return {
116 environmentId,
117 resource: {
118 _tag: "attachment" as const,
119 attachmentId: attachment.id,
120 fileName: attachment.name,
121 mimeType: videoMimeType(attachment) ?? attachment.mimeType,
122 },
123 };
124}
125
126/** Opens a persisted video through the same signed-asset dialog used by message media. */
127export function buildAttachmentVideoPreview(
128 environmentId: EnvironmentId,
129 attachment: ChatFileAttachment,
130): ExpandedImagePreview | null {
131 if (!isVideoAttachment(attachment)) return null;
132 const src = attachment.previewUrl ?? null;
133 const asset =
134 attachment.downloadable === false
135 ? undefined
136 : buildAttachmentVideoAsset(environmentId, attachment);
137 if (src === null && asset === undefined) return null;
138 return {
139 images: [
140 {
141 src,
142 name: attachment.name,
143 type: "video",
144 actionsSource: {
145 kind: "video",
146 name: attachment.name,
147 src,
148 ...(asset ? { asset } : {}),
149 },
150 },
151 ],
152 index: 0,
153 };
154}
155
156export function expandedImageKey(preview: ExpandedImagePreview): string {
157 const item = preview.images[preview.index];
158 const asset = item?.actionsSource?.asset;
159 return `${item?.src ?? (asset ? JSON.stringify([asset.environmentId, asset.resource]) : "image")}:${preview.index}`;
160}
161
162export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void {
163 const url = URL.createObjectURL(file);
164 video.src = url;
165 return () => URL.revokeObjectURL(url);
166}
167
168export function buildExpandedImagePreview(
169 images: ReadonlyArray<ChatImageAttachment | ComposerFileAttachment>,
170 selectedImageId: string,
171): ExpandedImagePreview | null {
172 const selected = images.find((image) => image.id === selectedImageId);
173 if (selected?.type === "file" && selected.file && isVideoAttachment(selected)) {
174 return {
175 images: [{ src: URL.createObjectURL(selected.file), name: selected.name, type: "video" }],
176 index: 0,
177 };
178 }
179 const previewableImages = images.flatMap((image) =>
180 image.type === "image" && image.previewUrl
181 ? [{ id: image.id, src: image.previewUrl, name: image.name, source: image.source }]
182 : [],
183 );
184 if (previewableImages.length === 0) {
185 return null;
186 }
187 const selectedIndex = previewableImages.findIndex((image) => image.id === selectedImageId);
188 if (selectedIndex < 0) {
189 return null;
190 }
191 return {
192 images: previewableImages.map((image) => ({
193 src: image.src,
194 name: image.name,
195 ...(image.source?.kind === "snap-shot" ? { source: image.source } : {}),
196 })),
197 index: selectedIndex,
198 };
199}