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

Configure Feed

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

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