import { TextInputWrapper } from "expo-paste-input"; import { AppTextInput as TextInput } from "../../components/AppText"; import { useNativePaste } from "../../lib/useNativePaste"; import { convertPastedImagesToAttachments } from "../../lib/composerImages"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type ApprovalRequestId, type UserInputQuestion, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { Alert, View } from "react-native"; import { useEffect, useRef } from "react"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useServerConfigs } from "../../state/entities"; import { appAtomRegistry } from "../../state/atom-registry"; import { appendComposerDraftAttachments, composerDraftsAtom, removeComposerDraftAttachment, releaseUnusedComposerAttachmentFiles, } from "../../state/use-composer-drafts"; import { changeQuestionAttachmentPreparation, questionAttachmentDraftKey, questionAttachmentPreparationAtom, } from "../../state/question-attachments"; export function QuestionAttachments(props: { requestId: ApprovalRequestId; question: UserInputQuestion; questions: ReadonlyArray; disabled: boolean; value: string; onChangeText: (value: string) => void; onInputFocusChange?: ((focused: boolean) => void) | undefined; }) { const { selectedThread } = useThreadSelection(); const configs = useServerConfigs(); const drafts = useAtomValue(composerDraftsAtom); const scopeKey = JSON.stringify([ selectedThread?.environmentId, selectedThread?.id, props.requestId, props.question.id, ]); const pickerScope = useRef<{ key: string; active: boolean } | null>(null); useEffect(() => { const scope = { key: scopeKey, active: true }; pickerScope.current = scope; return () => { scope.active = false; }; }, [scopeKey]); const append = ( key: string, attachments: Parameters[1], ) => { if (!selectedThread) return 0; const current = appAtomRegistry.get(composerDraftsAtom); const otherCount = props.questions.reduce((count, question) => { const target = questionAttachmentDraftKey( selectedThread.environmentId, selectedThread.id, props.requestId, question.id, ); return target === key ? count : count + (current[target]?.attachments.length ?? 0); }, 0); return appendComposerDraftAttachments(key, attachments, { maxAttachments: Math.max(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - otherCount), }); }; const paste = useNativePaste((uris) => { const scope = pickerScope.current; if ( !selectedThread || props.disabled || !configs.get(selectedThread.environmentId)?.environment.capabilities.questionAttachments ) return; const key = questionAttachmentDraftKey( selectedThread.environmentId, selectedThread.id, props.requestId, props.question.id, ); changeQuestionAttachmentPreparation(key, 1); void convertPastedImagesToAttachments({ uris, existingCount: appAtomRegistry.get(composerDraftsAtom)[key]?.attachments.length ?? 0, }) .then(async (images) => { if ( scope?.active && (appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) > 0 ) { if (append(key, images) > 0) Alert.alert("Could not paste image", "Too many attachments."); } else await releaseUnusedComposerAttachmentFiles(images); }) .catch((error) => Alert.alert("Could not paste image", error instanceof Error ? error.message : "Try again."), ) .finally(() => changeQuestionAttachmentPreparation(key, -1)); }); if (!selectedThread || props.question.allowCustomAnswer === false) return null; const { environmentId, id: threadId } = selectedThread; const capabilities = configs.get(environmentId)?.environment.capabilities; const canAttach = capabilities?.questionAttachments === true; const key = questionAttachmentDraftKey( environmentId, threadId, props.requestId, props.question.id, ); const attachments = drafts[key]?.attachments ?? []; const pick = async (kind: "media" | "files") => { const scope = pickerScope.current; changeQuestionAttachmentPreparation(key, 1); try { const existingCount = appAtomRegistry.get(composerDraftsAtom)[key]?.attachments.length ?? 0; const result = kind === "files" ? await pickComposerFiles({ existingCount, maxBytes: capabilities?.fileAttachments?.maxUploadBytes, }) : await pickComposerMedia({ existingCount, maxVideoBytes: capabilities?.fileAttachments?.maxUploadBytes, }); const picked = "files" in result ? result.files : result.attachments; // Resolution on another client clears the reservation while the picker is open. if ( !scope?.active || (appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) === 0 ) { await releaseUnusedComposerAttachmentFiles(picked); return; } const rejected = append(key, picked); if (result.error || rejected > 0) Alert.alert("Could not attach file", result.error ?? "Too many attachments."); } catch (error) { Alert.alert("Could not attach file", error instanceof Error ? error.message : "Try again."); } finally { changeQuestionAttachmentPreparation(key, -1); } }; return ( {canAttach ? ( pick("media")} onPickFiles={() => pick("files")} /> ) : null} { if (!props.disabled) removeComposerDraftAttachment(key, id); }} /> props.onInputFocusChange?.(true)} onBlur={() => props.onInputFocusChange?.(false)} placeholder="Or type a custom answer" className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground" /> ); }