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:
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user