forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React from 'react'
2import {Pressable, View} from 'react-native'
3import {msg} from '@lingui/macro'
4import {useLingui} from '@lingui/react'
5import {flushSync} from 'react-dom'
6import TextareaAutosize from 'react-textarea-autosize'
7import {countGraphemes} from 'unicode-segmenter/grapheme'
8
9import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
10import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
11import {
12 useMessageDraft,
13 useSaveMessageDraft,
14} from '#/state/messages/message-drafts'
15import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
16import {
17 type Emoji,
18 type EmojiPickerPosition,
19} from '#/view/com/composer/text-input/web/EmojiPicker'
20import * as Toast from '#/view/com/util/Toast'
21import {atoms as a, flatten, useTheme} from '#/alf'
22import {Button} from '#/components/Button'
23import {useSharedInputStyles} from '#/components/forms/TextField'
24import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
25import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
26import {IS_WEB_SAFARI, IS_WEB_TOUCH_DEVICE} from '#/env'
27import {useExtractEmbedFromFacets} from './MessageInputEmbed'
28
29export function MessageInput({
30 onSendMessage,
31 hasEmbed,
32 setEmbed,
33 children,
34 openEmojiPicker,
35}: {
36 onSendMessage: (message: string) => void
37 hasEmbed: boolean
38 setEmbed: (embedUrl: string | undefined) => void
39 children?: React.ReactNode
40 openEmojiPicker?: (pos: EmojiPickerPosition) => void
41}) {
42 const {isMobile} = useWebMediaQueries()
43 const {_} = useLingui()
44 const t = useTheme()
45 const {getDraft, clearDraft} = useMessageDraft()
46 const [message, setMessage] = React.useState(getDraft)
47
48 const inputStyles = useSharedInputStyles()
49 const isComposing = React.useRef(false)
50 const [isFocused, setIsFocused] = React.useState(false)
51 const [isHovered, setIsHovered] = React.useState(false)
52 const [textAreaHeight, setTextAreaHeight] = React.useState(38)
53 const textAreaRef = React.useRef<HTMLTextAreaElement>(null)
54
55 const onSubmit = React.useCallback(() => {
56 if (!hasEmbed && message.trim() === '') {
57 return
58 }
59 if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
60 Toast.show(_(msg`Message is too long`), 'xmark')
61 return
62 }
63 clearDraft()
64 onSendMessage(message)
65 setMessage('')
66 setEmbed(undefined)
67 }, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed])
68
69 const onKeyDown = React.useCallback(
70 (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
71 // Don't submit the form when the Japanese or any other IME is composing
72 if (isComposing.current) return
73
74 // see https://github.com/bluesky-social/social-app/issues/4178
75 // see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/
76 // see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html
77 //
78 // On Safari, the final keydown event to dismiss the IME - which is the enter key - is also "Enter" below.
79 // Obviously, this causes problems because the final dismissal should _not_ submit the text, but should just
80 // stop the IME editing. This is the behavior of Chrome and Firefox, but not Safari.
81 //
82 // Keycode is deprecated, however the alternative seems to only be to compare the timestamp from the
83 // onCompositionEnd event to the timestamp of the keydown event, which is not reliable. For example, this hack
84 // uses that method: https://github.com/ProseMirror/prosemirror-view/pull/44. However, from my 500ms resulted in
85 // far too long of a delay, and a subsequent enter press would often just end up doing nothing. A shorter time
86 // frame was also not great, since it was too short to be reliable (i.e. an older system might have a larger
87 // time gap between the two events firing.
88 if (IS_WEB_SAFARI && e.key === 'Enter' && e.keyCode === 229) {
89 return
90 }
91
92 if (e.key === 'Enter') {
93 if (e.shiftKey) return
94 e.preventDefault()
95 onSubmit()
96 }
97 },
98 [onSubmit],
99 )
100
101 const onChange = React.useCallback(
102 (e: React.ChangeEvent<HTMLTextAreaElement>) => {
103 setMessage(e.target.value)
104 },
105 [],
106 )
107
108 const onEmojiInserted = React.useCallback(
109 (emoji: Emoji) => {
110 if (!textAreaRef.current) {
111 return
112 }
113 const position = textAreaRef.current.selectionStart ?? 0
114 textAreaRef.current.focus()
115 flushSync(() => {
116 setMessage(
117 message =>
118 message.slice(0, position) + emoji.native + message.slice(position),
119 )
120 })
121 textAreaRef.current.selectionStart = position + emoji.native.length
122 textAreaRef.current.selectionEnd = position + emoji.native.length
123 },
124 [setMessage],
125 )
126 React.useEffect(() => {
127 textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
128 return () => {
129 textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
130 }
131 }, [onEmojiInserted])
132
133 useSaveMessageDraft(message)
134 useExtractEmbedFromFacets(message, setEmbed)
135
136 return (
137 <View style={a.p_sm}>
138 {children}
139 <View
140 style={[
141 a.flex_row,
142 t.atoms.bg_contrast_25,
143 {
144 paddingRight: a.p_sm.padding - 2,
145 paddingLeft: a.p_sm.padding - 2,
146 borderWidth: 1,
147 borderRadius: 23,
148 borderColor: 'transparent',
149 height: textAreaHeight + 23,
150 },
151 isHovered && inputStyles.chromeHover,
152 isFocused && inputStyles.chromeFocus,
153 ]}
154 // @ts-expect-error web only
155 onMouseEnter={() => setIsHovered(true)}
156 onMouseLeave={() => setIsHovered(false)}>
157 <Button
158 onPress={e => {
159 e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
160 openEmojiPicker?.({
161 top: py,
162 left: px,
163 right: px,
164 bottom: py,
165 nextFocusRef:
166 textAreaRef as unknown as React.MutableRefObject<HTMLElement>,
167 })
168 })
169 }}
170 style={[
171 a.rounded_full,
172 a.overflow_hidden,
173 a.align_center,
174 a.justify_center,
175 {
176 marginTop: 5,
177 height: 30,
178 width: 30,
179 },
180 ]}
181 label={_(msg`Open emoji picker`)}>
182 {state => (
183 <View
184 style={[
185 a.absolute,
186 a.inset_0,
187 a.align_center,
188 a.justify_center,
189 {
190 backgroundColor:
191 state.hovered || state.focused || state.pressed
192 ? t.atoms.bg.backgroundColor
193 : undefined,
194 },
195 ]}>
196 <EmojiSmile size="lg" />
197 </View>
198 )}
199 </Button>
200 <TextareaAutosize
201 ref={textAreaRef}
202 style={flatten([
203 a.flex_1,
204 a.px_sm,
205 a.border_0,
206 t.atoms.text,
207 {
208 paddingTop: 10,
209 backgroundColor: 'transparent',
210 resize: 'none',
211 },
212 ])}
213 maxRows={12}
214 placeholder={_(msg`Write a message`)}
215 defaultValue=""
216 value={message}
217 dirName="ltr"
218 autoFocus={true}
219 onFocus={() => setIsFocused(true)}
220 onBlur={() => setIsFocused(false)}
221 onCompositionStart={() => {
222 isComposing.current = true
223 }}
224 onCompositionEnd={() => {
225 isComposing.current = false
226 }}
227 onHeightChange={height => setTextAreaHeight(height)}
228 onChange={onChange}
229 // On mobile web phones, we want to keep the same behavior as the native app. Do not submit the message
230 // in these cases.
231 onKeyDown={IS_WEB_TOUCH_DEVICE && isMobile ? undefined : onKeyDown}
232 />
233 <Pressable
234 accessibilityRole="button"
235 accessibilityLabel={_(msg`Send message`)}
236 accessibilityHint=""
237 style={[
238 a.rounded_full,
239 a.align_center,
240 a.justify_center,
241 {
242 height: 30,
243 width: 30,
244 marginTop: 5,
245 backgroundColor: t.palette.primary_500,
246 },
247 ]}
248 onPress={onSubmit}>
249 <PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
250 </Pressable>
251 </View>
252 </View>
253 )
254}