import { SymbolView } from "../components/AppSymbol"; import { videoMimeType } from "@t3tools/shared/video"; import { useEffect, useMemo, useState } from "react"; import { Image, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "./AppText"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { isFileBackedComposerAttachment, type DraftComposerAttachment, type DraftComposerFileAttachment, type DraftComposerImageAttachment, } from "../lib/composerImages"; import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles"; import { VideoAttachmentTile } from "./VideoAttachmentTile"; import type { MediaActionsSource } from "../lib/mediaActions"; import { PresentationSource } from "./NativePresentation"; import type { FilePreviewSource } from "./FilePreviewModal"; import { isPdfFile } from "../lib/filePreview"; import type { EnvironmentId } from "@t3tools/contracts"; import { retryComposerAttachmentUpload, useComposerAttachmentUploadState, } from "../state/composer-attachment-uploads"; export interface ComposerAttachmentStripProps { readonly environmentId?: EnvironmentId; /** Attachments to display. */ readonly attachments: ReadonlyArray; /** Called when the user removes an attachment. */ readonly onRemove: (imageId: string) => void; /** Called when the user taps an image or PDF to preview it. */ readonly onPressPreview?: (source: FilePreviewSource) => void; readonly onPressVideo?: ( attachment: DraftComposerFileAttachment, sourceIdentifier: string, ) => void; /** Image thumbnail size in points. Defaults to 72. */ readonly imageSize?: number; /** Border radius of each image thumbnail. Defaults to 16. */ readonly imageBorderRadius?: number; /** Whether the remove button should sit in its own gutter instead of overlapping the image. */ readonly removeButtonPlacement?: "overlay" | "gutter"; } type ComposerAttachmentThumbnailProps = { readonly environmentId?: EnvironmentId; readonly attachment: DraftComposerAttachment; readonly size: number; readonly borderRadius: number; readonly compact?: boolean; readonly onPressPreview?: (source: FilePreviewSource) => void; readonly onPressVideo?: ( attachment: DraftComposerFileAttachment, sourceIdentifier: string, ) => void; }; export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailProps) { const upload = useComposerAttachmentUploadState(props.environmentId, props.attachment.id); return ( {upload && upload.status !== "ready" ? ( props.environmentId && retryComposerAttachmentUpload(props.environmentId, props.attachment.id) } 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" > {!props.compact ? ( {upload.status === "failed" ? "Retry" : `${Math.floor(upload.progress * 100)}%`} ) : null} ) : null} ); } /** * Thumbnail URI for a draft image. File-backed previews rebase into the * current iOS data container (its UUID changes across installs); the raw * persisted URI renders meanwhile, which is correct everywhere but after a * container move. */ function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): string { const { fileUri, previewUri } = attachment; const [rebased, setRebased] = useState<{ fileUri: string; uri: string } | null>(null); useEffect(() => { if (fileUri === undefined) return; let cancelled = false; void (async () => { const { Paths } = await import("expo-file-system"); const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri); // Re-render only when the container actually moved. if (!cancelled && owned !== null && owned !== previewUri) setRebased({ fileUri, uri: owned }); })(); return () => { cancelled = true; }; }, [fileUri, previewUri]); return fileUri !== undefined && rebased?.fileUri === fileUri ? rebased.uri : previewUri; } function ComposerImageAttachment( props: ComposerAttachmentThumbnailProps & { readonly attachment: DraftComposerImageAttachment }, ) { const { attachment } = props; const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; const previewUri = useComposerImagePreviewUri(attachment); const sourceIdentifier = `draft-image:${attachment.id}`; return ( props.onPressPreview?.( // File-backed images open through the retain-lease + container // rebase path; legacy drafts still carry their inline bytes. isFileBackedComposerAttachment(attachment) ? { kind: "image", attachment, name: attachment.name, sourceIdentifier } : { kind: "image", uri: attachment.dataUrl ?? attachment.previewUri, name: attachment.name, sourceIdentifier, }, ) } > ); } function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { const { attachment } = props; const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; if (attachment.type === "image") { return ; } const onPressVideo = props.onPressVideo; if (onPressVideo && videoMimeType(attachment) !== null) { return ( ); } const canPreview = isPdfFile(attachment) && props.onPressPreview !== undefined; const sourceIdentifier = `draft-file:${attachment.id}`; return ( props.onPressPreview?.({ kind: "pdf", name: attachment.name, attachment, sourceIdentifier, }) } className={ props.compact ? "items-center justify-center bg-subtle" : "items-center justify-center gap-1 bg-subtle px-2" } style={style} > {!props.compact ? ( {attachment.name} ) : null} ); } function ComposerVideoAttachment(props: { readonly attachment: DraftComposerFileAttachment; readonly size: number; readonly borderRadius: number; readonly compact?: boolean; readonly onPressVideo: ( attachment: DraftComposerFileAttachment, sourceIdentifier: string, ) => void; }) { const { attachment } = props; const sourceIdentifier = `draft:${attachment.id}`; const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; const actionsSource = useMemo( () => ({ name: attachment.name, mimeType: videoMimeType(attachment) ?? attachment.mimeType, sourceIdentifier, attachment, }), [attachment, sourceIdentifier], ); return ( props.onPressVideo(attachment, sourceIdentifier)} actionsSource={actionsSource} style={style} /> ); } /** * Attachment thumbnails used by the thread composer and the new-task draft screen. */ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { const size = props.imageSize ?? 72; const radius = props.imageBorderRadius ?? 16; const removeButtonPlacement = props.removeButtonPlacement ?? "overlay"; const removeButtonGutter = removeButtonPlacement === "gutter" ? 10 : 0; if (props.attachments.length === 0) { return null; } return ( {props.attachments.map((attachment) => ( props.onRemove(attachment.id)} > ))} ); }