forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}