Bluesky app fork with some witchin' additions 馃挮
at linkat-integration 257 lines 8.9 kB view raw
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}