1import type { ComposerContextRecord, EnvironmentId } from "@t3tools/contracts";
2import { Alert, Pressable, View } from "react-native";
3import { Image } from "expo-image";
4import { useEffect, useId, useMemo, useState } from "react";
5import type { DraftComposerAttachment } from "../lib/composerImages";
6import { composerAttachmentInlineUri, isFileBackedComposerAttachment } from "../lib/composerImages";
7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import { downloadAndShareAttachment } from "../lib/attachmentDownload";
9import { assetImageSource } from "../lib/assetImageSource";
10import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets";
11import { AppText as Text } from "./AppText";
12import { FilePreviewModal } from "./FilePreviewModal";
13import { PresentationSource } from "./NativePresentation";
14
15export 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}