Bluesky app fork with some witchin' additions 馃挮
at main 350 lines 11 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, UnicodeString} 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, utils} 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 const prevText = useRef(richtext.text) 61 62 useImperativeHandle(ref, () => ({ 63 focus: () => textInput.current?.focus(), 64 blur: () => { 65 textInput.current?.blur() 66 }, 67 getCursorPosition: () => undefined, // Not implemented on native 68 maybeClosePopup: () => false, // Not needed on native 69 })) 70 71 const pastSuggestedUris = useRef(new Set<string>()) 72 const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>()) 73 const onChangeText = useCallback( 74 async (newText: string) => { 75 const mayBePaste = newText.length > prevLength.current + 1 76 77 // Check if this is a paste over selected text with a URL 78 // NOTE: onChangeText happens before onSelectionChange, so textInputSelection.current 79 // still contains the selection from before the paste 80 if ( 81 mayBePaste && 82 textInputSelection.current.start !== textInputSelection.current.end 83 ) { 84 const selectionStart = textInputSelection.current.start 85 const selectionEnd = textInputSelection.current.end 86 const selectedText = prevText.current.substring( 87 selectionStart, 88 selectionEnd, 89 ) 90 91 // Calculate what was pasted 92 const beforeSelection = prevText.current.substring(0, selectionStart) 93 const afterSelection = prevText.current.substring(selectionEnd) 94 const expectedLength = beforeSelection.length + afterSelection.length 95 const pastedLength = newText.length - expectedLength 96 97 if (pastedLength > 0 && selectedText.length > 0) { 98 const pastedText = newText.substring( 99 selectionStart, 100 selectionStart + pastedLength, 101 ) 102 103 // Check if pasted text is a URL 104 const urlPattern = 105 /^(?:(?:(?:https?|ftp):)?\/\/)?(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i 106 107 if (urlPattern.test(pastedText.trim())) { 108 // Create markdown-style link: [selectedText](url) 109 const markdownLink = `[${selectedText}](${pastedText.trim()})` 110 newText = beforeSelection + markdownLink + afterSelection 111 } 112 } 113 } 114 115 const newRt = new RichText({text: newText}) 116 newRt.detectFacetsWithoutResolution() 117 118 const markdownFacets: AppBskyRichtextFacet.Main[] = [] 119 const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g 120 let match 121 while ((match = regex.exec(newText)) !== null) { 122 const [fullMatch, _linkText, linkUrl] = match 123 const matchStart = match.index 124 const matchEnd = matchStart + fullMatch.length 125 const prefix = newText.slice(0, matchStart) 126 const matchStr = newText.slice(matchStart, matchEnd) 127 const byteStart = new UnicodeString(prefix).length 128 const byteEnd = byteStart + new UnicodeString(matchStr).length 129 130 let validUrl = linkUrl 131 if ( 132 !validUrl.startsWith('http://') && 133 !validUrl.startsWith('https://') && 134 !validUrl.startsWith('mailto:') 135 ) { 136 validUrl = `https://${validUrl}` 137 } 138 139 markdownFacets.push({ 140 index: {byteStart, byteEnd}, 141 features: [{$type: 'app.bsky.richtext.facet#link', uri: validUrl}], 142 }) 143 } 144 145 if (markdownFacets.length > 0) { 146 const nonOverlapping = (newRt.facets || []).filter(f => { 147 return !markdownFacets.some(mf => { 148 return ( 149 (f.index.byteStart >= mf.index.byteStart && 150 f.index.byteStart < mf.index.byteEnd) || 151 (f.index.byteEnd > mf.index.byteStart && 152 f.index.byteEnd <= mf.index.byteEnd) || 153 (mf.index.byteStart >= f.index.byteStart && 154 mf.index.byteStart < f.index.byteEnd) 155 ) 156 }) 157 }) 158 newRt.facets = [...nonOverlapping, ...markdownFacets].sort( 159 (a, b) => a.index.byteStart - b.index.byteStart, 160 ) 161 } 162 163 setRichText(newRt) 164 165 // NOTE: BinaryFiddler 166 // onChangeText happens before onSelectionChange, cursorPos is out of bound if the user deletes characters, 167 const cursorPos = textInputSelection.current?.start ?? 0 168 const prefix = getMentionAt(newText, Math.min(cursorPos, newText.length)) 169 170 if (prefix) { 171 setAutocompletePrefix(prefix.value) 172 } else if (autocompletePrefix) { 173 setAutocompletePrefix('') 174 } 175 176 const nextDetectedUris = new Map<string, LinkFacetMatch>() 177 if (newRt.facets) { 178 for (const facet of newRt.facets) { 179 for (const feature of facet.features) { 180 if (AppBskyRichtextFacet.isLink(feature)) { 181 if (isUriImage(feature.uri)) { 182 const res = await downloadAndResize({ 183 uri: feature.uri, 184 width: POST_IMG_MAX.width, 185 height: POST_IMG_MAX.height, 186 mode: 'contain', 187 maxSize: POST_IMG_MAX.size, 188 timeout: 15e3, 189 }) 190 191 if (res !== undefined) { 192 onPhotoPasted(res.path) 193 } 194 } else { 195 nextDetectedUris.set(feature.uri, {facet, rt: newRt}) 196 } 197 } 198 } 199 } 200 } 201 const suggestedUri = suggestLinkCardUri( 202 mayBePaste, 203 nextDetectedUris, 204 prevDetectedUris.current, 205 pastSuggestedUris.current, 206 ) 207 prevDetectedUris.current = nextDetectedUris 208 if (suggestedUri) { 209 onNewLink(suggestedUri) 210 } 211 prevLength.current = newText.length 212 prevText.current = newText 213 }, 214 [setRichText, autocompletePrefix, onPhotoPasted, onNewLink], 215 ) 216 217 const onPaste = useCallback( 218 async (err: string | undefined, files: PastedFile[]) => { 219 if (err) { 220 return onError(cleanError(err)) 221 } 222 223 const uris = files.map(f => f.uri) 224 const uri = uris.find(isUriImage) 225 226 if (uri) { 227 onPhotoPasted(uri) 228 } 229 }, 230 [onError, onPhotoPasted], 231 ) 232 233 const onSelectionChange = useCallback( 234 (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { 235 // NOTE we track the input selection using a ref to avoid excessive renders -prf 236 textInputSelection.current = evt.nativeEvent.selection 237 }, 238 [textInputSelection], 239 ) 240 241 const onSelectAutocompleteItem = useCallback( 242 (item: string) => { 243 onChangeText( 244 insertMentionAt( 245 richtext.text, 246 textInputSelection.current?.start || 0, 247 item, 248 ), 249 ) 250 setAutocompletePrefix('') 251 }, 252 [onChangeText, richtext, setAutocompletePrefix], 253 ) 254 255 const inputTextStyle = useMemo(() => { 256 const style = normalizeTextStyles( 257 [a.text_lg, a.leading_snug, t.atoms.text], 258 { 259 fontScale: fonts.scaleMultiplier, 260 fontFamily: fonts.family, 261 flags: {}, 262 }, 263 ) 264 265 /** 266 * PasteInput doesn't like `lineHeight`, results in jumpiness 267 */ 268 if (IS_NATIVE) { 269 style.lineHeight = undefined 270 } 271 272 /* 273 * Android impl of `PasteInput` doesn't support the array syntax for `fontVariant` 274 */ 275 if (IS_ANDROID) { 276 // @ts-ignore 277 style.fontVariant = style.fontVariant 278 ? style.fontVariant.join(' ') 279 : undefined 280 } 281 return style 282 }, [t, fonts]) 283 284 const textDecorated = useMemo(() => { 285 let i = 0 286 287 return Array.from(richtext.segments()).map(segment => { 288 return ( 289 <RNText 290 key={i++} 291 style={[ 292 inputTextStyle, 293 { 294 color: segment.facet ? t.palette.primary_500 : t.atoms.text.color, 295 marginTop: -1, 296 }, 297 ]}> 298 {segment.text} 299 </RNText> 300 ) 301 }) 302 }, [t, richtext, inputTextStyle]) 303 304 return ( 305 <View style={[a.flex_1, a.pl_md, hasRightPadding && a.pr_4xl]}> 306 <PasteInput 307 testID="composerTextInput" 308 ref={textInput} 309 onChangeText={onChangeText} 310 onPaste={onPaste} 311 onSelectionChange={onSelectionChange} 312 placeholder={placeholder} 313 placeholderTextColor={t.atoms.text_contrast_low.color} 314 keyboardAppearance={theme.colorScheme} 315 autoFocus={props.autoFocus !== undefined ? props.autoFocus : true} 316 allowFontScaling 317 multiline 318 scrollEnabled={false} 319 numberOfLines={2} 320 // Note: should be the default value, but as of v1.104 321 // it switched to "none" on Android 322 autoCapitalize="sentences" 323 selectionColor={utils.alpha(t.palette.primary_500, 0.4)} 324 cursorColor={t.palette.primary_500} 325 selectionHandleColor={t.palette.primary_500} 326 {...props} 327 style={[ 328 inputTextStyle, 329 a.w_full, 330 !autocompletePrefix && a.h_full, 331 { 332 textAlignVertical: 'top', 333 minHeight: 60, 334 includeFontPadding: false, 335 }, 336 { 337 borderWidth: 1, 338 borderColor: 'transparent', 339 }, 340 props.style, 341 ]}> 342 {textDecorated} 343 </PasteInput> 344 <Autocomplete 345 prefix={autocompletePrefix} 346 onSelect={onSelectAutocompleteItem} 347 /> 348 </View> 349 ) 350}