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' import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
// TODO: Prevent naming components that coincide with RN primitives // TODO: Prevent naming components that coincide with RN primitives
// due to linting false positives // due to linting false positives
import { import {TextInput} from '#/view/com/composer/text-input/TextInput'
TextInput,
type TextInputRef,
} from '#/view/com/composer/text-input/TextInput'
import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn' import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
@@ -155,6 +152,7 @@ import {
processVideo, processVideo,
type VideoState, type VideoState,
} from './state/video' } from './state/video'
import {type TextInputRef} from './text-input/TextInput.types'
import {getVideoMetadata} from './videos/pickVideo' import {getVideoMetadata} from './videos/pickVideo'
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop' import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
@@ -306,7 +304,9 @@ export const ComposePost = ({
) )
const onPressCancel = useCallback(() => { const onPressCancel = useCallback(() => {
if ( if (textInput.current?.maybeClosePopup()) {
return
} else if (
thread.posts.some( thread.posts.some(
post => post =>
post.shortenedGraphemeLength > 0 || post.shortenedGraphemeLength > 0 ||
+11 -32
View File
@@ -1,7 +1,6 @@
import React, { import {
type ComponentProps,
forwardRef,
useCallback, useCallback,
useImperativeHandle,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@@ -9,7 +8,6 @@ import React, {
import { import {
type NativeSyntheticEvent, type NativeSyntheticEvent,
Text as RNText, Text as RNText,
type TextInput as RNTextInput,
type TextInputSelectionChangeEventData, type TextInputSelectionChangeEventData,
View, View,
} from 'react-native' } from 'react-native'
@@ -33,33 +31,15 @@ import {
import {atoms as a, useAlf} from '#/alf' import {atoms as a, useAlf} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography' import {normalizeTextStyles} from '#/alf/typography'
import {Autocomplete} from './mobile/Autocomplete' import {Autocomplete} from './mobile/Autocomplete'
import {type TextInputProps} from './TextInput.types'
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
}
interface Selection { interface Selection {
start: number start: number
end: number end: number
} }
export const TextInput = forwardRef(function TextInputImpl( export function TextInput({
{ ref,
richtext, richtext,
placeholder, placeholder,
hasRightPadding, hasRightPadding,
@@ -68,22 +48,21 @@ export const TextInput = forwardRef(function TextInputImpl(
onNewLink, onNewLink,
onError, onError,
...props ...props
}: TextInputProps, }: TextInputProps) {
ref,
) {
const {theme: t, fonts} = useAlf() const {theme: t, fonts} = useAlf()
const textInput = useRef<PasteInputRef>(null) const textInput = useRef<PasteInputRef>(null)
const textInputSelection = useRef<Selection>({start: 0, end: 0}) const textInputSelection = useRef<Selection>({start: 0, end: 0})
const theme = useTheme() const theme = useTheme()
const [autocompletePrefix, setAutocompletePrefix] = useState('') const [autocompletePrefix, setAutocompletePrefix] = useState('')
const prevLength = React.useRef(richtext.length) const prevLength = useRef(richtext.length)
React.useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focus: () => textInput.current?.focus(), focus: () => textInput.current?.focus(),
blur: () => { blur: () => {
textInput.current?.blur() textInput.current?.blur()
}, },
getCursorPosition: () => undefined, // Not implemented on native getCursorPosition: () => undefined, // Not implemented on native
maybeClosePopup: () => false, // Not needed on native
})) }))
const pastSuggestedUris = useRef(new Set<string>()) const pastSuggestedUris = useRef(new Set<string>())
@@ -185,7 +164,7 @@ export const TextInput = forwardRef(function TextInputImpl(
[onChangeText, richtext, setAutocompletePrefix], [onChangeText, richtext, setAutocompletePrefix],
) )
const inputTextStyle = React.useMemo(() => { const inputTextStyle = useMemo(() => {
const style = normalizeTextStyles( const style = normalizeTextStyles(
[a.text_lg, a.leading_snug, t.atoms.text], [a.text_lg, a.leading_snug, t.atoms.text],
{ {
@@ -277,4 +256,4 @@ export const TextInput = forwardRef(function TextInputImpl(
/> />
</View> </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 {StyleSheet, View} from 'react-native'
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
import {AppBskyRichtextFacet, RichText} from '@atproto/api' import {AppBskyRichtextFacet, RichText} from '@atproto/api'
@@ -16,7 +23,6 @@ import {EditorContent, type JSONContent, useEditor} from '@tiptap/react'
import Graphemer from 'graphemer' import Graphemer from 'graphemer'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette'
import {blobToDataUri, isUriImage} from '#/lib/media/util' import {blobToDataUri, isUriImage} from '#/lib/media/util'
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import { import {
@@ -27,35 +33,15 @@ import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEm
import {atoms as a, useAlf} from '#/alf' import {atoms as a, useAlf} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography' import {normalizeTextStyles} from '#/alf/typography'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {Text} from '../../util/text/Text' import {Text} from '#/components/Typography'
import {createSuggestion} from './web/Autocomplete' import {type TextInputProps} from './TextInput.types'
import {type AutocompleteRef, createSuggestion} from './web/Autocomplete'
import {type Emoji} from './web/EmojiPicker' import {type Emoji} from './web/EmojiPicker'
import {LinkDecorator} from './web/LinkDecorator' import {LinkDecorator} from './web/LinkDecorator'
import {TagDecorator} from './web/TagDecorator' import {TagDecorator} from './web/TagDecorator'
export interface TextInputRef { export function TextInput({
focus: () => void ref,
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, richtext,
placeholder, placeholder,
webForceMinHeight, webForceMinHeight,
@@ -66,18 +52,15 @@ export const TextInput = React.forwardRef(function TextInputImpl(
onPressPublish, onPressPublish,
onNewLink, onNewLink,
onFocus, onFocus,
}: // onError, TODO }: TextInputProps) {
TextInputProps,
ref,
) {
const {theme: t, fonts} = useAlf() const {theme: t, fonts} = useAlf()
const autocomplete = useActorAutocompleteFn() const autocomplete = useActorAutocompleteFn()
const pal = usePalette('default')
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') 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, Document,
LinkDecorator, LinkDecorator,
@@ -86,7 +69,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
HTMLAttributes: { HTMLAttributes: {
class: 'mention', class: 'mention',
}, },
suggestion: createSuggestion({autocomplete}), suggestion: createSuggestion({autocomplete, autocompleteRef}),
}), }),
Paragraph, Paragraph,
Placeholder.configure({ Placeholder.configure({
@@ -99,7 +82,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
[autocomplete, placeholder], [autocomplete, placeholder],
) )
React.useEffect(() => { useEffect(() => {
if (!isActive) { if (!isActive) {
return return
} }
@@ -109,7 +92,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
} }
}, [onPressPublish, isActive]) }, [onPressPublish, isActive])
React.useEffect(() => { useEffect(() => {
if (!isActive) { if (!isActive) {
return return
} }
@@ -119,7 +102,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
} }
}, [isActive, onPhotoPasted]) }, [isActive, onPhotoPasted])
React.useEffect(() => { useEffect(() => {
if (!isActive) { if (!isActive) {
return return
} }
@@ -296,13 +279,13 @@ export const TextInput = React.forwardRef(function TextInputImpl(
[modeClass], [modeClass],
) )
const onEmojiInserted = React.useCallback( const onEmojiInserted = useCallback(
(emoji: Emoji) => { (emoji: Emoji) => {
editor?.chain().focus().insertContent(emoji.native).run() editor?.chain().focus().insertContent(emoji.native).run()
}, },
[editor], [editor],
) )
React.useEffect(() => { useEffect(() => {
if (!isActive) { if (!isActive) {
return return
} }
@@ -312,7 +295,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
} }
}, [onEmojiInserted, isActive]) }, [onEmojiInserted, isActive])
React.useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focus: () => { focus: () => {
editor?.chain().focus() editor?.chain().focus()
}, },
@@ -323,9 +306,10 @@ export const TextInput = React.forwardRef(function TextInputImpl(
const pos = editor?.state.selection.$anchor.pos const pos = editor?.state.selection.$anchor.pos
return pos ? editor?.view.coordsAtPos(pos) : undefined return pos ? editor?.view.coordsAtPos(pos) : undefined
}, },
maybeClosePopup: () => autocompleteRef.current?.maybeClose() ?? false,
})) }))
const inputStyle = React.useMemo(() => { const inputStyle = useMemo(() => {
const style = normalizeTextStyles( const style = normalizeTextStyles(
[a.text_lg, a.leading_snug, t.atoms.text], [a.text_lg, a.leading_snug, t.atoms.text],
{ {
@@ -360,10 +344,20 @@ export const TextInput = React.forwardRef(function TextInputImpl(
style={styles.dropContainer} style={styles.dropContainer}
entering={FadeIn.duration(80)} entering={FadeIn.duration(80)}
exiting={FadeOut.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 <Text
type="lg" style={[
style={[pal.text, pal.borderDark, styles.dropText]}> a.text_lg,
a.font_bold,
t.atoms.text_contrast_medium,
t.atoms.border_contrast_high,
styles.dropText,
]}>
<Trans>Drop to add images</Trans> <Trans>Drop to add images</Trans>
</Text> </Text>
</View> </View>
@@ -372,7 +366,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
)} )}
</> </>
) )
}) }
function editorJsonToText( function editorJsonToText(
json: JSONContent, json: JSONContent,
@@ -1,6 +1,6 @@
import {forwardRef, useEffect, useImperativeHandle, useState} from 'react' import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'
import {Pressable, StyleSheet, View} from 'react-native' import {Pressable, View} from 'react-native'
import {type AppBskyActorDefs} from '@atproto/api' import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api'
import {Trans} from '@lingui/macro' import {Trans} from '@lingui/macro'
import {ReactRenderer} from '@tiptap/react' import {ReactRenderer} from '@tiptap/react'
import { import {
@@ -10,25 +10,26 @@ import {
} from '@tiptap/suggestion' } from '@tiptap/suggestion'
import tippy, {type Instance as TippyInstance} from 'tippy.js' import tippy, {type Instance as TippyInstance} from 'tippy.js'
import {usePalette} from '#/lib/hooks/usePalette' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {type ActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import {type ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {Text} from '#/view/com/util/text/Text' import {atoms as a, useTheme} from '#/alf'
import {UserAvatar} from '#/view/com/util/UserAvatar' import * as ProfileCard from '#/components/ProfileCard'
import {atoms as a} from '#/alf' import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {useGrapheme} from '../hooks/useGrapheme'
interface MentionListRef { interface MentionListRef {
onKeyDown: (props: SuggestionKeyDownProps) => boolean onKeyDown: (props: SuggestionKeyDownProps) => boolean
} }
export interface AutocompleteRef {
maybeClose: () => boolean
}
export function createSuggestion({ export function createSuggestion({
autocomplete, autocomplete,
autocompleteRef,
}: { }: {
autocomplete: ActorAutocompleteFn autocomplete: ActorAutocompleteFn
autocompleteRef: React.Ref<AutocompleteRef>
}): Omit<SuggestionOptions, 'editor'> { }): Omit<SuggestionOptions, 'editor'> {
return { return {
async items({query}) { async items({query}) {
@@ -40,10 +41,15 @@ export function createSuggestion({
let component: ReactRenderer<MentionListRef> | undefined let component: ReactRenderer<MentionListRef> | undefined
let popup: TippyInstance[] | undefined let popup: TippyInstance[] | undefined
const hide = () => {
popup?.[0]?.destroy()
component?.destroy()
}
return { return {
onStart: props => { onStart: props => {
component = new ReactRenderer(MentionList, { component = new ReactRenderer(MentionList, {
props, props: {...props, autocompleteRef, hide},
editor: props.editor, editor: props.editor,
}) })
@@ -78,51 +84,59 @@ export function createSuggestion({
onKeyDown(props) { onKeyDown(props) {
if (props.event.key === 'Escape') { if (props.event.key === 'Escape') {
popup?.[0]?.hide() return false
return true
} }
return component?.ref?.onKeyDown(props) || false return component?.ref?.onKeyDown(props) || false
}, },
onExit() { onExit() {
popup?.[0]?.destroy() hide()
component?.destroy()
}, },
} }
}, },
} }
} }
const MentionList = forwardRef<MentionListRef, SuggestionProps>( const MentionList = forwardRef<
function MentionListImpl(props: SuggestionProps, ref) { MentionListRef,
SuggestionProps & {
autocompleteRef: React.Ref<AutocompleteRef>
hide: () => void
}
>(function MentionListImpl({items, command, hide, autocompleteRef}, ref) {
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const pal = usePalette('default') const t = useTheme()
const moderationOpts = useModerationOpts()
const selectItem = (index: number) => { const selectItem = (index: number) => {
const item = props.items[index] const item = items[index]
if (item) { if (item) {
props.command({id: item.handle}) command({id: item.handle})
} }
} }
const upHandler = () => { const upHandler = () => {
setSelectedIndex( setSelectedIndex((selectedIndex + items.length - 1) % items.length)
(selectedIndex + props.items.length - 1) % props.items.length,
)
} }
const downHandler = () => { const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length) setSelectedIndex((selectedIndex + 1) % items.length)
} }
const enterHandler = () => { const enterHandler = () => {
selectItem(selectedIndex) selectItem(selectedIndex)
} }
useEffect(() => setSelectedIndex(0), [props.items]) useEffect(() => setSelectedIndex(0), [items])
useImperativeHandle(autocompleteRef, () => ({
maybeClose: () => {
hide()
return true
},
}))
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
onKeyDown: ({event}) => { onKeyDown: ({event}) => {
@@ -145,11 +159,19 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
}, },
})) }))
const {items} = props if (!moderationOpts) return null
return ( return (
<div className="items"> <div className="items">
<View style={[pal.borderDark, pal.view, styles.container]}> <View
style={[
t.atoms.border_contrast_low,
t.atoms.bg,
a.rounded_sm,
a.border,
a.p_xs,
{width: 300},
]}>
{items.length > 0 ? ( {items.length > 0 ? (
items.map((item, index) => { items.map((item, index) => {
const isSelected = selectedIndex === index const isSelected = selectedIndex === index
@@ -159,123 +181,66 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
key={item.handle} key={item.handle}
profile={item} profile={item}
isSelected={isSelected} isSelected={isSelected}
itemIndex={index} onPress={() => selectItem(index)}
totalItems={items.length} onHover={() => setSelectedIndex(index)}
onPress={() => { moderationOpts={moderationOpts}
selectItem(index)
}}
/> />
) )
}) })
) : ( ) : (
<Text type="sm" style={[pal.text, styles.noResult]}> <Text style={[a.text_sm, a.px_md, a.py_md]}>
<Trans>No result</Trans> <Trans>No result</Trans>
</Text> </Text>
)} )}
</View> </View>
</div> </div>
) )
}, })
)
function AutocompleteProfileCard({ function AutocompleteProfileCard({
profile, profile,
isSelected, isSelected,
itemIndex,
totalItems,
onPress, onPress,
onHover,
moderationOpts,
}: { }: {
profile: AppBskyActorDefs.ProfileViewBasic profile: AppBskyActorDefs.ProfileViewBasic
isSelected: boolean isSelected: boolean
itemIndex: number
totalItems: number
onPress: () => void onPress: () => void
onHover: () => void
moderationOpts: ModerationOpts
}) { }) {
const pal = usePalette('default') const t = useTheme()
const {getGraphemeString} = useGrapheme()
const {name: displayName} = getGraphemeString(
sanitizeDisplayName(profile.displayName || sanitizeHandle(profile.handle)),
30, // Heuristic value; can be modified
)
const state = useSimpleVerificationState({
profile,
})
return ( return (
<Pressable <Pressable
style={[ style={[
isSelected ? pal.viewLight : undefined, isSelected && t.atoms.bg_contrast_25,
pal.borderDark, a.align_center,
styles.mentionContainer, a.justify_between,
itemIndex === 0 a.flex_row,
? styles.firstMention a.px_md,
: itemIndex === totalItems - 1 a.py_sm,
? styles.lastMention a.gap_2xl,
: undefined, a.rounded_xs,
a.transition_color,
]} ]}
onPress={onPress} onPress={onPress}
onPointerEnter={onHover}
accessibilityRole="button"> accessibilityRole="button">
<View style={[styles.avatarAndDisplayName, a.flex_1]}> <View style={[a.flex_1]}>
<UserAvatar <ProfileCard.Header>
avatar={profile.avatar ?? null} <ProfileCard.Avatar
size={26} profile={profile}
type={profile.associated?.labeler ? 'labeler' : 'user'} moderationOpts={moderationOpts}
disabledPreview
/> />
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> <ProfileCard.NameAndHandle
<Text emoji style={[pal.text]} numberOfLines={1}> profile={profile}
{displayName} moderationOpts={moderationOpts}
</Text>
{state.isVerified && (
<View>
<VerificationCheck
width={12}
verifier={state.role === 'verifier'}
/> />
</View> </ProfileCard.Header>
)}
</View>
</View>
<View>
<Text type="xs" style={pal.textLight} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View> </View>
</Pressable> </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,
},
})