| 1 | import type { ComposerContextRecord, EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { Alert, Pressable, View } from "react-native"; |
| 3 | import { Image } from "expo-image"; |
| 4 | import { useEffect, useId, useMemo, useState } from "react"; |
| 5 | import type { DraftComposerAttachment } from "../lib/composerImages"; |
| 6 | import { composerAttachmentInlineUri, isFileBackedComposerAttachment } from "../lib/composerImages"; |
| 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 8 | import { downloadAndShareAttachment } from "../lib/attachmentDownload"; |
| 9 | import { assetImageSource } from "../lib/assetImageSource"; |
| 10 | import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; |
| 11 | import { AppText as Text } from "./AppText"; |
| 12 | import { FilePreviewModal } from "./FilePreviewModal"; |
| 13 | import { PresentationSource } from "./NativePresentation"; |
| 14 | |
| 15 | export function ComposerContextAttachment(props: { |
| 16 | record: Extract<ComposerContextRecord, { attachmentId: string }>; |
| 17 | environmentId?: EnvironmentId; |
| 18 | attachment?: DraftComposerAttachment; |
| 19 | }) { |
| 20 | const { record, attachment } = props; |
| 21 | const shareSourceIdentifier = useId(); |
| 22 | const resource = useMemo( |
| 23 | () => ({ |
| 24 | _tag: "attachment" as const, |
| 25 | attachmentId: record.attachmentId, |
| 26 | fileName: record.name, |
| 27 | }), |
| 28 | [record.attachmentId, record.name], |
| 29 | ); |
| 30 | const local = attachment?.fileUri |
| 31 | ? (attachment as DraftComposerAttachment & { fileUri: string }) |
| 32 | : undefined; |
| 33 | const inlineUri = composerAttachmentInlineUri(attachment); |
| 34 | const remoteEnvironmentId = local || inlineUri ? null : (props.environmentId ?? null); |
| 35 | const asset = useAssetUrlState(remoteEnvironmentId, resource); |
| 36 | const refresh = useRefreshAssetUrl(remoteEnvironmentId, resource); |
| 37 | const [localUri, setLocalUri] = useState<string | null>(null); |
| 38 | const [error, setError] = useState<string | null>(null); |
| 39 | const [sharing, setSharing] = useState(false); |
| 40 | const [previewOpen, setPreviewOpen] = useState(false); |
| 41 | useEffect(() => { |
| 42 | if (!local) return; |
| 43 | const controller = new AbortController(); |
| 44 | let dispose: (() => void) | undefined; |
| 45 | void loadLocalAttachmentPreview(local, controller.signal) |
| 46 | .then((preview) => { |
| 47 | if (!preview) return; |
| 48 | if (controller.signal.aborted) return preview.dispose(); |
| 49 | dispose = preview.dispose; |
| 50 | setLocalUri(preview.uri); |
| 51 | setError(null); |
| 52 | }) |
| 53 | .catch(() => { |
| 54 | if (!controller.signal.aborted) setError("The local file is unavailable. Attach it again."); |
| 55 | }); |
| 56 | return () => { |
| 57 | controller.abort(); |
| 58 | dispose?.(); |
| 59 | }; |
| 60 | }, [local]); |
| 61 | const uri = local ? localUri : (inlineUri ?? (asset._tag === "Success" ? asset.url : null)); |
| 62 | const share = async () => { |
| 63 | if (sharing) return; |
| 64 | setSharing(true); |
| 65 | const controller = new AbortController(); |
| 66 | try { |
| 67 | if (local) { |
| 68 | const preview = await loadLocalAttachmentPreview(local, controller.signal); |
| 69 | try { |
| 70 | await preview?.share(controller.signal, shareSourceIdentifier); |
| 71 | } finally { |
| 72 | preview?.dispose(); |
| 73 | } |
| 74 | } else { |
| 75 | const url = await refresh(); |
| 76 | if (!url) throw new Error("Reconnect to the environment and try again."); |
| 77 | await downloadAndShareAttachment({ |
| 78 | url, |
| 79 | attachment: record, |
| 80 | signal: controller.signal, |
| 81 | sourceIdentifier: shareSourceIdentifier, |
| 82 | }); |
| 83 | } |
| 84 | } catch (cause) { |
| 85 | Alert.alert( |
| 86 | "Could not open attachment", |
| 87 | cause instanceof Error ? cause.message : "Try again.", |
| 88 | ); |
| 89 | } finally { |
| 90 | setSharing(false); |
| 91 | } |
| 92 | }; |
| 93 | return ( |
| 94 | <View className="gap-3"> |
| 95 | {record.kind === "image" && uri ? ( |
| 96 | <Pressable |
| 97 | accessibilityRole="button" |
| 98 | accessibilityLabel={`Preview ${record.name}`} |
| 99 | onPress={() => setPreviewOpen(true)} |
| 100 | > |
| 101 | <Image |
| 102 | source={ |
| 103 | remoteEnvironmentId === null |
| 104 | ? { uri } |
| 105 | : assetImageSource(remoteEnvironmentId, resource, uri) |
| 106 | } |
| 107 | style={{ width: "100%", height: 280 }} |
| 108 | contentFit="contain" |
| 109 | accessibilityLabel={record.name} |
| 110 | /> |
| 111 | </Pressable> |
| 112 | ) : null} |
| 113 | {error || (!local && asset._tag === "Failure") ? ( |
| 114 | <Text className="text-foreground-muted"> |
| 115 | {error ?? "Attachment unavailable. Reconnect and try again."} |
| 116 | </Text> |
| 117 | ) : null} |
| 118 | <PresentationSource identifier={shareSourceIdentifier}> |
| 119 | <Pressable |
| 120 | accessibilityRole="button" |
| 121 | disabled={sharing} |
| 122 | onPress={() => void share()} |
| 123 | className="rounded-xl bg-subtle p-4" |
| 124 | > |
| 125 | <Text className="text-foreground"> |
| 126 | {sharing ? "Opening attachment…" : "Open or share attachment"} |
| 127 | </Text> |
| 128 | </Pressable> |
| 129 | </PresentationSource> |
| 130 | {previewOpen && record.kind === "image" && uri ? ( |
| 131 | <FilePreviewModal |
| 132 | source={{ |
| 133 | kind: "image", |
| 134 | name: record.name, |
| 135 | uri, |
| 136 | actionsSource: { |
| 137 | name: record.name, |
| 138 | mimeType: record.mimeType, |
| 139 | ...(attachment && isFileBackedComposerAttachment(attachment) |
| 140 | ? { attachment } |
| 141 | : inlineUri === undefined && props.environmentId |
| 142 | ? { environmentId: props.environmentId, resource } |
| 143 | : { uri }), |
| 144 | }, |
| 145 | }} |
| 146 | onRequestClose={() => setPreviewOpen(false)} |
| 147 | /> |
| 148 | ) : null} |
| 149 | </View> |
| 150 | ); |
| 151 | } |