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 260 lines 8.0 kB view raw
1import {useCallback, useState} from 'react' 2import {Pressable, TextInput, useWindowDimensions} from 'react-native' 3import { 4 useFocusedInputHandler, 5 useKeyboardHandler, 6 useReanimatedKeyboardAnimation, 7} from 'react-native-keyboard-controller' 8import Animated, { 9 measure, 10 runOnJS, 11 useAnimatedProps, 12 useAnimatedRef, 13 useAnimatedStyle, 14 useSharedValue, 15} from 'react-native-reanimated' 16import {useSafeAreaInsets} from 'react-native-safe-area-context' 17import {GlassContainer} from 'expo-glass-effect' 18import {useLingui} from '@lingui/react/macro' 19import {countGraphemes} from 'unicode-segmenter/grapheme' 20 21import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' 22import {useHaptics} from '#/lib/haptics' 23import {useEmail} from '#/state/email-verification' 24import { 25 useMessageDraft, 26 useSaveMessageDraft, 27} from '#/state/messages/message-drafts' 28import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 29import {atoms as a, platform, tokens, useTheme, utils} from '#/alf' 30import {GlassView} from '#/components/GlassView' 31import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' 32import * as Toast from '#/components/Toast' 33import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env' 34import {ComposerContainer} from './MessageComposer' 35import {useExtractEmbedFromFacets} from './MessageInputEmbed' 36 37const AnimatedTextInput = Animated.createAnimatedComponent(TextInput) 38 39const MIN_HEIGHT = 40 40 41export function MessageInput({ 42 textInputId, 43 onSendMessage, 44 hasEmbed, 45 setEmbed, 46 children, 47}: { 48 textInputId?: string 49 onSendMessage: (message: string) => Promise<void> | void 50 hasEmbed: boolean 51 setEmbed: (embedUrl: string | undefined) => void 52 children?: React.ReactNode 53}) { 54 const {t: l} = useLingui() 55 const t = useTheme() 56 const playHaptic = useHaptics() 57 const {getDraft, clearDraft} = useMessageDraft() 58 59 // Input layout 60 const {top: topInset} = useSafeAreaInsets() 61 const {height: windowHeight} = useWindowDimensions() 62 const {height: keyboardHeight} = useReanimatedKeyboardAnimation() 63 const maxHeight = useSharedValue<undefined | number>(undefined) 64 const isInputScrollable = useSharedValue(false) 65 66 const [message, setMessage] = useState(getDraft) 67 const inputRef = useAnimatedRef<TextInput>() 68 const [shouldEnforceClear, setShouldEnforceClear] = useState(false) 69 70 const {needsEmailVerification} = useEmail() 71 72 const enableSquareButtons = useEnableSquareButtons() 73 74 useSaveMessageDraft(message) 75 useExtractEmbedFromFacets(message, setEmbed) 76 77 const onSubmit = useCallback(() => { 78 if (needsEmailVerification) { 79 return 80 } 81 if (!hasEmbed && message.trim() === '') { 82 return 83 } 84 if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { 85 Toast.show(l`Message is too long`, { 86 type: 'error', 87 }) 88 return 89 } 90 clearDraft() 91 void onSendMessage(message) 92 playHaptic() 93 setEmbed(undefined) 94 setMessage('') 95 if (IS_IOS) { 96 setShouldEnforceClear(true) 97 } 98 if (IS_WEB) { 99 // Pressing the send button causes the text input to lose focus, so we need to 100 // re-focus it after sending 101 setTimeout(() => { 102 inputRef.current?.focus() 103 }, 100) 104 } 105 }, [ 106 needsEmailVerification, 107 hasEmbed, 108 message, 109 clearDraft, 110 onSendMessage, 111 playHaptic, 112 setEmbed, 113 inputRef, 114 l, 115 ]) 116 117 useFocusedInputHandler( 118 { 119 onChangeText: () => { 120 'worklet' 121 const measurement = measure(inputRef) 122 if (!measurement) return 123 124 const max = windowHeight - -keyboardHeight.get() - topInset - 150 125 const availableSpace = max - measurement.height 126 127 maxHeight.set(max) 128 isInputScrollable.set(availableSpace < 30) 129 }, 130 }, 131 [windowHeight, topInset], 132 ) 133 134 const animatedStyle = useAnimatedStyle(() => ({ 135 maxHeight: maxHeight.get(), 136 })) 137 138 const animatedProps = useAnimatedProps(() => ({ 139 scrollEnabled: isInputScrollable.get(), 140 })) 141 142 const submitDisabled = 143 needsEmailVerification || (!hasEmbed && message.trim().length === 0) 144 145 const blur = useCallback(() => { 146 inputRef.current?.blur() 147 }, [inputRef]) 148 149 useKeyboardHandler({ 150 onEnd: evt => { 151 'worklet' 152 // small hack: interactive dismiss on Android sometimes doesn't blur the input 153 if (IS_ANDROID && evt.progress === 0) { 154 runOnJS(blur)() 155 } 156 }, 157 }) 158 159 return ( 160 <ComposerContainer> 161 {children} 162 <GlassContainer 163 style={[a.flex_row, a.align_end, a.gap_sm]} 164 spacing={tokens.space.xs}> 165 <GlassView 166 isInteractive 167 glassEffectStyle="regular" 168 style={[ 169 a.flex_1, 170 enableSquareButtons ? a.rounded_sm : a.rounded_xl, 171 {minHeight: MIN_HEIGHT}, 172 ]} 173 tintColor={t.palette.contrast_50} 174 fallbackStyle={[t.atoms.bg_contrast_50]}> 175 <AnimatedTextInput 176 nativeID={textInputId} 177 accessibilityLabel={l`Message input field`} 178 accessibilityHint={l`Type your message here`} 179 placeholder={l`Message`} 180 placeholderTextColor={t.palette.contrast_500} 181 value={message} 182 onChange={evt => { 183 // bit of a hack: iOS automatically accepts autocomplete suggestions when you tap anywhere on the screen 184 // including the button we just pressed - and this overrides clearing the input! so we watch for the 185 // next change and double make sure the input is cleared. It should *always* send an onChange event after 186 // clearing via setMessage('') that happens in onSubmit() 187 // -sfn 188 if (IS_IOS && shouldEnforceClear) { 189 setShouldEnforceClear(false) 190 setMessage('') 191 return 192 } 193 const text = evt.nativeEvent.text 194 setMessage(text) 195 }} 196 multiline={true} 197 style={[ 198 {flexBasis: 'auto', minHeight: MIN_HEIGHT}, 199 a.flex_shrink_0, 200 a.flex_grow, 201 a.text_md, 202 a.px_lg, 203 t.atoms.text, 204 platform({ 205 android: {paddingTop: 2, paddingBottom: 3}, 206 ios: {paddingTop: 10, paddingBottom: 5}, 207 }), 208 animatedStyle, 209 ]} 210 verticalAlign="middle" 211 selectionColor={utils.alpha(t.palette.primary_500, 0.4)} 212 cursorColor={t.palette.primary_500} 213 selectionHandleColor={t.palette.primary_500} 214 keyboardAppearance={t.scheme} 215 submitBehavior="newline" 216 ref={inputRef} 217 hitSlop={HITSLOP_10} 218 animatedProps={animatedProps} 219 editable={!needsEmailVerification} 220 /> 221 </GlassView> 222 <GlassView 223 isInteractive 224 glassEffectStyle="regular" 225 style={[a.rounded_full]} 226 tintColor={ 227 submitDisabled ? t.palette.contrast_100 : t.palette.primary_500 228 } 229 fallbackStyle={{ 230 backgroundColor: submitDisabled 231 ? t.palette.contrast_100 232 : t.palette.primary_500, 233 }}> 234 <Pressable 235 accessibilityRole="button" 236 accessibilityLabel={l`Send message`} 237 accessibilityHint="" 238 hitSlop={HITSLOP_10} 239 style={[ 240 enableSquareButtons ? a.rounded_sm : a.rounded_full, 241 a.align_center, 242 a.justify_center, 243 { 244 height: MIN_HEIGHT, 245 width: MIN_HEIGHT, 246 }, 247 ]} 248 onPress={onSubmit} 249 disabled={submitDisabled}> 250 <PaperPlaneIcon 251 size="md" 252 fill={t.palette.white} 253 style={[a.mb_2xs]} 254 /> 255 </Pressable> 256 </GlassView> 257 </GlassContainer> 258 </ComposerContainer> 259 ) 260}