Skip to content
Source
Sign inSign up
Open History to see the last commit

QuestionAttachments.tsx

236 lines9.9 KB

1import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
2import { AsyncResult } from "effect/reactivity";
3import * as Dialog from "../../components/ui/Dialog";
4import { TextInputWrapper } from "expo-paste-input";
5import { AppTextInput as TextInput } from "../../components/ui/AppText";
6import { useNativePaste } from "../../lib/useNativePaste";
7import { convertPastedImagesToAttachments } from "../../lib/composerImages";
8import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type UserInputQuestion } from "@t3tools/contracts";
9import { useAtomValue } from "@effect/atom-react";
10import { View, type TextInputInstance } from "react-native";
11import { useEffect, useEffectEvent, useRef, useState } from "react";
12import { ComposerAttachmentButton } from "./ComposerAttachmentButton";
13import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
14import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages";
15import { useThreadSelection } from "../../state/use-thread-selection";
16import { useNavigation } from "@react-navigation/native";
17import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal";
18import { VideoPreviewModal, type VideoPreviewSource } from "../../components/ui/VideoPreviewModal";
19import { useServerConfigs } from "../../state/entities";
20import { appAtomRegistry } from "../../state/atom-registry";
21import {
22 appendComposerDraftAttachments,
23 composerDraftsAtom,
24 removeComposerDraftAttachment,
25 releaseUnusedComposerAttachmentFiles,
26} from "../../state/use-composer-drafts";
27import {
28 changeQuestionAttachmentPreparation,
29 questionAttachmentDraftKey,
30 questionAttachmentPreparationAtom,
31} from "../../state/question-attachments";
32​
33export function QuestionAttachments(props: {
34 requestId: string;
35 question: UserInputQuestion;
36 questions: ReadonlyArray<UserInputQuestion>;
37 disabled: boolean;
38 value: string;
39 onChangeText: (value: string) => void;
40 onInputFocusChange?: ((focused: boolean) => void) | undefined;
41}) {
42 const { selectedThread } = useThreadSelection();
43 const navigation = useNavigation();
44 const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
45 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
46 const configs = useServerConfigs();
47 const drafts = useAtomValue(composerDraftsAtom);
48 const focusedRef = useRef(false);
49 const onInputFocusChange = props.onInputFocusChange;
50 // The caller keys this component by request and question, so unmounting ends any open
51 // picker's scope.
52 const pickerScope = useRef<{ active: boolean }>({ active: false });
53 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens a scope that native picker and paste callbacks check, and closes it on unmount
54 useEffect(() => {
55 const scope = { active: true };
56 pickerScope.current = scope;
57 return () => {
58 scope.active = false;
59 };
60 }, []);
61 // Unmounted inputs never emit onBlur; explicitly release keyboard ownership.
62 const releaseFocus = useEffectEvent(() => {
63 if (focusedRef.current) onInputFocusChange?.(false);
64 });
65 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- releases keyboard ownership on unmount, since an unmounted native input never emits blur
66 useEffect(() => () => releaseFocus(), []);
67 // A controlled value makes Fabric rebuild the native text on every keystroke, which discards
68 // iOS's pending autocorrection. The input owns its text; the only outside write is an option
69 // selection clearing the draft.
70 const inputRef = useRef<TextInputInstance>(null);
71 const [initialText] = useState(props.value);
72 const nativeText = useRef(props.value);
73 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes an option selection's cleared draft into the uncontrolled native input
74 useEffect(() => {
75 if (props.value !== "" || nativeText.current === "") return;
76 nativeText.current = "";
77 inputRef.current?.clear();
78 }, [props.value]);
79 const append = (
80 key: string,
81 attachments: Parameters<typeof appendComposerDraftAttachments>[1],
82 ) => {
83 if (!selectedThread) return 0;
84 const current = appAtomRegistry.get(composerDraftsAtom);
85 const otherCount = props.questions.reduce((count, question) => {
86 const target = questionAttachmentDraftKey(
87 selectedThread.environmentId,
88 selectedThread.id,
89 props.requestId,
90 question.id,
91 );
92 return target === key ? count : count + (current[target]?.attachments.length ?? 0);
93 }, 0);
94 return appendComposerDraftAttachments(key, attachments, {
95 maxAttachments: Math.max(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - otherCount),
96 });
97 };
98 const paste = useNativePaste((uris) => {
99 const scope = pickerScope.current;
100 if (!selectedThread || props.disabled || !configs.get(selectedThread.environmentId)) return;
101 const key = questionAttachmentDraftKey(
102 selectedThread.environmentId,
103 selectedThread.id,
104 props.requestId,
105 props.question.id,
106 );
107 changeQuestionAttachmentPreparation(key, 1);
108 void convertPastedImagesToAttachments({
109 uris,
110 existingCount: appAtomRegistry.get(composerDraftsAtom)[key]?.attachments.length ?? 0,
111 })
112 .then(async (result) => {
113 if (
114 scope.active &&
115 (appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) > 0
116 ) {
117 const problems = [
118 ...(result.error ? [result.error] : []),
119 ...(append(key, result.attachments) > 0 ? ["Too many attachments."] : []),
120 ];
121 if (problems.length) Dialog.alert("Could not paste image", problems.join("\n\n"));
122 } else await releaseUnusedComposerAttachmentFiles(result.attachments);
123 })
124 .catch((error) =>
125 Dialog.alert(
126 "Could not paste image",
127 error instanceof Error ? error.message : "Try again.",
128 ),
129 )
130 .finally(() => changeQuestionAttachmentPreparation(key, -1));
131 });
132 if (!selectedThread || props.question.allowCustomAnswer === false) return null;
133 const { environmentId, id: threadId } = selectedThread;
134 const capabilities = configs.get(environmentId)?.environment.capabilities;
135 const canAttach = capabilities !== undefined;
136 const key = questionAttachmentDraftKey(
137 environmentId,
138 threadId,
139 props.requestId,
140 props.question.id,
141 );
142 const attachments = drafts[key]?.attachments ?? [];
143 const pick = async (kind: "media" | "files") => {
144 const scope = pickerScope.current;
145 changeQuestionAttachmentPreparation(key, 1);
146 const picked = await settlePromise(async () => {
147 const existingCount = appAtomRegistry.get(composerDraftsAtom)[key]?.attachments.length ?? 0;
148 const result =
149 kind === "files"
150 ? await pickComposerFiles({
151 existingCount,
152 maxBytes: capabilities?.fileAttachments?.maxUploadBytes,
153 })
154 : await pickComposerMedia({
155 existingCount,
156 maxVideoBytes: capabilities?.fileAttachments?.maxUploadBytes,
157 });
158 const picked = "files" in result ? result.files : result.attachments;
159 // Resolution on another client clears the reservation while the picker is open.
160 if (
161 !scope.active ||
162 (appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) === 0
163 ) {
164 await releaseUnusedComposerAttachmentFiles(picked);
165 return;
166 }
167 const rejected = append(key, picked);
168 if (result.error || rejected > 0)
169 Dialog.alert("Could not attach file", result.error ?? "Too many attachments.");
170 });
171 changeQuestionAttachmentPreparation(key, -1);
172 if (AsyncResult.isFailure(picked)) {
173 const error = squashAtomCommandFailure(picked);
174 Dialog.alert("Could not attach file", error instanceof Error ? error.message : "Try again.");
175 }
176 };
177 return (
178 <View className="gap-2">
179 {canAttach ? (
180 <ComposerAttachmentButton
181 disabled={props.disabled}
182 supportsFiles
183 onPickMedia={() => pick("media")}
184 onPickFiles={() => pick("files")}
185 />
186 ) : null}
187 <ComposerAttachmentStrip
188 environmentId={environmentId}
189 attachments={attachments}
190 onRemove={(id) => {
191 if (!props.disabled) removeComposerDraftAttachment(key, id);
192 }}
193 onPressPreview={setPreviewFile}
194 onPressVideo={(attachment, sourceIdentifier) =>
195 setPreviewVideo({ type: "local", attachment, sourceIdentifier })
196 }
197 onPressDocument={(attachment) =>
198 navigation.navigate("ThreadAttachment", {
199 environmentId: String(environmentId),
200 threadId: String(threadId),
201 attachmentId: attachment.id,
202 name: attachment.name,
203 mimeType: attachment.mimeType,
204 sizeBytes: String(attachment.sizeBytes),
205 draftKey: key,
206 })
207 }
208 />
209 <FilePreviewModal source={previewFile} onRequestClose={() => setPreviewFile(null)} />
210 <VideoPreviewModal source={previewVideo} onRequestClose={() => setPreviewVideo(null)} />
211 <TextInputWrapper onPaste={paste}>
212 <TextInput
213 ref={inputRef}
214 defaultValue={initialText}
215 multiline={props.question.initialAnswer !== undefined}
216 submitBehavior={props.question.initialAnswer !== undefined ? "newline" : undefined}
217 editable={!props.disabled}
218 onChangeText={(text) => {
219 nativeText.current = text;
220 props.onChangeText(text);
221 }}
222 onFocus={() => {
223 focusedRef.current = true;
224 onInputFocusChange?.(true);
225 }}
226 onBlur={() => {
227 focusedRef.current = false;
228 onInputFocusChange?.(false);
229 }}
230 placeholder="Or type a custom answer"
231 className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground"
232 />
233 </TextInputWrapper>
234 </View>
235 );