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
parent
30d2ab8dd3
commit
543be17674
|
@ -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()
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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}[]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
|
export const textInputWebEmitter = new EventEmitter()
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue