Bluesky app fork with some witchin' additions 💫

Rework native autocomplete (#5521)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by hailey.at

Samuel Newman and committed by
GitHub
587c0c62 4b5d6e6e

+105 -120
+9 -8
src/lib/custom-animations/PressableScale.tsx
··· 13 13 14 14 const DEFAULT_TARGET_SCALE = isNative || isTouchDevice ? 0.98 : 1 15 15 16 + const AnimatedPressable = Animated.createAnimatedComponent(Pressable) 17 + 16 18 export function PressableScale({ 17 19 targetScale = DEFAULT_TARGET_SCALE, 18 20 children, 19 - contentContainerStyle, 21 + style, 20 22 onPressIn, 21 23 onPressOut, 22 24 ...rest 23 25 }: { 24 26 targetScale?: number 25 - contentContainerStyle?: StyleProp<ViewStyle> 26 - } & Exclude<PressableProps, 'onPressIn' | 'onPressOut'>) { 27 + style?: StyleProp<ViewStyle> 28 + } & Exclude<PressableProps, 'onPressIn' | 'onPressOut' | 'style'>) { 27 29 const scale = useSharedValue(1) 28 30 29 31 const animatedStyle = useAnimatedStyle(() => ({ ··· 31 33 })) 32 34 33 35 return ( 34 - <Pressable 36 + <AnimatedPressable 35 37 accessibilityRole="button" 36 38 onPressIn={e => { 37 39 'worklet' ··· 49 51 cancelAnimation(scale) 50 52 scale.value = withTiming(1, {duration: 100}) 51 53 }} 54 + style={[animatedStyle, style]} 52 55 {...rest}> 53 - <Animated.View style={[animatedStyle, contentContainerStyle]}> 54 - {children as React.ReactNode} 55 - </Animated.View> 56 - </Pressable> 56 + {children} 57 + </AnimatedPressable> 57 58 ) 58 59 }
+5 -1
src/view/com/composer/text-input/TextInput.tsx
··· 245 245 multiline 246 246 scrollEnabled={false} 247 247 numberOfLines={4} 248 - style={[inputTextStyle, a.w_full, {textAlignVertical: 'top'}]} 248 + style={[ 249 + inputTextStyle, 250 + a.w_full, 251 + {textAlignVertical: 'top', minHeight: 60}, 252 + ]} 249 253 {...props}> 250 254 {textDecorated} 251 255 </PasteInput>
+88 -107
src/view/com/composer/text-input/mobile/Autocomplete.tsx
··· 1 - import React, {useEffect, useRef} from 'react' 2 - import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native' 3 - import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' 4 - import {usePalette} from 'lib/hooks/usePalette' 5 - import {Text} from 'view/com/util/text/Text' 6 - import {UserAvatar} from 'view/com/util/UserAvatar' 1 + import React, {useRef} from 'react' 2 + import {View} from 'react-native' 3 + import Animated, {FadeInDown, FadeOut} from 'react-native-reanimated' 4 + import {AppBskyActorDefs} from '@atproto/api' 5 + import {Trans} from '@lingui/macro' 6 + 7 + import {PressableScale} from '#/lib/custom-animations/PressableScale' 8 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 9 + import {sanitizeHandle} from '#/lib/strings/handles' 10 + import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 11 + import {UserAvatar} from '#/view/com/util/UserAvatar' 12 + import {atoms as a, useTheme} from '#/alf' 13 + import {Text} from '#/components/Typography' 7 14 import {useGrapheme} from '../hooks/useGrapheme' 8 - import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 9 - import {Trans} from '@lingui/macro' 10 - import {AppBskyActorDefs} from '@atproto/api' 11 15 12 16 export function Autocomplete({ 13 17 prefix, ··· 16 20 prefix: string 17 21 onSelect: (item: string) => void 18 22 }) { 19 - const pal = usePalette('default') 20 - const positionInterp = useAnimatedValue(0) 23 + const t = useTheme() 24 + 21 25 const {getGraphemeString} = useGrapheme() 22 26 const isActive = !!prefix 23 27 const {data: suggestions, isFetching} = useActorAutocompleteQuery(prefix) ··· 28 32 suggestionsRef.current = suggestions 29 33 } 30 34 31 - useEffect(() => { 32 - Animated.timing(positionInterp, { 33 - toValue: isActive ? 1 : 0, 34 - duration: 200, 35 - useNativeDriver: true, 36 - }).start() 37 - }, [positionInterp, isActive]) 38 - 39 - const topAnimStyle = { 40 - transform: [ 41 - { 42 - translateY: positionInterp.interpolate({ 43 - inputRange: [0, 1], 44 - outputRange: [200, 0], 45 - }), 46 - }, 47 - ], 48 - } 35 + if (!isActive) return null 49 36 50 37 return ( 51 - <Animated.View style={topAnimStyle}> 52 - {isActive ? ( 53 - <View style={[pal.view, styles.container, pal.border]}> 54 - {suggestionsRef.current?.length ? ( 55 - suggestionsRef.current.slice(0, 5).map(item => { 56 - // Eventually use an average length 57 - const MAX_CHARS = 40 58 - const MAX_HANDLE_CHARS = 20 38 + <Animated.View 39 + entering={FadeInDown.duration(200)} 40 + exiting={FadeOut.duration(100)} 41 + style={[ 42 + t.atoms.bg, 43 + a.mt_sm, 44 + a.border, 45 + a.rounded_sm, 46 + t.atoms.border_contrast_high, 47 + {marginLeft: -62}, 48 + ]}> 49 + {suggestionsRef.current?.length ? ( 50 + suggestionsRef.current.slice(0, 5).map((item, index, arr) => { 51 + // Eventually use an average length 52 + const MAX_CHARS = 40 53 + const MAX_HANDLE_CHARS = 20 59 54 60 - // Using this approach because styling is not respecting 61 - // bounding box wrapping (before converting to ellipsis) 62 - const {name: displayHandle, remainingCharacters} = 63 - getGraphemeString(item.handle, MAX_HANDLE_CHARS) 55 + // Using this approach because styling is not respecting 56 + // bounding box wrapping (before converting to ellipsis) 57 + const {name: displayHandle, remainingCharacters} = getGraphemeString( 58 + item.handle, 59 + MAX_HANDLE_CHARS, 60 + ) 64 61 65 - const {name: displayName} = getGraphemeString( 66 - item.displayName ?? item.handle, 67 - MAX_CHARS - 68 - MAX_HANDLE_CHARS + 69 - (remainingCharacters > 0 ? remainingCharacters : 0), 70 - ) 62 + const {name: displayName} = getGraphemeString( 63 + item.displayName || item.handle, 64 + MAX_CHARS - 65 + MAX_HANDLE_CHARS + 66 + (remainingCharacters > 0 ? remainingCharacters : 0), 67 + ) 71 68 72 - return ( 73 - <TouchableOpacity 74 - testID="autocompleteButton" 75 - key={item.handle} 76 - style={[pal.border, styles.item]} 77 - onPress={() => onSelect(item.handle)} 78 - accessibilityLabel={`Select ${item.handle}`} 79 - accessibilityHint=""> 80 - <View style={styles.avatarAndHandle}> 81 - <UserAvatar 82 - avatar={item.avatar ?? null} 83 - size={24} 84 - type={item.associated?.labeler ? 'labeler' : 'user'} 85 - /> 86 - <Text type="md-medium" style={pal.text}> 87 - {displayName} 88 - </Text> 89 - </View> 90 - <Text type="sm" style={pal.textLight} numberOfLines={1}> 91 - @{displayHandle} 69 + return ( 70 + <View 71 + style={[ 72 + index !== arr.length - 1 && a.border_b, 73 + t.atoms.border_contrast_high, 74 + a.px_sm, 75 + a.py_md, 76 + ]} 77 + key={item.handle}> 78 + <PressableScale 79 + testID="autocompleteButton" 80 + style={[ 81 + a.flex_row, 82 + a.gap_sm, 83 + a.justify_between, 84 + a.align_center, 85 + ]} 86 + onPress={() => onSelect(item.handle)} 87 + accessibilityLabel={`Select ${item.handle}`} 88 + accessibilityHint=""> 89 + <View style={[a.flex_row, a.gap_sm, a.align_center]}> 90 + <UserAvatar 91 + avatar={item.avatar ?? null} 92 + size={24} 93 + type={item.associated?.labeler ? 'labeler' : 'user'} 94 + /> 95 + <Text 96 + style={[a.text_md, a.font_bold]} 97 + emoji={true} 98 + numberOfLines={1}> 99 + {sanitizeDisplayName(displayName)} 92 100 </Text> 93 - </TouchableOpacity> 94 - ) 95 - }) 96 - ) : ( 97 - <Text type="sm" style={[pal.text, pal.border, styles.noResults]}> 98 - {isFetching ? ( 99 - <Trans>Loading...</Trans> 100 - ) : ( 101 - <Trans>No result</Trans> 102 - )} 103 - </Text> 104 - )} 105 - </View> 106 - ) : null} 101 + </View> 102 + <Text style={[t.atoms.text_contrast_medium]} numberOfLines={1}> 103 + {sanitizeHandle(displayHandle, '@')} 104 + </Text> 105 + </PressableScale> 106 + </View> 107 + ) 108 + }) 109 + ) : ( 110 + <Text style={[a.text_md, a.px_sm, a.py_md]}> 111 + {isFetching ? <Trans>Loading...</Trans> : <Trans>No result</Trans>} 112 + </Text> 113 + )} 107 114 </Animated.View> 108 115 ) 109 116 } 110 - 111 - const styles = StyleSheet.create({ 112 - container: { 113 - marginLeft: -50, // Composer avatar width 114 - top: 10, 115 - borderTopWidth: 1, 116 - }, 117 - item: { 118 - borderBottomWidth: 1, 119 - paddingVertical: 12, 120 - display: 'flex', 121 - flexDirection: 'row', 122 - alignItems: 'center', 123 - justifyContent: 'space-between', 124 - gap: 6, 125 - }, 126 - avatarAndHandle: { 127 - display: 'flex', 128 - flexDirection: 'row', 129 - gap: 6, 130 - alignItems: 'center', 131 - }, 132 - noResults: { 133 - paddingVertical: 12, 134 - }, 135 - })
+3 -4
src/view/shell/bottom-bar/BottomBar.tsx
··· 351 351 return ( 352 352 <PressableScale 353 353 testID={testID} 354 - style={styles.ctrl} 354 + style={[styles.ctrl, a.flex_1]} 355 355 onPress={onPress} 356 356 onLongPress={onLongPress} 357 357 accessible={accessible} 358 358 accessibilityLabel={accessibilityLabel} 359 359 accessibilityHint={accessibilityHint} 360 - targetScale={0.8} 361 - contentContainerStyle={[a.flex_1]}> 360 + targetScale={0.8}> 362 361 {icon} 363 362 {notificationCount ? ( 364 - <View style={[styles.notificationCount, {top: -5}]}> 363 + <View style={[styles.notificationCount]}> 365 364 <Text style={styles.notificationCountLabel}>{notificationCount}</Text> 366 365 </View> 367 366 ) : undefined}