| 1 | import { FilePreviewGallery, useFilePreviewGallery } from "./FilePreviewGallery"; |
| 2 | import { composerImageGallery } from "../lib/composerImageGallery"; |
| 3 | import { SymbolView } from "../components/AppSymbol"; |
| 4 | import { imageMimeType } from "@t3tools/shared/image"; |
| 5 | import { videoMimeType } from "@t3tools/shared/video"; |
| 6 | import { useEffect, useMemo, useState } from "react"; |
| 7 | import { Image, Pressable, ScrollView, View } from "react-native"; |
| 8 | |
| 9 | import { AppText as Text } from "./AppText"; |
| 10 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 11 | import { |
| 12 | isFileBackedComposerAttachment, |
| 13 | type DraftComposerAttachment, |
| 14 | type DraftComposerFileAttachment, |
| 15 | type DraftComposerImageAttachment, |
| 16 | } from "../lib/composerImages"; |
| 17 | import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles"; |
| 18 | import { VideoAttachmentTile } from "./VideoAttachmentTile"; |
| 19 | import { type MediaActionsSource } from "../lib/mediaActionsSource"; |
| 20 | import { PresentationSource } from "./NativePresentation"; |
| 21 | import type { FilePreviewSource } from "./FilePreviewModal"; |
| 22 | import { isPdfFile } from "../lib/filePreview"; |
| 23 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 24 | import { |
| 25 | retryComposerAttachmentUpload, |
| 26 | useComposerAttachmentUploadState, |
| 27 | } from "../state/composer-attachment-uploads"; |
| 28 | |
| 29 | export 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 | |
| 51 | type 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 | |
| 65 | export 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 | */ |
| 109 | const 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. */ |
| 119 | const INLINE_PREVIEW_FALLBACK_MAX_CHARS = 256_000; |
| 120 | |
| 121 | async 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. */ |
| 138 | function 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 | |
| 187 | function 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 | |
| 229 | function 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 | |
| 256 | function 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 | |
| 300 | function 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 | */ |
| 339 | export 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 | } |