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