forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useState} from 'react'
2import {Pressable, TextInput, useWindowDimensions, View} from 'react-native'
3import {
4 useFocusedInputHandler,
5 useReanimatedKeyboardAnimation,
6} from 'react-native-keyboard-controller'
7import Animated, {
8 measure,
9 useAnimatedProps,
10 useAnimatedRef,
11 useAnimatedStyle,
12 useSharedValue,
13} from 'react-native-reanimated'
14import {useSafeAreaInsets} from 'react-native-safe-area-context'
15import {msg} from '@lingui/core/macro'
16import {useLingui} from '@lingui/react'
17import {countGraphemes} from 'unicode-segmenter/grapheme'
18
19import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
20import {useHaptics} from '#/lib/haptics'
21import {useEmail} from '#/state/email-verification'
22import {
23 useMessageDraft,
24 useSaveMessageDraft,
25} from '#/state/messages/message-drafts'
26import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
27import {type EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker'
28import {android, atoms as a, useTheme, utils} from '#/alf'
29import {useSharedInputStyles} from '#/components/forms/TextField'
30import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
31import * as Toast from '#/components/Toast'
32import {IS_IOS, IS_WEB} from '#/env'
33import {useExtractEmbedFromFacets} from './MessageInputEmbed'
34
35const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
36
37export function MessageInput({
38 onSendMessage,
39 hasEmbed,
40 setEmbed,
41 children,
42}: {
43 onSendMessage: (message: string) => void
44 hasEmbed: boolean
45 setEmbed: (embedUrl: string | undefined) => void
46 children?: React.ReactNode
47 openEmojiPicker?: (pos: EmojiPickerPosition) => void
48}) {
49 const {_} = useLingui()
50 const t = useTheme()
51 const playHaptic = useHaptics()
52 const {getDraft, clearDraft} = useMessageDraft()
53
54 // Input layout
55 const {top: topInset} = useSafeAreaInsets()
56 const {height: windowHeight} = useWindowDimensions()
57 const {height: keyboardHeight} = useReanimatedKeyboardAnimation()
58 const maxHeight = useSharedValue<undefined | number>(undefined)
59 const isInputScrollable = useSharedValue(false)
60
61 const inputStyles = useSharedInputStyles()
62 const [isFocused, setIsFocused] = useState(false)
63 const [message, setMessage] = useState(getDraft)
64 const inputRef = useAnimatedRef<TextInput>()
65 const [shouldEnforceClear, setShouldEnforceClear] = useState(false)
66
67 const {needsEmailVerification} = useEmail()
68
69 const enableSquareButtons = useEnableSquareButtons()
70
71 useSaveMessageDraft(message)
72 useExtractEmbedFromFacets(message, setEmbed)
73
74 const onSubmit = useCallback(() => {
75 if (needsEmailVerification) {
76 return
77 }
78 if (!hasEmbed && message.trim() === '') {
79 return
80 }
81 if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
82 Toast.show(_(msg`Message is too long`), {
83 type: 'error',
84 })
85 return
86 }
87 clearDraft()
88 onSendMessage(message)
89 playHaptic()
90 setEmbed(undefined)
91 setMessage('')
92 if (IS_IOS) {
93 setShouldEnforceClear(true)
94 }
95 if (IS_WEB) {
96 // Pressing the send button causes the text input to lose focus, so we need to
97 // re-focus it after sending
98 setTimeout(() => {
99 inputRef.current?.focus()
100 }, 100)
101 }
102 }, [
103 needsEmailVerification,
104 hasEmbed,
105 message,
106 clearDraft,
107 onSendMessage,
108 playHaptic,
109 setEmbed,
110 inputRef,
111 _,
112 ])
113
114 useFocusedInputHandler(
115 {
116 onChangeText: () => {
117 'worklet'
118 const measurement = measure(inputRef)
119 if (!measurement) return
120
121 const max = windowHeight - -keyboardHeight.get() - topInset - 150
122 const availableSpace = max - measurement.height
123
124 maxHeight.set(max)
125 isInputScrollable.set(availableSpace < 30)
126 },
127 },
128 [windowHeight, topInset],
129 )
130
131 const animatedStyle = useAnimatedStyle(() => ({
132 maxHeight: maxHeight.get(),
133 }))
134
135 const animatedProps = useAnimatedProps(() => ({
136 scrollEnabled: isInputScrollable.get(),
137 }))
138
139 return (
140 <View style={[a.px_md, a.pb_sm, a.pt_xs]}>
141 {children}
142 <View
143 style={[
144 a.w_full,
145 a.flex_row,
146 t.atoms.bg_contrast_25,
147 {
148 padding: a.p_sm.padding - 2,
149 paddingLeft: a.p_md.padding - 2,
150 borderWidth: 1,
151 borderRadius: enableSquareButtons ? 11 : 23,
152 borderColor: 'transparent',
153 },
154 isFocused && inputStyles.chromeFocus,
155 ]}>
156 <AnimatedTextInput
157 accessibilityLabel={_(msg`Message input field`)}
158 accessibilityHint={_(msg`Type your message here`)}
159 placeholder={_(msg`Write a message`)}
160 placeholderTextColor={t.palette.contrast_500}
161 value={message}
162 onChange={evt => {
163 // bit of a hack: iOS automatically accepts autocomplete suggestions when you tap anywhere on the screen
164 // including the button we just pressed - and this overrides clearing the input! so we watch for the
165 // next change and double make sure the input is cleared. It should *always* send an onChange event after
166 // clearing via setMessage('') that happens in onSubmit()
167 // -sfn
168 if (IS_IOS && shouldEnforceClear) {
169 setShouldEnforceClear(false)
170 setMessage('')
171 return
172 }
173 const text = evt.nativeEvent.text
174 setMessage(text)
175 }}
176 multiline={true}
177 style={[
178 a.flex_1,
179 a.text_md,
180 a.px_sm,
181 t.atoms.text,
182 android({paddingTop: 0}),
183 {paddingBottom: IS_IOS ? 5 : 0},
184 animatedStyle,
185 ]}
186 selectionColor={utils.alpha(t.palette.primary_500, 0.4)}
187 cursorColor={t.palette.primary_500}
188 selectionHandleColor={t.palette.primary_500}
189 keyboardAppearance={t.scheme}
190 submitBehavior="newline"
191 onFocus={() => setIsFocused(true)}
192 onBlur={() => setIsFocused(false)}
193 ref={inputRef}
194 hitSlop={HITSLOP_10}
195 animatedProps={animatedProps}
196 editable={!needsEmailVerification}
197 />
198 <Pressable
199 accessibilityRole="button"
200 accessibilityLabel={_(msg`Send message`)}
201 accessibilityHint=""
202 hitSlop={HITSLOP_10}
203 style={[
204 enableSquareButtons ? a.rounded_sm : a.rounded_full,
205 a.align_center,
206 a.justify_center,
207 {height: 30, width: 30, backgroundColor: t.palette.primary_500},
208 ]}
209 onPress={onSubmit}
210 disabled={needsEmailVerification}>
211 <PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
212 </Pressable>
213 </View>
214 </View>
215 )
216}