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 /src/view/com/composer/text-input/web/Autocomplete.tsx | |
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
Diffstat (limited to 'src/view/com/composer/text-input/web/Autocomplete.tsx')
-rw-r--r-- | src/view/com/composer/text-input/web/Autocomplete.tsx | 313 |
1 files changed, 139 insertions, 174 deletions
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, - }, -}) |