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