Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
119
fork

Configure Feed

Select the types of activity you want to include in your feed.

✨ `EmojiPicker` component (#10249)

authored by

Samuel Newman and committed by
GitHub
a97b15b2 8f56fca8

+419 -501
+40
src/components/EmojiPicker/index.tsx
··· 1 + import {type PickerProps, type RootProps, type TriggerProps} from './types' 2 + 3 + export * from './types' 4 + 5 + /** 6 + * Provides emoji picker context and wraps children in a {@link Menu.Root}. 7 + * 8 + * On emoji select, fires a `textInputWebEmitter` event (for web text inputs 9 + * that listen for emoji insertions) and forwards to the optional 10 + * `onEmojiSelect` callback. 11 + * 12 + * @platform web 13 + */ 14 + export function Root(_props: RootProps): React.ReactNode { 15 + throw new Error('EmojiPopup is not implemented on native') 16 + } 17 + 18 + /** 19 + * Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children 20 + * pattern. 21 + * 22 + * @platform web 23 + */ 24 + export function Trigger(_props: TriggerProps): React.ReactNode { 25 + throw new Error('EmojiPopup is not implemented on native') 26 + } 27 + 28 + /** 29 + * Renders the emoji picker inside a Radix `DropdownMenu.Portal`. 30 + * 31 + * Holding Shift while selecting an emoji keeps the picker open for 32 + * multi-select. Otherwise the menu closes after each selection. 33 + * 34 + * Must be rendered inside a {@link Root}. 35 + * 36 + * @platform web 37 + */ 38 + export function Picker(_props: PickerProps): React.ReactNode { 39 + throw new Error('EmojiPopup is not implemented on native') 40 + }
+150
src/components/EmojiPicker/index.web.tsx
··· 1 + import {createContext, useContext, useEffect, useMemo, useRef} from 'react' 2 + import EmojiPicker from '@emoji-mart/react' 3 + import {DropdownMenu} from 'radix-ui' 4 + 5 + import {useA11y} from '#/state/a11y' 6 + import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' 7 + import {atoms as a, flatten} from '#/alf' 8 + import * as Menu from '../Menu' 9 + import {useWebPreloadEmoji} from './preload' 10 + import { 11 + type Emoji, 12 + type PickerProps, 13 + type RootProps, 14 + type TriggerProps, 15 + } from './types' 16 + 17 + export * from './types' 18 + 19 + const EmojiPickerContext = createContext<{ 20 + onEmojiSelect: (emoji: Emoji) => void 21 + nextFocusRef: RootProps['nextFocusRef'] 22 + } | null>(null) 23 + 24 + /** 25 + * Provides emoji picker context and wraps children in a {@link Menu.Root}. 26 + * 27 + * On emoji select, fires a `textInputWebEmitter` event (for web text inputs 28 + * that listen for emoji insertions) and forwards to the optional 29 + * `onEmojiSelect` callback. 30 + * 31 + * @platform web 32 + */ 33 + export function Root({ 34 + children, 35 + control, 36 + onEmojiSelect, 37 + preloadOnMount = true, 38 + nextFocusRef, 39 + }: RootProps) { 40 + useWebPreloadEmoji({immediate: preloadOnMount}) 41 + 42 + const value = useMemo( 43 + () => ({ 44 + onEmojiSelect: (emoji: Emoji) => { 45 + textInputWebEmitter.emit('emoji-inserted', emoji) 46 + 47 + if (onEmojiSelect) onEmojiSelect(emoji) 48 + }, 49 + nextFocusRef, 50 + }), 51 + [onEmojiSelect, nextFocusRef], 52 + ) 53 + 54 + return ( 55 + <EmojiPickerContext value={value}> 56 + <Menu.Root control={control}>{children}</Menu.Root> 57 + </EmojiPickerContext> 58 + ) 59 + } 60 + 61 + /** 62 + * Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children 63 + * pattern. 64 + * 65 + * @platform web 66 + */ 67 + export function Trigger(props: TriggerProps) { 68 + return <Menu.Trigger {...props} /> 69 + } 70 + 71 + /** 72 + * Renders the emoji picker inside a Radix `DropdownMenu.Portal`. 73 + * 74 + * Holding Shift while selecting an emoji keeps the picker open for 75 + * multi-select. Otherwise the menu closes after each selection. 76 + * 77 + * Must be rendered inside a {@link Root}. 78 + * 79 + * @platform web 80 + */ 81 + export function Picker({keepOpenWhenShiftHeld = true}: PickerProps) { 82 + const {onEmojiSelect, nextFocusRef} = useEmojiPickerContext() 83 + const {control} = Menu.useMenuContext() 84 + const {reduceMotionEnabled} = useA11y() 85 + const isShiftDown = useRef(false) 86 + 87 + useEffect(() => { 88 + const onKeyDown = (e: KeyboardEvent) => { 89 + if (e.key === 'Shift') { 90 + isShiftDown.current = true 91 + } 92 + } 93 + const onKeyUp = (e: KeyboardEvent) => { 94 + if (e.key === 'Shift') { 95 + isShiftDown.current = false 96 + } 97 + } 98 + window.addEventListener('keydown', onKeyDown, true) 99 + window.addEventListener('keyup', onKeyUp, true) 100 + 101 + return () => { 102 + window.removeEventListener('keydown', onKeyDown, true) 103 + window.removeEventListener('keyup', onKeyUp, true) 104 + } 105 + }, []) 106 + 107 + return ( 108 + <DropdownMenu.Portal> 109 + <DropdownMenu.Content 110 + sideOffset={5} 111 + collisionPadding={{left: 5, right: 5, bottom: 5}} 112 + className="dropdown-menu-transform-origin dropdown-menu-constrain-size" 113 + onCloseAutoFocus={evt => { 114 + if (!nextFocusRef) return 115 + let element = 116 + nextFocusRef instanceof Function 117 + ? nextFocusRef() 118 + : nextFocusRef.current 119 + if (element) { 120 + evt.preventDefault() 121 + element.focus() 122 + } 123 + }}> 124 + <div 125 + onWheel={evt => evt.stopPropagation()} 126 + style={flatten([!reduceMotionEnabled && a.zoom_fade_in])}> 127 + <EmojiPicker 128 + autoFocus 129 + onEmojiSelect={(emoji: Emoji) => { 130 + onEmojiSelect(emoji) 131 + 132 + if (!keepOpenWhenShiftHeld || !isShiftDown.current) { 133 + control.close() 134 + } 135 + }} 136 + /> 137 + </div> 138 + </DropdownMenu.Content> 139 + </DropdownMenu.Portal> 140 + ) 141 + } 142 + 143 + function useEmojiPickerContext() { 144 + const ctx = useContext(EmojiPickerContext) 145 + if (!ctx) 146 + throw new Error( 147 + 'EmojiPicker.Picker must be used within an EmojiPicker.Root component', 148 + ) 149 + return ctx 150 + }
+7
src/components/EmojiPicker/preload.ts
··· 1 + /** 2 + * Native no-op. Emoji data preloading is only needed on web where the picker 3 + * uses `emoji-mart`. 4 + */ 5 + export function useWebPreloadEmoji({}: {immediate?: boolean} = {}) { 6 + return () => Promise.resolve() 7 + }
+30
src/components/EmojiPicker/preload.web.ts
··· 1 + import {useCallback} from 'react' 2 + import {init} from 'emoji-mart' 3 + 4 + /** 5 + * Only load the emoji picker data once per page load. 6 + */ 7 + let loadRequested = false 8 + 9 + /** 10 + * Preloads emoji-mart data so the picker renders instantly when opened. 11 + * 12 + * Returns a function that can be called manually to trigger preloading (e.g. 13 + * on hover). When `immediate` is `true`, preloading starts on mount. 14 + * 15 + * Data is only fetched once per page load — subsequent calls are no-ops. 16 + * 17 + * @see {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194 | emoji-mart preloading docs} 18 + */ 19 + export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) { 20 + const preload = useCallback(async () => { 21 + if (loadRequested) return 22 + loadRequested = true 23 + try { 24 + const data = (await import('@emoji-mart/data')).default 25 + init({data}) 26 + } catch (e) {} 27 + }, []) 28 + if (immediate) preload() 29 + return preload 30 + }
+65
src/components/EmojiPicker/types.ts
··· 1 + import {type DialogControlProps} from '../Dialog' 2 + import {type TriggerProps as MenuTriggerProps} from '../Menu/types' 3 + 4 + /** 5 + * Represents an emoji selected from the picker. Sourced from the `emoji-mart` 6 + * library's selection data. 7 + */ 8 + export type Emoji = { 9 + aliases?: string[] 10 + emoticons: string[] 11 + id: string 12 + keywords: string[] 13 + name: string 14 + /** The native unicode character for the emoji, e.g. "😀" */ 15 + native: string 16 + shortcodes?: string 17 + /** The unicode codepoint, e.g. "1f600" */ 18 + unified: string 19 + /** Skin tone variant (1–6), if applicable */ 20 + skin?: number 21 + } 22 + 23 + type FocusableElement = {focus: () => void} 24 + 25 + export interface RootProps { 26 + children: React.ReactNode 27 + control?: DialogControlProps 28 + /** 29 + * Called when the user selects an emoji. On web this fires in addition to 30 + * the `textInputWebEmitter` event, so callers that only need the text 31 + * insertion can omit this. 32 + */ 33 + onEmojiSelect?: (emoji: Emoji) => void 34 + /** 35 + * When `true` (default), preloads emoji data as soon as the component 36 + * mounts so the picker opens instantly. Set to `false` to defer loading 37 + * until the picker is actually opened. 38 + */ 39 + preloadOnMount?: boolean 40 + /** 41 + * Element to return focus to when the picker closes. Accepts either a ref 42 + * or a getter function. 43 + */ 44 + nextFocusRef?: 45 + | React.RefObject<FocusableElement | null> 46 + | (() => FocusableElement | null | undefined) 47 + } 48 + 49 + /** 50 + * Props for the trigger button that opens the emoji picker. Extends 51 + * {@link MenuTriggerProps} — accepts the same render-prop children pattern. 52 + */ 53 + export interface TriggerProps extends MenuTriggerProps {} 54 + 55 + /** 56 + * Props for the picker panel itself. 57 + */ 58 + export interface PickerProps { 59 + /** 60 + * When `true`, the picker will remain open after selecting an emoji when the Shift key is held down. 61 + * 62 + * @default true 63 + */ 64 + keepOpenWhenShiftHeld?: boolean 65 + }
+10 -29
src/components/dms/EmojiReactionPicker.web.tsx
··· 1 1 import {useState} from 'react' 2 2 import {Pressable, View} from 'react-native' 3 3 import {type ChatBskyConvoDefs} from '@atproto/api' 4 - import EmojiPicker from '@emoji-mart/react' 5 - import {msg} from '@lingui/core/macro' 6 - import {useLingui} from '@lingui/react' 4 + import {useLingui} from '@lingui/react/macro' 7 5 import {DropdownMenu} from 'radix-ui' 8 6 9 7 import {useSession} from '#/state/session' 10 - import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker' 11 - import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji' 12 8 import {atoms as a, flatten, useTheme} from '#/alf' 9 + import * as EmojiPicker from '#/components/EmojiPicker' 13 10 import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' 14 11 import * as Menu from '#/components/Menu' 15 - import {type TriggerProps} from '#/components/Menu/types' 16 12 import {Text} from '#/components/Typography' 17 13 import {hasAlreadyReacted, hasReachedReactionLimit} from './util' 18 14 ··· 22 18 onEmojiSelect, 23 19 }: { 24 20 message: ChatBskyConvoDefs.MessageView 25 - children?: TriggerProps['children'] 21 + children?: EmojiPicker.TriggerProps['children'] 26 22 onEmojiSelect: (emoji: string) => void 27 23 }) { 28 24 if (!children) 29 25 throw new Error('EmojiReactionPicker requires the children prop on web') 30 26 31 - const {_} = useLingui() 27 + const {t: l} = useLingui() 32 28 33 29 return ( 34 - <Menu.Root> 35 - <Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger> 30 + <EmojiPicker.Root onEmojiSelect={emoji => onEmojiSelect(emoji.native)}> 31 + <EmojiPicker.Trigger label={l`Add emoji reaction`}> 32 + {children} 33 + </EmojiPicker.Trigger> 36 34 <MenuInner message={message} onEmojiSelect={onEmojiSelect} /> 37 - </Menu.Root> 35 + </EmojiPicker.Root> 38 36 ) 39 37 } 40 38 ··· 49 47 const {control} = Menu.useMenuContext() 50 48 const {currentAccount} = useSession() 51 49 52 - useWebPreloadEmoji({immediate: true}) 53 - 54 50 const [expanded, setExpanded] = useState(false) 55 51 56 52 const [prevOpen, setPrevOpen] = useState(control.isOpen) ··· 62 58 } 63 59 } 64 60 65 - const handleEmojiPickerResponse = (emoji: Emoji) => { 66 - handleEmojiSelect(emoji.native) 67 - } 68 - 69 61 const handleEmojiSelect = (emoji: string) => { 70 62 control.close() 71 63 onEmojiSelect(emoji) ··· 74 66 const limitReacted = hasReachedReactionLimit(message, currentAccount?.did) 75 67 76 68 return expanded ? ( 77 - <DropdownMenu.Portal> 78 - <DropdownMenu.Content 79 - sideOffset={5} 80 - collisionPadding={{left: 5, right: 5, bottom: 5}}> 81 - <div onWheel={evt => evt.stopPropagation()}> 82 - <EmojiPicker 83 - onEmojiSelect={handleEmojiPickerResponse} 84 - autoFocus={true} 85 - /> 86 - </div> 87 - </DropdownMenu.Content> 88 - </DropdownMenu.Portal> 69 + <EmojiPicker.Picker keepOpenWhenShiftHeld={false} /> 89 70 ) : ( 90 71 <Menu.Outer style={[a.rounded_full]}> 91 72 <View style={[a.flex_row, a.gap_xs]}>
+42 -80
src/screens/Messages/components/MessageComposer.tsx
··· 1 - import {useEffect, useState} from 'react' 1 + import {useState} from 'react' 2 2 import {Pressable, View} from 'react-native' 3 3 import { 4 4 useKeyboardHandler, ··· 25 25 useMessageDraft, 26 26 useSaveMessageDraft, 27 27 } from '#/state/messages/message-drafts' 28 - import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' 29 - import { 30 - type Emoji, 31 - EmojiPicker, 32 - type EmojiPickerState, 33 - } from '#/view/com/composer/text-input/web/EmojiPicker' 34 28 import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf' 35 29 import {Composer, useComposerInternalApiRef} from '#/components/Composer' 30 + import * as EmojiPicker from '#/components/EmojiPicker' 36 31 import {GlassView} from '#/components/GlassView' 37 32 import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' 38 33 import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' ··· 60 55 const {needsEmailVerification} = useEmail() 61 56 const editable = !needsEmailVerification 62 57 const {getDraft, clearDraft} = useMessageDraft() 63 - const [emojiPickerState, setEmojiPickerState] = useState<EmojiPickerState>({ 64 - isOpen: false, 65 - pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, 66 - }) 67 58 const composerInternalApiRef = useComposerInternalApiRef() 68 59 69 60 const [text, setText] = useState(getDraft) ··· 85 76 86 77 const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0) 87 78 88 - const openEmojiPicker = (pos: any) => { 89 - setEmojiPickerState({isOpen: true, pos}) 90 - } 91 - 92 79 const onSubmit = () => { 93 80 if (!editable) return 94 81 if (!hasEmbed && text.trim() === '') return ··· 112 99 } 113 100 } 114 101 115 - useEffect(() => { 116 - function onEmojiInserted(emoji: Emoji) { 117 - composerInternalApiRef.current?.insert(emoji.native) 118 - } 119 - textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) 120 - return () => { 121 - textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) 122 - } 123 - }, [composerInternalApiRef]) 124 - 125 102 return ( 126 103 <ComposerContainer> 127 104 {children} ··· 142 119 tintColor={t.palette.contrast_50} 143 120 fallbackStyle={[t.atoms.bg_contrast_50]}> 144 121 {IS_WEB && ( 145 - <Pressable 146 - onPress={e => { 147 - e.currentTarget.measure( 148 - (_fx, _fy, _width, _height, px, py) => { 149 - // TODO: rip this horrible system out 150 - openEmojiPicker?.({ 151 - top: py, 152 - left: px - 400, 153 - right: px - 400, 154 - bottom: py, 155 - nextFocusRef: { 156 - current: 157 - composerInternalApiRef.current?.input?.element, 122 + <EmojiPicker.Root 123 + onEmojiSelect={emoji => 124 + composerInternalApiRef.current?.insert(emoji.native) 125 + } 126 + nextFocusRef={() => 127 + composerInternalApiRef.current?.input?.element 128 + }> 129 + <EmojiPicker.Trigger label={l`Open emoji picker`}> 130 + {({props, state, control}) => ( 131 + <Pressable 132 + {...props} 133 + style={[ 134 + a.overflow_hidden, 135 + a.absolute, 136 + a.rounded_full, 137 + a.align_center, 138 + a.justify_center, 139 + a.z_30, 140 + { 141 + height: 20, 142 + width: 20, 143 + top: 10, 144 + right: 10, 158 145 }, 159 - }) 160 - }, 161 - ) 162 - }} 163 - style={[ 164 - a.overflow_hidden, 165 - a.absolute, 166 - a.rounded_full, 167 - a.align_center, 168 - a.justify_center, 169 - a.z_30, 170 - { 171 - height: 20, 172 - width: 20, 173 - top: 10, 174 - right: 10, 175 - }, 176 - ]} 177 - accessibilityLabel={l`Open emoji picker`} 178 - accessibilityHint=""> 179 - {state => ( 180 - <EmojiSmileIcon 181 - size="md" 182 - style={ 183 - state.hovered || 184 - state.focused || 185 - state.pressed || 186 - emojiPickerState.isOpen 187 - ? {color: t.palette.primary_500} 188 - : t.atoms.text_contrast_high 189 - } 190 - /> 191 - )} 192 - </Pressable> 146 + ]}> 147 + <EmojiSmileIcon 148 + size="md" 149 + style={ 150 + state.hovered || 151 + state.focused || 152 + state.pressed || 153 + control.isOpen 154 + ? {color: t.palette.primary_500} 155 + : t.atoms.text_contrast_high 156 + } 157 + /> 158 + </Pressable> 159 + )} 160 + </EmojiPicker.Trigger> 161 + <EmojiPicker.Picker /> 162 + </EmojiPicker.Root> 193 163 )} 194 164 195 165 <Composer ··· 226 196 <SubmitButton onPress={onSubmit} disabled={submitDisabled} /> 227 197 </GlassContainer> 228 198 </View> 229 - 230 - {IS_WEB && ( 231 - <EmojiPicker 232 - pinToTop 233 - state={emojiPickerState} 234 - close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))} 235 - /> 236 - )} 237 199 </ComposerContainer> 238 200 ) 239 201 }
-2
src/screens/Messages/components/MessageInput.tsx
··· 25 25 useMessageDraft, 26 26 useSaveMessageDraft, 27 27 } from '#/state/messages/message-drafts' 28 - import {type EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker' 29 28 import {atoms as a, platform, tokens, useTheme} from '#/alf' 30 29 import {GlassView} from '#/components/GlassView' 31 30 import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' ··· 50 49 hasEmbed: boolean 51 50 setEmbed: (embedUrl: string | undefined) => void 52 51 children?: React.ReactNode 53 - openEmojiPicker?: (pos: EmojiPickerPosition) => void 54 52 }) { 55 53 const {t: l} = useLingui() 56 54 const t = useTheme()
+42 -59
src/screens/Messages/components/MessageInput.web.tsx
··· 1 - import {useCallback, useEffect, useRef, useState} from 'react' 1 + import {useCallback, useRef, useState} from 'react' 2 2 import {Pressable, View} from 'react-native' 3 3 import {useLingui} from '@lingui/react/macro' 4 4 import {flushSync} from 'react-dom' ··· 11 11 useMessageDraft, 12 12 useSaveMessageDraft, 13 13 } from '#/state/messages/message-drafts' 14 - import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' 15 - import { 16 - type Emoji, 17 - type EmojiPickerPosition, 18 - } from '#/view/com/composer/text-input/web/EmojiPicker' 19 14 import {atoms as a, flatten, useTheme} from '#/alf' 20 15 import {Button} from '#/components/Button' 16 + import * as EmojiPicker from '#/components/EmojiPicker' 21 17 import {useSharedInputStyles} from '#/components/forms/TextField' 22 18 import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' 23 19 import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' ··· 30 26 hasEmbed, 31 27 setEmbed, 32 28 children, 33 - openEmojiPicker, 34 29 }: { 35 30 onSendMessage: (message: string) => void 36 31 hasEmbed: boolean 37 32 setEmbed: (embedUrl: string | undefined) => void 38 33 children?: React.ReactNode 39 - openEmojiPicker?: (pos: EmojiPickerPosition) => void 40 34 }) { 41 35 const {isMobile} = useWebMediaQueries() 42 36 const {t: l} = useLingui() ··· 104 98 }, []) 105 99 106 100 const onEmojiInserted = useCallback( 107 - (emoji: Emoji) => { 101 + (emoji: EmojiPicker.Emoji) => { 108 102 if (!textAreaRef.current) { 109 103 return 110 104 } 111 105 const position = textAreaRef.current.selectionStart ?? 0 112 - textAreaRef.current.focus() 113 106 flushSync(() => { 114 107 setMessage( 115 108 message => ··· 121 114 }, 122 115 [setMessage], 123 116 ) 124 - useEffect(() => { 125 - textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) 126 - return () => { 127 - textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) 128 - } 129 - }, [onEmojiInserted]) 130 117 131 118 useSaveMessageDraft(message) 132 119 useExtractEmbedFromFacets(message, setEmbed) ··· 152 139 // @ts-expect-error web only 153 140 onMouseEnter={() => setIsHovered(true)} 154 141 onMouseLeave={() => setIsHovered(false)}> 155 - <Button 156 - onPress={e => { 157 - e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => { 158 - openEmojiPicker?.({ 159 - top: py, 160 - left: px, 161 - right: px, 162 - bottom: py, 163 - nextFocusRef: 164 - textAreaRef as unknown as React.MutableRefObject<HTMLElement>, 165 - }) 166 - }) 167 - }} 168 - style={[ 169 - a.rounded_full, 170 - a.overflow_hidden, 171 - a.align_center, 172 - a.justify_center, 173 - { 174 - marginTop: 5, 175 - height: 30, 176 - width: 30, 177 - }, 178 - ]} 179 - label={l`Open emoji picker`}> 180 - {state => ( 181 - <View 182 - style={[ 183 - a.absolute, 184 - a.inset_0, 185 - a.align_center, 186 - a.justify_center, 187 - { 188 - backgroundColor: 189 - state.hovered || state.focused || state.pressed 190 - ? t.atoms.bg.backgroundColor 191 - : undefined, 192 - }, 193 - ]}> 194 - <EmojiSmile size="lg" /> 195 - </View> 196 - )} 197 - </Button> 142 + <EmojiPicker.Root 143 + onEmojiSelect={onEmojiInserted} 144 + nextFocusRef={textAreaRef}> 145 + <EmojiPicker.Trigger label={l`Open emoji picker`}> 146 + {({props, state}) => ( 147 + <Button 148 + style={[ 149 + a.rounded_full, 150 + a.overflow_hidden, 151 + a.align_center, 152 + a.justify_center, 153 + { 154 + marginTop: 5, 155 + height: 30, 156 + width: 30, 157 + }, 158 + ]} 159 + label={props.accessibilityLabel} 160 + {...props}> 161 + <View 162 + style={[ 163 + a.absolute, 164 + a.inset_0, 165 + a.align_center, 166 + a.justify_center, 167 + { 168 + backgroundColor: 169 + state.hovered || state.focused || state.pressed 170 + ? t.atoms.bg.backgroundColor 171 + : undefined, 172 + }, 173 + ]}> 174 + <EmojiSmile size="lg" /> 175 + </View> 176 + </Button> 177 + )} 178 + </EmojiPicker.Trigger> 179 + <EmojiPicker.Picker /> 180 + </EmojiPicker.Root> 198 181 <TextareaAutosize 199 182 ref={textAreaRef} 200 183 style={flatten([
+1 -23
src/screens/Messages/components/MessagesList.tsx
··· 49 49 } from '#/state/messages/convo/types' 50 50 import {useGetPost} from '#/state/queries/post' 51 51 import {useAgent} from '#/state/session' 52 - import { 53 - EmojiPicker, 54 - type EmojiPickerState, 55 - } from '#/view/com/composer/text-input/web/EmojiPicker' 56 52 import {List, type ListMethods} from '#/view/com/util/List' 57 53 import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled' 58 54 import {MessageComposer} from '#/screens/Messages/components/MessageComposer' ··· 122 118 const [newMessagesPill, setNewMessagesPill] = useState({ 123 119 show: false, 124 120 startContentOffset: 0, 125 - }) 126 - 127 - const [emojiPickerState, setEmojiPickerState] = useState<EmojiPickerState>({ 128 - isOpen: false, 129 - pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, 130 121 }) 131 122 132 123 const inputHeightUI = useSharedValue(0) ··· 382 373 }) 383 374 }, [flatListRef]) 384 375 385 - const onOpenEmojiPicker = useCallback((pos: any) => { 386 - setEmojiPickerState({isOpen: true, pos}) 387 - }, []) 388 - 389 376 const renderItem = ({item}: {item: ConvoItem}) => { 390 377 if (item.type === 'message' || item.type === 'pending-message') { 391 378 return ( ··· 525 512 textInputId={textInputId} 526 513 onSendMessage={onSendMessage} 527 514 hasEmbed={!!embedUri} 528 - setEmbed={setEmbed} 529 - openEmojiPicker={onOpenEmojiPicker}> 515 + setEmbed={setEmbed}> 530 516 <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> 531 517 </MessageInput> 532 518 )} ··· 534 520 )} 535 521 </KeyboardStickyView> 536 522 </KeyboardGestureArea> 537 - 538 - {IS_WEB && ( 539 - <EmojiPicker 540 - pinToTop 541 - state={emojiPickerState} 542 - close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))} 543 - /> 544 - )} 545 523 546 524 {newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />} 547 525 </DateDividerToggleProvider>
-2
src/state/shell/composer/index.tsx
··· 17 17 RQKEY_GIF_ROOT, 18 18 RQKEY_LINK_ROOT, 19 19 } from '#/state/queries/resolve-link' 20 - import {type EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker' 21 20 import * as Toast from '#/components/Toast' 22 21 23 22 export interface ComposerOptsPostRef { ··· 51 50 onPostSuccess?: (data: OnPostSuccessData) => void 52 51 quote?: AppBskyFeedDefs.PostView 53 52 mention?: string // handle of user to mention 54 - openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void 55 53 text?: string 56 54 imageUris?: {uri: string; width: number; height: number; altText?: string}[] 57 55 videoUri?: {uri: string; width: number; height: number}
+30 -36
src/view/com/composer/Composer.tsx
··· 72 72 } from '#/lib/constants' 73 73 import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 74 74 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 75 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 76 75 import {mimeToExt} from '#/lib/media/video/util' 77 76 import {useCallOnce} from '#/lib/once' 78 77 import {type NavigationProp} from '#/lib/routes/types' ··· 122 121 import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' 123 122 import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' 124 123 import {UserAvatar} from '#/view/com/util/UserAvatar' 125 - import {atoms as a, native, useTheme, web} from '#/alf' 124 + import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf' 126 125 import {Admonition} from '#/components/Admonition' 127 126 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 127 + import * as EmojiPicker from '#/components/EmojiPicker' 128 128 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' 129 129 import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' 130 130 import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' ··· 185 185 onPostSuccess, 186 186 quote: initQuote, 187 187 mention: initMention, 188 - openEmojiPicker, 189 188 text: initText, 190 189 imageUris: initImageUris, 191 190 videoUri: initVideoUri, ··· 206 205 const requireAltTextEnabled = useRequireAltTextEnabled() 207 206 const langPrefs = useLanguagePrefs() 208 207 const setLangPrefs = useLanguagePrefsApi() 209 - const textInput = useRef<TextInputRef>(null) 208 + const textInputRef = useRef<TextInputRef>(null) 210 209 const discardPromptControl = Prompt.usePromptControl() 211 210 const {mutateAsync: saveDraft, isPending: _isSavingDraft} = 212 211 useSaveDraftMutation() ··· 708 707 ) 709 708 710 709 const onPressCancel = useCallback(() => { 711 - if (textInput.current?.maybeClosePopup()) { 710 + if (textInputRef.current?.maybeClosePopup()) { 712 711 return 713 712 } 714 713 ··· 1064 1063 } 1065 1064 } 1066 1065 1067 - const onEmojiButtonPress = useCallback(() => { 1068 - const rect = textInput.current?.getCursorPosition() 1069 - if (rect) { 1070 - openEmojiPicker?.({ 1071 - ...rect, 1072 - nextFocusRef: 1073 - textInput as unknown as React.MutableRefObject<HTMLElement>, 1074 - }) 1075 - } 1076 - }, [openEmojiPicker]) 1077 - 1078 1066 const scrollViewRef = useAnimatedRef<Animated.ScrollView>() 1079 1067 useEffect(() => { 1080 1068 if (composerState.mutableNeedsFocusActive) { ··· 1082 1070 // On Android, this risks getting the cursor stuck behind the keyboard. 1083 1071 // Not worth it. 1084 1072 if (!IS_ANDROID) { 1085 - textInput.current?.focus() 1073 + textInputRef.current?.focus() 1086 1074 } 1087 1075 } 1088 1076 }, [composerState]) ··· 1123 1111 !isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost)) 1124 1112 } 1125 1113 onError={setError} 1126 - onEmojiButtonPress={onEmojiButtonPress} 1127 1114 onSelectVideo={selectVideo} 1128 1115 onAddPost={() => { 1129 1116 composerDispatch({ ··· 1133 1120 currentLanguages={currentLanguages} 1134 1121 onSelectLanguage={onSelectLanguage} 1135 1122 openGallery={openGallery} 1123 + textInputRef={textInputRef} 1136 1124 /> 1137 1125 </> 1138 1126 ) ··· 1201 1189 <ComposerPost 1202 1190 post={post} 1203 1191 dispatch={composerDispatch} 1204 - textInput={post.id === activePost.id ? textInput : null} 1192 + textInputRef={post.id === activePost.id ? textInputRef : null} 1205 1193 isFirstPost={index === 0} 1206 1194 isLastPost={index === thread.posts.length - 1} 1207 1195 isPartOfThread={thread.posts.length > 1} ··· 1288 1276 let ComposerPost = memo(function ComposerPost({ 1289 1277 post, 1290 1278 dispatch, 1291 - textInput, 1279 + textInputRef, 1292 1280 isActive, 1293 1281 isReply, 1294 1282 isFirstPost, ··· 1303 1291 }: { 1304 1292 post: PostDraft 1305 1293 dispatch: (action: ComposerAction) => void 1306 - textInput: React.Ref<TextInputRef> 1294 + textInputRef: React.RefObject<TextInputRef | null> | null 1307 1295 isActive: boolean 1308 1296 isReply: boolean 1309 1297 isFirstPost: boolean ··· 1404 1392 style={[a.mt_xs]} 1405 1393 /> 1406 1394 <TextInput 1407 - ref={textInput} 1395 + ref={textInputRef} 1408 1396 style={[a.pt_xs]} 1409 1397 richtext={richtext} 1410 1398 placeholder={selectTextInputPlaceholder} ··· 1822 1810 post, 1823 1811 dispatch, 1824 1812 showAddButton, 1825 - onEmojiButtonPress, 1826 1813 onSelectVideo, 1827 1814 onAddPost, 1828 1815 currentLanguages, 1829 1816 onSelectLanguage, 1830 1817 openGallery, 1818 + textInputRef, 1831 1819 }: { 1832 1820 post: PostDraft 1833 1821 dispatch: (action: PostAction) => void 1834 1822 showAddButton: boolean 1835 - onEmojiButtonPress: () => void 1836 1823 onError: (error: string) => void 1837 1824 onSelectVideo: (postId: string, asset: ImagePickerAsset) => void 1838 1825 onAddPost: () => void 1839 1826 currentLanguages: string[] 1840 1827 onSelectLanguage?: (language: string) => void 1841 1828 openGallery?: boolean 1829 + textInputRef: React.RefObject<TextInputRef | null> 1842 1830 }) { 1843 1831 const t = useTheme() 1844 1832 const {t: l} = useLingui() 1845 - const {isMobile} = useWebMediaQueries() 1833 + const {gtPhone} = useBreakpoints() 1846 1834 /* 1847 1835 * Once we've allowed a certain type of asset to be selected, we don't allow 1848 1836 * other types of media to be selected. ··· 1965 1953 onAdd={onImageAdd} 1966 1954 /> 1967 1955 <SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} /> 1968 - {!isMobile ? ( 1969 - <Button 1970 - onPress={onEmojiButtonPress} 1971 - style={a.p_sm} 1972 - label={l`Open emoji picker`} 1973 - accessibilityHint={l`Opens emoji picker`} 1974 - variant="ghost" 1975 - shape="round" 1976 - color="primary"> 1977 - <EmojiSmileIcon size="lg" /> 1978 - </Button> 1956 + {IS_WEB && gtPhone ? ( 1957 + <EmojiPicker.Root nextFocusRef={textInputRef}> 1958 + <EmojiPicker.Trigger label={l`Open emoji picker`}> 1959 + {({props}) => ( 1960 + <Button 1961 + style={a.p_sm} 1962 + label={props.accessibilityLabel} 1963 + variant="ghost" 1964 + shape="round" 1965 + color="primary" 1966 + {...props}> 1967 + <EmojiSmileIcon size="lg" /> 1968 + </Button> 1969 + )} 1970 + </EmojiPicker.Trigger> 1971 + <EmojiPicker.Picker /> 1972 + </EmojiPicker.Root> 1979 1973 ) : null} 1980 1974 </ToolbarWrapper> 1981 1975 )}
+1 -1
src/view/com/composer/SelectMediaButton.tsx
··· 32 32 type: AssetType 33 33 assets: ImagePickerAsset[] 34 34 errors: string[] 35 - }) => void 35 + }) => void | Promise<void> 36 36 /** 37 37 * If true, automatically open the media picker when the component mounts. 38 38 */
+1 -1
src/view/com/composer/text-input/TextInput.web.tsx
··· 32 32 import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' 33 33 import {atoms as a, useAlf} from '#/alf' 34 34 import {normalizeTextStyles} from '#/alf/typography' 35 + import {type Emoji} from '#/components/EmojiPicker' 35 36 import {Portal} from '#/components/Portal' 36 37 import {Text} from '#/components/Typography' 37 38 import {type TextInputProps} from './TextInput.types' 38 39 import {type AutocompleteRef, createSuggestion} from './web/Autocomplete' 39 - import {type Emoji} from './web/EmojiPicker' 40 40 import {LinkDecorator} from './web/LinkDecorator' 41 41 import {TagDecorator} from './web/TagDecorator' 42 42
-37
src/view/com/composer/text-input/web/EmojiPicker.tsx
··· 1 - export type Emoji = { 2 - aliases?: string[] 3 - emoticons: string[] 4 - id: string 5 - keywords: string[] 6 - name: string 7 - native: string 8 - shortcodes?: string 9 - unified: string 10 - } 11 - 12 - export interface EmojiPickerPosition { 13 - top: number 14 - left: number 15 - right: number 16 - bottom: number 17 - nextFocusRef: React.MutableRefObject<HTMLElement> | null 18 - } 19 - 20 - export interface EmojiPickerState { 21 - isOpen: boolean 22 - pos: EmojiPickerPosition 23 - } 24 - 25 - interface IProps { 26 - state: EmojiPickerState 27 - close: () => void 28 - /** 29 - * If `true`, overrides position and ensures picker is pinned to the top of 30 - * the target element. 31 - */ 32 - pinToTop?: boolean 33 - } 34 - 35 - export function EmojiPicker(_opts: IProps) { 36 - return null 37 - }
-180
src/view/com/composer/text-input/web/EmojiPicker.web.tsx
··· 1 - import {useEffect, useMemo, useRef} from 'react' 2 - import {Pressable, useWindowDimensions, View} from 'react-native' 3 - import Picker from '@emoji-mart/react' 4 - import {msg} from '@lingui/core/macro' 5 - import {useLingui} from '@lingui/react' 6 - import {DismissableLayer, FocusScope} from 'radix-ui/internal' 7 - 8 - import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' 9 - import {atoms as a, flatten} from '#/alf' 10 - import {Portal} from '#/components/Portal' 11 - 12 - const HEIGHT_OFFSET = 40 13 - const WIDTH_OFFSET = 100 14 - const PICKER_HEIGHT = 435 + HEIGHT_OFFSET 15 - const PICKER_WIDTH = 350 + WIDTH_OFFSET 16 - 17 - export type Emoji = { 18 - aliases?: string[] 19 - emoticons: string[] 20 - id: string 21 - keywords: string[] 22 - name: string 23 - native: string 24 - shortcodes?: string 25 - unified: string 26 - } 27 - 28 - export interface EmojiPickerPosition { 29 - top: number 30 - left: number 31 - right: number 32 - bottom: number 33 - nextFocusRef: React.MutableRefObject<HTMLElement> | null 34 - } 35 - 36 - export interface EmojiPickerState { 37 - isOpen: boolean 38 - pos: EmojiPickerPosition 39 - } 40 - 41 - interface IProps { 42 - state: EmojiPickerState 43 - close: () => void 44 - /** 45 - * If `true`, overrides position and ensures picker is pinned to the top of 46 - * the target element. 47 - */ 48 - pinToTop?: boolean 49 - } 50 - 51 - export function EmojiPicker({state, close, pinToTop}: IProps) { 52 - const {_} = useLingui() 53 - const {height, width} = useWindowDimensions() 54 - 55 - const isShiftDown = useRef(false) 56 - 57 - const position = useMemo(() => { 58 - if (pinToTop) { 59 - return { 60 - top: state.pos.top - PICKER_HEIGHT + HEIGHT_OFFSET - 10, 61 - left: state.pos.left, 62 - } 63 - } 64 - 65 - const fitsBelow = state.pos.top + PICKER_HEIGHT < height 66 - const fitsAbove = PICKER_HEIGHT < state.pos.top 67 - const placeOnLeft = PICKER_WIDTH < state.pos.left 68 - const screenYMiddle = height / 2 - PICKER_HEIGHT / 2 69 - 70 - if (fitsBelow) { 71 - return { 72 - top: state.pos.top + HEIGHT_OFFSET, 73 - } 74 - } else if (fitsAbove) { 75 - return { 76 - bottom: height - state.pos.bottom + HEIGHT_OFFSET, 77 - } 78 - } else { 79 - return { 80 - top: screenYMiddle, 81 - left: placeOnLeft ? state.pos.left - PICKER_WIDTH : undefined, 82 - right: !placeOnLeft 83 - ? width - state.pos.right - PICKER_WIDTH 84 - : undefined, 85 - } 86 - } 87 - }, [state.pos, height, width, pinToTop]) 88 - 89 - useEffect(() => { 90 - if (!state.isOpen) return 91 - 92 - const onKeyDown = (e: KeyboardEvent) => { 93 - if (e.key === 'Shift') { 94 - isShiftDown.current = true 95 - } 96 - } 97 - const onKeyUp = (e: KeyboardEvent) => { 98 - if (e.key === 'Shift') { 99 - isShiftDown.current = false 100 - } 101 - } 102 - window.addEventListener('keydown', onKeyDown, true) 103 - window.addEventListener('keyup', onKeyUp, true) 104 - 105 - return () => { 106 - window.removeEventListener('keydown', onKeyDown, true) 107 - window.removeEventListener('keyup', onKeyUp, true) 108 - } 109 - }, [state.isOpen]) 110 - 111 - const onInsert = (emoji: Emoji) => { 112 - textInputWebEmitter.emit('emoji-inserted', emoji) 113 - 114 - if (!isShiftDown.current) { 115 - close() 116 - } 117 - } 118 - 119 - if (!state.isOpen) return null 120 - 121 - return ( 122 - <Portal> 123 - <FocusScope.FocusScope 124 - loop 125 - trapped 126 - onUnmountAutoFocus={e => { 127 - const nextFocusRef = state.pos.nextFocusRef 128 - const node = nextFocusRef?.current 129 - if (node) { 130 - e.preventDefault() 131 - node.focus() 132 - } 133 - }}> 134 - <Pressable 135 - accessible 136 - accessibilityLabel={_(msg`Close emoji picker`)} 137 - accessibilityHint={_(msg`Closes the emoji picker`)} 138 - onPress={close} 139 - style={[a.fixed, a.inset_0]} 140 - /> 141 - 142 - <View 143 - style={flatten([ 144 - a.fixed, 145 - a.w_full, 146 - a.h_full, 147 - a.align_center, 148 - a.z_10, 149 - { 150 - top: 0, 151 - left: 0, 152 - right: 0, 153 - }, 154 - ])}> 155 - <View style={[{position: 'absolute'}, position]}> 156 - <DismissableLayer.DismissableLayer 157 - onFocusOutside={evt => evt.preventDefault()} 158 - onDismiss={close}> 159 - <Picker 160 - data={async () => { 161 - return (await import('@emoji-mart/data')).default 162 - }} 163 - onEmojiSelect={onInsert} 164 - autoFocus={true} 165 - /> 166 - </DismissableLayer.DismissableLayer> 167 - </View> 168 - </View> 169 - 170 - <Pressable 171 - accessible 172 - accessibilityLabel={_(msg`Close emoji picker`)} 173 - accessibilityHint={_(msg`Closes the emoji picker`)} 174 - onPress={close} 175 - style={[a.fixed, a.inset_0]} 176 - /> 177 - </FocusScope.FocusScope> 178 - </Portal> 179 - ) 180 - }
-24
src/view/com/composer/text-input/web/useWebPreloadEmoji.ts
··· 1 - import {useCallback} from 'react' 2 - import {init} from 'emoji-mart' 3 - 4 - /** 5 - * Only load the emoji picker data once per page load. 6 - */ 7 - let loadRequested = false 8 - 9 - /** 10 - * Preload the emoji picker data to prevent flash. 11 - * {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194} 12 - */ 13 - export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) { 14 - const preload = useCallback(async () => { 15 - if (loadRequested) return 16 - loadRequested = true 17 - try { 18 - const data = (await import('@emoji-mart/data')).default 19 - init({data}) 20 - } catch (e) {} 21 - }, []) 22 - if (immediate) preload() 23 - return preload 24 - }
-27
src/view/shell/Composer.web.tsx
··· 1 - import {useCallback, useState} from 'react' 2 1 import {StyleSheet, View} from 'react-native' 3 2 import {DismissableLayer, FocusGuards, FocusScope} from 'radix-ui/internal' 4 3 import {RemoveScrollBar} from 'react-remove-scroll-bar' ··· 6 5 import {useA11y} from '#/state/a11y' 7 6 import {useModals} from '#/state/modals' 8 7 import {type ComposerOpts, useComposerState} from '#/state/shell/composer' 9 - import { 10 - EmojiPicker, 11 - type EmojiPickerPosition, 12 - type EmojiPickerState, 13 - } from '#/view/com/composer/text-input/web/EmojiPicker' 14 8 import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf' 15 9 import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' 16 10 ··· 41 35 const t = useTheme() 42 36 const {gtMobile} = useBreakpoints() 43 37 const {reduceMotionEnabled} = useA11y() 44 - const [pickerState, setPickerState] = useState<EmojiPickerState>({ 45 - isOpen: false, 46 - pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, 47 - }) 48 - 49 - const onOpenPicker = useCallback((pos: EmojiPickerPosition | undefined) => { 50 - if (!pos) return 51 - setPickerState({ 52 - isOpen: true, 53 - pos, 54 - }) 55 - }, []) 56 - 57 - const onClosePicker = useCallback(() => { 58 - setPickerState(prev => ({ 59 - ...prev, 60 - isOpen: false, 61 - })) 62 - }, []) 63 38 64 39 FocusGuards.useFocusGuards() 65 40 ··· 104 79 onPost={state.onPost} 105 80 onPostSuccess={state.onPostSuccess} 106 81 mention={state.mention} 107 - openEmojiPicker={onOpenPicker} 108 82 text={state.text} 109 83 imageUris={state.imageUris} 110 84 openGallery={state.openGallery} 111 85 /> 112 86 </View> 113 - <EmojiPicker state={pickerState} close={onClosePicker} /> 114 87 </DismissableLayer.DismissableLayer> 115 88 </FocusScope.FocusScope> 116 89 )