Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 256 lines 8.9 kB view raw
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}