✨ EmojiPicker component (#10249)
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user