mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at thread-bug 280 lines 7.8 kB view raw
1import React, { 2 type ComponentProps, 3 forwardRef, 4 useCallback, 5 useMemo, 6 useRef, 7 useState, 8} from 'react' 9import { 10 type NativeSyntheticEvent, 11 Text as RNText, 12 type TextInput as RNTextInput, 13 type TextInputSelectionChangeEventData, 14 View, 15} from 'react-native' 16import {AppBskyRichtextFacet, RichText} from '@atproto/api' 17import PasteInput, { 18 type PastedFile, 19 type PasteInputRef, // @ts-expect-error no types when installing from github 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 type 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 // NOTE: BinaryFiddler 100 // onChangeText happens before onSelectionChange, cursorPos is out of bound if the user deletes characters, 101 const cursorPos = textInputSelection.current?.start ?? 0 102 const prefix = getMentionAt(newText, Math.min(cursorPos, newText.length)) 103 104 if (prefix) { 105 setAutocompletePrefix(prefix.value) 106 } else if (autocompletePrefix) { 107 setAutocompletePrefix('') 108 } 109 110 const nextDetectedUris = new Map<string, LinkFacetMatch>() 111 if (newRt.facets) { 112 for (const facet of newRt.facets) { 113 for (const feature of facet.features) { 114 if (AppBskyRichtextFacet.isLink(feature)) { 115 if (isUriImage(feature.uri)) { 116 const res = await downloadAndResize({ 117 uri: feature.uri, 118 width: POST_IMG_MAX.width, 119 height: POST_IMG_MAX.height, 120 mode: 'contain', 121 maxSize: POST_IMG_MAX.size, 122 timeout: 15e3, 123 }) 124 125 if (res !== undefined) { 126 onPhotoPasted(res.path) 127 } 128 } else { 129 nextDetectedUris.set(feature.uri, {facet, rt: newRt}) 130 } 131 } 132 } 133 } 134 } 135 const suggestedUri = suggestLinkCardUri( 136 mayBePaste, 137 nextDetectedUris, 138 prevDetectedUris.current, 139 pastSuggestedUris.current, 140 ) 141 prevDetectedUris.current = nextDetectedUris 142 if (suggestedUri) { 143 onNewLink(suggestedUri) 144 } 145 prevLength.current = newText.length 146 }, 147 [setRichText, autocompletePrefix, onPhotoPasted, onNewLink], 148 ) 149 150 const onPaste = useCallback( 151 async (err: string | undefined, files: PastedFile[]) => { 152 if (err) { 153 return onError(cleanError(err)) 154 } 155 156 const uris = files.map(f => f.uri) 157 const uri = uris.find(isUriImage) 158 159 if (uri) { 160 onPhotoPasted(uri) 161 } 162 }, 163 [onError, onPhotoPasted], 164 ) 165 166 const onSelectionChange = useCallback( 167 (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { 168 // NOTE we track the input selection using a ref to avoid excessive renders -prf 169 textInputSelection.current = evt.nativeEvent.selection 170 }, 171 [textInputSelection], 172 ) 173 174 const onSelectAutocompleteItem = useCallback( 175 (item: string) => { 176 onChangeText( 177 insertMentionAt( 178 richtext.text, 179 textInputSelection.current?.start || 0, 180 item, 181 ), 182 ) 183 setAutocompletePrefix('') 184 }, 185 [onChangeText, richtext, setAutocompletePrefix], 186 ) 187 188 const inputTextStyle = React.useMemo(() => { 189 const style = normalizeTextStyles( 190 [a.text_lg, a.leading_snug, t.atoms.text], 191 { 192 fontScale: fonts.scaleMultiplier, 193 fontFamily: fonts.family, 194 flags: {}, 195 }, 196 ) 197 198 /** 199 * PasteInput doesn't like `lineHeight`, results in jumpiness 200 */ 201 if (isNative) { 202 style.lineHeight = undefined 203 } 204 205 /* 206 * Android impl of `PasteInput` doesn't support the array syntax for `fontVariant` 207 */ 208 if (isAndroid) { 209 // @ts-ignore 210 style.fontVariant = style.fontVariant 211 ? style.fontVariant.join(' ') 212 : undefined 213 } 214 return style 215 }, [t, fonts]) 216 217 const textDecorated = useMemo(() => { 218 let i = 0 219 220 return Array.from(richtext.segments()).map(segment => { 221 return ( 222 <RNText 223 key={i++} 224 style={[ 225 inputTextStyle, 226 { 227 color: segment.facet ? t.palette.primary_500 : t.atoms.text.color, 228 marginTop: -1, 229 }, 230 ]}> 231 {segment.text} 232 </RNText> 233 ) 234 }) 235 }, [t, richtext, inputTextStyle]) 236 237 return ( 238 <View style={[a.flex_1, a.pl_md, hasRightPadding && a.pr_4xl]}> 239 <PasteInput 240 testID="composerTextInput" 241 ref={textInput} 242 onChangeText={onChangeText} 243 onPaste={onPaste} 244 onSelectionChange={onSelectionChange} 245 placeholder={placeholder} 246 placeholderTextColor={t.atoms.text_contrast_medium.color} 247 keyboardAppearance={theme.colorScheme} 248 autoFocus={true} 249 allowFontScaling 250 multiline 251 scrollEnabled={false} 252 numberOfLines={2} 253 // Note: should be the default value, but as of v1.104 254 // it switched to "none" on Android 255 autoCapitalize="sentences" 256 {...props} 257 style={[ 258 inputTextStyle, 259 a.w_full, 260 !autocompletePrefix && a.h_full, 261 { 262 textAlignVertical: 'top', 263 minHeight: 60, 264 includeFontPadding: false, 265 }, 266 { 267 borderWidth: 1, 268 borderColor: 'transparent', 269 }, 270 props.style, 271 ]}> 272 {textDecorated} 273 </PasteInput> 274 <Autocomplete 275 prefix={autocompletePrefix} 276 onSelect={onSelectAutocompleteItem} 277 /> 278 </View> 279 ) 280})