1import { FilePreviewGallery, useFilePreviewGallery } from "./FilePreviewGallery";
2import { composerImageGallery } from "../lib/composerImageGallery";
3import { SymbolView } from "../components/AppSymbol";
4import { imageMimeType } from "@t3tools/shared/image";
5import { videoMimeType } from "@t3tools/shared/video";
6import { useEffect, useMemo, useState } from "react";
7import { Image, Pressable, ScrollView, View } from "react-native";
8
9import { AppText as Text } from "./AppText";
10import { PierreEntryIcon } from "./PierreEntryIcon";
11import {
12 isFileBackedComposerAttachment,
13 type DraftComposerAttachment,
14 type DraftComposerFileAttachment,
15 type DraftComposerImageAttachment,
16} from "../lib/composerImages";
17import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles";
18import { VideoAttachmentTile } from "./VideoAttachmentTile";
19import { type MediaActionsSource } from "../lib/mediaActionsSource";
20import { PresentationSource } from "./NativePresentation";
21import type { FilePreviewSource } from "./FilePreviewModal";
22import { isPdfFile } from "../lib/filePreview";
23import type { EnvironmentId } from "@t3tools/contracts";
24import {
25 retryComposerAttachmentUpload,
26 useComposerAttachmentUploadState,
27} from "../state/composer-attachment-uploads";
28
29export interface ComposerAttachmentStripProps {
30 readonly environmentId?: EnvironmentId;
31 /** Attachments to display. */
32 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
33 /** Called when the user removes an attachment. */
34 readonly onRemove: (imageId: string) => void;
35 /** Called when the user taps an image or PDF to preview it. */
36 readonly onPressPreview?: (source: FilePreviewSource) => void;
37 readonly onPressVideo?: (
38 attachment: DraftComposerFileAttachment,
39 sourceIdentifier: string,
40 ) => void;
41 /** Called when the user taps a document that is not a picture, video or PDF. */
42 readonly onPressDocument?: (attachment: DraftComposerFileAttachment) => void;
43 /** Image thumbnail size in points. Defaults to 72. */
44 readonly imageSize?: number;
45 /** Border radius of each image thumbnail. Defaults to 16. */
46 readonly imageBorderRadius?: number;
47 /** Whether the remove button should sit in its own gutter instead of overlapping the image. */
48 readonly removeButtonPlacement?: "overlay" | "gutter";
49}
50
51type ComposerAttachmentThumbnailProps = {
52 readonly environmentId?: EnvironmentId;
53 readonly attachment: DraftComposerAttachment;
54 readonly size: number;
55 readonly borderRadius: number;
56 readonly compact?: boolean;
57 readonly onPressPreview?: (source: FilePreviewSource) => void;
58 readonly onPressVideo?: (
59 attachment: DraftComposerFileAttachment,
60 sourceIdentifier: string,
61 ) => void;
62 readonly onPressDocument?: (attachment: DraftComposerFileAttachment) => void;
63};
64
65export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailProps) {
66 const upload = useComposerAttachmentUploadState(props.environmentId, props.attachment.id);
67 return (
68 <View style={{ width: props.size, height: props.size }}>
69 <ComposerAttachmentContent {...props} />
70 {upload && upload.status !== "ready" ? (
71 <Pressable
72 accessibilityRole={upload.status === "failed" ? "button" : "text"}
73 accessibilityLabel={
74 upload.status === "failed"
75 ? `Retry uploading ${props.attachment.name}`
76 : `Uploading ${props.attachment.name}, ${Math.floor(upload.progress * 100)}%`
77 }
78 accessibilityHint={upload.status === "failed" ? upload.reason : undefined}
79 disabled={upload.status !== "failed"}
80 onPress={() =>
81 props.environmentId &&
82 retryComposerAttachmentUpload(props.environmentId, props.attachment.id)
83 }
84 className="absolute bottom-0.5 left-0.5 flex-row items-center gap-0.5 rounded-full bg-black/70 px-1 py-0.5"
85 >
86 <SymbolView
87 name={upload.status === "failed" ? "arrow.clockwise" : "arrow.up"}
88 size={props.compact ? 8 : 10}
89 tintColor="#ffffff"
90 type="monochrome"
91 />
92 {!props.compact ? (
93 <Text className="text-2xs text-white">
94 {upload.status === "failed" ? "Retry" : `${Math.floor(upload.progress * 100)}%`}
95 </Text>
96 ) : null}
97 </Pressable>
98 ) : null}
99 </View>
100 );
101}
102
103/**
104 * Thumbnail URI for a draft image. File-backed previews rebase into the
105 * current iOS data container (its UUID changes across installs); the raw
106 * persisted URI renders meanwhile, which is correct everywhere but after a
107 * container move.
108 */
109const PREVIEW_CACHE_DIRECTORY = "t3-composer-previews";
110
111/**
112 * Fabric re-parses an image source URL on every layout pass of the node, and a
113 * multi-megabyte data URL makes each Fabric commit slow enough that concurrent
114 * UI-thread commits (the question card's coverage animation) win the race every
115 * time until the renderer aborts. Inline bytes are written to the cache once and
116 * the thumbnail renders from that file instead.
117 */
118/** Roughly 192KB of base64: small enough that re-parsing it per layout stays imperceptible. */
119const INLINE_PREVIEW_FALLBACK_MAX_CHARS = 256_000;
120
121async function materializeDataUrlPreview(id: string, dataUrl: string): Promise<string | null> {
122 const comma = dataUrl.indexOf(",");
123 if (comma < 0) return null;
124 const { Directory, File, Paths } = await import("expo-file-system");
125 const mimeType = /^data:([^;,]+)/.exec(dataUrl)?.[1] ?? "image/jpeg";
126 const extension = (mimeType.split("/")[1] ?? "jpg").replace("jpeg", "jpg");
127 const directory = new Directory(Paths.cache, PREVIEW_CACHE_DIRECTORY);
128 directory.create({ idempotent: true, intermediates: true });
129 const file = new File(directory, `${id}.${extension}`);
130 if (!file.exists) {
131 file.create();
132 await file.write(dataUrl.slice(comma + 1), { encoding: "base64" });
133 }
134 return file.uri;
135}
136
137/** The thumbnail source for a draft image: an owned file when there is one, never a data URL. */
138function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): string | null {
139 const { id, fileUri, previewUri } = attachment;
140 const [rebased, setRebased] = useState<{ fileUri: string; uri: string } | null>(null);
141 const [materialized, setMaterialized] = useState<{ id: string; uri: string | null } | null>(null);
142 const inlinePreview = fileUri === undefined && previewUri.startsWith("data:");
143 useEffect(() => {
144 if (fileUri === undefined) return;
145 let cancelled = false;
146 void (async () => {
147 const { Paths } = await import("expo-file-system");
148 const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri);
149 // Re-render only when the container actually moved.
150 if (!cancelled && owned !== null && owned !== previewUri) setRebased({ fileUri, uri: owned });
151 })();
152 return () => {
153 cancelled = true;
154 };
155 }, [fileUri, previewUri]);
156 useEffect(() => {
157 if (!inlinePreview) return;
158 let cancelled = false;
159 void materializeDataUrlPreview(id, previewUri)
160 .then((uri) => {
161 if (!cancelled && uri !== null) setMaterialized({ id, uri });
162 })
163 .catch((error: unknown) => {
164 console.warn("[composer-attachments] could not cache an image preview", error);
165 // Record the failure so the thumbnail stops waiting on a file that will never arrive.
166 if (!cancelled) setMaterialized({ id, uri: null });
167 });
168 return () => {
169 cancelled = true;
170 };
171 }, [id, inlinePreview, previewUri]);
172 if (fileUri !== undefined && rebased?.fileUri === fileUri) return rebased.uri;
173 if (fileUri !== undefined) return previewUri.startsWith("data:") ? fileUri : previewUri;
174 if (inlinePreview) {
175 if (materialized?.id !== id) return null;
176 // Falling back to the data URL is a last resort: a large one re-parses on every layout and
177 // starves the Fabric commit, which is what the cache file exists to avoid. Small ones are
178 // cheap enough to render directly rather than leaving the thumbnail blank forever.
179 return (
180 materialized.uri ??
181 (previewUri.length <= INLINE_PREVIEW_FALLBACK_MAX_CHARS ? previewUri : null)
182 );
183 }
184 return previewUri;
185}
186
187function ComposerImageAttachment(
188 props: ComposerAttachmentThumbnailProps & { readonly attachment: DraftComposerImageAttachment },
189) {
190 const { attachment } = props;
191 const withGallery = useFilePreviewGallery();
192 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
193 const previewUri = useComposerImagePreviewUri(attachment);
194 const sourceIdentifier = `draft-image:${attachment.id}`;
195 return (
196 <PresentationSource identifier={sourceIdentifier}>
197 <Pressable
198 accessibilityRole="imagebutton"
199 accessibilityLabel={`Open ${attachment.name}`}
200 disabled={!props.onPressPreview}
201 onPress={() =>
202 props.onPressPreview?.(
203 withGallery(
204 // File-backed images open through the retain-lease + container
205 // rebase path; legacy drafts still carry their inline bytes.
206 isFileBackedComposerAttachment(attachment)
207 ? { kind: "image", attachment, name: attachment.name, sourceIdentifier }
208 : {
209 kind: "image",
210 uri: attachment.dataUrl ?? attachment.previewUri,
211 name: attachment.name,
212 sourceIdentifier,
213 },
214 ),
215 )
216 }
217 >
218 <Image
219 source={previewUri === null ? undefined : { uri: previewUri }}
220 style={style}
221 className="bg-subtle"
222 resizeMode="cover"
223 />
224 </Pressable>
225 </PresentationSource>
226 );
227}
228
229function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) {
230 const { attachment } = props;
231 // The document picker types every pick as a plain file, so a picture arrives here as one.
232 // What it *is* decides how it presents, the same way videos are already recognised below.
233 if (attachment.type === "image" || imageMimeType(attachment) !== null) {
234 // A pasted-text marker does not fit the snapshot source a picture carries.
235 const { source: _droppedSource, ...rest } = attachment;
236 return (
237 <ComposerImageAttachment
238 {...props}
239 attachment={
240 attachment.type === "image"
241 ? attachment
242 : { ...rest, type: "image", previewUri: attachment.fileUri }
243 }
244 />
245 );
246 }
247 const onPressVideo = props.onPressVideo;
248 if (onPressVideo && videoMimeType(attachment) !== null) {
249 return (
250 <ComposerVideoAttachment {...props} attachment={attachment} onPressVideo={onPressVideo} />
251 );
252 }
253 return <ComposerFileAttachment {...props} attachment={attachment} />;
254}
255
256function ComposerFileAttachment(
257 props: ComposerAttachmentThumbnailProps & { readonly attachment: DraftComposerFileAttachment },
258) {
259 const { attachment } = props;
260 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
261 const canPreview = isPdfFile(attachment) && props.onPressPreview !== undefined;
262 const sourceIdentifier = `draft-file:${attachment.id}`;
263 const onPressDocument = props.onPressDocument;
264 return (
265 <>
266 <PresentationSource identifier={sourceIdentifier}>
267 <Pressable
268 accessibilityRole="button"
269 accessibilityLabel={`Open ${attachment.name}`}
270 disabled={!canPreview && onPressDocument === undefined}
271 onPress={() =>
272 canPreview
273 ? props.onPressPreview?.({
274 kind: "pdf",
275 name: attachment.name,
276 attachment,
277 sourceIdentifier,
278 })
279 : onPressDocument?.(attachment)
280 }
281 className={
282 props.compact
283 ? "items-center justify-center bg-subtle"
284 : "items-center justify-center gap-1 bg-subtle px-2"
285 }
286 style={style}
287 >
288 <PierreEntryIcon path={attachment.name} kind="file" size={props.compact ? 15 : 22} />
289 {!props.compact ? (
290 <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}>
291 {attachment.name}
292 </Text>
293 ) : null}
294 </Pressable>
295 </PresentationSource>
296 </>
297 );
298}
299
300function ComposerVideoAttachment(props: {
301 readonly attachment: DraftComposerFileAttachment;
302 readonly size: number;
303 readonly borderRadius: number;
304 readonly compact?: boolean;
305 readonly onPressVideo: (
306 attachment: DraftComposerFileAttachment,
307 sourceIdentifier: string,
308 ) => void;
309}) {
310 const { attachment } = props;
311 const sourceIdentifier = `draft:${attachment.id}`;
312 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
313 const actionsSource = useMemo<MediaActionsSource>(
314 () => ({
315 name: attachment.name,
316 mimeType: videoMimeType(attachment) ?? attachment.mimeType,
317 sourceIdentifier,
318 attachment,
319 }),
320 [attachment, sourceIdentifier],
321 );
322
323 return (
324 <VideoAttachmentTile
325 name={attachment.name}
326 sourceIdentifier={sourceIdentifier}
327 thumbnailSource={attachment}
328 compact={props.compact}
329 onPress={() => props.onPressVideo(attachment, sourceIdentifier)}
330 actionsSource={actionsSource}
331 style={style}
332 />
333 );
334}
335
336/**
337 * Attachment thumbnails used by the thread composer and the new-task draft screen.
338 */
339export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
340 const gallery = useMemo(() => composerImageGallery(props.attachments), [props.attachments]);
341 const size = props.imageSize ?? 72;
342 const radius = props.imageBorderRadius ?? 16;
343 const removeButtonPlacement = props.removeButtonPlacement ?? "overlay";
344 const removeButtonGutter = removeButtonPlacement === "gutter" ? 10 : 0;
345
346 if (props.attachments.length === 0) {
347 return null;
348 }
349
350 return (
351 <FilePreviewGallery value={gallery}>
352 <ScrollView
353 horizontal
354 showsHorizontalScrollIndicator={false}
355 keyboardShouldPersistTaps="always"
356 className="grow-0"
357 >
358 <View className="flex-row gap-2.5">
359 {props.attachments.map((attachment) => (
360 <View
361 key={attachment.id}
362 className="relative"
363 style={{
364 paddingTop: removeButtonGutter,
365 paddingRight: removeButtonGutter,
366 }}
367 >
368 <ComposerAttachmentThumbnail
369 environmentId={props.environmentId}
370 attachment={attachment}
371 size={size}
372 borderRadius={radius}
373 onPressPreview={props.onPressPreview}
374 onPressVideo={props.onPressVideo}
375 onPressDocument={props.onPressDocument}
376 />
377 <Pressable
378 className="absolute h-[22px] w-[22px] items-center justify-center rounded-[11px] bg-black/55"
379 style={{
380 top: removeButtonPlacement === "gutter" ? 0 : 4,
381 right: removeButtonPlacement === "gutter" ? 0 : 4,
382 }}
383 hitSlop={6}
384 onPress={() => props.onRemove(attachment.id)}
385 >
386 <SymbolView
387 name="xmark"
388 size={9}
389 tintColor="#ffffff"
390 type="monochrome"
391 weight="bold"
392 />
393 </Pressable>
394 </View>
395 ))}
396 </View>
397 </ScrollView>
398 </FilePreviewGallery>
399 );
400}