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