Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Emoji picker portaling #7217

Merged
merged 7 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions src/screens/Messages/components/MessageInput.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {Pressable, StyleSheet, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import Graphemer from 'graphemer'
import {flushSync} from 'react-dom'
import TextareaAutosize from 'react-textarea-autosize'

import {isSafari, isTouchDevice} from '#/lib/browser'
Expand Down Expand Up @@ -106,11 +107,19 @@ export function MessageInput({

const onEmojiInserted = React.useCallback(
(emoji: Emoji) => {
const position = textAreaRef.current?.selectionStart ?? 0
setMessage(
message =>
message.slice(0, position) + emoji.native + message.slice(position),
)
if (!textAreaRef.current) {
return
}
const position = textAreaRef.current.selectionStart ?? 0
textAreaRef.current.focus()
flushSync(() => {
setMessage(
message =>
message.slice(0, position) + emoji.native + message.slice(position),
)
})
textAreaRef.current.selectionStart = position + emoji.native.length
textAreaRef.current.selectionEnd = position + emoji.native.length
},
[setMessage],
)
Expand Down Expand Up @@ -148,7 +157,14 @@ export function MessageInput({
<Button
onPress={e => {
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
openEmojiPicker?.({top: py, left: px, right: px, bottom: py})
openEmojiPicker?.({
top: py,
left: px,
right: px,
bottom: py,
nextFocusRef:
textAreaRef as unknown as React.MutableRefObject<HTMLElement>,
})
})
}}
style={[
Expand Down
2 changes: 1 addition & 1 deletion src/screens/Messages/components/MessagesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function MessagesList({
const [emojiPickerState, setEmojiPickerState] =
React.useState<EmojiPickerState>({
isOpen: false,
pos: {top: 0, left: 0, right: 0, bottom: 0},
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
})

// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
Expand Down
3 changes: 2 additions & 1 deletion src/state/shell/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {postUriToRelativePath, toBskyAppUrl} from '#/lib/strings/url-helpers'
import {purgeTemporaryImageFiles} from '#/state/gallery'
import {precacheResolveLinkQuery} from '#/state/queries/resolve-link'
import type {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import * as Toast from '#/view/com/util/Toast'

export interface ComposerOptsPostRef {
Expand All @@ -29,7 +30,7 @@ export interface ComposerOpts {
onPost?: (postUri: string | undefined) => void
quote?: AppBskyFeedDefs.PostView
mention?: string // handle of user to mention
openEmojiPicker?: (pos: DOMRect | undefined) => void
openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void
text?: string
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
videoUri?: {uri: string; width: number; height: number}
Expand Down
9 changes: 8 additions & 1 deletion src/view/com/composer/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,14 @@ export const ComposePost = ({
}

const onEmojiButtonPress = useCallback(() => {
openEmojiPicker?.(textInput.current?.getCursorPosition())
const rect = textInput.current?.getCursorPosition()
if (rect) {
openEmojiPicker?.({
...rect,
nextFocusRef:
textInput as unknown as React.MutableRefObject<HTMLElement>,
})
}
}, [openEmojiPicker])

const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
Expand Down
87 changes: 51 additions & 36 deletions src/view/com/composer/text-input/web/EmojiPicker.web.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import React from 'react'
import {
GestureResponderEvent,
TouchableWithoutFeedback,
useWindowDimensions,
View,
} from 'react-native'
import {Pressable, useWindowDimensions, View} from 'react-native'
import Picker from '@emoji-mart/react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {DismissableLayer} from '@radix-ui/react-dismissable-layer'
import {FocusScope} from '@radix-ui/react-focus-scope'

import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {atoms as a} from '#/alf'
import {atoms as a, flatten} from '#/alf'
import {Portal} from '#/components/Portal'

const HEIGHT_OFFSET = 40
Expand All @@ -33,6 +31,7 @@ export interface EmojiPickerPosition {
left: number
right: number
bottom: number
nextFocusRef: React.MutableRefObject<HTMLElement> | null
}

export interface EmojiPickerState {
Expand All @@ -51,6 +50,7 @@ interface IProps {
}

export function EmojiPicker({state, close, pinToTop}: IProps) {
const {_} = useLingui()
const {height, width} = useWindowDimensions()

const isShiftDown = React.useRef(false)
Expand Down Expand Up @@ -119,48 +119,63 @@ export function EmojiPicker({state, close, pinToTop}: IProps) {

if (!state.isOpen) return null

const onPressBackdrop = (e: GestureResponderEvent) => {
// @ts-ignore web only
if (e.nativeEvent?.pointerId === -1) return
close()
}

return (
<Portal>
<TouchableWithoutFeedback
accessibilityRole="button"
onPress={onPressBackdrop}
accessibilityViewIsModal>
<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`Tap to close the emoji picker`)}
onPress={close}
style={[a.fixed, a.inset_0]}
/>

<View
style={[
style={flatten([
a.fixed,
a.w_full,
a.h_full,
a.align_center,
a.z_10,
{
top: 0,
left: 0,
right: 0,
},
]}>
{/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */}
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[{position: 'absolute'}, position]}>
<DismissableLayer
onFocusOutside={evt => evt.preventDefault()}
onDismiss={close}>
<Picker
data={async () => {
return (await import('./EmojiPickerData.json')).default
}}
onEmojiSelect={onInsert}
autoFocus={true}
/>
</DismissableLayer>
</View>
</TouchableWithoutFeedback>
])}>
<View style={[{position: 'absolute'}, position]}>
<DismissableLayer
onFocusOutside={evt => evt.preventDefault()}
onDismiss={close}>
<Picker
data={async () => {
return (await import('./EmojiPickerData.json')).default
}}
onEmojiSelect={onInsert}
autoFocus={true}
/>
</DismissableLayer>
</View>
</View>
</TouchableWithoutFeedback>

<Pressable
accessible
accessibilityLabel={_(msg`Close emoji picker`)}
accessibilityHint={_(msg`Tap to close the emoji picker`)}
onPress={close}
style={[a.fixed, a.inset_0]}
/>
</FocusScope>
</Portal>
)
}
20 changes: 12 additions & 8 deletions src/view/shell/Composer.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {useModals} from '#/state/modals'
import {ComposerOpts, useComposerState} from '#/state/shell/composer'
import {
EmojiPicker,
EmojiPickerPosition,
EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import {useBreakpoints, useTheme} from '#/alf'
Expand Down Expand Up @@ -42,16 +43,19 @@ function Inner({state}: {state: ComposerOpts}) {
const {gtMobile} = useBreakpoints()
const [pickerState, setPickerState] = React.useState<EmojiPickerState>({
isOpen: false,
pos: {top: 0, left: 0, right: 0, bottom: 0},
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
})

const onOpenPicker = React.useCallback((pos: DOMRect | undefined) => {
if (!pos) return
setPickerState({
isOpen: true,
pos,
})
}, [])
const onOpenPicker = React.useCallback(
(pos: EmojiPickerPosition | undefined) => {
if (!pos) return
setPickerState({
isOpen: true,
pos,
})
},
[],
)

const onClosePicker = React.useCallback(() => {
setPickerState(prev => ({
Expand Down
Loading