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