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 238 lines 8.4 kB view raw
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}