235 lines
6 KiB
TypeScript
235 lines
6 KiB
TypeScript
import React, {
|
|
forwardRef,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useState,
|
|
} from 'react'
|
|
import {Pressable, StyleSheet, View} from 'react-native'
|
|
import {ReactRenderer} from '@tiptap/react'
|
|
import tippy, {Instance as TippyInstance} from 'tippy.js'
|
|
import {
|
|
SuggestionOptions,
|
|
SuggestionProps,
|
|
SuggestionKeyDownProps,
|
|
} from '@tiptap/suggestion'
|
|
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
|
import {usePalette} from 'lib/hooks/usePalette'
|
|
import {Text} from 'view/com/util/text/Text'
|
|
import {UserAvatar} from 'view/com/util/UserAvatar'
|
|
import {useGrapheme} from '../hooks/useGrapheme'
|
|
|
|
interface MentionListRef {
|
|
onKeyDown: (props: SuggestionKeyDownProps) => boolean
|
|
}
|
|
|
|
export function createSuggestion({
|
|
autocompleteView,
|
|
}: {
|
|
autocompleteView: UserAutocompleteModel
|
|
}): Omit<SuggestionOptions, 'editor'> {
|
|
return {
|
|
async items({query}) {
|
|
autocompleteView.setActive(true)
|
|
await autocompleteView.setPrefix(query)
|
|
return autocompleteView.suggestions.slice(0, 8)
|
|
},
|
|
|
|
render: () => {
|
|
let component: ReactRenderer<MentionListRef> | undefined
|
|
let popup: TippyInstance[] | undefined
|
|
|
|
return {
|
|
onStart: props => {
|
|
component = new ReactRenderer(MentionList, {
|
|
props,
|
|
editor: props.editor,
|
|
})
|
|
|
|
if (!props.clientRect) {
|
|
return
|
|
}
|
|
|
|
// @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
|
|
popup = tippy('body', {
|
|
getReferenceClientRect: props.clientRect,
|
|
appendTo: () => document.body,
|
|
content: component.element,
|
|
showOnCreate: true,
|
|
interactive: true,
|
|
trigger: 'manual',
|
|
placement: 'bottom-start',
|
|
})
|
|
},
|
|
|
|
onUpdate(props) {
|
|
component?.updateProps(props)
|
|
|
|
if (!props.clientRect) {
|
|
return
|
|
}
|
|
|
|
popup?.[0]?.setProps({
|
|
// @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
|
|
getReferenceClientRect: props.clientRect,
|
|
})
|
|
},
|
|
|
|
onKeyDown(props) {
|
|
if (props.event.key === 'Escape') {
|
|
popup?.[0]?.hide()
|
|
|
|
return true
|
|
}
|
|
|
|
return component?.ref?.onKeyDown(props) || false
|
|
},
|
|
|
|
onExit() {
|
|
popup?.[0]?.destroy()
|
|
component?.destroy()
|
|
},
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
const MentionList = forwardRef<MentionListRef, SuggestionProps>(
|
|
(props: SuggestionProps, ref) => {
|
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
const pal = usePalette('default')
|
|
const {getGraphemeString} = useGrapheme()
|
|
|
|
const selectItem = (index: number) => {
|
|
const item = props.items[index]
|
|
|
|
if (item) {
|
|
props.command({id: item.handle})
|
|
}
|
|
}
|
|
|
|
const upHandler = () => {
|
|
setSelectedIndex(
|
|
(selectedIndex + props.items.length - 1) % props.items.length,
|
|
)
|
|
}
|
|
|
|
const downHandler = () => {
|
|
setSelectedIndex((selectedIndex + 1) % props.items.length)
|
|
}
|
|
|
|
const enterHandler = () => {
|
|
selectItem(selectedIndex)
|
|
}
|
|
|
|
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') {
|
|
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 {name: displayName} = getGraphemeString(
|
|
item.displayName ?? item.handle,
|
|
30, // Heuristic value; can be modified
|
|
)
|
|
const isSelected = selectedIndex === index
|
|
|
|
return (
|
|
<Pressable
|
|
key={item.handle}
|
|
style={[
|
|
isSelected ? pal.viewLight : undefined,
|
|
pal.borderDark,
|
|
styles.mentionContainer,
|
|
index === 0
|
|
? styles.firstMention
|
|
: index === items.length - 1
|
|
? styles.lastMention
|
|
: undefined,
|
|
]}
|
|
onPress={() => {
|
|
selectItem(index)
|
|
}}
|
|
accessibilityRole="button">
|
|
<View style={styles.avatarAndDisplayName}>
|
|
<UserAvatar avatar={item.avatar ?? null} size={26} />
|
|
<Text style={pal.text} numberOfLines={1}>
|
|
{displayName}
|
|
</Text>
|
|
</View>
|
|
<Text type="xs" style={pal.textLight} numberOfLines={1}>
|
|
@{item.handle}
|
|
</Text>
|
|
</Pressable>
|
|
)
|
|
})
|
|
) : (
|
|
<Text type="sm" style={[pal.text, styles.noResult]}>
|
|
No result
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</div>
|
|
)
|
|
},
|
|
)
|
|
|
|
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: 4,
|
|
},
|
|
firstMention: {
|
|
borderTopLeftRadius: 2,
|
|
borderTopRightRadius: 2,
|
|
},
|
|
lastMention: {
|
|
borderBottomLeftRadius: 2,
|
|
borderBottomRightRadius: 2,
|
|
},
|
|
avatarAndDisplayName: {
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
},
|
|
noResult: {
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 8,
|
|
},
|
|
})
|