this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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