1import { ComposerContextId } from "@t3tools/contracts";
2import { useAtomValue } from "@effect/atom-react";
3import { AsyncResult } from "effect/reactivity";
4import { useNavigation } from "@react-navigation/native";
5import { useEffect, useMemo, useRef, useState } from "react";
6import { Alert } from "react-native";
7import type { EnvironmentId } from "@t3tools/contracts";
8import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
9import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences";
10import { ComposerEditor as NativeComposerEditor } from "../native/T3ComposerEditor";
11import type { ComposerEditorProps as NativeComposerEditorProps } from "../native/T3ComposerEditor";
12import {
13 appendComposerDraftAttachments,
14 createComposerDraftContextHistory,
15 getComposerDraftAfterSelection,
16 getComposerDraftSnapshot,
17 insertComposerDraftContext,
18 insertComposerDraftText,
19 rememberComposerDraftSelection,
20 setComposerDraftContext,
21 setComposerContextImporting,
22 useComposerDraft,
23} from "../state/use-composer-drafts";
24import { importComposerContextClipboard } from "../lib/composerContextClipboard";
25import { mobilePreferencesAtom } from "../state/preferences";
26import { ComposerContextSheet } from "./ComposerContextSheet";
27import { AppText as Text } from "./AppText";
28import {
29 composerDocumentAttachment,
30 composerMentionPath,
31 type ComposerDocumentAttachment,
32} from "../lib/composerContext";
33
34export type ComposerEditorProps = NativeComposerEditorProps & {
35 readonly draftKey?: string | null;
36 readonly environmentId?: EnvironmentId;
37 readonly onOpenMention?: (path: string) => void;
38 /** Documents open in the file screen; pictures, video and PDF keep their native viewers. */
39 readonly onOpenAttachment?: (attachment: ComposerDocumentAttachment) => void;
40 /**
41 * A resting composer is a target to type in, not a document to navigate. Its chips go inert
42 * so a draft full of them can still be tapped anywhere to start writing; the caller focuses
43 * the editor instead. Chips become live again once the composer is open.
44 */
45 readonly chipsInert?: boolean;
46 /** Called instead of opening a chip while `chipsInert` is set. */
47 readonly onInertChipPress?: () => void;
48};
49
50export function ComposerEditor({
51 draftKey,
52 environmentId,
53 onOpenMention,
54 onOpenAttachment,
55 chipsInert,
56 onInertChipPress,
57 ...props
58}: ComposerEditorProps) {
59 const draft = useComposerDraft(draftKey ?? null);
60 const preferencesResult = useAtomValue(mobilePreferencesAtom);
61 const preferredEnterBehavior = AsyncResult.isSuccess(preferencesResult)
62 ? preferencesResult.value.composerEnterBehavior
63 : undefined;
64 const contextHistory = useMemo(() => createComposerDraftContextHistory(), [draftKey]);
65 useEffect(() => () => contextHistory.dispose(), [contextHistory]);
66 const changeText = (text: string) => {
67 const restored = contextHistory.restore(
68 text,
69 draftKey ? getComposerDraftSnapshot(draftKey) : draft,
70 );
71 props.onChangeText(text);
72 if (draftKey) {
73 setComposerDraftContext(draftKey, restored.context);
74 appendComposerDraftAttachments(draftKey, restored.attachments, { allowOverflow: true });
75 }
76 };
77 const [selected, setSelected] = useState<{ source: string; start: number; end: number } | null>(
78 null,
79 );
80 const importRef = useRef<AbortController | null>(null);
81 const [importing, setImporting] = useState(false);
82 useEffect(
83 () => () => {
84 importRef.current?.abort();
85 },
86 [draftKey],
87 );
88 const pasteContext = async (
89 clipboard: Parameters<NonNullable<NativeComposerEditorProps["onPasteContext"]>>[0],
90 ) => {
91 if (!draftKey || importRef.current || props.readOnly || props.editable === false) return;
92 const insertion = { text: clipboard.value, ...clipboard.selection };
93 const controller = new AbortController();
94 importRef.current = controller;
95 setImporting(true);
96 setComposerContextImporting(draftKey, true);
97 try {
98 const retained = getComposerDraftAfterSelection(draftKey, insertion);
99 const result = await importComposerContextClipboard(
100 clipboard,
101 retained.attachments.length,
102 controller.signal,
103 retained.context?.records.length ?? 0,
104 );
105 if (!result) {
106 insertComposerDraftText(draftKey, clipboard.text, insertion);
107 return;
108 }
109 if (!insertComposerDraftContext(draftKey, result, insertion)) {
110 Alert.alert(
111 "Could not paste context",
112 "Remove some attachments or context items from the draft, then paste again.",
113 );
114 return;
115 }
116 if (result.failures.length > 0)
117 Alert.alert(
118 "Some attachments could not be copied",
119 "Reconnect to the source environment and copy them again. References without their files are marked unavailable.",
120 );
121 } catch (error) {
122 if (!controller.signal.aborted)
123 Alert.alert(
124 "Could not paste context",
125 error instanceof Error ? error.message : "Try copying again.",
126 );
127 } finally {
128 setComposerContextImporting(draftKey, false);
129 importRef.current = null;
130 setImporting(false);
131 }
132 };
133 const clipboardFragment = useMemo(
134 () =>
135 environmentId && draft.context
136 ? encodeComposerContextFragment({
137 version: 1,
138 source: { environmentId },
139 records: draft.context.records.map((record) => {
140 if (!("attachmentId" in record)) return record;
141 const attachment = draft.attachments.find(
142 (entry) => entry.id === record.attachmentId,
143 );
144 return {
145 ...record,
146 attachmentId:
147 attachment?.uploadEnvironmentId === environmentId
148 ? (attachment.uploadedAttachmentId ?? record.attachmentId)
149 : record.attachmentId,
150 };
151 }),
152 })
153 : "",
154 [environmentId, draft.context, draft.attachments],
155 );
156 const navigation = useNavigation();
157 const selectedReference = selected
158 ? collectComposerContextReferences(selected.source)[0]
159 : undefined;
160 const selectedSkillName = selected?.source.match(/^\p{Sc}(.+)$/u)?.[1];
161 const selectedSkill = selectedSkillName
162 ? props.skills?.find((skill) => skill.name === selectedSkillName)
163 : undefined;
164 const record = draft.context?.records.find(
165 (entry) => entry.contextId === selectedReference?.contextId,
166 );
167 return (
168 <>
169 <NativeComposerEditor
170 {...props}
171 enterBehavior={props.enterBehavior ?? preferredEnterBehavior}
172 onChangeText={changeText}
173 readOnly={props.readOnly || importing}
174 onSubmit={importing ? undefined : props.onSubmit}
175 clipboardFragment={clipboardFragment ?? undefined}
176 onPasteContext={(clipboard) => void pasteContext(clipboard)}
177 context={draft.context}
178 onContextPress={(selection) => {
179 if (chipsInert) {
180 onInertChipPress?.();
181 return;
182 }
183 const path = composerMentionPath(selection.source, draft.context);
184 if (path && onOpenMention) {
185 onOpenMention(path);
186 return;
187 }
188 const document = composerDocumentAttachment(selection.source, draft.context);
189 if (document && onOpenAttachment) {
190 onOpenAttachment(document);
191 return;
192 }
193 setSelected(selection);
194 }}
195 onSelectionChange={(selection) => {
196 if (draftKey)
197 rememberComposerDraftSelection(
198 draftKey,
199 getComposerDraftSnapshot(draftKey).text,
200 selection,
201 );
202 props.onSelectionChange?.(selection);
203 }}
204 />
205 {importing ? (
206 <Text className="py-2 text-xs text-foreground-muted">Copying context…</Text>
207 ) : null}
208 {selected && (selectedReference || selectedSkill) ? (
209 <ComposerContextSheet
210 label={
211 selectedReference?.label ?? selectedSkill?.displayName ?? selectedSkill?.name ?? "Skill"
212 }
213 record={
214 record ??
215 (selectedSkill
216 ? {
217 version: 1,
218 kind: "skill",
219 contextId: ComposerContextId.make("skill-preview"),
220 label: selectedSkill.name,
221 name: selectedSkill.name,
222 }
223 : undefined)
224 }
225 {...(selectedSkill?.description ? { skillDescription: selectedSkill.description } : {})}
226 {...(selectedSkill?.path && onOpenMention
227 ? {
228 onOpenSkill: () => {
229 setSelected(null);
230 onOpenMention(selectedSkill.path!);
231 },
232 }
233 : {})}
234 onOpenThread={(thread) => {
235 setSelected(null);
236 navigation.navigate("Thread", {
237 environmentId: String(thread.environmentId),
238 threadId: String(thread.threadId),
239 });
240 }}
241 environmentId={environmentId ?? draft.project?.environmentId}
242 projectId={draft.project?.projectId}
243 records={draft.context?.records}
244 attachments={draft.attachments}
245 onClose={() => setSelected(null)}
246 onRemove={
247 props.readOnly || props.editable === false
248 ? undefined
249 : () => {
250 if (props.value.slice(selected.start, selected.end) === selected.source) {
251 changeText(
252 props.value.slice(0, selected.start) + props.value.slice(selected.end),
253 );
254 props.onSelectionChange?.({ start: selected.start, end: selected.start });
255 }
256 setSelected(null);
257 }
258 }
259 />
260 ) : null}
261 </>
262 );
263}
264export type {
265 ComposerEditorHandle,
266 ComposerEditorSelection,
267 ComposerTextPaste,
268} from "../native/T3ComposerEditor";