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