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>
zio/stable
Eric Bailey 2024-09-06 17:58:47 -05:00 committed by GitHub
parent 30d2ab8dd3
commit 543be17674
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 119 additions and 14 deletions

View File

@ -23,6 +23,7 @@ import {
useSaveMessageDraft, useSaveMessageDraft,
} from '#/state/messages/message-drafts' } from '#/state/messages/message-drafts'
import {isIOS} from 'platform/detection' 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 * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {useSharedInputStyles} from '#/components/forms/TextField' import {useSharedInputStyles} from '#/components/forms/TextField'
@ -41,6 +42,7 @@ export function MessageInput({
hasEmbed: boolean hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode children?: React.ReactNode
openEmojiPicker?: (pos: EmojiPickerPosition) => void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()

View File

@ -12,9 +12,16 @@ import {
} from '#/state/messages/message-drafts' } from '#/state/messages/message-drafts'
import {isSafari, isTouchDevice} from 'lib/browser' import {isSafari, isTouchDevice} from 'lib/browser'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 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 * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useSharedInputStyles} from '#/components/forms/TextField' 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 {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import {useExtractEmbedFromFacets} from './MessageInputEmbed' import {useExtractEmbedFromFacets} from './MessageInputEmbed'
@ -23,11 +30,13 @@ export function MessageInput({
hasEmbed, hasEmbed,
setEmbed, setEmbed,
children, children,
openEmojiPicker,
}: { }: {
onSendMessage: (message: string) => void onSendMessage: (message: string) => void
hasEmbed: boolean hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode children?: React.ReactNode
openEmojiPicker?: (pos: EmojiPickerPosition) => void
}) { }) {
const {isTabletOrDesktop} = useWebMediaQueries() const {isTabletOrDesktop} = useWebMediaQueries()
const {_} = useLingui() const {_} = useLingui()
@ -40,6 +49,7 @@ export function MessageInput({
const [isFocused, setIsFocused] = React.useState(false) const [isFocused, setIsFocused] = React.useState(false)
const [isHovered, setIsHovered] = React.useState(false) const [isHovered, setIsHovered] = React.useState(false)
const [textAreaHeight, setTextAreaHeight] = React.useState(38) const [textAreaHeight, setTextAreaHeight] = React.useState(38)
const textAreaRef = React.useRef<HTMLTextAreaElement>(null)
const onSubmit = React.useCallback(() => { const onSubmit = React.useCallback(() => {
if (!hasEmbed && message.trim() === '') { 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) useSaveMessageDraft(message)
useExtractEmbedFromFacets(message, setEmbed) useExtractEmbedFromFacets(message, setEmbed)
@ -106,7 +133,7 @@ export function MessageInput({
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
{ {
paddingRight: a.p_sm.padding - 2, paddingRight: a.p_sm.padding - 2,
paddingLeft: a.p_md.padding - 2, paddingLeft: a.p_sm.padding - 2,
borderWidth: 1, borderWidth: 1,
borderRadius: 23, borderRadius: 23,
borderColor: 'transparent', borderColor: 'transparent',
@ -118,7 +145,44 @@ export function MessageInput({
// @ts-expect-error web only // @ts-expect-error web only
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}> onMouseLeave={() => setIsHovered(false)}>
<Button
onPress={e => {
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
openEmojiPicker?.({top: py, left: px, right: px, bottom: py})
})
}}
style={[
a.rounded_full,
a.overflow_hidden,
a.align_center,
a.justify_center,
{
marginTop: 5,
height: 30,
width: 30,
},
]}
label={_(msg`Open emoji picker`)}>
{state => (
<View
style={[
a.absolute,
a.inset_0,
a.align_center,
a.justify_center,
{
backgroundColor:
state.hovered || state.focused || state.pressed
? t.atoms.bg.backgroundColor
: undefined,
},
]}>
<EmojiSmile size="lg" />
</View>
)}
</Button>
<TextareaAutosize <TextareaAutosize
ref={textAreaRef}
style={StyleSheet.flatten([ style={StyleSheet.flatten([
a.flex_1, a.flex_1,
a.px_sm, a.px_sm,

View File

@ -29,6 +29,10 @@ import {useAgent} from '#/state/session'
import {clamp} from 'lib/numbers' import {clamp} from 'lib/numbers'
import {ScrollProvider} from 'lib/ScrollContext' import {ScrollProvider} from 'lib/ScrollContext'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {
EmojiPicker,
EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import {List} from 'view/com/util/List' import {List} from 'view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled' import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
@ -97,6 +101,12 @@ export function MessagesList({
startContentOffset: 0, startContentOffset: 0,
}) })
const [emojiPickerState, setEmojiPickerState] =
React.useState<EmojiPickerState>({
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 // 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 // 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. // the bottom.
@ -422,13 +432,22 @@ export function MessagesList({
<MessageInput <MessageInput
onSendMessage={onSendMessage} onSendMessage={onSendMessage}
hasEmbed={!!embedUri} hasEmbed={!!embedUri}
setEmbed={setEmbed}> setEmbed={setEmbed}
openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
</MessageInput> </MessageInput>
</> </>
)} )}
</KeyboardStickyView> </KeyboardStickyView>
{isWeb && (
<EmojiPicker
pinToTop
state={emojiPickerState}
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
/>
)}
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />} {newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
</> </>
) )

View File

@ -34,7 +34,7 @@ export interface ComposerOpts {
quote?: ComposerOptsQuote quote?: ComposerOptsQuote
quoteCount?: number quoteCount?: number
mention?: string // handle of user to mention mention?: string // handle of user to mention
openPicker?: (pos: DOMRect | undefined) => void openEmojiPicker?: (pos: DOMRect | undefined) => void
text?: string text?: string
imageUris?: {uri: string; width: number; height: number}[] imageUris?: {uri: string; width: number; height: number}[]
} }

View File

@ -133,7 +133,7 @@ export const ComposePost = observer(function ComposePost({
quote: initQuote, quote: initQuote,
quoteCount, quoteCount,
mention: initMention, mention: initMention,
openPicker, openEmojiPicker,
text: initText, text: initText,
imageUris: initImageUris, imageUris: initImageUris,
cancelRef, cancelRef,
@ -520,8 +520,8 @@ export const ComposePost = observer(function ComposePost({
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video) gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
const onEmojiButtonPress = useCallback(() => { const onEmojiButtonPress = useCallback(() => {
openPicker?.(textInput.current?.getCursorPosition()) openEmojiPicker?.(textInput.current?.getCursorPosition())
}, [openPicker]) }, [openEmojiPicker])
const focusTextInput = useCallback(() => { const focusTextInput = useCallback(() => {
textInput.current?.focus() textInput.current?.focus()

View File

@ -12,12 +12,12 @@ import {Placeholder} from '@tiptap/extension-placeholder'
import {Text as TiptapText} from '@tiptap/extension-text' import {Text as TiptapText} from '@tiptap/extension-text'
import {generateJSON} from '@tiptap/html' import {generateJSON} from '@tiptap/html'
import {EditorContent, JSONContent, useEditor} from '@tiptap/react' import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
import EventEmitter from 'eventemitter3'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {blobToDataUri, isUriImage} from 'lib/media/util' import {blobToDataUri, isUriImage} from 'lib/media/util'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import { import {
LinkFacetMatch, LinkFacetMatch,
suggestLinkCardUri, suggestLinkCardUri,
@ -46,8 +46,6 @@ interface TextInputProps {
onError: (err: string) => void onError: (err: string) => void
} }
export const textInputWebEmitter = new EventEmitter()
export const TextInput = React.forwardRef(function TextInputImpl( export const TextInput = React.forwardRef(function TextInputImpl(
{ {
richtext, richtext,

View File

@ -0,0 +1,3 @@
import EventEmitter from 'eventemitter3'
export const textInputWebEmitter = new EventEmitter()

View File

@ -7,8 +7,8 @@ import {
} from 'react-native' } from 'react-native'
import Picker from '@emoji-mart/react' import Picker from '@emoji-mart/react'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {textInputWebEmitter} from '../TextInput.web'
const HEIGHT_OFFSET = 40 const HEIGHT_OFFSET = 40
const WIDTH_OFFSET = 100 const WIDTH_OFFSET = 100
@ -26,22 +26,41 @@ export type Emoji = {
unified: string unified: string
} }
export interface EmojiPickerPosition {
top: number
left: number
right: number
bottom: number
}
export interface EmojiPickerState { export interface EmojiPickerState {
isOpen: boolean isOpen: boolean
pos: {top: number; left: number; right: number; bottom: number} pos: EmojiPickerPosition
} }
interface IProps { interface IProps {
state: EmojiPickerState state: EmojiPickerState
close: () => void 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 {height, width} = useWindowDimensions()
const isShiftDown = React.useRef(false) const isShiftDown = React.useRef(false)
const position = React.useMemo(() => { 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 fitsBelow = state.pos.top + PICKER_HEIGHT < height
const fitsAbove = PICKER_HEIGHT < state.pos.top const fitsAbove = PICKER_HEIGHT < state.pos.top
const placeOnLeft = PICKER_WIDTH < state.pos.left const placeOnLeft = PICKER_WIDTH < state.pos.left
@ -64,7 +83,7 @@ export function EmojiPicker({state, close}: IProps) {
: undefined, : undefined,
} }
} }
}, [state.pos, height, width]) }, [state.pos, height, width, pinToTop])
React.useEffect(() => { React.useEffect(() => {
if (!state.isOpen) return if (!state.isOpen) return

View File

@ -61,7 +61,7 @@ export function Composer({}: {winHeight: number}) {
quoteCount={state?.quoteCount} quoteCount={state?.quoteCount}
onPost={state.onPost} onPost={state.onPost}
mention={state.mention} mention={state.mention}
openPicker={onOpenPicker} openEmojiPicker={onOpenPicker}
text={state.text} text={state.text}
/> />
</View> </View>