Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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