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