| 1 | import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste"; |
| 2 | import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; |
| 3 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; |
| 4 | import { composerContextEditorTokens } from "../lib/composerContext"; |
| 5 | import { requireNativeView } from "expo"; |
| 6 | import { TextInputWrapper } from "expo-paste-input"; |
| 7 | import { |
| 8 | useCallback, |
| 9 | useEffect, |
| 10 | useImperativeHandle, |
| 11 | useMemo, |
| 12 | useRef, |
| 13 | useState, |
| 14 | type Ref, |
| 15 | } from "react"; |
| 16 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; |
| 17 | import { Image, Platform, StyleSheet } from "react-native"; |
| 18 | |
| 19 | import { markdownFileIconSource, markdownIconAssetUri } from "../lib/markdownFileIcons"; |
| 20 | import { composerChipSizeSuffix, contextChipPresentation } from "../lib/composerChipPresentation"; |
| 21 | import { resolveMarkdownFileIcon } from "../lib/markdownLinks"; |
| 22 | import { MOBILE_TYPOGRAPHY } from "../lib/typography"; |
| 23 | import { useNativePaste } from "../lib/useNativePaste"; |
| 24 | import { useFontFamily } from "../lib/useFontFamily"; |
| 25 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 26 | import { useUniwindTheme } from "../lib/useUniwindTheme"; |
| 27 | import { createNativeComposerTheme } from "../lib/nativeComposerTheme"; |
| 28 | import { |
| 29 | acknowledgeComposerNativeEvent, |
| 30 | assumeComposerControlledState, |
| 31 | isComposerNativeEcho, |
| 32 | pruneAcknowledgedComposerNativeEvents, |
| 33 | resolveComposerControlledEventCount, |
| 34 | type ComposerNativeEventSnapshot, |
| 35 | } from "./composerEditorRevision"; |
| 36 | import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; |
| 37 | |
| 38 | const NATIVE_MODULE_NAME = "T3ComposerEditor"; |
| 39 | const EMPTY_SKILLS: NonNullable<ComposerEditorProps["skills"]> = []; |
| 40 | |
| 41 | type NativeEditorEvent = NativeSyntheticEvent<{ |
| 42 | readonly value: string; |
| 43 | readonly selection: ComposerEditorSelection; |
| 44 | readonly eventCount: number; |
| 45 | }>; |
| 46 | |
| 47 | type NativeSelectionEvent = NativeSyntheticEvent<{ |
| 48 | readonly value: string; |
| 49 | readonly selection: ComposerEditorSelection; |
| 50 | readonly eventCount: number; |
| 51 | }>; |
| 52 | |
| 53 | type NativePasteImagesEvent = NativeSyntheticEvent<{ |
| 54 | readonly uris: ReadonlyArray<string>; |
| 55 | }>; |
| 56 | |
| 57 | type NativePasteTextEvent = NativeSyntheticEvent<{ |
| 58 | readonly value: string; |
| 59 | readonly eventCount: number; |
| 60 | readonly text: string; |
| 61 | readonly selection: ComposerEditorSelection; |
| 62 | }>; |
| 63 | |
| 64 | interface NativeComposerEditorRef { |
| 65 | focus: () => Promise<void>; |
| 66 | blur: () => Promise<void>; |
| 67 | setSelection: (start: number, end: number) => Promise<void>; |
| 68 | } |
| 69 | |
| 70 | interface 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 | |
| 103 | const NativeView = requireNativeView<NativeComposerEditorProps>(NATIVE_MODULE_NAME); |
| 104 | |
| 105 | function basename(path: string): string { |
| 106 | const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); |
| 107 | return separator >= 0 ? path.slice(separator + 1) : path; |
| 108 | } |
| 109 | |
| 110 | function fileIconUri(path: string): string | null { |
| 111 | return markdownIconAssetUri(markdownFileIconSource(resolveMarkdownFileIcon(path))) ?? null; |
| 112 | } |
| 113 | |
| 114 | export 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 | |
| 368 | export type { |
| 369 | ComposerEditorHandle, |
| 370 | ComposerEditorProps, |
| 371 | ComposerEditorSelection, |
| 372 | ComposerTextPaste, |
| 373 | } from "./T3ComposerEditor.types"; |