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
+40
View File
@@ -0,0 +1,40 @@
import {type PickerProps, type RootProps, type TriggerProps} from './types'
export * from './types'
/**
* Provides emoji picker context and wraps children in a {@link Menu.Root}.
*
* On emoji select, fires a `textInputWebEmitter` event (for web text inputs
* that listen for emoji insertions) and forwards to the optional
* `onEmojiSelect` callback.
*
* @platform web
*/
export function Root(_props: RootProps): React.ReactNode {
throw new Error('EmojiPopup is not implemented on native')
}
/**
* Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children
* pattern.
*
* @platform web
*/
export function Trigger(_props: TriggerProps): React.ReactNode {
throw new Error('EmojiPopup is not implemented on native')
}
/**
* Renders the emoji picker inside a Radix `DropdownMenu.Portal`.
*
* Holding Shift while selecting an emoji keeps the picker open for
* multi-select. Otherwise the menu closes after each selection.
*
* Must be rendered inside a {@link Root}.
*
* @platform web
*/
export function Picker(_props: PickerProps): React.ReactNode {
throw new Error('EmojiPopup is not implemented on native')
}
+150
View File
@@ -0,0 +1,150 @@
import {createContext, useContext, useEffect, useMemo, useRef} from 'react'
import EmojiPicker from '@emoji-mart/react'
import {DropdownMenu} from 'radix-ui'
import {useA11y} from '#/state/a11y'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {atoms as a, flatten} from '#/alf'
import * as Menu from '../Menu'
import {useWebPreloadEmoji} from './preload'
import {
type Emoji,
type PickerProps,
type RootProps,
type TriggerProps,
} from './types'
export * from './types'
const EmojiPickerContext = createContext<{
onEmojiSelect: (emoji: Emoji) => void
nextFocusRef: RootProps['nextFocusRef']
} | null>(null)
/**
* Provides emoji picker context and wraps children in a {@link Menu.Root}.
*
* On emoji select, fires a `textInputWebEmitter` event (for web text inputs
* that listen for emoji insertions) and forwards to the optional
* `onEmojiSelect` callback.
*
* @platform web
*/
export function Root({
children,
control,
onEmojiSelect,
preloadOnMount = true,
nextFocusRef,
}: RootProps) {
useWebPreloadEmoji({immediate: preloadOnMount})
const value = useMemo(
() => ({
onEmojiSelect: (emoji: Emoji) => {
textInputWebEmitter.emit('emoji-inserted', emoji)
if (onEmojiSelect) onEmojiSelect(emoji)
},
nextFocusRef,
}),
[onEmojiSelect, nextFocusRef],
)
return (
<EmojiPickerContext value={value}>
<Menu.Root control={control}>{children}</Menu.Root>
</EmojiPickerContext>
)
}
/**
* Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children
* pattern.
*
* @platform web
*/
export function Trigger(props: TriggerProps) {
return <Menu.Trigger {...props} />
}
/**
* Renders the emoji picker inside a Radix `DropdownMenu.Portal`.
*
* Holding Shift while selecting an emoji keeps the picker open for
* multi-select. Otherwise the menu closes after each selection.
*
* Must be rendered inside a {@link Root}.
*
* @platform web
*/
export function Picker({keepOpenWhenShiftHeld = true}: PickerProps) {
const {onEmojiSelect, nextFocusRef} = useEmojiPickerContext()
const {control} = Menu.useMenuContext()
const {reduceMotionEnabled} = useA11y()
const isShiftDown = useRef(false)
useEffect(() => {
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)
}
}, [])
return (
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={5}
collisionPadding={{left: 5, right: 5, bottom: 5}}
className="dropdown-menu-transform-origin dropdown-menu-constrain-size"
onCloseAutoFocus={evt => {
if (!nextFocusRef) return
let element =
nextFocusRef instanceof Function
? nextFocusRef()
: nextFocusRef.current
if (element) {
evt.preventDefault()
element.focus()
}
}}>
<div
onWheel={evt => evt.stopPropagation()}
style={flatten([!reduceMotionEnabled && a.zoom_fade_in])}>
<EmojiPicker
autoFocus
onEmojiSelect={(emoji: Emoji) => {
onEmojiSelect(emoji)
if (!keepOpenWhenShiftHeld || !isShiftDown.current) {
control.close()
}
}}
/>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
)
}
function useEmojiPickerContext() {
const ctx = useContext(EmojiPickerContext)
if (!ctx)
throw new Error(
'EmojiPicker.Picker must be used within an EmojiPicker.Root component',
)
return ctx
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Native no-op. Emoji data preloading is only needed on web where the picker
* uses `emoji-mart`.
*/
export function useWebPreloadEmoji({}: {immediate?: boolean} = {}) {
return () => Promise.resolve()
}
@@ -7,8 +7,14 @@ import {init} from 'emoji-mart'
let loadRequested = false let loadRequested = false
/** /**
* Preload the emoji picker data to prevent flash. * Preloads emoji-mart data so the picker renders instantly when opened.
* {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194} *
* Returns a function that can be called manually to trigger preloading (e.g.
* on hover). When `immediate` is `true`, preloading starts on mount.
*
* Data is only fetched once per page load subsequent calls are no-ops.
*
* @see {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194 | emoji-mart preloading docs}
*/ */
export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) { export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) {
const preload = useCallback(async () => { const preload = useCallback(async () => {
+65
View File
@@ -0,0 +1,65 @@
import {type DialogControlProps} from '../Dialog'
import {type TriggerProps as MenuTriggerProps} from '../Menu/types'
/**
* Represents an emoji selected from the picker. Sourced from the `emoji-mart`
* library's selection data.
*/
export type Emoji = {
aliases?: string[]
emoticons: string[]
id: string
keywords: string[]
name: string
/** The native unicode character for the emoji, e.g. "😀" */
native: string
shortcodes?: string
/** The unicode codepoint, e.g. "1f600" */
unified: string
/** Skin tone variant (16), if applicable */
skin?: number
}
type FocusableElement = {focus: () => void}
export interface RootProps {
children: React.ReactNode
control?: DialogControlProps
/**
* Called when the user selects an emoji. On web this fires in addition to
* the `textInputWebEmitter` event, so callers that only need the text
* insertion can omit this.
*/
onEmojiSelect?: (emoji: Emoji) => void
/**
* When `true` (default), preloads emoji data as soon as the component
* mounts so the picker opens instantly. Set to `false` to defer loading
* until the picker is actually opened.
*/
preloadOnMount?: boolean
/**
* Element to return focus to when the picker closes. Accepts either a ref
* or a getter function.
*/
nextFocusRef?:
| React.RefObject<FocusableElement | null>
| (() => FocusableElement | null | undefined)
}
/**
* Props for the trigger button that opens the emoji picker. Extends
* {@link MenuTriggerProps} — accepts the same render-prop children pattern.
*/
export interface TriggerProps extends MenuTriggerProps {}
/**
* Props for the picker panel itself.
*/
export interface PickerProps {
/**
* When `true`, the picker will remain open after selecting an emoji when the Shift key is held down.
*
* @default true
*/
keepOpenWhenShiftHeld?: boolean
}
+10 -29
View File
@@ -1,18 +1,14 @@
import {useState} from 'react' import {useState} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api' import {type ChatBskyConvoDefs} from '@atproto/api'
import EmojiPicker from '@emoji-mart/react' import {useLingui} from '@lingui/react/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {DropdownMenu} from 'radix-ui' import {DropdownMenu} from 'radix-ui'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker'
import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji'
import {atoms as a, flatten, useTheme} from '#/alf' import {atoms as a, flatten, useTheme} from '#/alf'
import * as EmojiPicker from '#/components/EmojiPicker'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import {type TriggerProps} from '#/components/Menu/types'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {hasAlreadyReacted, hasReachedReactionLimit} from './util' import {hasAlreadyReacted, hasReachedReactionLimit} from './util'
@@ -22,19 +18,21 @@ export function EmojiReactionPicker({
onEmojiSelect, onEmojiSelect,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
children?: TriggerProps['children'] children?: EmojiPicker.TriggerProps['children']
onEmojiSelect: (emoji: string) => void onEmojiSelect: (emoji: string) => void
}) { }) {
if (!children) if (!children)
throw new Error('EmojiReactionPicker requires the children prop on web') throw new Error('EmojiReactionPicker requires the children prop on web')
const {_} = useLingui() const {t: l} = useLingui()
return ( return (
<Menu.Root> <EmojiPicker.Root onEmojiSelect={emoji => onEmojiSelect(emoji.native)}>
<Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger> <EmojiPicker.Trigger label={l`Add emoji reaction`}>
{children}
</EmojiPicker.Trigger>
<MenuInner message={message} onEmojiSelect={onEmojiSelect} /> <MenuInner message={message} onEmojiSelect={onEmojiSelect} />
</Menu.Root> </EmojiPicker.Root>
) )
} }
@@ -49,8 +47,6 @@ function MenuInner({
const {control} = Menu.useMenuContext() const {control} = Menu.useMenuContext()
const {currentAccount} = useSession() const {currentAccount} = useSession()
useWebPreloadEmoji({immediate: true})
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [prevOpen, setPrevOpen] = useState(control.isOpen) const [prevOpen, setPrevOpen] = useState(control.isOpen)
@@ -62,10 +58,6 @@ function MenuInner({
} }
} }
const handleEmojiPickerResponse = (emoji: Emoji) => {
handleEmojiSelect(emoji.native)
}
const handleEmojiSelect = (emoji: string) => { const handleEmojiSelect = (emoji: string) => {
control.close() control.close()
onEmojiSelect(emoji) onEmojiSelect(emoji)
@@ -74,18 +66,7 @@ function MenuInner({
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did) const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
return expanded ? ( return expanded ? (
<DropdownMenu.Portal> <EmojiPicker.Picker keepOpenWhenShiftHeld={false} />
<DropdownMenu.Content
sideOffset={5}
collisionPadding={{left: 5, right: 5, bottom: 5}}>
<div onWheel={evt => evt.stopPropagation()}>
<EmojiPicker
onEmojiSelect={handleEmojiPickerResponse}
autoFocus={true}
/>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
) : ( ) : (
<Menu.Outer style={[a.rounded_full]}> <Menu.Outer style={[a.rounded_full]}>
<View style={[a.flex_row, a.gap_xs]}> <View style={[a.flex_row, a.gap_xs]}>
@@ -1,4 +1,4 @@
import {useEffect, useState} from 'react' import {useState} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import { import {
useKeyboardHandler, useKeyboardHandler,
@@ -25,14 +25,9 @@ import {
useMessageDraft, useMessageDraft,
useSaveMessageDraft, useSaveMessageDraft,
} from '#/state/messages/message-drafts' } from '#/state/messages/message-drafts'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {
type Emoji,
EmojiPicker,
type EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker'
import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf' import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'
import {Composer, useComposerInternalApiRef} from '#/components/Composer' import {Composer, useComposerInternalApiRef} from '#/components/Composer'
import * as EmojiPicker from '#/components/EmojiPicker'
import {GlassView} from '#/components/GlassView' import {GlassView} from '#/components/GlassView'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
@@ -60,10 +55,6 @@ export function MessageComposer({
const {needsEmailVerification} = useEmail() const {needsEmailVerification} = useEmail()
const editable = !needsEmailVerification const editable = !needsEmailVerification
const {getDraft, clearDraft} = useMessageDraft() const {getDraft, clearDraft} = useMessageDraft()
const [emojiPickerState, setEmojiPickerState] = useState<EmojiPickerState>({
isOpen: false,
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
})
const composerInternalApiRef = useComposerInternalApiRef() const composerInternalApiRef = useComposerInternalApiRef()
const [text, setText] = useState(getDraft) const [text, setText] = useState(getDraft)
@@ -85,10 +76,6 @@ export function MessageComposer({
const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0) const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0)
const openEmojiPicker = (pos: any) => {
setEmojiPickerState({isOpen: true, pos})
}
const onSubmit = () => { const onSubmit = () => {
if (!editable) return if (!editable) return
if (!hasEmbed && text.trim() === '') return if (!hasEmbed && text.trim() === '') return
@@ -112,16 +99,6 @@ export function MessageComposer({
} }
} }
useEffect(() => {
function onEmojiInserted(emoji: Emoji) {
composerInternalApiRef.current?.insert(emoji.native)
}
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
return () => {
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
}
}, [composerInternalApiRef])
return ( return (
<ComposerContainer> <ComposerContainer>
{children} {children}
@@ -142,24 +119,17 @@ export function MessageComposer({
tintColor={t.palette.contrast_50} tintColor={t.palette.contrast_50}
fallbackStyle={[t.atoms.bg_contrast_50]}> fallbackStyle={[t.atoms.bg_contrast_50]}>
{IS_WEB && ( {IS_WEB && (
<EmojiPicker.Root
onEmojiSelect={emoji =>
composerInternalApiRef.current?.insert(emoji.native)
}
nextFocusRef={() =>
composerInternalApiRef.current?.input?.element
}>
<EmojiPicker.Trigger label={l`Open emoji picker`}>
{({props, state, control}) => (
<Pressable <Pressable
onPress={e => { {...props}
e.currentTarget.measure(
(_fx, _fy, _width, _height, px, py) => {
// TODO: rip this horrible system out
openEmojiPicker?.({
top: py,
left: px - 400,
right: px - 400,
bottom: py,
nextFocusRef: {
current:
composerInternalApiRef.current?.input?.element,
},
})
},
)
}}
style={[ style={[
a.overflow_hidden, a.overflow_hidden,
a.absolute, a.absolute,
@@ -173,24 +143,24 @@ export function MessageComposer({
top: 10, top: 10,
right: 10, right: 10,
}, },
]} ]}>
accessibilityLabel={l`Open emoji picker`}
accessibilityHint="">
{state => (
<EmojiSmileIcon <EmojiSmileIcon
size="md" size="md"
style={ style={
state.hovered || state.hovered ||
state.focused || state.focused ||
state.pressed || state.pressed ||
emojiPickerState.isOpen control.isOpen
? {color: t.palette.primary_500} ? {color: t.palette.primary_500}
: t.atoms.text_contrast_high : t.atoms.text_contrast_high
} }
/> />
)}
</Pressable> </Pressable>
)} )}
</EmojiPicker.Trigger>
<EmojiPicker.Picker />
</EmojiPicker.Root>
)}
<Composer <Composer
nativeID={textInputId} nativeID={textInputId}
@@ -226,14 +196,6 @@ export function MessageComposer({
<SubmitButton onPress={onSubmit} disabled={submitDisabled} /> <SubmitButton onPress={onSubmit} disabled={submitDisabled} />
</GlassContainer> </GlassContainer>
</View> </View>
{IS_WEB && (
<EmojiPicker
pinToTop
state={emojiPickerState}
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
/>
)}
</ComposerContainer> </ComposerContainer>
) )
} }
@@ -25,7 +25,6 @@ import {
useMessageDraft, useMessageDraft,
useSaveMessageDraft, useSaveMessageDraft,
} from '#/state/messages/message-drafts' } from '#/state/messages/message-drafts'
import {type EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker'
import {atoms as a, platform, tokens, useTheme} from '#/alf' import {atoms as a, platform, tokens, useTheme} from '#/alf'
import {GlassView} from '#/components/GlassView' import {GlassView} from '#/components/GlassView'
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
@@ -50,7 +49,6 @@ 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 {t: l} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useRef, useState} from 'react' import {useCallback, useRef, useState} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {flushSync} from 'react-dom' import {flushSync} from 'react-dom'
@@ -11,13 +11,9 @@ import {
useMessageDraft, useMessageDraft,
useSaveMessageDraft, useSaveMessageDraft,
} from '#/state/messages/message-drafts' } from '#/state/messages/message-drafts'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {
type Emoji,
type EmojiPickerPosition,
} from '#/view/com/composer/text-input/web/EmojiPicker'
import {atoms as a, flatten, useTheme} from '#/alf' import {atoms as a, flatten, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import * as EmojiPicker from '#/components/EmojiPicker'
import {useSharedInputStyles} from '#/components/forms/TextField' import {useSharedInputStyles} from '#/components/forms/TextField'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' 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'
@@ -30,13 +26,11 @@ 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 {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -104,12 +98,11 @@ export function MessageInput({
}, []) }, [])
const onEmojiInserted = useCallback( const onEmojiInserted = useCallback(
(emoji: Emoji) => { (emoji: EmojiPicker.Emoji) => {
if (!textAreaRef.current) { if (!textAreaRef.current) {
return return
} }
const position = textAreaRef.current.selectionStart ?? 0 const position = textAreaRef.current.selectionStart ?? 0
textAreaRef.current.focus()
flushSync(() => { flushSync(() => {
setMessage( setMessage(
message => message =>
@@ -121,12 +114,6 @@ export function MessageInput({
}, },
[setMessage], [setMessage],
) )
useEffect(() => {
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
return () => {
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
}
}, [onEmojiInserted])
useSaveMessageDraft(message) useSaveMessageDraft(message)
useExtractEmbedFromFacets(message, setEmbed) useExtractEmbedFromFacets(message, setEmbed)
@@ -152,19 +139,12 @@ 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)}>
<EmojiPicker.Root
onEmojiSelect={onEmojiInserted}
nextFocusRef={textAreaRef}>
<EmojiPicker.Trigger label={l`Open emoji picker`}>
{({props, state}) => (
<Button <Button
onPress={e => {
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
openEmojiPicker?.({
top: py,
left: px,
right: px,
bottom: py,
nextFocusRef:
textAreaRef as unknown as React.MutableRefObject<HTMLElement>,
})
})
}}
style={[ style={[
a.rounded_full, a.rounded_full,
a.overflow_hidden, a.overflow_hidden,
@@ -176,8 +156,8 @@ export function MessageInput({
width: 30, width: 30,
}, },
]} ]}
label={l`Open emoji picker`}> label={props.accessibilityLabel}
{state => ( {...props}>
<View <View
style={[ style={[
a.absolute, a.absolute,
@@ -193,8 +173,11 @@ export function MessageInput({
]}> ]}>
<EmojiSmile size="lg" /> <EmojiSmile size="lg" />
</View> </View>
)}
</Button> </Button>
)}
</EmojiPicker.Trigger>
<EmojiPicker.Picker />
</EmojiPicker.Root>
<TextareaAutosize <TextareaAutosize
ref={textAreaRef} ref={textAreaRef}
style={flatten([ style={flatten([
@@ -49,10 +49,6 @@ import {
} from '#/state/messages/convo/types' } from '#/state/messages/convo/types'
import {useGetPost} from '#/state/queries/post' import {useGetPost} from '#/state/queries/post'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {
EmojiPicker,
type EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker'
import {List, type ListMethods} from '#/view/com/util/List' import {List, type ListMethods} from '#/view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled' import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
import {MessageComposer} from '#/screens/Messages/components/MessageComposer' import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
@@ -124,11 +120,6 @@ export function MessagesList({
startContentOffset: 0, startContentOffset: 0,
}) })
const [emojiPickerState, setEmojiPickerState] = useState<EmojiPickerState>({
isOpen: false,
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
})
const inputHeightUI = useSharedValue(0) const inputHeightUI = useSharedValue(0)
const [inputHeightJS, setInputHeightJS] = useState(0) const [inputHeightJS, setInputHeightJS] = useState(0)
@@ -382,10 +373,6 @@ export function MessagesList({
}) })
}, [flatListRef]) }, [flatListRef])
const onOpenEmojiPicker = useCallback((pos: any) => {
setEmojiPickerState({isOpen: true, pos})
}, [])
const renderItem = ({item}: {item: ConvoItem}) => { const renderItem = ({item}: {item: ConvoItem}) => {
if (item.type === 'message' || item.type === 'pending-message') { if (item.type === 'message' || item.type === 'pending-message') {
return ( return (
@@ -525,8 +512,7 @@ export function MessagesList({
textInputId={textInputId} textInputId={textInputId}
onSendMessage={onSendMessage} onSendMessage={onSendMessage}
hasEmbed={!!embedUri} hasEmbed={!!embedUri}
setEmbed={setEmbed} setEmbed={setEmbed}>
openEmojiPicker={onOpenEmojiPicker}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
</MessageInput> </MessageInput>
)} )}
@@ -535,14 +521,6 @@ export function MessagesList({
</KeyboardStickyView> </KeyboardStickyView>
</KeyboardGestureArea> </KeyboardGestureArea>
{IS_WEB && (
<EmojiPicker
pinToTop
state={emojiPickerState}
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
/>
)}
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />} {newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
</DateDividerToggleProvider> </DateDividerToggleProvider>
) )
-2
View File
@@ -17,7 +17,6 @@ import {
RQKEY_GIF_ROOT, RQKEY_GIF_ROOT,
RQKEY_LINK_ROOT, RQKEY_LINK_ROOT,
} from '#/state/queries/resolve-link' } from '#/state/queries/resolve-link'
import {type EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
export interface ComposerOptsPostRef { export interface ComposerOptsPostRef {
@@ -51,7 +50,6 @@ export interface ComposerOpts {
onPostSuccess?: (data: OnPostSuccessData) => void onPostSuccess?: (data: OnPostSuccessData) => void
quote?: AppBskyFeedDefs.PostView quote?: AppBskyFeedDefs.PostView
mention?: string // handle of user to mention mention?: string // handle of user to mention
openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void
text?: string text?: string
imageUris?: {uri: string; width: number; height: number; altText?: string}[] imageUris?: {uri: string; width: number; height: number; altText?: string}[]
videoUri?: {uri: string; width: number; height: number} videoUri?: {uri: string; width: number; height: number}
+24 -30
View File
@@ -72,7 +72,6 @@ import {
} from '#/lib/constants' } from '#/lib/constants'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {mimeToExt} from '#/lib/media/video/util' import {mimeToExt} from '#/lib/media/video/util'
import {useCallOnce} from '#/lib/once' import {useCallOnce} from '#/lib/once'
import {type NavigationProp} from '#/lib/routes/types' 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 {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
import {UserAvatar} from '#/view/com/util/UserAvatar' 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 {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' 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 {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
@@ -185,7 +185,6 @@ export const ComposePost = ({
onPostSuccess, onPostSuccess,
quote: initQuote, quote: initQuote,
mention: initMention, mention: initMention,
openEmojiPicker,
text: initText, text: initText,
imageUris: initImageUris, imageUris: initImageUris,
videoUri: initVideoUri, videoUri: initVideoUri,
@@ -206,7 +205,7 @@ export const ComposePost = ({
const requireAltTextEnabled = useRequireAltTextEnabled() const requireAltTextEnabled = useRequireAltTextEnabled()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi() const setLangPrefs = useLanguagePrefsApi()
const textInput = useRef<TextInputRef>(null) const textInputRef = useRef<TextInputRef>(null)
const discardPromptControl = Prompt.usePromptControl() const discardPromptControl = Prompt.usePromptControl()
const {mutateAsync: saveDraft, isPending: _isSavingDraft} = const {mutateAsync: saveDraft, isPending: _isSavingDraft} =
useSaveDraftMutation() useSaveDraftMutation()
@@ -708,7 +707,7 @@ export const ComposePost = ({
) )
const onPressCancel = useCallback(() => { const onPressCancel = useCallback(() => {
if (textInput.current?.maybeClosePopup()) { if (textInputRef.current?.maybeClosePopup()) {
return 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>() const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
useEffect(() => { useEffect(() => {
if (composerState.mutableNeedsFocusActive) { if (composerState.mutableNeedsFocusActive) {
@@ -1082,7 +1070,7 @@ export const ComposePost = ({
// On Android, this risks getting the cursor stuck behind the keyboard. // On Android, this risks getting the cursor stuck behind the keyboard.
// Not worth it. // Not worth it.
if (!IS_ANDROID) { if (!IS_ANDROID) {
textInput.current?.focus() textInputRef.current?.focus()
} }
} }
}, [composerState]) }, [composerState])
@@ -1123,7 +1111,6 @@ export const ComposePost = ({
!isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost)) !isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost))
} }
onError={setError} onError={setError}
onEmojiButtonPress={onEmojiButtonPress}
onSelectVideo={selectVideo} onSelectVideo={selectVideo}
onAddPost={() => { onAddPost={() => {
composerDispatch({ composerDispatch({
@@ -1133,6 +1120,7 @@ export const ComposePost = ({
currentLanguages={currentLanguages} currentLanguages={currentLanguages}
onSelectLanguage={onSelectLanguage} onSelectLanguage={onSelectLanguage}
openGallery={openGallery} openGallery={openGallery}
textInputRef={textInputRef}
/> />
</> </>
) )
@@ -1201,7 +1189,7 @@ export const ComposePost = ({
<ComposerPost <ComposerPost
post={post} post={post}
dispatch={composerDispatch} dispatch={composerDispatch}
textInput={post.id === activePost.id ? textInput : null} textInputRef={post.id === activePost.id ? textInputRef : null}
isFirstPost={index === 0} isFirstPost={index === 0}
isLastPost={index === thread.posts.length - 1} isLastPost={index === thread.posts.length - 1}
isPartOfThread={thread.posts.length > 1} isPartOfThread={thread.posts.length > 1}
@@ -1288,7 +1276,7 @@ export const ComposePost = ({
let ComposerPost = memo(function ComposerPost({ let ComposerPost = memo(function ComposerPost({
post, post,
dispatch, dispatch,
textInput, textInputRef,
isActive, isActive,
isReply, isReply,
isFirstPost, isFirstPost,
@@ -1303,7 +1291,7 @@ let ComposerPost = memo(function ComposerPost({
}: { }: {
post: PostDraft post: PostDraft
dispatch: (action: ComposerAction) => void dispatch: (action: ComposerAction) => void
textInput: React.Ref<TextInputRef> textInputRef: React.RefObject<TextInputRef | null> | null
isActive: boolean isActive: boolean
isReply: boolean isReply: boolean
isFirstPost: boolean isFirstPost: boolean
@@ -1404,7 +1392,7 @@ let ComposerPost = memo(function ComposerPost({
style={[a.mt_xs]} style={[a.mt_xs]}
/> />
<TextInput <TextInput
ref={textInput} ref={textInputRef}
style={[a.pt_xs]} style={[a.pt_xs]}
richtext={richtext} richtext={richtext}
placeholder={selectTextInputPlaceholder} placeholder={selectTextInputPlaceholder}
@@ -1822,27 +1810,27 @@ function ComposerFooter({
post, post,
dispatch, dispatch,
showAddButton, showAddButton,
onEmojiButtonPress,
onSelectVideo, onSelectVideo,
onAddPost, onAddPost,
currentLanguages, currentLanguages,
onSelectLanguage, onSelectLanguage,
openGallery, openGallery,
textInputRef,
}: { }: {
post: PostDraft post: PostDraft
dispatch: (action: PostAction) => void dispatch: (action: PostAction) => void
showAddButton: boolean showAddButton: boolean
onEmojiButtonPress: () => void
onError: (error: string) => void onError: (error: string) => void
onSelectVideo: (postId: string, asset: ImagePickerAsset) => void onSelectVideo: (postId: string, asset: ImagePickerAsset) => void
onAddPost: () => void onAddPost: () => void
currentLanguages: string[] currentLanguages: string[]
onSelectLanguage?: (language: string) => void onSelectLanguage?: (language: string) => void
openGallery?: boolean openGallery?: boolean
textInputRef: React.RefObject<TextInputRef | null>
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() 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 * Once we've allowed a certain type of asset to be selected, we don't allow
* other types of media to be selected. * other types of media to be selected.
@@ -1965,17 +1953,23 @@ function ComposerFooter({
onAdd={onImageAdd} onAdd={onImageAdd}
/> />
<SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} /> <SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} />
{!isMobile ? ( {IS_WEB && gtPhone ? (
<EmojiPicker.Root nextFocusRef={textInputRef}>
<EmojiPicker.Trigger label={l`Open emoji picker`}>
{({props}) => (
<Button <Button
onPress={onEmojiButtonPress}
style={a.p_sm} style={a.p_sm}
label={l`Open emoji picker`} label={props.accessibilityLabel}
accessibilityHint={l`Opens emoji picker`}
variant="ghost" variant="ghost"
shape="round" shape="round"
color="primary"> color="primary"
{...props}>
<EmojiSmileIcon size="lg" /> <EmojiSmileIcon size="lg" />
</Button> </Button>
)}
</EmojiPicker.Trigger>
<EmojiPicker.Picker />
</EmojiPicker.Root>
) : null} ) : null}
</ToolbarWrapper> </ToolbarWrapper>
)} )}
+1 -1
View File
@@ -32,7 +32,7 @@ export type SelectMediaButtonProps = {
type: AssetType type: AssetType
assets: ImagePickerAsset[] assets: ImagePickerAsset[]
errors: string[] errors: string[]
}) => void }) => void | Promise<void>
/** /**
* If true, automatically open the media picker when the component mounts. * 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 {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {atoms as a, useAlf} from '#/alf' import {atoms as a, useAlf} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography' import {normalizeTextStyles} from '#/alf/typography'
import {type Emoji} from '#/components/EmojiPicker'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {type TextInputProps} from './TextInput.types' import {type TextInputProps} from './TextInput.types'
import {type AutocompleteRef, createSuggestion} from './web/Autocomplete' import {type AutocompleteRef, createSuggestion} from './web/Autocomplete'
import {type Emoji} from './web/EmojiPicker'
import {LinkDecorator} from './web/LinkDecorator' import {LinkDecorator} from './web/LinkDecorator'
import {TagDecorator} from './web/TagDecorator' 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>
)
}
-27
View File
@@ -1,4 +1,3 @@
import {useCallback, useState} from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {DismissableLayer, FocusGuards, FocusScope} from 'radix-ui/internal' import {DismissableLayer, FocusGuards, FocusScope} from 'radix-ui/internal'
import {RemoveScrollBar} from 'react-remove-scroll-bar' import {RemoveScrollBar} from 'react-remove-scroll-bar'
@@ -6,11 +5,6 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {useA11y} from '#/state/a11y' import {useA11y} from '#/state/a11y'
import {useModals} from '#/state/modals' import {useModals} from '#/state/modals'
import {type ComposerOpts, useComposerState} from '#/state/shell/composer' import {type ComposerOpts, useComposerState} from '#/state/shell/composer'
import {
EmojiPicker,
type EmojiPickerPosition,
type EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker'
import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf' import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf'
import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' import {ComposePost, useComposerCancelRef} from '../com/composer/Composer'
@@ -41,25 +35,6 @@ function Inner({state}: {state: ComposerOpts}) {
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {reduceMotionEnabled} = useA11y() const {reduceMotionEnabled} = useA11y()
const [pickerState, setPickerState] = useState<EmojiPickerState>({
isOpen: false,
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
})
const onOpenPicker = useCallback((pos: EmojiPickerPosition | undefined) => {
if (!pos) return
setPickerState({
isOpen: true,
pos,
})
}, [])
const onClosePicker = useCallback(() => {
setPickerState(prev => ({
...prev,
isOpen: false,
}))
}, [])
FocusGuards.useFocusGuards() FocusGuards.useFocusGuards()
@@ -104,13 +79,11 @@ function Inner({state}: {state: ComposerOpts}) {
onPost={state.onPost} onPost={state.onPost}
onPostSuccess={state.onPostSuccess} onPostSuccess={state.onPostSuccess}
mention={state.mention} mention={state.mention}
openEmojiPicker={onOpenPicker}
text={state.text} text={state.text}
imageUris={state.imageUris} imageUris={state.imageUris}
openGallery={state.openGallery} openGallery={state.openGallery}
/> />
</View> </View>
<EmojiPicker state={pickerState} close={onClosePicker} />
</DismissableLayer.DismissableLayer> </DismissableLayer.DismissableLayer>
</FocusScope.FocusScope> </FocusScope.FocusScope>
) )