Close web mention suggestions popup on Escape (#8605)

* alf web typeahead

* fix type error

* fix escape behaviour

* change selection on hover

* rm React.

* undo random change
This commit is contained in:
Samuel Newman
2025-08-29 03:03:12 +03:00
committed by GitHub
parent 27c1058568
commit 8a4398608a
5 changed files with 251 additions and 271 deletions
+5 -5
View File
@@ -114,10 +114,7 @@ import {SelectPostLanguagesBtn} from '#/view/com/composer/select-language/Select
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
// TODO: Prevent naming components that coincide with RN primitives
// due to linting false positives
import {
TextInput,
type TextInputRef,
} from '#/view/com/composer/text-input/TextInput'
import {TextInput} from '#/view/com/composer/text-input/TextInput'
import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
@@ -155,6 +152,7 @@ import {
processVideo,
type VideoState,
} from './state/video'
import {type TextInputRef} from './text-input/TextInput.types'
import {getVideoMetadata} from './videos/pickVideo'
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
@@ -306,7 +304,9 @@ export const ComposePost = ({
)
const onPressCancel = useCallback(() => {
if (
if (textInput.current?.maybeClosePopup()) {
return
} else if (
thread.posts.some(
post =>
post.shortenedGraphemeLength > 0 ||
+18 -39
View File
@@ -1,7 +1,6 @@
import React, {
type ComponentProps,
forwardRef,
import {
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
@@ -9,7 +8,6 @@ import React, {
import {
type NativeSyntheticEvent,
Text as RNText,
type TextInput as RNTextInput,
type TextInputSelectionChangeEventData,
View,
} from 'react-native'
@@ -33,57 +31,38 @@ import {
import {atoms as a, useAlf} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography'
import {Autocomplete} from './mobile/Autocomplete'
export interface TextInputRef {
focus: () => void
blur: () => void
getCursorPosition: () => DOMRect | undefined
}
interface TextInputProps extends ComponentProps<typeof RNTextInput> {
richtext: RichText
placeholder: string
webForceMinHeight: boolean
hasRightPadding: boolean
isActive: boolean
setRichText: (v: RichText) => void
onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => void
onNewLink: (uri: string) => void
onError: (err: string) => void
}
import {type TextInputProps} from './TextInput.types'
interface Selection {
start: number
end: number
}
export const TextInput = forwardRef(function TextInputImpl(
{
richtext,
placeholder,
hasRightPadding,
setRichText,
onPhotoPasted,
onNewLink,
onError,
...props
}: TextInputProps,
export function TextInput({
ref,
) {
richtext,
placeholder,
hasRightPadding,
setRichText,
onPhotoPasted,
onNewLink,
onError,
...props
}: TextInputProps) {
const {theme: t, fonts} = useAlf()
const textInput = useRef<PasteInputRef>(null)
const textInputSelection = useRef<Selection>({start: 0, end: 0})
const theme = useTheme()
const [autocompletePrefix, setAutocompletePrefix] = useState('')
const prevLength = React.useRef(richtext.length)
const prevLength = useRef(richtext.length)
React.useImperativeHandle(ref, () => ({
useImperativeHandle(ref, () => ({
focus: () => textInput.current?.focus(),
blur: () => {
textInput.current?.blur()
},
getCursorPosition: () => undefined, // Not implemented on native
maybeClosePopup: () => false, // Not needed on native
}))
const pastSuggestedUris = useRef(new Set<string>())
@@ -185,7 +164,7 @@ export const TextInput = forwardRef(function TextInputImpl(
[onChangeText, richtext, setAutocompletePrefix],
)
const inputTextStyle = React.useMemo(() => {
const inputTextStyle = useMemo(() => {
const style = normalizeTextStyles(
[a.text_lg, a.leading_snug, t.atoms.text],
{
@@ -277,4 +256,4 @@ export const TextInput = forwardRef(function TextInputImpl(
/>
</View>
)
})
}
@@ -0,0 +1,42 @@
import {type TextInput} from 'react-native'
import {type RichText} from '@atproto/api'
export type TextInputRef = {
focus: () => void
blur: () => void
/**
* @platform web
*/
getCursorPosition: () =>
| {left: number; right: number; top: number; bottom: number}
| undefined
/**
* Closes the autocomplete popup if it is open.
* Returns `true` if the popup was closed, `false` otherwise.
*
* @platform web
*/
maybeClosePopup: () => boolean
}
export type TextInputProps = {
ref: React.Ref<TextInputRef>
richtext: RichText
webForceMinHeight: boolean
hasRightPadding: boolean
isActive: boolean
setRichText: (v: RichText) => void
onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => void
onNewLink: (uri: string) => void
onError: (err: string) => void
onFocus: () => void
} & Pick<
React.ComponentProps<typeof TextInput>,
| 'placeholder'
| 'autoFocus'
| 'style'
| 'accessible'
| 'accessibilityLabel'
| 'accessibilityHint'
>
@@ -1,4 +1,11 @@
import React, {useRef} from 'react'
import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {StyleSheet, View} from 'react-native'
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
@@ -16,7 +23,6 @@ import {EditorContent, type JSONContent, useEditor} from '@tiptap/react'
import Graphemer from 'graphemer'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette'
import {blobToDataUri, isUriImage} from '#/lib/media/util'
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {
@@ -27,57 +33,34 @@ import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEm
import {atoms as a, useAlf} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography'
import {Portal} from '#/components/Portal'
import {Text} from '../../util/text/Text'
import {createSuggestion} from './web/Autocomplete'
import {Text} from '#/components/Typography'
import {type TextInputProps} from './TextInput.types'
import {type AutocompleteRef, createSuggestion} from './web/Autocomplete'
import {type Emoji} from './web/EmojiPicker'
import {LinkDecorator} from './web/LinkDecorator'
import {TagDecorator} from './web/TagDecorator'
export interface TextInputRef {
focus: () => void
blur: () => void
getCursorPosition: () => DOMRect | undefined
}
interface TextInputProps {
richtext: RichText
placeholder: string
suggestedLinks: Set<string>
webForceMinHeight: boolean
hasRightPadding: boolean
isActive: boolean
setRichText: (v: RichText | ((v: RichText) => RichText)) => void
onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => void
onNewLink: (uri: string) => void
onError: (err: string) => void
onFocus: () => void
}
export const TextInput = React.forwardRef(function TextInputImpl(
{
richtext,
placeholder,
webForceMinHeight,
hasRightPadding,
isActive,
setRichText,
onPhotoPasted,
onPressPublish,
onNewLink,
onFocus,
}: // onError, TODO
TextInputProps,
export function TextInput({
ref,
) {
richtext,
placeholder,
webForceMinHeight,
hasRightPadding,
isActive,
setRichText,
onPhotoPasted,
onPressPublish,
onNewLink,
onFocus,
}: TextInputProps) {
const {theme: t, fonts} = useAlf()
const autocomplete = useActorAutocompleteFn()
const pal = usePalette('default')
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
const [isDropping, setIsDropping] = React.useState(false)
const [isDropping, setIsDropping] = useState(false)
const autocompleteRef = useRef<AutocompleteRef>(null)
const extensions = React.useMemo(
const extensions = useMemo(
() => [
Document,
LinkDecorator,
@@ -86,7 +69,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
HTMLAttributes: {
class: 'mention',
},
suggestion: createSuggestion({autocomplete}),
suggestion: createSuggestion({autocomplete, autocompleteRef}),
}),
Paragraph,
Placeholder.configure({
@@ -99,7 +82,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
[autocomplete, placeholder],
)
React.useEffect(() => {
useEffect(() => {
if (!isActive) {
return
}
@@ -109,7 +92,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
}
}, [onPressPublish, isActive])
React.useEffect(() => {
useEffect(() => {
if (!isActive) {
return
}
@@ -119,7 +102,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
}
}, [isActive, onPhotoPasted])
React.useEffect(() => {
useEffect(() => {
if (!isActive) {
return
}
@@ -296,13 +279,13 @@ export const TextInput = React.forwardRef(function TextInputImpl(
[modeClass],
)
const onEmojiInserted = React.useCallback(
const onEmojiInserted = useCallback(
(emoji: Emoji) => {
editor?.chain().focus().insertContent(emoji.native).run()
},
[editor],
)
React.useEffect(() => {
useEffect(() => {
if (!isActive) {
return
}
@@ -312,7 +295,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
}
}, [onEmojiInserted, isActive])
React.useImperativeHandle(ref, () => ({
useImperativeHandle(ref, () => ({
focus: () => {
editor?.chain().focus()
},
@@ -323,9 +306,10 @@ export const TextInput = React.forwardRef(function TextInputImpl(
const pos = editor?.state.selection.$anchor.pos
return pos ? editor?.view.coordsAtPos(pos) : undefined
},
maybeClosePopup: () => autocompleteRef.current?.maybeClose() ?? false,
}))
const inputStyle = React.useMemo(() => {
const inputStyle = useMemo(() => {
const style = normalizeTextStyles(
[a.text_lg, a.leading_snug, t.atoms.text],
{
@@ -360,10 +344,20 @@ export const TextInput = React.forwardRef(function TextInputImpl(
style={styles.dropContainer}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}>
<View style={[pal.view, pal.border, styles.dropModal]}>
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_low,
styles.dropModal,
]}>
<Text
type="lg"
style={[pal.text, pal.borderDark, styles.dropText]}>
style={[
a.text_lg,
a.font_bold,
t.atoms.text_contrast_medium,
t.atoms.border_contrast_high,
styles.dropText,
]}>
<Trans>Drop to add images</Trans>
</Text>
</View>
@@ -372,7 +366,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
)}
</>
)
})
}
function editorJsonToText(
json: JSONContent,
@@ -1,6 +1,6 @@
import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {type AppBskyActorDefs} from '@atproto/api'
import {Pressable, View} from 'react-native'
import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {ReactRenderer} from '@tiptap/react'
import {
@@ -10,25 +10,26 @@ import {
} from '@tiptap/suggestion'
import tippy, {type Instance as TippyInstance} from 'tippy.js'
import {usePalette} from '#/lib/hooks/usePalette'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {type ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {Text} from '#/view/com/util/text/Text'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a} from '#/alf'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {useGrapheme} from '../hooks/useGrapheme'
import {atoms as a, useTheme} from '#/alf'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
interface MentionListRef {
onKeyDown: (props: SuggestionKeyDownProps) => boolean
}
export interface AutocompleteRef {
maybeClose: () => boolean
}
export function createSuggestion({
autocomplete,
autocompleteRef,
}: {
autocomplete: ActorAutocompleteFn
autocompleteRef: React.Ref<AutocompleteRef>
}): Omit<SuggestionOptions, 'editor'> {
return {
async items({query}) {
@@ -40,10 +41,15 @@ export function createSuggestion({
let component: ReactRenderer<MentionListRef> | undefined
let popup: TippyInstance[] | undefined
const hide = () => {
popup?.[0]?.destroy()
component?.destroy()
}
return {
onStart: props => {
component = new ReactRenderer(MentionList, {
props,
props: {...props, autocompleteRef, hide},
editor: props.editor,
})
@@ -78,204 +84,163 @@ export function createSuggestion({
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup?.[0]?.hide()
return true
return false
}
return component?.ref?.onKeyDown(props) || false
},
onExit() {
popup?.[0]?.destroy()
component?.destroy()
hide()
},
}
},
}
}
const MentionList = forwardRef<MentionListRef, SuggestionProps>(
function MentionListImpl(props: SuggestionProps, ref) {
const [selectedIndex, setSelectedIndex] = useState(0)
const pal = usePalette('default')
const MentionList = forwardRef<
MentionListRef,
SuggestionProps & {
autocompleteRef: React.Ref<AutocompleteRef>
hide: () => void
}
>(function MentionListImpl({items, command, hide, autocompleteRef}, ref) {
const [selectedIndex, setSelectedIndex] = useState(0)
const t = useTheme()
const moderationOpts = useModerationOpts()
const selectItem = (index: number) => {
const item = props.items[index]
const selectItem = (index: number) => {
const item = items[index]
if (item) {
props.command({id: item.handle})
if (item) {
command({id: item.handle})
}
}
const upHandler = () => {
setSelectedIndex((selectedIndex + items.length - 1) % items.length)
}
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % items.length)
}
const enterHandler = () => {
selectItem(selectedIndex)
}
useEffect(() => setSelectedIndex(0), [items])
useImperativeHandle(autocompleteRef, () => ({
maybeClose: () => {
hide()
return true
},
}))
useImperativeHandle(ref, () => ({
onKeyDown: ({event}) => {
if (event.key === 'ArrowUp') {
upHandler()
return true
}
}
const upHandler = () => {
setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length,
)
}
if (event.key === 'ArrowDown') {
downHandler()
return true
}
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length)
}
if (event.key === 'Enter' || event.key === 'Tab') {
enterHandler()
return true
}
const enterHandler = () => {
selectItem(selectedIndex)
}
return false
},
}))
useEffect(() => setSelectedIndex(0), [props.items])
if (!moderationOpts) return null
useImperativeHandle(ref, () => ({
onKeyDown: ({event}) => {
if (event.key === 'ArrowUp') {
upHandler()
return true
}
return (
<div className="items">
<View
style={[
t.atoms.border_contrast_low,
t.atoms.bg,
a.rounded_sm,
a.border,
a.p_xs,
{width: 300},
]}>
{items.length > 0 ? (
items.map((item, index) => {
const isSelected = selectedIndex === index
if (event.key === 'ArrowDown') {
downHandler()
return true
}
if (event.key === 'Enter' || event.key === 'Tab') {
enterHandler()
return true
}
return false
},
}))
const {items} = props
return (
<div className="items">
<View style={[pal.borderDark, pal.view, styles.container]}>
{items.length > 0 ? (
items.map((item, index) => {
const isSelected = selectedIndex === index
return (
<AutocompleteProfileCard
key={item.handle}
profile={item}
isSelected={isSelected}
itemIndex={index}
totalItems={items.length}
onPress={() => {
selectItem(index)
}}
/>
)
})
) : (
<Text type="sm" style={[pal.text, styles.noResult]}>
<Trans>No result</Trans>
</Text>
)}
</View>
</div>
)
},
)
return (
<AutocompleteProfileCard
key={item.handle}
profile={item}
isSelected={isSelected}
onPress={() => selectItem(index)}
onHover={() => setSelectedIndex(index)}
moderationOpts={moderationOpts}
/>
)
})
) : (
<Text style={[a.text_sm, a.px_md, a.py_md]}>
<Trans>No result</Trans>
</Text>
)}
</View>
</div>
)
})
function AutocompleteProfileCard({
profile,
isSelected,
itemIndex,
totalItems,
onPress,
onHover,
moderationOpts,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
isSelected: boolean
itemIndex: number
totalItems: number
onPress: () => void
onHover: () => void
moderationOpts: ModerationOpts
}) {
const pal = usePalette('default')
const {getGraphemeString} = useGrapheme()
const {name: displayName} = getGraphemeString(
sanitizeDisplayName(profile.displayName || sanitizeHandle(profile.handle)),
30, // Heuristic value; can be modified
)
const state = useSimpleVerificationState({
profile,
})
const t = useTheme()
return (
<Pressable
style={[
isSelected ? pal.viewLight : undefined,
pal.borderDark,
styles.mentionContainer,
itemIndex === 0
? styles.firstMention
: itemIndex === totalItems - 1
? styles.lastMention
: undefined,
isSelected && t.atoms.bg_contrast_25,
a.align_center,
a.justify_between,
a.flex_row,
a.px_md,
a.py_sm,
a.gap_2xl,
a.rounded_xs,
a.transition_color,
]}
onPress={onPress}
onPointerEnter={onHover}
accessibilityRole="button">
<View style={[styles.avatarAndDisplayName, a.flex_1]}>
<UserAvatar
avatar={profile.avatar ?? null}
size={26}
type={profile.associated?.labeler ? 'labeler' : 'user'}
/>
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
<Text emoji style={[pal.text]} numberOfLines={1}>
{displayName}
</Text>
{state.isVerified && (
<View>
<VerificationCheck
width={12}
verifier={state.role === 'verifier'}
/>
</View>
)}
</View>
</View>
<View>
<Text type="xs" style={pal.textLight} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
<View style={[a.flex_1]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
</View>
</Pressable>
)
}
const styles = StyleSheet.create({
container: {
width: 500,
borderRadius: 6,
borderWidth: 1,
borderStyle: 'solid',
padding: 4,
},
mentionContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: 'row',
paddingHorizontal: 12,
paddingVertical: 8,
gap: 16,
},
firstMention: {
borderTopLeftRadius: 2,
borderTopRightRadius: 2,
},
lastMention: {
borderBottomLeftRadius: 2,
borderBottomRightRadius: 2,
},
avatarAndDisplayName: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
noResult: {
paddingHorizontal: 12,
paddingVertical: 8,
},
})