Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 351 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 {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' 23import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip' 24import {useTheme} from '#/lib/ThemeContext' 25import { 26 type LinkFacetMatch, 27 suggestLinkCardUri, 28} from '#/view/com/composer/text-input/text-input-util' 29import {atoms as a, useAlf, utils} from '#/alf' 30import {normalizeTextStyles} from '#/alf/typography' 31import {IS_ANDROID, IS_NATIVE} from '#/env' 32import {Autocomplete} from './mobile/Autocomplete' 33import {type TextInputProps} from './TextInput.types' 34 35interface Selection { 36 start: number 37 end: number 38} 39 40export function TextInput({ 41 ref, 42 richtext, 43 placeholder, 44 hasRightPadding, 45 setRichText, 46 onPhotoPasted, 47 onNewLink, 48 onError, 49 ...props 50}: TextInputProps) { 51 const {t: l} = useLingui() 52 const {theme: t, fonts} = useAlf() 53 const textInput = useRef<RNTextInput>(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 const prevText = useRef(richtext.text) 59 60 useImperativeHandle(ref, () => ({ 61 focus: () => textInput.current?.focus(), 62 blur: () => { 63 textInput.current?.blur() 64 }, 65 getCursorPosition: () => undefined, // Not implemented on native 66 maybeClosePopup: () => false, // Not needed on native 67 })) 68 69 const pastSuggestedUris = useRef(new Set<string>()) 70 const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>()) 71 const onChangeText = useCallback( 72 async (newText: string) => { 73 const mayBePaste = newText.length > prevLength.current + 1 74 75 // Check if this is a paste over selected text with a URL 76 // NOTE: onChangeText happens before onSelectionChange, so textInputSelection.current 77 // still contains the selection from before the paste 78 if ( 79 mayBePaste && 80 textInputSelection.current.start !== textInputSelection.current.end 81 ) { 82 const selectionStart = textInputSelection.current.start 83 const selectionEnd = textInputSelection.current.end 84 const selectedText = prevText.current.substring( 85 selectionStart, 86 selectionEnd, 87 ) 88 89 // Calculate what was pasted 90 const beforeSelection = prevText.current.substring(0, selectionStart) 91 const afterSelection = prevText.current.substring(selectionEnd) 92 const expectedLength = beforeSelection.length + afterSelection.length 93 const pastedLength = newText.length - expectedLength 94 95 if (pastedLength > 0 && selectedText.length > 0) { 96 const pastedText = newText.substring( 97 selectionStart, 98 selectionStart + pastedLength, 99 ) 100 101 // Check if pasted text is a URL 102 const urlPattern = 103 /^(?:(?:(?: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 104 105 if (urlPattern.test(pastedText.trim())) { 106 // Create markdown-style link: [selectedText](url) 107 const markdownLink = `[${selectedText}](${pastedText.trim()})` 108 newText = beforeSelection + markdownLink + afterSelection 109 } 110 } 111 } 112 113 const newRt = new RichText({text: newText}) 114 detectFacetsWithoutResolution(newRt) 115 116 const markdownFacets: AppBskyRichtextFacet.Main[] = [] 117 const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g 118 let match 119 while ((match = regex.exec(newText)) !== null) { 120 const [fullMatch, _linkText, linkUrl] = match 121 const matchStart = match.index 122 const matchEnd = matchStart + fullMatch.length 123 const prefix = newText.slice(0, matchStart) 124 const matchStr = newText.slice(matchStart, matchEnd) 125 const byteStart = new UnicodeString(prefix).length 126 const byteEnd = byteStart + new UnicodeString(matchStr).length 127 128 let validUrl = linkUrl 129 if ( 130 !validUrl.startsWith('http://') && 131 !validUrl.startsWith('https://') && 132 !validUrl.startsWith('mailto:') 133 ) { 134 validUrl = `https://${validUrl}` 135 } 136 137 markdownFacets.push({ 138 index: {byteStart, byteEnd}, 139 features: [{$type: 'app.bsky.richtext.facet#link', uri: validUrl}], 140 }) 141 } 142 143 if (markdownFacets.length > 0) { 144 const nonOverlapping = (newRt.facets || []).filter(f => { 145 return !markdownFacets.some(mf => { 146 return ( 147 (f.index.byteStart >= mf.index.byteStart && 148 f.index.byteStart < mf.index.byteEnd) || 149 (f.index.byteEnd > mf.index.byteStart && 150 f.index.byteEnd <= mf.index.byteEnd) || 151 (mf.index.byteStart >= f.index.byteStart && 152 mf.index.byteStart < f.index.byteEnd) 153 ) 154 }) 155 }) 156 newRt.facets = [...nonOverlapping, ...markdownFacets].sort( 157 (a, b) => a.index.byteStart - b.index.byteStart, 158 ) 159 } 160 161 setRichText(newRt) 162 163 // NOTE: BinaryFiddler 164 // onChangeText happens before onSelectionChange, cursorPos is out of bound if the user deletes characters, 165 const cursorPos = textInputSelection.current?.start ?? 0 166 const prefix = getMentionAt(newText, Math.min(cursorPos, newText.length)) 167 168 if (prefix) { 169 setAutocompletePrefix(prefix.value) 170 } else if (autocompletePrefix) { 171 setAutocompletePrefix('') 172 } 173 174 const nextDetectedUris = new Map<string, LinkFacetMatch>() 175 if (newRt.facets) { 176 for (const facet of newRt.facets) { 177 for (const feature of facet.features) { 178 if (AppBskyRichtextFacet.isLink(feature)) { 179 if (isUriImage(feature.uri)) { 180 const res = await downloadAndResize({ 181 uri: feature.uri, 182 width: POST_IMG_MAX.width, 183 height: POST_IMG_MAX.height, 184 mode: 'contain', 185 maxSize: POST_IMG_MAX.size, 186 timeout: 15e3, 187 }) 188 189 if (res !== undefined) { 190 onPhotoPasted(res.path) 191 } 192 } else { 193 nextDetectedUris.set(feature.uri, {facet, rt: newRt}) 194 } 195 } 196 } 197 } 198 } 199 const suggestedUri = suggestLinkCardUri( 200 mayBePaste, 201 nextDetectedUris, 202 prevDetectedUris.current, 203 pastSuggestedUris.current, 204 ) 205 prevDetectedUris.current = nextDetectedUris 206 if (suggestedUri) { 207 onNewLink(suggestedUri) 208 } 209 prevLength.current = newText.length 210 prevText.current = newText 211 }, 212 [setRichText, autocompletePrefix, onPhotoPasted, onNewLink], 213 ) 214 215 const onPaste = useCallback( 216 (payload: PasteEventPayload) => { 217 if (payload.type === 'unsupported') { 218 onError(l`Unsupported clipboard content`) 219 return 220 } 221 222 if (payload.type === 'images') { 223 for (const uri of payload.uris) { 224 if (isUriImage(uri)) { 225 onPhotoPasted(uri) 226 } 227 } 228 } 229 }, 230 [l, 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 <TextInputWrapper onPaste={onPaste}> 307 <RNTextInput 308 testID="composerTextInput" 309 ref={textInput} 310 onChangeText={onChangeText} 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 </RNTextInput> 344 </TextInputWrapper> 345 <Autocomplete 346 prefix={autocompletePrefix} 347 onSelect={onSelectAutocompleteItem} 348 /> 349 </View> 350 ) 351}