mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import { 2 useCallback, 3 useImperativeHandle, 4 useMemo, 5 useRef, 6 useState, 7} from 'react' 8import { 9 type NativeSyntheticEvent, 10 Text as RNText, 11 type TextInputSelectionChangeEventData, 12 View, 13} from 'react-native' 14import {AppBskyRichtextFacet, RichText} from '@atproto/api' 15import PasteInput, { 16 type PastedFile, 17 type PasteInputRef, // @ts-expect-error no types when installing from github 18} from '@mattermost/react-native-paste-input' 19 20import {POST_IMG_MAX} from '#/lib/constants' 21import {downloadAndResize} from '#/lib/media/manip' 22import {isUriImage} from '#/lib/media/util' 23import {cleanError} from '#/lib/strings/errors' 24import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip' 25import {useTheme} from '#/lib/ThemeContext' 26import {isAndroid, isNative} from '#/platform/detection' 27import { 28 type LinkFacetMatch, 29 suggestLinkCardUri, 30} from '#/view/com/composer/text-input/text-input-util' 31import {atoms as a, useAlf} from '#/alf' 32import {normalizeTextStyles} from '#/alf/typography' 33import {Autocomplete} from './mobile/Autocomplete' 34import {type TextInputProps} from './TextInput.types' 35 36interface Selection { 37 start: number 38 end: number 39} 40 41export function TextInput({ 42 ref, 43 richtext, 44 placeholder, 45 hasRightPadding, 46 setRichText, 47 onPhotoPasted, 48 onNewLink, 49 onError, 50 ...props 51}: TextInputProps) { 52 const {theme: t, fonts} = useAlf() 53 const textInput = useRef<PasteInputRef>(null) 54 const textInputSelection = useRef<Selection>({start: 0, end: 0}) 55 const theme = useTheme() 56 const [autocompletePrefix, setAutocompletePrefix] = useState('') 57 const prevLength = useRef(richtext.length) 58 59 useImperativeHandle(ref, () => ({ 60 focus: () => textInput.current?.focus(), 61 blur: () => { 62 textInput.current?.blur() 63 }, 64 getCursorPosition: () => undefined, // Not implemented on native 65 maybeClosePopup: () => false, // Not needed on native 66 })) 67 68 const pastSuggestedUris = useRef(new Set<string>()) 69 const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>()) 70 const onChangeText = useCallback( 71 async (newText: string) => { 72 const mayBePaste = newText.length > prevLength.current + 1 73 74 const newRt = new RichText({text: newText}) 75 newRt.detectFacetsWithoutResolution() 76 setRichText(newRt) 77 78 // NOTE: BinaryFiddler 79 // onChangeText happens before onSelectionChange, cursorPos is out of bound if the user deletes characters, 80 const cursorPos = textInputSelection.current?.start ?? 0 81 const prefix = getMentionAt(newText, Math.min(cursorPos, newText.length)) 82 83 if (prefix) { 84 setAutocompletePrefix(prefix.value) 85 } else if (autocompletePrefix) { 86 setAutocompletePrefix('') 87 } 88 89 const nextDetectedUris = new Map<string, LinkFacetMatch>() 90 if (newRt.facets) { 91 for (const facet of newRt.facets) { 92 for (const feature of facet.features) { 93 if (AppBskyRichtextFacet.isLink(feature)) { 94 if (isUriImage(feature.uri)) { 95 const res = await downloadAndResize({ 96 uri: feature.uri, 97 width: POST_IMG_MAX.width, 98 height: POST_IMG_MAX.height, 99 mode: 'contain', 100 maxSize: POST_IMG_MAX.size, 101 timeout: 15e3, 102 }) 103 104 if (res !== undefined) { 105 onPhotoPasted(res.path) 106 } 107 } else { 108 nextDetectedUris.set(feature.uri, {facet, rt: newRt}) 109 } 110 } 111 } 112 } 113 } 114 const suggestedUri = suggestLinkCardUri( 115 mayBePaste, 116 nextDetectedUris, 117 prevDetectedUris.current, 118 pastSuggestedUris.current, 119 ) 120 prevDetectedUris.current = nextDetectedUris 121 if (suggestedUri) { 122 onNewLink(suggestedUri) 123 } 124 prevLength.current = newText.length 125 }, 126 [setRichText, autocompletePrefix, onPhotoPasted, onNewLink], 127 ) 128 129 const onPaste = useCallback( 130 async (err: string | undefined, files: PastedFile[]) => { 131 if (err) { 132 return onError(cleanError(err)) 133 } 134 135 const uris = files.map(f => f.uri) 136 const uri = uris.find(isUriImage) 137 138 if (uri) { 139 onPhotoPasted(uri) 140 } 141 }, 142 [onError, onPhotoPasted], 143 ) 144 145 const onSelectionChange = useCallback( 146 (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { 147 // NOTE we track the input selection using a ref to avoid excessive renders -prf 148 textInputSelection.current = evt.nativeEvent.selection 149 }, 150 [textInputSelection], 151 ) 152 153 const onSelectAutocompleteItem = useCallback( 154 (item: string) => { 155 onChangeText( 156 insertMentionAt( 157 richtext.text, 158 textInputSelection.current?.start || 0, 159 item, 160 ), 161 ) 162 setAutocompletePrefix('') 163 }, 164 [onChangeText, richtext, setAutocompletePrefix], 165 ) 166 167 const inputTextStyle = useMemo(() => { 168 const style = normalizeTextStyles( 169 [a.text_lg, a.leading_snug, t.atoms.text], 170 { 171 fontScale: fonts.scaleMultiplier, 172 fontFamily: fonts.family, 173 flags: {}, 174 }, 175 ) 176 177 /** 178 * PasteInput doesn't like `lineHeight`, results in jumpiness 179 */ 180 if (isNative) { 181 style.lineHeight = undefined 182 } 183 184 /* 185 * Android impl of `PasteInput` doesn't support the array syntax for `fontVariant` 186 */ 187 if (isAndroid) { 188 // @ts-ignore 189 style.fontVariant = style.fontVariant 190 ? style.fontVariant.join(' ') 191 : undefined 192 } 193 return style 194 }, [t, fonts]) 195 196 const textDecorated = useMemo(() => { 197 let i = 0 198 199 return Array.from(richtext.segments()).map(segment => { 200 return ( 201 <RNText 202 key={i++} 203 style={[ 204 inputTextStyle, 205 { 206 color: segment.facet ? t.palette.primary_500 : t.atoms.text.color, 207 marginTop: -1, 208 }, 209 ]}> 210 {segment.text} 211 </RNText> 212 ) 213 }) 214 }, [t, richtext, inputTextStyle]) 215 216 return ( 217 <View style={[a.flex_1, a.pl_md, hasRightPadding && a.pr_4xl]}> 218 <PasteInput 219 testID="composerTextInput" 220 ref={textInput} 221 onChangeText={onChangeText} 222 onPaste={onPaste} 223 onSelectionChange={onSelectionChange} 224 placeholder={placeholder} 225 placeholderTextColor={t.atoms.text_contrast_medium.color} 226 keyboardAppearance={theme.colorScheme} 227 autoFocus={true} 228 allowFontScaling 229 multiline 230 scrollEnabled={false} 231 numberOfLines={2} 232 // Note: should be the default value, but as of v1.104 233 // it switched to "none" on Android 234 autoCapitalize="sentences" 235 {...props} 236 style={[ 237 inputTextStyle, 238 a.w_full, 239 !autocompletePrefix && a.h_full, 240 { 241 textAlignVertical: 'top', 242 minHeight: 60, 243 includeFontPadding: false, 244 }, 245 { 246 borderWidth: 1, 247 borderColor: 'transparent', 248 }, 249 props.style, 250 ]}> 251 {textDecorated} 252 </PasteInput> 253 <Autocomplete 254 prefix={autocompletePrefix} 255 onSelect={onSelectAutocompleteItem} 256 /> 257 </View> 258 ) 259}