Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
120
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 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}