Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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}