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 432 lines 12 kB view raw
1import {useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react' 2import { 3 type TextInput, 4 type TextInputSubmitEditingEvent, 5 View, 6} from 'react-native' 7import Animated, { 8 useAnimatedStyle, 9 useSharedValue, 10} from 'react-native-reanimated' 11import {useSafeAreaInsets} from 'react-native-safe-area-context' 12import {useSift, type UseSiftReturn} from '@bsky.app/sift' 13import { 14 facets, 15 type TapperActiveFacet, 16 type TapperFacet, 17 useTapper, 18} from '@bsky.app/tapper' 19 20import {mergeRefs} from '#/lib/merge-refs' 21import { 22 atoms as a, 23 type TextStyleProp, 24 useAlf, 25 type ViewStyleProp, 26 web, 27} from '#/alf' 28import {normalizeTextStyles} from '#/alf/typography' 29import { 30 Autocomplete as AutocompleteBase, 31 AutocompleteItemEmoji, 32 AutocompleteItemProfile, 33 parseAutocompleteItemType, 34 useAutocomplete, 35} from '#/components/Autocomplete' 36import { 37 AutosizedTextarea, 38 type AutosizedTextareaProps, 39} from '#/components/forms/AutosizedTextarea' 40import {Span, Text} from '#/components/Typography' 41import {IS_IOS, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' 42 43export type SubmitRequest = 44 | { 45 platform: 'web' 46 shiftKey: boolean 47 metaKey: boolean 48 nativeEvent: KeyboardEvent 49 } 50 | { 51 platform: 'native' 52 nativeEvent: TextInputSubmitEditingEvent 53 } 54 55/** 56 * Imperative API exposed via `internalApiRef` prop for parent components that 57 * need to control the composer programmatically, e.g. to clear the input or 58 * insert text at the current cursor position. 59 */ 60export type ComposerInternalApi = { 61 input?: ReturnType<typeof useTapper>['input'] 62 clear: () => void 63 insert(text: string): void 64 setAutocompleteAnchor: (node: View | null) => void 65} 66 67export function useComposerInternalApiRef() { 68 return useRef<ComposerInternalApi>(null) 69} 70 71/* 72 * ─── Composer ───────────────────────────────────────────────────────────────── 73 */ 74 75export type ComposerProps = Omit< 76 AutosizedTextareaProps, 77 | 'value' 78 | 'onChange' 79 | 'onChangeText' 80 | 'onSelectionChange' 81 | 'selection' 82 | 'style' 83 | 'onSubmitEditing' 84> & { 85 label: string 86 ref?: React.RefObject<TextInput> 87 internalApiRef?: React.Ref<ComposerInternalApi> 88 outerStyle?: ViewStyleProp['style'] 89 contentTextStyle?: TextStyleProp['style'] 90 contentPaddingStyle?: { 91 paddingTop?: number 92 paddingBottom?: number 93 paddingLeft?: number 94 paddingRight?: number 95 } 96 onChange?: (text: string) => void 97 onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void 98 onFacetCommitted?: (facet: TapperFacet) => void 99 onRequestSubmit?: (request: SubmitRequest) => void 100 autocompletePlacement?: Exclude< 101 Parameters<typeof useSift>[0], 102 undefined 103 >['placement'] 104 disableEmojiFacets?: boolean 105} 106 107export function Composer({ 108 label, 109 ref, 110 internalApiRef, 111 outerStyle, 112 contentTextStyle, 113 contentPaddingStyle, 114 onChange: onChangeOuter, 115 onActiveFacet: onActiveFacetOuter, 116 onFacetCommitted: onFacetCommittedOuter, 117 onRequestSubmit, 118 autocompletePlacement, 119 defaultValue, 120 disableEmojiFacets = !IS_WEB, 121 ...rest 122}: ComposerProps) { 123 const {theme: t, fonts} = useAlf() 124 const insets = useSafeAreaInsets() 125 126 /* 127 * Meat and potatoes 128 */ 129 const tapper = useTapper({ 130 initialText: defaultValue ?? '', 131 facets: disableEmojiFacets 132 ? { 133 mention: facets.mention, 134 tag: facets.tag, 135 url: facets.url, 136 } 137 : facets, 138 }) 139 const sift = useSift({ 140 offset: a.p_sm.padding, 141 placement: autocompletePlacement, 142 dynamicWidth: IS_WEB, 143 insets, 144 }) 145 146 /* 147 * Active facet state for controlling the visibility of the Autocomplete. 148 */ 149 const [activeFacet, setActiveFacet] = useState<TapperActiveFacet | null>(null) 150 151 /* 152 * Reanimated shared value for syncing scroll on all platforms. 153 */ 154 const inputScrollSharedValue = useSharedValue(0) 155 156 /* 157 * Expose imperative internal API 158 */ 159 useImperativeHandle( 160 internalApiRef, 161 () => ({ 162 input: tapper.input, 163 clear: () => { 164 tapper.inputProps.onChangeText('') 165 inputScrollSharedValue.value = 0 166 }, 167 insert: tapper.insert, 168 setAutocompleteAnchor: sift.refs.setAnchor, 169 }), 170 [tapper.input, tapper.insert, inputScrollSharedValue, sift.refs.setAnchor], 171 ) 172 173 /* 174 * Skip the initial mount to avoid an unnecessary re-render — the parent 175 * already knows the initial value since it passed `initialText`. 176 */ 177 const isFirstRender = useRef(true) 178 useEffect(() => { 179 if (isFirstRender.current) { 180 isFirstRender.current = false 181 return 182 } 183 onChangeOuter?.(tapper.state.text) 184 }, [tapper.state.text, onChangeOuter]) 185 186 /* 187 * Tapper callbacks 188 */ 189 const callbackRefs = useRef({ 190 onActiveFacetOuter, 191 onFacetCommittedOuter, 192 }) 193 callbackRefs.current = { 194 onActiveFacetOuter, 195 onFacetCommittedOuter, 196 } 197 useEffect(() => { 198 const offActiveFacet = tapper.on('activeFacet', facet => { 199 setActiveFacet(facet) 200 callbackRefs.current.onActiveFacetOuter?.(facet) 201 }) 202 const offFacetCommitted = tapper.on('facetCommitted', facet => { 203 callbackRefs.current.onFacetCommittedOuter?.(facet) 204 }) 205 const offAfterInsert = tapper.on('afterInsert', () => { 206 tapper.input.focus() 207 }) 208 return () => { 209 offActiveFacet() 210 offFacetCommitted() 211 offAfterInsert() 212 } 213 }, [tapper.on, tapper.input]) 214 215 /* 216 * Styles 217 */ 218 const previewScrollStyle = useAnimatedStyle(() => ({ 219 transform: [{translateY: -inputScrollSharedValue.value}], 220 })) 221 const textStyle = useMemo(() => { 222 const ts = normalizeTextStyles( 223 [a.leading_snug, t.atoms.text, contentTextStyle], 224 { 225 fontScale: fonts.scaleMultiplier, 226 fontFamily: fonts.family, 227 flags: {}, 228 }, 229 ) 230 /** 231 * On iOS, having a lineHeight on the Text component causes the text to be 232 * vertically misaligned with the TextInput. 233 * 234 * This only seems to be an issue on iOS, and not on Android or web. It's 235 * possible that this is a bug in React Native's Text component on iOS, 236 * but in the meantime, we'll just remove the lineHeight on iOS to ensure 237 * the text is properly aligned. 238 */ 239 if (IS_IOS) { 240 delete ts.lineHeight 241 } 242 return ts 243 }, [contentTextStyle, fonts]) 244 245 /* 246 * Web keyboard handling 247 */ 248 const isComposing = useRef(false) 249 const onKeyPressWeb = (e: React.KeyboardEvent | any) => { 250 if (IS_WEB_TOUCH_DEVICE) return 251 if (isComposing.current) return 252 253 /* 254 * On Safari, the final keydown to dismiss an IME is also "Enter" with 255 * keyCode 229. Chrome/Firefox don't have this problem. 256 * 257 * @see https://github.com/bluesky-social/social-app/issues/4178 258 */ 259 if (e.key === 'Enter' && e.keyCode === 229) return 260 261 if (e.key === 'Enter') { 262 onRequestSubmit?.({ 263 platform: 'web', 264 shiftKey: e.shiftKey, 265 metaKey: e.metaKey, 266 nativeEvent: e.nativeEvent, 267 }) 268 } 269 } 270 271 /* 272 * Sift popover positioning 273 */ 274 const updateAutocompletePosition = () => { 275 sift.updatePosition() 276 } 277 278 const textContent = ( 279 <Text style={[textStyle, web({whiteSpace: 'pre-wrap'})]}> 280 {tapper.state.nodes.map((node, i) => { 281 switch (node.type) { 282 case 'text': 283 return <Span key={i}>{node.value}</Span> 284 case 'trigger': 285 case 'facet': 286 return ( 287 <Span 288 key={i} 289 ref={IS_WEB ? sift.refs.setAnchor : undefined} 290 style={ 291 node.type === 'facet' && { 292 color: t.palette.primary_500, 293 } 294 }> 295 {node.raw} 296 </Span> 297 ) 298 } 299 })} 300 </Text> 301 ) 302 303 return ( 304 <> 305 <View style={[a.relative, outerStyle]}> 306 {IS_WEB && ( 307 <View 308 pointerEvents="none" 309 style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}> 310 <Animated.View 311 style={[ 312 contentPaddingStyle, 313 {position: 'absolute', left: 0, right: 0}, 314 previewScrollStyle, 315 ]}> 316 {textContent} 317 </Animated.View> 318 </View> 319 )} 320 <AutosizedTextarea 321 placeholderTextColor={t.palette.contrast_500} 322 accessibilityLabel={label} 323 accessibilityHint={label} 324 onSubmitEditing={e => { 325 onRequestSubmit?.({platform: 'native', nativeEvent: e}) 326 }} 327 style={[ 328 textStyle, 329 contentPaddingStyle, 330 a.z_20, 331 { 332 color: 'transparent', 333 background: 'transparent', 334 }, 335 web({ 336 caretColor: textStyle.color ?? 'black', 337 overscrollBehavior: 'none', 338 }), 339 ]} 340 {...rest} 341 {...tapper.inputProps} 342 {...sift.targetProps} 343 ref={mergeRefs([ref, tapper.inputProps.ref, sift.targetProps.ref])} 344 onBlur={e => { 345 rest.onBlur?.(e) 346 setActiveFacet(null) 347 }} 348 onKeyPress={IS_WEB ? onKeyPressWeb : undefined} 349 onScroll={e => { 350 if (IS_WEB) { 351 inputScrollSharedValue.value = (e.target as any).scrollTop 352 } else { 353 inputScrollSharedValue.value = e.nativeEvent.contentOffset.y 354 } 355 }} 356 // @ts-ignore web only 357 onCompositionStart={() => { 358 isComposing.current = true 359 }} 360 // @ts-ignore web only 361 onCompositionEnd={() => { 362 isComposing.current = false 363 }} 364 onUpdateHeight={updateAutocompletePosition}> 365 {IS_WEB ? null : textContent} 366 </AutosizedTextarea> 367 </View> 368 369 {activeFacet && activeFacet.type !== 'url' && ( 370 <AutocompleteInner 371 sift={sift} 372 activeFacet={activeFacet} 373 onDismiss={() => setActiveFacet(null)} 374 /> 375 )} 376 </> 377 ) 378} 379 380/* 381 * ─── Autocomplete (private) ─────────────────────────────────────────────────── 382 */ 383 384function AutocompleteInner({ 385 sift, 386 activeFacet, 387 onDismiss, 388}: { 389 sift: UseSiftReturn 390 activeFacet: TapperActiveFacet 391 onDismiss: () => void 392}) { 393 const {items} = useAutocomplete({ 394 type: parseAutocompleteItemType(activeFacet.type), 395 query: activeFacet.value, 396 }) 397 398 useEffect(() => { 399 if ( 400 activeFacet?.type === 'emoji' && 401 !!activeFacet.value.length && 402 activeFacet.raw.endsWith(':') 403 ) { 404 if (items?.[0]) { 405 activeFacet.replace(items[0].value, {noTrailingSpace: true}) 406 onDismiss() 407 } 408 } 409 }, [items, activeFacet]) 410 411 return items && items.length ? ( 412 <AutocompleteBase 413 inverted={!IS_WEB} 414 sift={sift} 415 data={items} 416 render={props => { 417 if (props.item.type === 'profile') { 418 return <AutocompleteItemProfile {...props} /> 419 } 420 if (props.item.type === 'emoji') { 421 return <AutocompleteItemEmoji {...props} /> 422 } 423 return <View /> 424 }} 425 onSelect={item => { 426 activeFacet.replace(item.value) 427 onDismiss() 428 }} 429 onDismiss={onDismiss} 430 /> 431 ) : null 432}