Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

emoji picker improvements (#2392)

* rework emoji picker

* dynamic position

* always prefer the left if it will fit

* add accessibility label

* Update EmojiPicker.web.tsx

oops. remove accessibility from fake button

authored by

Hailey and committed by
GitHub
c1dc0b7e e460b304

+136 -59
+1
src/state/shell/composer.tsx
··· 30 30 onPost?: () => void 31 31 quote?: ComposerOptsQuote 32 32 mention?: string // handle of user to mention 33 + openPicker?: (pos: DOMRect | undefined) => void 33 34 } 34 35 35 36 type StateContext = ComposerOpts | undefined
+19 -2
src/view/com/composer/Composer.tsx
··· 6 6 Keyboard, 7 7 KeyboardAvoidingView, 8 8 Platform, 9 + Pressable, 9 10 ScrollView, 10 11 StyleSheet, 11 12 TouchableOpacity, ··· 46 47 import {MAX_GRAPHEME_LENGTH} from 'lib/constants' 47 48 import {LabelsBtn} from './labels/LabelsBtn' 48 49 import {SelectLangBtn} from './select-language/SelectLangBtn' 49 - import {EmojiPickerButton} from './text-input/web/EmojiPicker.web' 50 50 import {insertMentionAt} from 'lib/strings/mention-manip' 51 51 import {Trans, msg} from '@lingui/macro' 52 52 import {useLingui} from '@lingui/react' ··· 70 70 onPost, 71 71 quote: initQuote, 72 72 mention: initMention, 73 + openPicker, 73 74 }: Props) { 74 75 const {currentAccount} = useSession() 75 76 const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) ··· 274 275 const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size]) 275 276 const hasMedia = gallery.size > 0 || Boolean(extLink) 276 277 278 + const onEmojiButtonPress = useCallback(() => { 279 + openPicker?.(textInput.current?.getCursorPosition()) 280 + }, [openPicker]) 281 + 277 282 return ( 278 283 <KeyboardAvoidingView 279 284 testID="composePostView" ··· 456 461 <OpenCameraBtn gallery={gallery} /> 457 462 </> 458 463 ) : null} 459 - {!isMobile ? <EmojiPickerButton /> : null} 464 + {!isMobile ? ( 465 + <Pressable 466 + onPress={onEmojiButtonPress} 467 + accessibilityRole="button" 468 + accessibilityLabel={_(msg`Open emoji picker`)} 469 + accessibilityHint={_(msg`Open emoji picker`)}> 470 + <FontAwesomeIcon 471 + icon={['far', 'face-smile']} 472 + color={pal.colors.link} 473 + size={22} 474 + /> 475 + </Pressable> 476 + ) : null} 460 477 <View style={s.flex1} /> 461 478 <SelectLangBtn /> 462 479 <CharProgress count={graphemeLength} />
+2
src/view/com/composer/text-input/TextInput.tsx
··· 32 32 export interface TextInputRef { 33 33 focus: () => void 34 34 blur: () => void 35 + getCursorPosition: () => DOMRect | undefined 35 36 } 36 37 37 38 interface TextInputProps extends ComponentProps<typeof RNTextInput> { ··· 74 75 blur: () => { 75 76 textInput.current?.blur() 76 77 }, 78 + getCursorPosition: () => undefined, // Not implemented on native 77 79 })) 78 80 79 81 const onChangeText = useCallback(
+5
src/view/com/composer/text-input/TextInput.web.tsx
··· 22 22 export interface TextInputRef { 23 23 focus: () => void 24 24 blur: () => void 25 + getCursorPosition: () => DOMRect | undefined 25 26 } 26 27 27 28 interface TextInputProps { ··· 169 170 React.useImperativeHandle(ref, () => ({ 170 171 focus: () => {}, // TODO 171 172 blur: () => {}, // TODO 173 + getCursorPosition: () => { 174 + const pos = editor?.state.selection.$anchor.pos 175 + return pos ? editor?.view.coordsAtPos(pos) : undefined 176 + }, 172 177 })) 173 178 174 179 return (
+83 -57
src/view/com/composer/text-input/web/EmojiPicker.web.tsx
··· 1 1 import React from 'react' 2 2 import Picker from '@emoji-mart/react' 3 - import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' 4 - import * as DropdownMenu from '@radix-ui/react-dropdown-menu' 3 + import { 4 + StyleSheet, 5 + TouchableWithoutFeedback, 6 + useWindowDimensions, 7 + View, 8 + } from 'react-native' 5 9 import {textInputWebEmitter} from '../TextInput.web' 6 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 7 - import {usePalette} from 'lib/hooks/usePalette' 8 - import {useMediaQuery} from 'react-responsive' 10 + 11 + const HEIGHT_OFFSET = 40 12 + const WIDTH_OFFSET = 100 13 + const PICKER_HEIGHT = 435 + HEIGHT_OFFSET 14 + const PICKER_WIDTH = 350 + WIDTH_OFFSET 9 15 10 16 export type Emoji = { 11 17 aliases?: string[] ··· 18 24 unified: string 19 25 } 20 26 21 - export function EmojiPickerButton() { 22 - const pal = usePalette('default') 23 - const [open, setOpen] = React.useState(false) 24 - const onOpenChange = (o: boolean) => { 25 - setOpen(o) 26 - } 27 - const close = () => { 28 - setOpen(false) 29 - } 30 - 31 - return ( 32 - <DropdownMenu.Root open={open} onOpenChange={onOpenChange}> 33 - <DropdownMenu.Trigger style={styles.trigger}> 34 - <FontAwesomeIcon 35 - icon={['far', 'face-smile']} 36 - color={pal.colors.link} 37 - size={22} 38 - /> 39 - </DropdownMenu.Trigger> 27 + export interface EmojiPickerState { 28 + isOpen: boolean 29 + pos: {top: number; left: number; right: number; bottom: number} 30 + } 40 31 41 - <DropdownMenu.Portal> 42 - <EmojiPicker close={close} /> 43 - </DropdownMenu.Portal> 44 - </DropdownMenu.Root> 45 - ) 32 + interface IProps { 33 + state: EmojiPickerState 34 + close: () => void 46 35 } 47 36 48 - export function EmojiPicker({close}: {close: () => void}) { 37 + export function EmojiPicker({state, close}: IProps) { 38 + const {height, width} = useWindowDimensions() 39 + 40 + const isShiftDown = React.useRef(false) 41 + 42 + const position = React.useMemo(() => { 43 + const fitsBelow = state.pos.top + PICKER_HEIGHT < height 44 + const fitsAbove = PICKER_HEIGHT < state.pos.top 45 + const placeOnLeft = PICKER_WIDTH < state.pos.left 46 + const screenYMiddle = height / 2 - PICKER_HEIGHT / 2 47 + 48 + if (fitsBelow) { 49 + return { 50 + top: state.pos.top + HEIGHT_OFFSET, 51 + } 52 + } else if (fitsAbove) { 53 + return { 54 + bottom: height - state.pos.bottom + HEIGHT_OFFSET, 55 + } 56 + } else { 57 + return { 58 + top: screenYMiddle, 59 + left: placeOnLeft ? state.pos.left - PICKER_WIDTH : undefined, 60 + right: !placeOnLeft 61 + ? width - state.pos.right - PICKER_WIDTH 62 + : undefined, 63 + } 64 + } 65 + }, [state.pos, height, width]) 66 + 67 + React.useEffect(() => { 68 + if (!state.isOpen) return 69 + 70 + const onKeyDown = (e: KeyboardEvent) => { 71 + if (e.key === 'Shift') { 72 + isShiftDown.current = true 73 + } 74 + } 75 + const onKeyUp = (e: KeyboardEvent) => { 76 + if (e.key === 'Shift') { 77 + isShiftDown.current = false 78 + } 79 + } 80 + window.addEventListener('keydown', onKeyDown, true) 81 + window.addEventListener('keyup', onKeyUp, true) 82 + 83 + return () => { 84 + window.removeEventListener('keydown', onKeyDown, true) 85 + window.removeEventListener('keyup', onKeyUp, true) 86 + } 87 + }, [state.isOpen]) 88 + 49 89 const onInsert = (emoji: Emoji) => { 50 90 textInputWebEmitter.emit('emoji-inserted', emoji) 51 - close() 91 + 92 + if (!isShiftDown.current) { 93 + close() 94 + } 52 95 } 53 - const reducedPadding = useMediaQuery({query: '(max-height: 750px)'}) 54 - const noPadding = useMediaQuery({query: '(max-height: 550px)'}) 55 - const noPicker = useMediaQuery({query: '(max-height: 350px)'}) 96 + 97 + if (!state.isOpen) return null 56 98 57 99 return ( 58 - // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors 59 - <TouchableWithoutFeedback onPress={close} accessibilityViewIsModal> 100 + <TouchableWithoutFeedback 101 + accessibilityRole="button" 102 + onPress={close} 103 + accessibilityViewIsModal> 60 104 <View style={styles.mask}> 61 105 {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */} 62 - <TouchableWithoutFeedback 63 - onPress={e => { 64 - e.stopPropagation() // prevent event from bubbling up to the mask 65 - }}> 66 - <View 67 - style={[ 68 - styles.picker, 69 - { 70 - paddingTop: noPadding ? 0 : reducedPadding ? 150 : 325, 71 - display: noPicker ? 'none' : 'flex', 72 - }, 73 - ]}> 106 + <TouchableWithoutFeedback onPress={e => e.stopPropagation()}> 107 + <View style={[{position: 'absolute'}, position]}> 74 108 <Picker 75 109 data={async () => { 76 110 return (await import('./EmojiPickerData.json')).default ··· 93 127 right: 0, 94 128 width: '100%', 95 129 height: '100%', 96 - }, 97 - trigger: { 98 - backgroundColor: 'transparent', 99 - // @ts-ignore web only -prf 100 - border: 'none', 101 - paddingTop: 4, 102 - paddingLeft: 12, 103 - paddingRight: 12, 104 - cursor: 'pointer', 130 + alignItems: 'center', 105 131 }, 106 132 picker: { 107 133 marginHorizontal: 'auto',
+26
src/view/shell/Composer.web.tsx
··· 5 5 import {useComposerState} from 'state/shell/composer' 6 6 import {usePalette} from 'lib/hooks/usePalette' 7 7 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 8 + import { 9 + EmojiPicker, 10 + EmojiPickerState, 11 + } from 'view/com/composer/text-input/web/EmojiPicker.web.tsx' 8 12 9 13 const BOTTOM_BAR_HEIGHT = 61 10 14 ··· 13 17 const {isMobile} = useWebMediaQueries() 14 18 const state = useComposerState() 15 19 20 + const [pickerState, setPickerState] = React.useState<EmojiPickerState>({ 21 + isOpen: false, 22 + pos: {top: 0, left: 0, right: 0, bottom: 0}, 23 + }) 24 + 25 + const onOpenPicker = React.useCallback((pos: DOMRect | undefined) => { 26 + if (!pos) return 27 + setPickerState({ 28 + isOpen: true, 29 + pos, 30 + }) 31 + }, []) 32 + 33 + const onClosePicker = React.useCallback(() => { 34 + setPickerState(prev => ({ 35 + ...prev, 36 + isOpen: false, 37 + })) 38 + }, []) 39 + 16 40 // rendering 17 41 // = 18 42 ··· 41 65 quote={state.quote} 42 66 onPost={state.onPost} 43 67 mention={state.mention} 68 + openPicker={onOpenPicker} 44 69 /> 45 70 </Animated.View> 71 + <EmojiPicker state={pickerState} close={onClosePicker} /> 46 72 </Animated.View> 47 73 ) 48 74 }