EmojiPicker component (#10249)

This commit is contained in:
Samuel Newman
2026-04-17 02:42:10 -07:00
committed by GitHub
parent 8f56fca82c
commit a97b15b204
17 changed files with 397 additions and 479 deletions
+30 -36
View File
@@ -72,7 +72,6 @@ import {
} from '#/lib/constants'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {mimeToExt} from '#/lib/media/video/util'
import {useCallOnce} from '#/lib/once'
import {type NavigationProp} from '#/lib/routes/types'
@@ -122,9 +121,10 @@ import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf'
import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as EmojiPicker from '#/components/EmojiPicker'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
@@ -185,7 +185,6 @@ export const ComposePost = ({
onPostSuccess,
quote: initQuote,
mention: initMention,
openEmojiPicker,
text: initText,
imageUris: initImageUris,
videoUri: initVideoUri,
@@ -206,7 +205,7 @@ export const ComposePost = ({
const requireAltTextEnabled = useRequireAltTextEnabled()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const textInput = useRef<TextInputRef>(null)
const textInputRef = useRef<TextInputRef>(null)
const discardPromptControl = Prompt.usePromptControl()
const {mutateAsync: saveDraft, isPending: _isSavingDraft} =
useSaveDraftMutation()
@@ -708,7 +707,7 @@ export const ComposePost = ({
)
const onPressCancel = useCallback(() => {
if (textInput.current?.maybeClosePopup()) {
if (textInputRef.current?.maybeClosePopup()) {
return
}
@@ -1064,17 +1063,6 @@ export const ComposePost = ({
}
}
const onEmojiButtonPress = useCallback(() => {
const rect = textInput.current?.getCursorPosition()
if (rect) {
openEmojiPicker?.({
...rect,
nextFocusRef:
textInput as unknown as React.MutableRefObject<HTMLElement>,
})
}
}, [openEmojiPicker])
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
useEffect(() => {
if (composerState.mutableNeedsFocusActive) {
@@ -1082,7 +1070,7 @@ export const ComposePost = ({
// On Android, this risks getting the cursor stuck behind the keyboard.
// Not worth it.
if (!IS_ANDROID) {
textInput.current?.focus()
textInputRef.current?.focus()
}
}
}, [composerState])
@@ -1123,7 +1111,6 @@ export const ComposePost = ({
!isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost))
}
onError={setError}
onEmojiButtonPress={onEmojiButtonPress}
onSelectVideo={selectVideo}
onAddPost={() => {
composerDispatch({
@@ -1133,6 +1120,7 @@ export const ComposePost = ({
currentLanguages={currentLanguages}
onSelectLanguage={onSelectLanguage}
openGallery={openGallery}
textInputRef={textInputRef}
/>
</>
)
@@ -1201,7 +1189,7 @@ export const ComposePost = ({
<ComposerPost
post={post}
dispatch={composerDispatch}
textInput={post.id === activePost.id ? textInput : null}
textInputRef={post.id === activePost.id ? textInputRef : null}
isFirstPost={index === 0}
isLastPost={index === thread.posts.length - 1}
isPartOfThread={thread.posts.length > 1}
@@ -1288,7 +1276,7 @@ export const ComposePost = ({
let ComposerPost = memo(function ComposerPost({
post,
dispatch,
textInput,
textInputRef,
isActive,
isReply,
isFirstPost,
@@ -1303,7 +1291,7 @@ let ComposerPost = memo(function ComposerPost({
}: {
post: PostDraft
dispatch: (action: ComposerAction) => void
textInput: React.Ref<TextInputRef>
textInputRef: React.RefObject<TextInputRef | null> | null
isActive: boolean
isReply: boolean
isFirstPost: boolean
@@ -1404,7 +1392,7 @@ let ComposerPost = memo(function ComposerPost({
style={[a.mt_xs]}
/>
<TextInput
ref={textInput}
ref={textInputRef}
style={[a.pt_xs]}
richtext={richtext}
placeholder={selectTextInputPlaceholder}
@@ -1822,27 +1810,27 @@ function ComposerFooter({
post,
dispatch,
showAddButton,
onEmojiButtonPress,
onSelectVideo,
onAddPost,
currentLanguages,
onSelectLanguage,
openGallery,
textInputRef,
}: {
post: PostDraft
dispatch: (action: PostAction) => void
showAddButton: boolean
onEmojiButtonPress: () => void
onError: (error: string) => void
onSelectVideo: (postId: string, asset: ImagePickerAsset) => void
onAddPost: () => void
currentLanguages: string[]
onSelectLanguage?: (language: string) => void
openGallery?: boolean
textInputRef: React.RefObject<TextInputRef | null>
}) {
const t = useTheme()
const {t: l} = useLingui()
const {isMobile} = useWebMediaQueries()
const {gtPhone} = useBreakpoints()
/*
* Once we've allowed a certain type of asset to be selected, we don't allow
* other types of media to be selected.
@@ -1965,17 +1953,23 @@ function ComposerFooter({
onAdd={onImageAdd}
/>
<SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} />
{!isMobile ? (
<Button
onPress={onEmojiButtonPress}
style={a.p_sm}
label={l`Open emoji picker`}
accessibilityHint={l`Opens emoji picker`}
variant="ghost"
shape="round"
color="primary">
<EmojiSmileIcon size="lg" />
</Button>
{IS_WEB && gtPhone ? (
<EmojiPicker.Root nextFocusRef={textInputRef}>
<EmojiPicker.Trigger label={l`Open emoji picker`}>
{({props}) => (
<Button
style={a.p_sm}
label={props.accessibilityLabel}
variant="ghost"
shape="round"
color="primary"
{...props}>
<EmojiSmileIcon size="lg" />
</Button>
)}
</EmojiPicker.Trigger>
<EmojiPicker.Picker />
</EmojiPicker.Root>
) : null}
</ToolbarWrapper>
)}
+1 -1
View File
@@ -32,7 +32,7 @@ export type SelectMediaButtonProps = {
type: AssetType
assets: ImagePickerAsset[]
errors: string[]
}) => void
}) => void | Promise<void>
/**
* If true, automatically open the media picker when the component mounts.
*/
@@ -32,11 +32,11 @@ import {
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {atoms as a, useAlf} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography'
import {type Emoji} from '#/components/EmojiPicker'
import {Portal} from '#/components/Portal'
import {Text} from '#/components/Typography'
import {type TextInputProps} from './TextInput.types'
import {type AutocompleteRef, createSuggestion} from './web/Autocomplete'
import {type Emoji} from './web/EmojiPicker'
import {LinkDecorator} from './web/LinkDecorator'
import {TagDecorator} from './web/TagDecorator'
@@ -1,37 +0,0 @@
export type Emoji = {
aliases?: string[]
emoticons: string[]
id: string
keywords: string[]
name: string
native: string
shortcodes?: string
unified: string
}
export interface EmojiPickerPosition {
top: number
left: number
right: number
bottom: number
nextFocusRef: React.MutableRefObject<HTMLElement> | null
}
export interface EmojiPickerState {
isOpen: boolean
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(_opts: IProps) {
return null
}
@@ -1,180 +0,0 @@
import {useEffect, useMemo, useRef} from 'react'
import {Pressable, useWindowDimensions, View} from 'react-native'
import Picker from '@emoji-mart/react'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {DismissableLayer, FocusScope} from 'radix-ui/internal'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {atoms as a, flatten} from '#/alf'
import {Portal} from '#/components/Portal'
const HEIGHT_OFFSET = 40
const WIDTH_OFFSET = 100
const PICKER_HEIGHT = 435 + HEIGHT_OFFSET
const PICKER_WIDTH = 350 + WIDTH_OFFSET
export type Emoji = {
aliases?: string[]
emoticons: string[]
id: string
keywords: string[]
name: string
native: string
shortcodes?: string
unified: string
}
export interface EmojiPickerPosition {
top: number
left: number
right: number
bottom: number
nextFocusRef: React.MutableRefObject<HTMLElement> | null
}
export interface EmojiPickerState {
isOpen: boolean
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, pinToTop}: IProps) {
const {_} = useLingui()
const {height, width} = useWindowDimensions()
const isShiftDown = useRef(false)
const position = 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
const screenYMiddle = height / 2 - PICKER_HEIGHT / 2
if (fitsBelow) {
return {
top: state.pos.top + HEIGHT_OFFSET,
}
} else if (fitsAbove) {
return {
bottom: height - state.pos.bottom + HEIGHT_OFFSET,
}
} else {
return {
top: screenYMiddle,
left: placeOnLeft ? state.pos.left - PICKER_WIDTH : undefined,
right: !placeOnLeft
? width - state.pos.right - PICKER_WIDTH
: undefined,
}
}
}, [state.pos, height, width, pinToTop])
useEffect(() => {
if (!state.isOpen) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
isShiftDown.current = true
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
isShiftDown.current = false
}
}
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('keyup', onKeyUp, true)
return () => {
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('keyup', onKeyUp, true)
}
}, [state.isOpen])
const onInsert = (emoji: Emoji) => {
textInputWebEmitter.emit('emoji-inserted', emoji)
if (!isShiftDown.current) {
close()
}
}
if (!state.isOpen) return null
return (
<Portal>
<FocusScope.FocusScope
loop
trapped
onUnmountAutoFocus={e => {
const nextFocusRef = state.pos.nextFocusRef
const node = nextFocusRef?.current
if (node) {
e.preventDefault()
node.focus()
}
}}>
<Pressable
accessible
accessibilityLabel={_(msg`Close emoji picker`)}
accessibilityHint={_(msg`Closes the emoji picker`)}
onPress={close}
style={[a.fixed, a.inset_0]}
/>
<View
style={flatten([
a.fixed,
a.w_full,
a.h_full,
a.align_center,
a.z_10,
{
top: 0,
left: 0,
right: 0,
},
])}>
<View style={[{position: 'absolute'}, position]}>
<DismissableLayer.DismissableLayer
onFocusOutside={evt => evt.preventDefault()}
onDismiss={close}>
<Picker
data={async () => {
return (await import('@emoji-mart/data')).default
}}
onEmojiSelect={onInsert}
autoFocus={true}
/>
</DismissableLayer.DismissableLayer>
</View>
</View>
<Pressable
accessible
accessibilityLabel={_(msg`Close emoji picker`)}
accessibilityHint={_(msg`Closes the emoji picker`)}
onPress={close}
style={[a.fixed, a.inset_0]}
/>
</FocusScope.FocusScope>
</Portal>
)
}
@@ -1,24 +0,0 @@
import {useCallback} from 'react'
import {init} from 'emoji-mart'
/**
* Only load the emoji picker data once per page load.
*/
let loadRequested = false
/**
* Preload the emoji picker data to prevent flash.
* {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194}
*/
export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) {
const preload = useCallback(async () => {
if (loadRequested) return
loadRequested = true
try {
const data = (await import('@emoji-mart/data')).default
init({data})
} catch (e) {}
}, [])
if (immediate) preload()
return preload
}