1import { TextInputWrapper } from "expo-paste-input";
2import { AppTextInput as TextInput } from "../../components/AppText";
3import { useNativePaste } from "../../lib/useNativePaste";
4import { convertPastedImagesToAttachments } from "../../lib/composerImages";
5import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type UserInputQuestion } from "@t3tools/contracts";
6import { useAtomValue } from "@effect/atom-react";
7import { Alert, View } from "react-native";
8import { useEffect, useRef, useState } from "react";
9import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
10import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
11import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages";
12import { useThreadSelection } from "../../state/use-thread-selection";
13import { useNavigation } from "@react-navigation/native";
14import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
15import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
16import { useServerConfigs } from "../../state/entities";
17import { appAtomRegistry } from "../../state/atom-registry";
18import {
19 appendComposerDraftAttachments,
20 composerDraftsAtom,
21 removeComposerDraftAttachment,
22 releaseUnusedComposerAttachmentFiles,
23} from "../../state/use-composer-drafts";
24import {
25 changeQuestionAttachmentPreparation,
26 questionAttachmentDraftKey,
27 questionAttachmentPreparationAtom,
28} from "../../state/question-attachments";
29
30export function QuestionAttachments(props: {
31 requestId: string;
32 question: UserInputQuestion;
33 questions: ReadonlyArray<UserInputQuestion>;
34 disabled: boolean;
35 value: string;
36 onChangeText: (value: string) => void;
37 onInputFocusChange?: ((focused: boolean) => void) | undefined;
38}) {
39 const { selectedThread } = useThreadSelection();
40 const navigation = useNavigation();
41 const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
42 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
43 const configs = useServerConfigs();
44 const drafts = useAtomValue(composerDraftsAtom);
45 const scopeKey = JSON.stringify([
46 selectedThread?.environmentId,
47 selectedThread?.id,
48 props.requestId,
49 props.question.id,
50 ]);
51 const pickerScope = useRef<{ key: string; active: boolean } | null>(null);
52 useEffect(() => {
53 const scope = { key: scopeKey, active: true };
54 pickerScope.current = scope;
55 return () => {
56 scope.active = false;
57 };
58 }, [scopeKey]);
59 const append = (
60 key: string,
61 attachments: Parameters<typeof appendComposerDraftAttachments>[1],
62 ) => {
63 if (!selectedThread) return 0;
64 const current = appAtomRegistry.get(composerDraftsAtom);
65 const otherCount = props.questions.reduce((count, question) => {
66 const target = questionAttachmentDraftKey(
67 selectedThread.environmentId,
68 selectedThread.id,
69 props.requestId,
70 question.id,
71 );
72 return target === key ? count : count + (current[target]?.attachments.length ?? 0);
73 }, 0);
74 return appendComposerDraftAttachments(key, attachments, {
75 maxAttachments: Math.max(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - otherCount),
76 });
77 };
78 const paste = useNativePaste((uris) => {
79 const scope = pickerScope.current;
80 if (
81 !selectedThread ||
82 props.disabled ||
83 !configs.get(selectedThread.environmentId)?.environment.capabilities.questionAttachments
84 )
85 return;
86 const key = questionAttachmentDraftKey(
87 selectedThread.environmentId,
88 selectedThread.id,
89 props.requestId,
90 props.question.id,
91 );
92 changeQuestionAttachmentPreparation(key, 1);
93 void convertPastedImagesToAttachments({
94 uris,
95 existingCount: appAtomRegistry.get(composerDraftsAtom)[key]?.attachments.length ?? 0,
96 })
97 .then(async (images) => {
98 if (
99 scope?.active &&
100 (appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) > 0
101 ) {
102 if (append(key, images) > 0)
103 Alert.alert("Could not paste image", "Too many attachments.");
104 } else await releaseUnusedComposerAttachmentFiles(images);
105 })
106 .catch((error) =>
107 Alert.alert("Could not paste image", error instanceof Error ? error.message : "Try again."),
108 )
109 .finally(() => changeQuestionAttachmentPreparation(key, -1));
110 });
111 if (!selectedThread || props.question.allowCustomAnswer === false) return null;
112 const { environmentId, id: threadId } = selectedThread;
113 const capabilities = configs.get(environmentId)?.environment.capabilities;
114 const canAttach = capabilities?.questionAttachments === true;
115 const key = questionAttachmentDraftKey(
116 environmentId,
117 threadId,
118 props.requestId,
119 props.question.id,
120 );
121 const attachments = drafts[key]?.attachments ?? [];
122 const pick = async (kind: "media" | "files") => {
123 const scope = pickerScope.current;
124 changeQuestionAttachmentPreparation(key, 1);
125 try {
126 const existingCount = appAtomRegistry.get(composerDraftsAtom)[key]?.attachments.length ?? 0;
127 const result =
128 kind === "files"
129 ? await pickComposerFiles({
130 existingCount,
131 maxBytes: capabilities?.fileAttachments?.maxUploadBytes,
132 })
133 : await pickComposerMedia({
134 existingCount,
135 maxVideoBytes: capabilities?.fileAttachments?.maxUploadBytes,
136 });
137 const picked = "files" in result ? result.files : result.attachments;
138 // Resolution on another client clears the reservation while the picker is open.
139 if (
140 !scope?.active ||
141 (appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) === 0
142 ) {
143 await releaseUnusedComposerAttachmentFiles(picked);
144 return;
145 }
146 const rejected = append(key, picked);
147 if (result.error || rejected > 0)
148 Alert.alert("Could not attach file", result.error ?? "Too many attachments.");
149 } catch (error) {
150 Alert.alert("Could not attach file", error instanceof Error ? error.message : "Try again.");
151 } finally {
152 changeQuestionAttachmentPreparation(key, -1);
153 }
154 };
155 return (
156 <View className="gap-2">
157 {canAttach ? (
158 <ComposerAttachmentButton
159 disabled={props.disabled}
160 supportsFiles={Boolean(capabilities?.fileAttachments)}
161 onPickMedia={() => pick("media")}
162 onPickFiles={() => pick("files")}
163 />
164 ) : null}
165 <ComposerAttachmentStrip
166 environmentId={environmentId}
167 attachments={attachments}
168 onRemove={(id) => {
169 if (!props.disabled) removeComposerDraftAttachment(key, id);
170 }}
171 onPressPreview={setPreviewFile}
172 onPressVideo={(attachment, sourceIdentifier) =>
173 setPreviewVideo({ type: "local", attachment, sourceIdentifier })
174 }
175 onPressDocument={(attachment) =>
176 navigation.navigate("ThreadAttachment", {
177 environmentId: String(environmentId),
178 threadId: String(threadId),
179 attachmentId: attachment.id,
180 name: attachment.name,
181 mimeType: attachment.mimeType,
182 sizeBytes: String(attachment.sizeBytes),
183 draftKey: key,
184 })
185 }
186 />
187 <FilePreviewModal source={previewFile} onRequestClose={() => setPreviewFile(null)} />
188 <VideoPreviewModal source={previewVideo} onRequestClose={() => setPreviewVideo(null)} />
189 <TextInputWrapper onPaste={paste}>
190 <TextInput
191 value={props.value}
192 multiline={props.question.initialAnswer !== undefined}
193 submitBehavior={props.question.initialAnswer !== undefined ? "newline" : undefined}
194 editable={!props.disabled}
195 onChangeText={props.onChangeText}
196 onFocus={() => props.onInputFocusChange?.(true)}
197 onBlur={() => props.onInputFocusChange?.(false)}
198 placeholder="Or type a custom answer"
199 className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground"
200 />
201 </TextInputWrapper>
202 </View>
203 );
204}