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