| 1 | import { TextInputWrapper } from "expo-paste-input"; |
| 2 | import { AppTextInput as TextInput } from "../../components/AppText"; |
| 3 | import { useNativePaste } from "../../lib/useNativePaste"; |
| 4 | import { convertPastedImagesToAttachments } from "../../lib/composerImages"; |
| 5 | import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type UserInputQuestion } from "@t3tools/contracts"; |
| 6 | import { useAtomValue } from "@effect/atom-react"; |
| 7 | import { Alert, View } from "react-native"; |
| 8 | import { useEffect, useRef, useState } from "react"; |
| 9 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; |
| 10 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 11 | import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages"; |
| 12 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 13 | import { useNavigation } from "@react-navigation/native"; |
| 14 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 15 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; |
| 16 | import { useServerConfigs } from "../../state/entities"; |
| 17 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 18 | import { |
| 19 | appendComposerDraftAttachments, |
| 20 | composerDraftsAtom, |
| 21 | removeComposerDraftAttachment, |
| 22 | releaseUnusedComposerAttachmentFiles, |
| 23 | } from "../../state/use-composer-drafts"; |
| 24 | import { |
| 25 | changeQuestionAttachmentPreparation, |
| 26 | questionAttachmentDraftKey, |
| 27 | questionAttachmentPreparationAtom, |
| 28 | } from "../../state/question-attachments"; |
| 29 | |
| 30 | export 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 | } |