Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

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