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 259 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 = needsEmailVerification || message.trim().length === 0 143 144 const blur = useCallback(() => { 145 inputRef.current?.blur() 146 }, [inputRef]) 147 148 useKeyboardHandler({ 149 onEnd: evt => { 150 'worklet' 151 // small hack: interactive dismiss on Android sometimes doesn't blur the input 152 if (IS_ANDROID && evt.progress === 0) { 153 runOnJS(blur)() 154 } 155 }, 156 }) 157 158 return ( 159 <ComposerContainer> 160 {children} 161 <GlassContainer 162 style={[a.flex_row, a.align_end, a.gap_sm]} 163 spacing={tokens.space.xs}> 164 <GlassView 165 isInteractive 166 glassEffectStyle="regular" 167 style={[ 168 a.flex_1, 169 enableSquareButtons ? a.rounded_sm : a.rounded_xl, 170 {minHeight: MIN_HEIGHT}, 171 ]} 172 tintColor={t.palette.contrast_50} 173 fallbackStyle={[t.atoms.bg_contrast_50]}> 174 <AnimatedTextInput 175 nativeID={textInputId} 176 accessibilityLabel={l`Message input field`} 177 accessibilityHint={l`Type your message here`} 178 placeholder={l`Message`} 179 placeholderTextColor={t.palette.contrast_500} 180 value={message} 181 onChange={evt => { 182 // bit of a hack: iOS automatically accepts autocomplete suggestions when you tap anywhere on the screen 183 // including the button we just pressed - and this overrides clearing the input! so we watch for the 184 // next change and double make sure the input is cleared. It should *always* send an onChange event after 185 // clearing via setMessage('') that happens in onSubmit() 186 // -sfn 187 if (IS_IOS && shouldEnforceClear) { 188 setShouldEnforceClear(false) 189 setMessage('') 190 return 191 } 192 const text = evt.nativeEvent.text 193 setMessage(text) 194 }} 195 multiline={true} 196 style={[ 197 {flexBasis: 'auto', minHeight: MIN_HEIGHT}, 198 a.flex_shrink_0, 199 a.flex_grow, 200 a.text_md, 201 a.px_lg, 202 t.atoms.text, 203 platform({ 204 android: {paddingTop: 2, paddingBottom: 3}, 205 ios: {paddingTop: 10, paddingBottom: 5}, 206 }), 207 animatedStyle, 208 ]} 209 verticalAlign="middle" 210 selectionColor={utils.alpha(t.palette.primary_500, 0.4)} 211 cursorColor={t.palette.primary_500} 212 selectionHandleColor={t.palette.primary_500} 213 keyboardAppearance={t.scheme} 214 submitBehavior="newline" 215 ref={inputRef} 216 hitSlop={HITSLOP_10} 217 animatedProps={animatedProps} 218 editable={!needsEmailVerification} 219 /> 220 </GlassView> 221 <GlassView 222 isInteractive 223 glassEffectStyle="regular" 224 style={[a.rounded_full]} 225 tintColor={ 226 submitDisabled ? t.palette.contrast_100 : t.palette.primary_500 227 } 228 fallbackStyle={{ 229 backgroundColor: submitDisabled 230 ? t.palette.contrast_100 231 : t.palette.primary_500, 232 }}> 233 <Pressable 234 accessibilityRole="button" 235 accessibilityLabel={l`Send message`} 236 accessibilityHint="" 237 hitSlop={HITSLOP_10} 238 style={[ 239 enableSquareButtons ? a.rounded_sm : a.rounded_full, 240 a.align_center, 241 a.justify_center, 242 { 243 height: MIN_HEIGHT, 244 width: MIN_HEIGHT, 245 }, 246 ]} 247 onPress={onSubmit} 248 disabled={submitDisabled}> 249 <PaperPlaneIcon 250 size="md" 251 fill={t.palette.white} 252 style={[a.mb_2xs]} 253 /> 254 </Pressable> 255 </GlassView> 256 </GlassContainer> 257 </ComposerContainer> 258 ) 259}