1import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste";
2import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts";
3import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
4import { composerContextEditorTokens } from "../lib/composerContext";
5import { requireNativeView } from "expo";
6import {
7 useCallback,
8 useEffect,
9 useImperativeHandle,
10 useMemo,
11 useRef,
12 useState,
13 type Ref,
14} from "react";
15import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native";
16import { StyleSheet } from "react-native";
17
18import { markdownFileIconSource, markdownIconAssetUri } from "../lib/markdownFileIcons";
19import { composerChipSizeSuffix, contextChipPresentation } from "../lib/composerChipPresentation";
20import { resolveMarkdownFileIcon } from "../lib/markdownLinks";
21import { useUniwindTheme } from "../lib/useUniwindTheme";
22import { createNativeComposerTheme } from "../lib/nativeComposerTheme";
23import { useFontFamily } from "../lib/useFontFamily";
24import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
25import {
26 acknowledgeComposerNativeEvent,
27 assumeComposerControlledState,
28 isComposerNativeEcho,
29 pruneAcknowledgedComposerNativeEvents,
30 resolveComposerControlledEventCount,
31 type ComposerNativeEventSnapshot,
32} from "./composerEditorRevision";
33import { DEFAULT_COMPOSER_ENTER_BEHAVIOR } from "../lib/composerEnterBehavior";
34import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types";
35
36const NATIVE_MODULE_NAME = "T3ComposerEditor";
37const EMPTY_SKILLS: NonNullable<ComposerEditorProps["skills"]> = [];
38
39type NativeEditorEvent = NativeSyntheticEvent<{
40 readonly value: string;
41 readonly selection: ComposerEditorSelection;
42 readonly eventCount: number;
43}>;
44
45type NativeSelectionEvent = NativeSyntheticEvent<{
46 readonly value: string;
47 readonly selection: ComposerEditorSelection;
48 readonly eventCount: number;
49}>;
50
51type NativePasteImagesEvent = NativeSyntheticEvent<{
52 readonly uris: ReadonlyArray<string>;
53}>;
54
55type NativePasteTextEvent = NativeSyntheticEvent<{
56 readonly value: string;
57 readonly eventCount: number;
58 readonly text: string;
59 readonly selection: ComposerEditorSelection;
60}>;
61
62interface NativeComposerEditorRef {
63 focus: () => Promise<void>;
64 blur: () => Promise<void>;
65 setSelection: (start: number, end: number) => Promise<void>;
66}
67
68interface NativeComposerEditorProps extends ViewProps {
69 readonly ref?: Ref<NativeComposerEditorRef>;
70 readonly controlledDocumentJson: string;
71 readonly clipboardFragment: string;
72 readonly themeJson: string;
73 readonly placeholder: string;
74 readonly fontFamily: string;
75 readonly fontSize: number;
76 readonly lineHeight: number;
77 readonly contentInsetVertical: number;
78 readonly editable: boolean;
79 readonly readOnly: boolean;
80 readonly enterBehavior: string;
81 readonly submitTitle: string;
82 readonly alternateSubmitTitle: string;
83 readonly scrollEnabled: boolean;
84 readonly autoFocus: boolean;
85 readonly autoCorrect: boolean;
86 readonly spellCheck: boolean;
87 readonly onComposerChange: (event: NativeEditorEvent) => void;
88 readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void;
89 readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void;
90 readonly onComposerContextPress?: (
91 event: NativeSyntheticEvent<{ source: string; start: number; end: number }>,
92 ) => void;
93 readonly onComposerPasteContext?: (
94 event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>,
95 ) => void;
96 readonly textPasteThresholdBytes: number;
97 readonly maxInputChars: number;
98 readonly onComposerPasteText?: (event: NativePasteTextEvent) => void;
99 readonly onComposerFocus?: () => void;
100 readonly onComposerBlur?: () => void;
101 readonly onComposerSubmit?: (event: NativeSyntheticEvent<{ alternate: boolean }>) => void;
102}
103
104const NativeView = requireNativeView<NativeComposerEditorProps>(NATIVE_MODULE_NAME);
105
106function basename(path: string): string {
107 const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
108 return separator >= 0 ? path.slice(separator + 1) : path;
109}
110
111function fileIconUri(path: string): string | null {
112 return markdownIconAssetUri(markdownFileIconSource(resolveMarkdownFileIcon(path))) ?? null;
113}
114
115export function ComposerEditor({
116 ref,
117 skills = EMPTY_SKILLS,
118 selection,
119 style,
120 textStyle,
121 onChangeText,
122 onSelectionChange,
123 onPasteImages,
124 onPasteText,
125 onFocus,
126 onBlur,
127 onSubmit,
128 contentInsetVertical = 0,
129 ...props
130}: ComposerEditorProps) {
131 const nativeRef = useRef<NativeComposerEditorRef>(null);
132 const mostRecentEventCountRef = useRef(0);
133 const [mostRecentEventCount, setMostRecentEventCount] = useState(0);
134 const [, forceNativeEventRender] = useState(0);
135 // The native editor mounts empty, so the snapshot history starts empty: the
136 // first controlled payload must be a non-echo so a restored draft (or a
137 // recycled native view) is applied rather than skipped.
138 const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([]);
139 const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));
140 const bodyText = useScaledTextRole("body");
141 const theme = useUniwindTheme();
142 const fontFamily = useFontFamily("regular");
143
144 useImperativeHandle(
145 ref,
146 () => ({
147 focus: () => void nativeRef.current?.focus(),
148 blur: () => void nativeRef.current?.blur(),
149 setSelection: (nextSelection) =>
150 void nativeRef.current?.setSelection(nextSelection.start, nextSelection.end),
151 }),
152 [],
153 );
154
155 const skillLabels = useMemo(
156 () => new Map(skills.map((skill) => [skill.name, skill.displayName?.trim() || skill.name])),
157 [skills],
158 );
159 const tokensJson = useMemo(() => {
160 const tokens = collectComposerInlineTokens(props.value, {
161 preserveTrailingFrom: confirmedTokensRef.current,
162 });
163 confirmedTokensRef.current = tokens;
164 return JSON.stringify(
165 composerContextEditorTokens(props.value, tokens).map((token) => {
166 const record =
167 token.type === "context"
168 ? props.context?.records.find((record) => record.contextId === token.contextId)
169 : undefined;
170 return {
171 type: token.type,
172 source: token.source,
173 start: token.start,
174 end: token.end,
175 ...contextChipPresentation(token.type === "context" ? token.kind : token.type, record),
176 label:
177 token.type === "skill"
178 ? (skillLabels.get(token.value) ?? token.value)
179 : token.type === "context"
180 ? `${token.label}${props.context?.records.some((record) => record.contextId === token.contextId) ? "" : " · unavailable"}`
181 : basename(token.value),
182 detail: token.type === "context" ? composerChipSizeSuffix(record) : "",
183 // Only a mention wears per-filetype artwork. An attachment chip keeps the tinted
184 // monochrome glyph web draws for it: coloured artwork ignores the chip's accent and
185 // makes the composer chip read differently from the same chip in a sent message.
186 iconUri:
187 token.type === "mention"
188 ? fileIconUri(token.value)
189 : record?.kind === "mention" && "path" in record
190 ? fileIconUri(record.path)
191 : null,
192 };
193 }),
194 );
195 }, [props.value, props.context, skillLabels]);
196 // Every render resolves against the snapshot history, so a render whose
197 // (value, selection) lags the acknowledged native state is stamped behind
198 // the native revision and rejected by the editor instead of re-applying a
199 // stale caret or stale text mid-typing.
200 const controlledEventCount = resolveComposerControlledEventCount(
201 props.value,
202 selection ?? null,
203 mostRecentEventCount,
204 nativeEventSnapshotsRef.current,
205 );
206 const acknowledgesLatestNativeEvent = isComposerNativeEcho(
207 props.value,
208 selection ?? null,
209 mostRecentEventCount,
210 nativeEventSnapshotsRef.current,
211 );
212 const isNativeEcho =
213 controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent;
214 const controlledDocumentJson = JSON.stringify({
215 value: props.value,
216 selection: isNativeEcho ? null : (selection ?? null),
217 tokensJson,
218 mostRecentEventCount: controlledEventCount,
219 isNativeEcho,
220 });
221 useEffect(() => {
222 if (!acknowledgesLatestNativeEvent) return;
223 nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents(
224 nativeEventSnapshotsRef.current,
225 mostRecentEventCount,
226 );
227 }, [acknowledgesLatestNativeEvent, mostRecentEventCount]);
228 const assumedValue = props.value;
229 useEffect(() => {
230 // A native event that arrived after this render was committed moves the
231 // acknowledged revision forward; the editor rejects this payload, so the
232 // snapshot history must not assume it applied.
233 if (isNativeEcho || controlledEventCount !== mostRecentEventCountRef.current) return;
234 nativeEventSnapshotsRef.current = assumeComposerControlledState(
235 nativeEventSnapshotsRef.current,
236 controlledEventCount,
237 assumedValue,
238 );
239 }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]);
240 const acceptNativeEvent = useCallback(
241 (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => {
242 const acknowledgedEventCount = acknowledgeComposerNativeEvent(
243 mostRecentEventCountRef.current,
244 eventCount,
245 );
246 if (acknowledgedEventCount === null) {
247 return false;
248 }
249 mostRecentEventCountRef.current = acknowledgedEventCount;
250 nativeEventSnapshotsRef.current.push({
251 eventCount: acknowledgedEventCount,
252 value,
253 selection: nextSelection,
254 });
255 return acknowledgedEventCount;
256 },
257 [],
258 );
259 const themeJson = JSON.stringify(createNativeComposerTheme(theme));
260 const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {};
261 return (
262 <NativeView
263 ref={nativeRef}
264 controlledDocumentJson={controlledDocumentJson}
265 clipboardFragment={props.clipboardFragment ?? ""}
266 themeJson={themeJson}
267 placeholder={props.placeholder ?? ""}
268 fontFamily={
269 typeof resolvedTextStyle.fontFamily === "string" ? resolvedTextStyle.fontFamily : fontFamily
270 }
271 fontSize={
272 typeof resolvedTextStyle.fontSize === "number"
273 ? resolvedTextStyle.fontSize
274 : bodyText.fontSize
275 }
276 lineHeight={
277 typeof resolvedTextStyle.lineHeight === "number"
278 ? resolvedTextStyle.lineHeight
279 : bodyText.lineHeight
280 }
281 contentInsetVertical={contentInsetVertical}
282 editable={props.editable ?? true}
283 readOnly={props.readOnly ?? false}
284 enterBehavior={props.enterBehavior ?? DEFAULT_COMPOSER_ENTER_BEHAVIOR}
285 submitTitle={props.submitTitle ?? "Send Message"}
286 alternateSubmitTitle={props.alternateSubmitTitle ?? props.submitTitle ?? "Send Message"}
287 scrollEnabled={props.scrollEnabled ?? true}
288 autoFocus={props.autoFocus ?? false}
289 autoCorrect={props.autoCorrect ?? true}
290 spellCheck={props.spellCheck ?? true}
291 textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0}
292 maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS}
293 style={style as StyleProp<ViewStyle>}
294 onComposerChange={(event) => {
295 const acknowledgedEventCount = acceptNativeEvent(
296 event.nativeEvent.eventCount,
297 event.nativeEvent.value,
298 event.nativeEvent.selection,
299 );
300 if (acknowledgedEventCount === false) return;
301 onChangeText(event.nativeEvent.value);
302 onSelectionChange?.(event.nativeEvent.selection);
303 setMostRecentEventCount(acknowledgedEventCount);
304 forceNativeEventRender((sequence) => sequence + 1);
305 }}
306 onComposerSelectionChange={(event) => {
307 const acknowledgedEventCount = acceptNativeEvent(
308 event.nativeEvent.eventCount,
309 event.nativeEvent.value,
310 event.nativeEvent.selection,
311 );
312 if (acknowledgedEventCount === false) return;
313 // A selection change that raced a text mutation can carry post-edit
314 // text. It must reach the parent alongside the acknowledged revision,
315 // or the next render stamps the stale draft at that revision and can
316 // re-apply it over the newer native text.
317 if (event.nativeEvent.value !== props.value) {
318 onChangeText(event.nativeEvent.value);
319 }
320 onSelectionChange?.(event.nativeEvent.selection);
321 setMostRecentEventCount(acknowledgedEventCount);
322 forceNativeEventRender((sequence) => sequence + 1);
323 }}
324 onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}
325 onComposerContextPress={(event) => props.onContextPress?.(event.nativeEvent)}
326 onComposerPasteContext={(event) => {
327 const paste = event.nativeEvent;
328 const acknowledgedEventCount = acceptNativeEvent(
329 paste.eventCount,
330 paste.value,
331 paste.selection,
332 );
333 if (acknowledgedEventCount === false) return;
334 onChangeText(paste.value);
335 onSelectionChange?.(paste.selection);
336 props.onPasteContext?.(paste);
337 setMostRecentEventCount(acknowledgedEventCount);
338 forceNativeEventRender((sequence) => sequence + 1);
339 }}
340 onComposerPasteText={(event) => {
341 const paste = event.nativeEvent;
342 const acknowledgedEventCount = acceptNativeEvent(
343 paste.eventCount,
344 paste.value,
345 paste.selection,
346 );
347 if (acknowledgedEventCount === false) return;
348 // Synchronize the draft before an async paste captures its insertion target.
349 // React props can still precede the last native keystroke.
350 onChangeText(paste.value);
351 onSelectionChange?.(paste.selection);
352 onPasteText?.(paste);
353 setMostRecentEventCount(acknowledgedEventCount);
354 forceNativeEventRender((sequence) => sequence + 1);
355 }}
356 onComposerFocus={onFocus}
357 onComposerBlur={onBlur}
358 onComposerSubmit={
359 onSubmit === undefined
360 ? undefined
361 : (event) => onSubmit(event.nativeEvent.alternate === true)
362 }
363 />
364 );
365}
366
367export type {
368 ComposerEditorHandle,
369 ComposerEditorProps,
370 ComposerEditorSelection,
371 ComposerTextPaste,
372} from "./T3ComposerEditor.types";