···11+import {type PickerProps, type RootProps, type TriggerProps} from './types'
22+33+export * from './types'
44+55+/**
66+ * Provides emoji picker context and wraps children in a {@link Menu.Root}.
77+ *
88+ * On emoji select, fires a `textInputWebEmitter` event (for web text inputs
99+ * that listen for emoji insertions) and forwards to the optional
1010+ * `onEmojiSelect` callback.
1111+ *
1212+ * @platform web
1313+ */
1414+export function Root(_props: RootProps): React.ReactNode {
1515+ throw new Error('EmojiPopup is not implemented on native')
1616+}
1717+1818+/**
1919+ * Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children
2020+ * pattern.
2121+ *
2222+ * @platform web
2323+ */
2424+export function Trigger(_props: TriggerProps): React.ReactNode {
2525+ throw new Error('EmojiPopup is not implemented on native')
2626+}
2727+2828+/**
2929+ * Renders the emoji picker inside a Radix `DropdownMenu.Portal`.
3030+ *
3131+ * Holding Shift while selecting an emoji keeps the picker open for
3232+ * multi-select. Otherwise the menu closes after each selection.
3333+ *
3434+ * Must be rendered inside a {@link Root}.
3535+ *
3636+ * @platform web
3737+ */
3838+export function Picker(_props: PickerProps): React.ReactNode {
3939+ throw new Error('EmojiPopup is not implemented on native')
4040+}
+150
src/components/EmojiPicker/index.web.tsx
···11+import {createContext, useContext, useEffect, useMemo, useRef} from 'react'
22+import EmojiPicker from '@emoji-mart/react'
33+import {DropdownMenu} from 'radix-ui'
44+55+import {useA11y} from '#/state/a11y'
66+import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
77+import {atoms as a, flatten} from '#/alf'
88+import * as Menu from '../Menu'
99+import {useWebPreloadEmoji} from './preload'
1010+import {
1111+ type Emoji,
1212+ type PickerProps,
1313+ type RootProps,
1414+ type TriggerProps,
1515+} from './types'
1616+1717+export * from './types'
1818+1919+const EmojiPickerContext = createContext<{
2020+ onEmojiSelect: (emoji: Emoji) => void
2121+ nextFocusRef: RootProps['nextFocusRef']
2222+} | null>(null)
2323+2424+/**
2525+ * Provides emoji picker context and wraps children in a {@link Menu.Root}.
2626+ *
2727+ * On emoji select, fires a `textInputWebEmitter` event (for web text inputs
2828+ * that listen for emoji insertions) and forwards to the optional
2929+ * `onEmojiSelect` callback.
3030+ *
3131+ * @platform web
3232+ */
3333+export function Root({
3434+ children,
3535+ control,
3636+ onEmojiSelect,
3737+ preloadOnMount = true,
3838+ nextFocusRef,
3939+}: RootProps) {
4040+ useWebPreloadEmoji({immediate: preloadOnMount})
4141+4242+ const value = useMemo(
4343+ () => ({
4444+ onEmojiSelect: (emoji: Emoji) => {
4545+ textInputWebEmitter.emit('emoji-inserted', emoji)
4646+4747+ if (onEmojiSelect) onEmojiSelect(emoji)
4848+ },
4949+ nextFocusRef,
5050+ }),
5151+ [onEmojiSelect, nextFocusRef],
5252+ )
5353+5454+ return (
5555+ <EmojiPickerContext value={value}>
5656+ <Menu.Root control={control}>{children}</Menu.Root>
5757+ </EmojiPickerContext>
5858+ )
5959+}
6060+6161+/**
6262+ * Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children
6363+ * pattern.
6464+ *
6565+ * @platform web
6666+ */
6767+export function Trigger(props: TriggerProps) {
6868+ return <Menu.Trigger {...props} />
6969+}
7070+7171+/**
7272+ * Renders the emoji picker inside a Radix `DropdownMenu.Portal`.
7373+ *
7474+ * Holding Shift while selecting an emoji keeps the picker open for
7575+ * multi-select. Otherwise the menu closes after each selection.
7676+ *
7777+ * Must be rendered inside a {@link Root}.
7878+ *
7979+ * @platform web
8080+ */
8181+export function Picker({keepOpenWhenShiftHeld = true}: PickerProps) {
8282+ const {onEmojiSelect, nextFocusRef} = useEmojiPickerContext()
8383+ const {control} = Menu.useMenuContext()
8484+ const {reduceMotionEnabled} = useA11y()
8585+ const isShiftDown = useRef(false)
8686+8787+ useEffect(() => {
8888+ const onKeyDown = (e: KeyboardEvent) => {
8989+ if (e.key === 'Shift') {
9090+ isShiftDown.current = true
9191+ }
9292+ }
9393+ const onKeyUp = (e: KeyboardEvent) => {
9494+ if (e.key === 'Shift') {
9595+ isShiftDown.current = false
9696+ }
9797+ }
9898+ window.addEventListener('keydown', onKeyDown, true)
9999+ window.addEventListener('keyup', onKeyUp, true)
100100+101101+ return () => {
102102+ window.removeEventListener('keydown', onKeyDown, true)
103103+ window.removeEventListener('keyup', onKeyUp, true)
104104+ }
105105+ }, [])
106106+107107+ return (
108108+ <DropdownMenu.Portal>
109109+ <DropdownMenu.Content
110110+ sideOffset={5}
111111+ collisionPadding={{left: 5, right: 5, bottom: 5}}
112112+ className="dropdown-menu-transform-origin dropdown-menu-constrain-size"
113113+ onCloseAutoFocus={evt => {
114114+ if (!nextFocusRef) return
115115+ let element =
116116+ nextFocusRef instanceof Function
117117+ ? nextFocusRef()
118118+ : nextFocusRef.current
119119+ if (element) {
120120+ evt.preventDefault()
121121+ element.focus()
122122+ }
123123+ }}>
124124+ <div
125125+ onWheel={evt => evt.stopPropagation()}
126126+ style={flatten([!reduceMotionEnabled && a.zoom_fade_in])}>
127127+ <EmojiPicker
128128+ autoFocus
129129+ onEmojiSelect={(emoji: Emoji) => {
130130+ onEmojiSelect(emoji)
131131+132132+ if (!keepOpenWhenShiftHeld || !isShiftDown.current) {
133133+ control.close()
134134+ }
135135+ }}
136136+ />
137137+ </div>
138138+ </DropdownMenu.Content>
139139+ </DropdownMenu.Portal>
140140+ )
141141+}
142142+143143+function useEmojiPickerContext() {
144144+ const ctx = useContext(EmojiPickerContext)
145145+ if (!ctx)
146146+ throw new Error(
147147+ 'EmojiPicker.Picker must be used within an EmojiPicker.Root component',
148148+ )
149149+ return ctx
150150+}
+7
src/components/EmojiPicker/preload.ts
···11+/**
22+ * Native no-op. Emoji data preloading is only needed on web where the picker
33+ * uses `emoji-mart`.
44+ */
55+export function useWebPreloadEmoji({}: {immediate?: boolean} = {}) {
66+ return () => Promise.resolve()
77+}
+30
src/components/EmojiPicker/preload.web.ts
···11+import {useCallback} from 'react'
22+import {init} from 'emoji-mart'
33+44+/**
55+ * Only load the emoji picker data once per page load.
66+ */
77+let loadRequested = false
88+99+/**
1010+ * Preloads emoji-mart data so the picker renders instantly when opened.
1111+ *
1212+ * Returns a function that can be called manually to trigger preloading (e.g.
1313+ * on hover). When `immediate` is `true`, preloading starts on mount.
1414+ *
1515+ * Data is only fetched once per page load — subsequent calls are no-ops.
1616+ *
1717+ * @see {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194 | emoji-mart preloading docs}
1818+ */
1919+export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) {
2020+ const preload = useCallback(async () => {
2121+ if (loadRequested) return
2222+ loadRequested = true
2323+ try {
2424+ const data = (await import('@emoji-mart/data')).default
2525+ init({data})
2626+ } catch (e) {}
2727+ }, [])
2828+ if (immediate) preload()
2929+ return preload
3030+}
+65
src/components/EmojiPicker/types.ts
···11+import {type DialogControlProps} from '../Dialog'
22+import {type TriggerProps as MenuTriggerProps} from '../Menu/types'
33+44+/**
55+ * Represents an emoji selected from the picker. Sourced from the `emoji-mart`
66+ * library's selection data.
77+ */
88+export type Emoji = {
99+ aliases?: string[]
1010+ emoticons: string[]
1111+ id: string
1212+ keywords: string[]
1313+ name: string
1414+ /** The native unicode character for the emoji, e.g. "😀" */
1515+ native: string
1616+ shortcodes?: string
1717+ /** The unicode codepoint, e.g. "1f600" */
1818+ unified: string
1919+ /** Skin tone variant (1–6), if applicable */
2020+ skin?: number
2121+}
2222+2323+type FocusableElement = {focus: () => void}
2424+2525+export interface RootProps {
2626+ children: React.ReactNode
2727+ control?: DialogControlProps
2828+ /**
2929+ * Called when the user selects an emoji. On web this fires in addition to
3030+ * the `textInputWebEmitter` event, so callers that only need the text
3131+ * insertion can omit this.
3232+ */
3333+ onEmojiSelect?: (emoji: Emoji) => void
3434+ /**
3535+ * When `true` (default), preloads emoji data as soon as the component
3636+ * mounts so the picker opens instantly. Set to `false` to defer loading
3737+ * until the picker is actually opened.
3838+ */
3939+ preloadOnMount?: boolean
4040+ /**
4141+ * Element to return focus to when the picker closes. Accepts either a ref
4242+ * or a getter function.
4343+ */
4444+ nextFocusRef?:
4545+ | React.RefObject<FocusableElement | null>
4646+ | (() => FocusableElement | null | undefined)
4747+}
4848+4949+/**
5050+ * Props for the trigger button that opens the emoji picker. Extends
5151+ * {@link MenuTriggerProps} — accepts the same render-prop children pattern.
5252+ */
5353+export interface TriggerProps extends MenuTriggerProps {}
5454+5555+/**
5656+ * Props for the picker panel itself.
5757+ */
5858+export interface PickerProps {
5959+ /**
6060+ * When `true`, the picker will remain open after selecting an emoji when the Shift key is held down.
6161+ *
6262+ * @default true
6363+ */
6464+ keepOpenWhenShiftHeld?: boolean
6565+}
+10-29
src/components/dms/EmojiReactionPicker.web.tsx
···11import {useState} from 'react'
22import {Pressable, View} from 'react-native'
33import {type ChatBskyConvoDefs} from '@atproto/api'
44-import EmojiPicker from '@emoji-mart/react'
55-import {msg} from '@lingui/core/macro'
66-import {useLingui} from '@lingui/react'
44+import {useLingui} from '@lingui/react/macro'
75import {DropdownMenu} from 'radix-ui'
8697import {useSession} from '#/state/session'
1010-import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker'
1111-import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji'
128import {atoms as a, flatten, useTheme} from '#/alf'
99+import * as EmojiPicker from '#/components/EmojiPicker'
1310import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid'
1411import * as Menu from '#/components/Menu'
1515-import {type TriggerProps} from '#/components/Menu/types'
1612import {Text} from '#/components/Typography'
1713import {hasAlreadyReacted, hasReachedReactionLimit} from './util'
1814···2218 onEmojiSelect,
2319}: {
2420 message: ChatBskyConvoDefs.MessageView
2525- children?: TriggerProps['children']
2121+ children?: EmojiPicker.TriggerProps['children']
2622 onEmojiSelect: (emoji: string) => void
2723}) {
2824 if (!children)
2925 throw new Error('EmojiReactionPicker requires the children prop on web')
30263131- const {_} = useLingui()
2727+ const {t: l} = useLingui()
32283329 return (
3434- <Menu.Root>
3535- <Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger>
3030+ <EmojiPicker.Root onEmojiSelect={emoji => onEmojiSelect(emoji.native)}>
3131+ <EmojiPicker.Trigger label={l`Add emoji reaction`}>
3232+ {children}
3333+ </EmojiPicker.Trigger>
3634 <MenuInner message={message} onEmojiSelect={onEmojiSelect} />
3737- </Menu.Root>
3535+ </EmojiPicker.Root>
3836 )
3937}
4038···4947 const {control} = Menu.useMenuContext()
5048 const {currentAccount} = useSession()
51495252- useWebPreloadEmoji({immediate: true})
5353-5450 const [expanded, setExpanded] = useState(false)
55515652 const [prevOpen, setPrevOpen] = useState(control.isOpen)
···6258 }
6359 }
64606565- const handleEmojiPickerResponse = (emoji: Emoji) => {
6666- handleEmojiSelect(emoji.native)
6767- }
6868-6961 const handleEmojiSelect = (emoji: string) => {
7062 control.close()
7163 onEmojiSelect(emoji)
···7466 const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
75677668 return expanded ? (
7777- <DropdownMenu.Portal>
7878- <DropdownMenu.Content
7979- sideOffset={5}
8080- collisionPadding={{left: 5, right: 5, bottom: 5}}>
8181- <div onWheel={evt => evt.stopPropagation()}>
8282- <EmojiPicker
8383- onEmojiSelect={handleEmojiPickerResponse}
8484- autoFocus={true}
8585- />
8686- </div>
8787- </DropdownMenu.Content>
8888- </DropdownMenu.Portal>
6969+ <EmojiPicker.Picker keepOpenWhenShiftHeld={false} />
8970 ) : (
9071 <Menu.Outer style={[a.rounded_full]}>
9172 <View style={[a.flex_row, a.gap_xs]}>
···3232import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
3333import {atoms as a, useAlf} from '#/alf'
3434import {normalizeTextStyles} from '#/alf/typography'
3535+import {type Emoji} from '#/components/EmojiPicker'
3536import {Portal} from '#/components/Portal'
3637import {Text} from '#/components/Typography'
3738import {type TextInputProps} from './TextInput.types'
3839import {type AutocompleteRef, createSuggestion} from './web/Autocomplete'
3939-import {type Emoji} from './web/EmojiPicker'
4040import {LinkDecorator} from './web/LinkDecorator'
4141import {TagDecorator} from './web/TagDecorator'
4242