Bluesky app fork with some witchin' additions 💫

Add emoji picker to chat composer (#5196)

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Adrov Igor <nucleartux@gmail.com>

authored by

Eric Bailey
surfdude29
Adrov Igor
and committed by
GitHub
543be176 30d2ab8d

+119 -14
+2
src/screens/Messages/Conversation/MessageInput.tsx
··· 23 23 useSaveMessageDraft, 24 24 } from '#/state/messages/message-drafts' 25 25 import {isIOS} from 'platform/detection' 26 + import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web' 26 27 import * as Toast from '#/view/com/util/Toast' 27 28 import {atoms as a, useTheme} from '#/alf' 28 29 import {useSharedInputStyles} from '#/components/forms/TextField' ··· 41 42 hasEmbed: boolean 42 43 setEmbed: (embedUrl: string | undefined) => void 43 44 children?: React.ReactNode 45 + openEmojiPicker?: (pos: EmojiPickerPosition) => void 44 46 }) { 45 47 const {_} = useLingui() 46 48 const t = useTheme()
+65 -1
src/screens/Messages/Conversation/MessageInput.web.tsx
··· 12 12 } from '#/state/messages/message-drafts' 13 13 import {isSafari, isTouchDevice} from 'lib/browser' 14 14 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 15 + import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' 16 + import { 17 + Emoji, 18 + EmojiPickerPosition, 19 + } from '#/view/com/composer/text-input/web/EmojiPicker.web' 15 20 import * as Toast from '#/view/com/util/Toast' 16 21 import {atoms as a, useTheme} from '#/alf' 22 + import {Button} from '#/components/Button' 17 23 import {useSharedInputStyles} from '#/components/forms/TextField' 24 + import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' 18 25 import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' 19 26 import {useExtractEmbedFromFacets} from './MessageInputEmbed' 20 27 ··· 23 30 hasEmbed, 24 31 setEmbed, 25 32 children, 33 + openEmojiPicker, 26 34 }: { 27 35 onSendMessage: (message: string) => void 28 36 hasEmbed: boolean 29 37 setEmbed: (embedUrl: string | undefined) => void 30 38 children?: React.ReactNode 39 + openEmojiPicker?: (pos: EmojiPickerPosition) => void 31 40 }) { 32 41 const {isTabletOrDesktop} = useWebMediaQueries() 33 42 const {_} = useLingui() ··· 40 49 const [isFocused, setIsFocused] = React.useState(false) 41 50 const [isHovered, setIsHovered] = React.useState(false) 42 51 const [textAreaHeight, setTextAreaHeight] = React.useState(38) 52 + const textAreaRef = React.useRef<HTMLTextAreaElement>(null) 43 53 44 54 const onSubmit = React.useCallback(() => { 45 55 if (!hasEmbed && message.trim() === '') { ··· 94 104 [], 95 105 ) 96 106 107 + const onEmojiInserted = React.useCallback( 108 + (emoji: Emoji) => { 109 + const position = textAreaRef.current?.selectionStart ?? 0 110 + setMessage( 111 + message => 112 + message.slice(0, position) + emoji.native + message.slice(position), 113 + ) 114 + }, 115 + [setMessage], 116 + ) 117 + React.useEffect(() => { 118 + textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) 119 + return () => { 120 + textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) 121 + } 122 + }, [onEmojiInserted]) 123 + 97 124 useSaveMessageDraft(message) 98 125 useExtractEmbedFromFacets(message, setEmbed) 99 126 ··· 106 133 t.atoms.bg_contrast_25, 107 134 { 108 135 paddingRight: a.p_sm.padding - 2, 109 - paddingLeft: a.p_md.padding - 2, 136 + paddingLeft: a.p_sm.padding - 2, 110 137 borderWidth: 1, 111 138 borderRadius: 23, 112 139 borderColor: 'transparent', ··· 118 145 // @ts-expect-error web only 119 146 onMouseEnter={() => setIsHovered(true)} 120 147 onMouseLeave={() => setIsHovered(false)}> 148 + <Button 149 + onPress={e => { 150 + e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => { 151 + openEmojiPicker?.({top: py, left: px, right: px, bottom: py}) 152 + }) 153 + }} 154 + style={[ 155 + a.rounded_full, 156 + a.overflow_hidden, 157 + a.align_center, 158 + a.justify_center, 159 + { 160 + marginTop: 5, 161 + height: 30, 162 + width: 30, 163 + }, 164 + ]} 165 + label={_(msg`Open emoji picker`)}> 166 + {state => ( 167 + <View 168 + style={[ 169 + a.absolute, 170 + a.inset_0, 171 + a.align_center, 172 + a.justify_center, 173 + { 174 + backgroundColor: 175 + state.hovered || state.focused || state.pressed 176 + ? t.atoms.bg.backgroundColor 177 + : undefined, 178 + }, 179 + ]}> 180 + <EmojiSmile size="lg" /> 181 + </View> 182 + )} 183 + </Button> 121 184 <TextareaAutosize 185 + ref={textAreaRef} 122 186 style={StyleSheet.flatten([ 123 187 a.flex_1, 124 188 a.px_sm,
+20 -1
src/screens/Messages/Conversation/MessagesList.tsx
··· 29 29 import {clamp} from 'lib/numbers' 30 30 import {ScrollProvider} from 'lib/ScrollContext' 31 31 import {isWeb} from 'platform/detection' 32 + import { 33 + EmojiPicker, 34 + EmojiPickerState, 35 + } from '#/view/com/composer/text-input/web/EmojiPicker.web' 32 36 import {List} from 'view/com/util/List' 33 37 import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled' 34 38 import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' ··· 96 100 show: false, 97 101 startContentOffset: 0, 98 102 }) 103 + 104 + const [emojiPickerState, setEmojiPickerState] = 105 + React.useState<EmojiPickerState>({ 106 + isOpen: false, 107 + pos: {top: 0, left: 0, right: 0, bottom: 0}, 108 + }) 99 109 100 110 // We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items 101 111 // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to ··· 422 432 <MessageInput 423 433 onSendMessage={onSendMessage} 424 434 hasEmbed={!!embedUri} 425 - setEmbed={setEmbed}> 435 + setEmbed={setEmbed} 436 + openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}> 426 437 <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> 427 438 </MessageInput> 428 439 </> 429 440 )} 430 441 </KeyboardStickyView> 442 + 443 + {isWeb && ( 444 + <EmojiPicker 445 + pinToTop 446 + state={emojiPickerState} 447 + close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))} 448 + /> 449 + )} 431 450 432 451 {newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />} 433 452 </>
+1 -1
src/state/shell/composer.tsx
··· 34 34 quote?: ComposerOptsQuote 35 35 quoteCount?: number 36 36 mention?: string // handle of user to mention 37 - openPicker?: (pos: DOMRect | undefined) => void 37 + openEmojiPicker?: (pos: DOMRect | undefined) => void 38 38 text?: string 39 39 imageUris?: {uri: string; width: number; height: number}[] 40 40 }
+3 -3
src/view/com/composer/Composer.tsx
··· 133 133 quote: initQuote, 134 134 quoteCount, 135 135 mention: initMention, 136 - openPicker, 136 + openEmojiPicker, 137 137 text: initText, 138 138 imageUris: initImageUris, 139 139 cancelRef, ··· 520 520 gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video) 521 521 522 522 const onEmojiButtonPress = useCallback(() => { 523 - openPicker?.(textInput.current?.getCursorPosition()) 524 - }, [openPicker]) 523 + openEmojiPicker?.(textInput.current?.getCursorPosition()) 524 + }, [openEmojiPicker]) 525 525 526 526 const focusTextInput = useCallback(() => { 527 527 textInput.current?.focus()
+1 -3
src/view/com/composer/text-input/TextInput.web.tsx
··· 12 12 import {Text as TiptapText} from '@tiptap/extension-text' 13 13 import {generateJSON} from '@tiptap/html' 14 14 import {EditorContent, JSONContent, useEditor} from '@tiptap/react' 15 - import EventEmitter from 'eventemitter3' 16 15 17 16 import {usePalette} from '#/lib/hooks/usePalette' 18 17 import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' 19 18 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' 20 19 import {blobToDataUri, isUriImage} from 'lib/media/util' 20 + import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' 21 21 import { 22 22 LinkFacetMatch, 23 23 suggestLinkCardUri, ··· 45 45 onNewLink: (uri: string) => void 46 46 onError: (err: string) => void 47 47 } 48 - 49 - export const textInputWebEmitter = new EventEmitter() 50 48 51 49 export const TextInput = React.forwardRef(function TextInputImpl( 52 50 {
+3
src/view/com/composer/text-input/textInputWebEmitter.ts
··· 1 + import EventEmitter from 'eventemitter3' 2 + 3 + export const textInputWebEmitter = new EventEmitter()
+23 -4
src/view/com/composer/text-input/web/EmojiPicker.web.tsx
··· 7 7 } from 'react-native' 8 8 import Picker from '@emoji-mart/react' 9 9 10 + import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' 10 11 import {atoms as a} from '#/alf' 11 - import {textInputWebEmitter} from '../TextInput.web' 12 12 13 13 const HEIGHT_OFFSET = 40 14 14 const WIDTH_OFFSET = 100 ··· 26 26 unified: string 27 27 } 28 28 29 + export interface EmojiPickerPosition { 30 + top: number 31 + left: number 32 + right: number 33 + bottom: number 34 + } 35 + 29 36 export interface EmojiPickerState { 30 37 isOpen: boolean 31 - pos: {top: number; left: number; right: number; bottom: number} 38 + pos: EmojiPickerPosition 32 39 } 33 40 34 41 interface IProps { 35 42 state: EmojiPickerState 36 43 close: () => void 44 + /** 45 + * If `true`, overrides position and ensures picker is pinned to the top of 46 + * the target element. 47 + */ 48 + pinToTop?: boolean 37 49 } 38 50 39 - export function EmojiPicker({state, close}: IProps) { 51 + export function EmojiPicker({state, close, pinToTop}: IProps) { 40 52 const {height, width} = useWindowDimensions() 41 53 42 54 const isShiftDown = React.useRef(false) 43 55 44 56 const position = React.useMemo(() => { 57 + if (pinToTop) { 58 + return { 59 + top: state.pos.top - PICKER_HEIGHT + HEIGHT_OFFSET - 10, 60 + left: state.pos.left, 61 + } 62 + } 63 + 45 64 const fitsBelow = state.pos.top + PICKER_HEIGHT < height 46 65 const fitsAbove = PICKER_HEIGHT < state.pos.top 47 66 const placeOnLeft = PICKER_WIDTH < state.pos.left ··· 64 83 : undefined, 65 84 } 66 85 } 67 - }, [state.pos, height, width]) 86 + }, [state.pos, height, width, pinToTop]) 68 87 69 88 React.useEffect(() => { 70 89 if (!state.isOpen) return
+1 -1
src/view/shell/Composer.web.tsx
··· 61 61 quoteCount={state?.quoteCount} 62 62 onPost={state.onPost} 63 63 mention={state.mention} 64 - openPicker={onOpenPicker} 64 + openEmojiPicker={onOpenPicker} 65 65 text={state.text} 66 66 /> 67 67 </View>