From 543be176741bfcc6c093143799376972818908c4 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 6 Sep 2024 17:58:47 -0500 Subject: [PATCH] Add emoji picker to chat composer (#5196) Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Adrov Igor --- .../Messages/Conversation/MessageInput.tsx | 2 + .../Conversation/MessageInput.web.tsx | 66 ++++++++++++++++++- .../Messages/Conversation/MessagesList.tsx | 21 +++++- src/state/shell/composer.tsx | 2 +- src/view/com/composer/Composer.tsx | 6 +- .../com/composer/text-input/TextInput.web.tsx | 4 +- .../text-input/textInputWebEmitter.ts | 3 + .../text-input/web/EmojiPicker.web.tsx | 27 ++++++-- src/view/shell/Composer.web.tsx | 2 +- 9 files changed, 119 insertions(+), 14 deletions(-) create mode 100644 src/view/com/composer/text-input/textInputWebEmitter.ts diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx index dc63a869..674edc41 100644 --- a/src/screens/Messages/Conversation/MessageInput.tsx +++ b/src/screens/Messages/Conversation/MessageInput.tsx @@ -23,6 +23,7 @@ import { useSaveMessageDraft, } from '#/state/messages/message-drafts' import {isIOS} from 'platform/detection' +import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {useSharedInputStyles} from '#/components/forms/TextField' @@ -41,6 +42,7 @@ export function MessageInput({ hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode + openEmojiPicker?: (pos: EmojiPickerPosition) => void }) { const {_} = useLingui() const t = useTheme() diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/Conversation/MessageInput.web.tsx index a4a8a785..0b7e4792 100644 --- a/src/screens/Messages/Conversation/MessageInput.web.tsx +++ b/src/screens/Messages/Conversation/MessageInput.web.tsx @@ -12,9 +12,16 @@ import { } from '#/state/messages/message-drafts' import {isSafari, isTouchDevice} from 'lib/browser' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' +import { + Emoji, + EmojiPickerPosition, +} from '#/view/com/composer/text-input/web/EmojiPicker.web' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' import {useSharedInputStyles} from '#/components/forms/TextField' +import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' import {useExtractEmbedFromFacets} from './MessageInputEmbed' @@ -23,11 +30,13 @@ export function MessageInput({ hasEmbed, setEmbed, children, + openEmojiPicker, }: { onSendMessage: (message: string) => void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode + openEmojiPicker?: (pos: EmojiPickerPosition) => void }) { const {isTabletOrDesktop} = useWebMediaQueries() const {_} = useLingui() @@ -40,6 +49,7 @@ export function MessageInput({ const [isFocused, setIsFocused] = React.useState(false) const [isHovered, setIsHovered] = React.useState(false) const [textAreaHeight, setTextAreaHeight] = React.useState(38) + const textAreaRef = React.useRef(null) const onSubmit = React.useCallback(() => { if (!hasEmbed && message.trim() === '') { @@ -94,6 +104,23 @@ export function MessageInput({ [], ) + const onEmojiInserted = React.useCallback( + (emoji: Emoji) => { + const position = textAreaRef.current?.selectionStart ?? 0 + setMessage( + message => + message.slice(0, position) + emoji.native + message.slice(position), + ) + }, + [setMessage], + ) + React.useEffect(() => { + textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) + return () => { + textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) + } + }, [onEmojiInserted]) + useSaveMessageDraft(message) useExtractEmbedFromFacets(message, setEmbed) @@ -106,7 +133,7 @@ export function MessageInput({ t.atoms.bg_contrast_25, { paddingRight: a.p_sm.padding - 2, - paddingLeft: a.p_md.padding - 2, + paddingLeft: a.p_sm.padding - 2, borderWidth: 1, borderRadius: 23, borderColor: 'transparent', @@ -118,7 +145,44 @@ export function MessageInput({ // @ts-expect-error web only onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}> + ({ + isOpen: false, + pos: {top: 0, left: 0, right: 0, bottom: 0}, + }) + // 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 // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to // the bottom. @@ -422,13 +432,22 @@ export function MessagesList({ + setEmbed={setEmbed} + openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}> )} + {isWeb && ( + setEmojiPickerState(prev => ({...prev, isOpen: false}))} + /> + )} + {newMessagesPill.show && } ) diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx index 74802a99..612388ff 100644 --- a/src/state/shell/composer.tsx +++ b/src/state/shell/composer.tsx @@ -34,7 +34,7 @@ export interface ComposerOpts { quote?: ComposerOptsQuote quoteCount?: number mention?: string // handle of user to mention - openPicker?: (pos: DOMRect | undefined) => void + openEmojiPicker?: (pos: DOMRect | undefined) => void text?: string imageUris?: {uri: string; width: number; height: number}[] } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 8ae92b01..3c7868ad 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -133,7 +133,7 @@ export const ComposePost = observer(function ComposePost({ quote: initQuote, quoteCount, mention: initMention, - openPicker, + openEmojiPicker, text: initText, imageUris: initImageUris, cancelRef, @@ -520,8 +520,8 @@ export const ComposePost = observer(function ComposePost({ gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video) const onEmojiButtonPress = useCallback(() => { - openPicker?.(textInput.current?.getCursorPosition()) - }, [openPicker]) + openEmojiPicker?.(textInput.current?.getCursorPosition()) + }, [openEmojiPicker]) const focusTextInput = useCallback(() => { textInput.current?.focus() diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 3c4aaf73..c477ada0 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -12,12 +12,12 @@ import {Placeholder} from '@tiptap/extension-placeholder' import {Text as TiptapText} from '@tiptap/extension-text' import {generateJSON} from '@tiptap/html' import {EditorContent, JSONContent, useEditor} from '@tiptap/react' -import EventEmitter from 'eventemitter3' import {usePalette} from '#/lib/hooks/usePalette' import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {blobToDataUri, isUriImage} from 'lib/media/util' +import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import { LinkFacetMatch, suggestLinkCardUri, @@ -46,8 +46,6 @@ interface TextInputProps { onError: (err: string) => void } -export const textInputWebEmitter = new EventEmitter() - export const TextInput = React.forwardRef(function TextInputImpl( { richtext, diff --git a/src/view/com/composer/text-input/textInputWebEmitter.ts b/src/view/com/composer/text-input/textInputWebEmitter.ts new file mode 100644 index 00000000..fb037cac --- /dev/null +++ b/src/view/com/composer/text-input/textInputWebEmitter.ts @@ -0,0 +1,3 @@ +import EventEmitter from 'eventemitter3' + +export const textInputWebEmitter = new EventEmitter() diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx index 1f4178f7..ad3bb30e 100644 --- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx +++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx @@ -7,8 +7,8 @@ import { } from 'react-native' import Picker from '@emoji-mart/react' +import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import {atoms as a} from '#/alf' -import {textInputWebEmitter} from '../TextInput.web' const HEIGHT_OFFSET = 40 const WIDTH_OFFSET = 100 @@ -26,22 +26,41 @@ export type Emoji = { unified: string } +export interface EmojiPickerPosition { + top: number + left: number + right: number + bottom: number +} + export interface EmojiPickerState { isOpen: boolean - pos: {top: number; left: number; right: number; bottom: number} + pos: EmojiPickerPosition } interface IProps { state: EmojiPickerState close: () => void + /** + * If `true`, overrides position and ensures picker is pinned to the top of + * the target element. + */ + pinToTop?: boolean } -export function EmojiPicker({state, close}: IProps) { +export function EmojiPicker({state, close, pinToTop}: IProps) { const {height, width} = useWindowDimensions() const isShiftDown = React.useRef(false) const position = React.useMemo(() => { + if (pinToTop) { + return { + top: state.pos.top - PICKER_HEIGHT + HEIGHT_OFFSET - 10, + left: state.pos.left, + } + } + const fitsBelow = state.pos.top + PICKER_HEIGHT < height const fitsAbove = PICKER_HEIGHT < state.pos.top const placeOnLeft = PICKER_WIDTH < state.pos.left @@ -64,7 +83,7 @@ export function EmojiPicker({state, close}: IProps) { : undefined, } } - }, [state.pos, height, width]) + }, [state.pos, height, width, pinToTop]) React.useEffect(() => { if (!state.isOpen) return diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index 5d80dc42..42696139 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -61,7 +61,7 @@ export function Composer({}: {winHeight: number}) { quoteCount={state?.quoteCount} onPost={state.onPost} mention={state.mention} - openPicker={onOpenPicker} + openEmojiPicker={onOpenPicker} text={state.text} />