diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-08-29 03:03:12 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-08-28 17:03:12 -0700 |
commit | 8a4398608acac5f44e8d3c0ea8f6976b8ae1119a (patch) | |
tree | 7e7f75a11d12403efd4069ba00c4e362254a5d1a | |
parent | 27c105856868da9c25a0e8732ff625a602967287 (diff) | |
download | voidsky-8a4398608acac5f44e8d3c0ea8f6976b8ae1119a.tar.zst |
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
-rw-r--r-- | src/view/com/composer/Composer.tsx | 10 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.tsx | 57 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.types.ts | 42 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.web.tsx | 104 | ||||
-rw-r--r-- | src/view/com/composer/text-input/web/Autocomplete.tsx | 313 |
5 files changed, 253 insertions, 273 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index b533510ec..20f2549ad 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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 || diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index ea92d0b91..8b3e61b0e 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -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> ) -}) +} diff --git a/src/view/com/composer/text-input/TextInput.types.ts b/src/view/com/composer/text-input/TextInput.types.ts new file mode 100644 index 000000000..fab2bc32f --- /dev/null +++ b/src/view/com/composer/text-input/TextInput.types.ts @@ -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' +> diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index cb7ed194a..9f6cc6ae2 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -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, diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index 94ecb53cc..1a95736c3 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -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 + props.items.length - 1) % props.items.length, - ) - } + const upHandler = () => { + setSelectedIndex((selectedIndex + items.length - 1) % items.length) + } - const downHandler = () => { - setSelectedIndex((selectedIndex + 1) % props.items.length) - } + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % items.length) + } - const enterHandler = () => { - selectItem(selectedIndex) - } + 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 + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter' || event.key === 'Tab') { + enterHandler() + return true + } + + return false + }, + })) - useEffect(() => setSelectedIndex(0), [props.items]) - - useImperativeHandle(ref, () => ({ - onKeyDown: ({event}) => { - if (event.key === 'ArrowUp') { - upHandler() - return true - } - - 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> - ) - }, -) + if (!moderationOpts) return null + + 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 + + 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, - }, -}) |