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