Bluesky app fork with some witchin' additions 馃挮
at main 308 lines 9.0 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, // @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 77 const markdownFacets: AppBskyRichtextFacet.Main[] = [] 78 const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g 79 let match 80 while ((match = regex.exec(newText)) !== null) { 81 const [fullMatch, _linkText, linkUrl] = match 82 const matchStart = match.index 83 const matchEnd = matchStart + fullMatch.length 84 const prefix = newText.slice(0, matchStart) 85 const matchStr = newText.slice(matchStart, matchEnd) 86 const byteStart = new UnicodeString(prefix).length 87 const byteEnd = byteStart + new UnicodeString(matchStr).length 88 89 let validUrl = linkUrl 90 if ( 91 !validUrl.startsWith('http://') && 92 !validUrl.startsWith('https://') && 93 !validUrl.startsWith('mailto:') 94 ) { 95 validUrl = `https://${validUrl}` 96 } 97 98 markdownFacets.push({ 99 index: {byteStart, byteEnd}, 100 features: [ 101 {$type: 'app.bsky.richtext.facet#link', uri: validUrl}, 102 ], 103 }) 104 } 105 106 if (markdownFacets.length > 0) { 107 108 const nonOverlapping = (newRt.facets || []).filter(f => { 109 return !markdownFacets.some(mf => { 110 return ( 111 (f.index.byteStart >= mf.index.byteStart && 112 f.index.byteStart < mf.index.byteEnd) || 113 (f.index.byteEnd > mf.index.byteStart && 114 f.index.byteEnd <= mf.index.byteEnd) || 115 (mf.index.byteStart >= f.index.byteStart && 116 mf.index.byteStart < f.index.byteEnd) 117 ) 118 }) 119 }) 120 newRt.facets = [...nonOverlapping, ...markdownFacets].sort( 121 (a, b) => a.index.byteStart - b.index.byteStart, 122 ) 123 } 124 125 setRichText(newRt) 126 127 // NOTE: BinaryFiddler 128 // onChangeText happens before onSelectionChange, cursorPos is out of bound if the user deletes characters, 129 const cursorPos = textInputSelection.current?.start ?? 0 130 const prefix = getMentionAt(newText, Math.min(cursorPos, newText.length)) 131 132 if (prefix) { 133 setAutocompletePrefix(prefix.value) 134 } else if (autocompletePrefix) { 135 setAutocompletePrefix('') 136 } 137 138 const nextDetectedUris = new Map<string, LinkFacetMatch>() 139 if (newRt.facets) { 140 for (const facet of newRt.facets) { 141 for (const feature of facet.features) { 142 if (AppBskyRichtextFacet.isLink(feature)) { 143 if (isUriImage(feature.uri)) { 144 const res = await downloadAndResize({ 145 uri: feature.uri, 146 width: POST_IMG_MAX.width, 147 height: POST_IMG_MAX.height, 148 mode: 'contain', 149 maxSize: POST_IMG_MAX.size, 150 timeout: 15e3, 151 }) 152 153 if (res !== undefined) { 154 onPhotoPasted(res.path) 155 } 156 } else { 157 nextDetectedUris.set(feature.uri, {facet, rt: newRt}) 158 } 159 } 160 } 161 } 162 } 163 const suggestedUri = suggestLinkCardUri( 164 mayBePaste, 165 nextDetectedUris, 166 prevDetectedUris.current, 167 pastSuggestedUris.current, 168 ) 169 prevDetectedUris.current = nextDetectedUris 170 if (suggestedUri) { 171 onNewLink(suggestedUri) 172 } 173 prevLength.current = newText.length 174 }, 175 [setRichText, autocompletePrefix, onPhotoPasted, onNewLink], 176 ) 177 178 const onPaste = useCallback( 179 async (err: string | undefined, files: PastedFile[]) => { 180 if (err) { 181 return onError(cleanError(err)) 182 } 183 184 const uris = files.map(f => f.uri) 185 const uri = uris.find(isUriImage) 186 187 if (uri) { 188 onPhotoPasted(uri) 189 } 190 }, 191 [onError, onPhotoPasted], 192 ) 193 194 const onSelectionChange = useCallback( 195 (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { 196 // NOTE we track the input selection using a ref to avoid excessive renders -prf 197 textInputSelection.current = evt.nativeEvent.selection 198 }, 199 [textInputSelection], 200 ) 201 202 const onSelectAutocompleteItem = useCallback( 203 (item: string) => { 204 onChangeText( 205 insertMentionAt( 206 richtext.text, 207 textInputSelection.current?.start || 0, 208 item, 209 ), 210 ) 211 setAutocompletePrefix('') 212 }, 213 [onChangeText, richtext, setAutocompletePrefix], 214 ) 215 216 const inputTextStyle = useMemo(() => { 217 const style = normalizeTextStyles( 218 [a.text_lg, a.leading_snug, t.atoms.text], 219 { 220 fontScale: fonts.scaleMultiplier, 221 fontFamily: fonts.family, 222 flags: {}, 223 }, 224 ) 225 226 /** 227 * PasteInput doesn't like `lineHeight`, results in jumpiness 228 */ 229 if (isNative) { 230 style.lineHeight = undefined 231 } 232 233 /* 234 * Android impl of `PasteInput` doesn't support the array syntax for `fontVariant` 235 */ 236 if (isAndroid) { 237 // @ts-ignore 238 style.fontVariant = style.fontVariant 239 ? style.fontVariant.join(' ') 240 : undefined 241 } 242 return style 243 }, [t, fonts]) 244 245 const textDecorated = useMemo(() => { 246 let i = 0 247 248 return Array.from(richtext.segments()).map(segment => { 249 return ( 250 <RNText 251 key={i++} 252 style={[ 253 inputTextStyle, 254 { 255 color: segment.facet ? t.palette.primary_500 : t.atoms.text.color, 256 marginTop: -1, 257 }, 258 ]}> 259 {segment.text} 260 </RNText> 261 ) 262 }) 263 }, [t, richtext, inputTextStyle]) 264 265 return ( 266 <View style={[a.flex_1, a.pl_md, hasRightPadding && a.pr_4xl]}> 267 <PasteInput 268 testID="composerTextInput" 269 ref={textInput} 270 onChangeText={onChangeText} 271 onPaste={onPaste} 272 onSelectionChange={onSelectionChange} 273 placeholder={placeholder} 274 placeholderTextColor={t.atoms.text_contrast_medium.color} 275 keyboardAppearance={theme.colorScheme} 276 autoFocus={true} 277 allowFontScaling 278 multiline 279 scrollEnabled={false} 280 numberOfLines={2} 281 // Note: should be the default value, but as of v1.104 282 // it switched to "none" on Android 283 autoCapitalize="sentences" 284 {...props} 285 style={[ 286 inputTextStyle, 287 a.w_full, 288 !autocompletePrefix && a.h_full, 289 { 290 textAlignVertical: 'top', 291 minHeight: 60, 292 includeFontPadding: false, 293 }, 294 { 295 borderWidth: 1, 296 borderColor: 'transparent', 297 }, 298 props.style, 299 ]}> 300 {textDecorated} 301 </PasteInput> 302 <Autocomplete 303 prefix={autocompletePrefix} 304 onSelect={onSelectAutocompleteItem} 305 /> 306 </View> 307 ) 308}