mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {
2 forwardRef,
3 useEffect,
4 useImperativeHandle,
5 useState,
6} from 'react'
7import {Pressable, StyleSheet, View} from 'react-native'
8import {ReactRenderer} from '@tiptap/react'
9import tippy, {Instance as TippyInstance} from 'tippy.js'
10import {
11 SuggestionOptions,
12 SuggestionProps,
13 SuggestionKeyDownProps,
14} from '@tiptap/suggestion'
15import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
16import {usePalette} from 'lib/hooks/usePalette'
17import {Text} from 'view/com/util/text/Text'
18import {UserAvatar} from 'view/com/util/UserAvatar'
19import {useGrapheme} from '../hooks/useGrapheme'
20import {Trans} from '@lingui/macro'
21
22interface MentionListRef {
23 onKeyDown: (props: SuggestionKeyDownProps) => boolean
24}
25
26export function createSuggestion({
27 autocomplete,
28}: {
29 autocomplete: ActorAutocompleteFn
30}): Omit<SuggestionOptions, 'editor'> {
31 return {
32 async items({query}) {
33 const suggestions = await autocomplete({query})
34 return suggestions.slice(0, 8)
35 },
36
37 render: () => {
38 let component: ReactRenderer<MentionListRef> | undefined
39 let popup: TippyInstance[] | undefined
40
41 return {
42 onStart: props => {
43 component = new ReactRenderer(MentionList, {
44 props,
45 editor: props.editor,
46 })
47
48 if (!props.clientRect) {
49 return
50 }
51
52 // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
53 popup = tippy('body', {
54 getReferenceClientRect: props.clientRect,
55 appendTo: () => document.body,
56 content: component.element,
57 showOnCreate: true,
58 interactive: true,
59 trigger: 'manual',
60 placement: 'bottom-start',
61 })
62 },
63
64 onUpdate(props) {
65 component?.updateProps(props)
66
67 if (!props.clientRect) {
68 return
69 }
70
71 popup?.[0]?.setProps({
72 // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
73 getReferenceClientRect: props.clientRect,
74 })
75 },
76
77 onKeyDown(props) {
78 if (props.event.key === 'Escape') {
79 popup?.[0]?.hide()
80
81 return true
82 }
83
84 return component?.ref?.onKeyDown(props) || false
85 },
86
87 onExit() {
88 popup?.[0]?.destroy()
89 component?.destroy()
90 },
91 }
92 },
93 }
94}
95
96const MentionList = forwardRef<MentionListRef, SuggestionProps>(
97 function MentionListImpl(props: SuggestionProps, ref) {
98 const [selectedIndex, setSelectedIndex] = useState(0)
99 const pal = usePalette('default')
100 const {getGraphemeString} = useGrapheme()
101
102 const selectItem = (index: number) => {
103 const item = props.items[index]
104
105 if (item) {
106 props.command({id: item.handle})
107 }
108 }
109
110 const upHandler = () => {
111 setSelectedIndex(
112 (selectedIndex + props.items.length - 1) % props.items.length,
113 )
114 }
115
116 const downHandler = () => {
117 setSelectedIndex((selectedIndex + 1) % props.items.length)
118 }
119
120 const enterHandler = () => {
121 selectItem(selectedIndex)
122 }
123
124 useEffect(() => setSelectedIndex(0), [props.items])
125
126 useImperativeHandle(ref, () => ({
127 onKeyDown: ({event}) => {
128 if (event.key === 'ArrowUp') {
129 upHandler()
130 return true
131 }
132
133 if (event.key === 'ArrowDown') {
134 downHandler()
135 return true
136 }
137
138 if (event.key === 'Enter' || event.key === 'Tab') {
139 enterHandler()
140 return true
141 }
142
143 return false
144 },
145 }))
146
147 const {items} = props
148
149 return (
150 <div className="items">
151 <View style={[pal.borderDark, pal.view, styles.container]}>
152 {items.length > 0 ? (
153 items.map((item, index) => {
154 const {name: displayName} = getGraphemeString(
155 item.displayName ?? item.handle,
156 30, // Heuristic value; can be modified
157 )
158 const isSelected = selectedIndex === index
159
160 return (
161 <Pressable
162 key={item.handle}
163 style={[
164 isSelected ? pal.viewLight : undefined,
165 pal.borderDark,
166 styles.mentionContainer,
167 index === 0
168 ? styles.firstMention
169 : index === items.length - 1
170 ? styles.lastMention
171 : undefined,
172 ]}
173 onPress={() => {
174 selectItem(index)
175 }}
176 accessibilityRole="button">
177 <View style={styles.avatarAndDisplayName}>
178 <UserAvatar avatar={item.avatar ?? null} size={26} />
179 <Text style={pal.text} numberOfLines={1}>
180 {displayName}
181 </Text>
182 </View>
183 <Text type="xs" style={pal.textLight} numberOfLines={1}>
184 @{item.handle}
185 </Text>
186 </Pressable>
187 )
188 })
189 ) : (
190 <Text type="sm" style={[pal.text, styles.noResult]}>
191 <Trans>No result</Trans>
192 </Text>
193 )}
194 </View>
195 </div>
196 )
197 },
198)
199
200const styles = StyleSheet.create({
201 container: {
202 width: 500,
203 borderRadius: 6,
204 borderWidth: 1,
205 borderStyle: 'solid',
206 padding: 4,
207 },
208 mentionContainer: {
209 display: 'flex',
210 alignItems: 'center',
211 justifyContent: 'space-between',
212 flexDirection: 'row',
213 paddingHorizontal: 12,
214 paddingVertical: 8,
215 gap: 4,
216 },
217 firstMention: {
218 borderTopLeftRadius: 2,
219 borderTopRightRadius: 2,
220 },
221 lastMention: {
222 borderBottomLeftRadius: 2,
223 borderBottomRightRadius: 2,
224 },
225 avatarAndDisplayName: {
226 display: 'flex',
227 flexDirection: 'row',
228 alignItems: 'center',
229 gap: 6,
230 },
231 noResult: {
232 paddingHorizontal: 12,
233 paddingVertical: 8,
234 },
235})