forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'
2import {Pressable, View} from 'react-native'
3import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api'
4import {Trans} from '@lingui/macro'
5import {ReactRenderer} from '@tiptap/react'
6import {
7 type SuggestionKeyDownProps,
8 type SuggestionOptions,
9 type SuggestionProps,
10} from '@tiptap/suggestion'
11import tippy, {type Instance as TippyInstance} from 'tippy.js'
12
13import {useModerationOpts} from '#/state/preferences/moderation-opts'
14import {type ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
15import {atoms as a, useTheme} from '#/alf'
16import * as ProfileCard from '#/components/ProfileCard'
17import {Text} from '#/components/Typography'
18
19interface MentionListRef {
20 onKeyDown: (props: SuggestionKeyDownProps) => boolean
21}
22
23export interface AutocompleteRef {
24 maybeClose: () => boolean
25}
26
27export function createSuggestion({
28 autocomplete,
29 autocompleteRef,
30}: {
31 autocomplete: ActorAutocompleteFn
32 autocompleteRef: React.Ref<AutocompleteRef>
33}): Omit<SuggestionOptions, 'editor'> {
34 return {
35 async items({query}) {
36 const suggestions = await autocomplete({query})
37 return suggestions.slice(0, 8)
38 },
39
40 render: () => {
41 let component: ReactRenderer<MentionListRef> | undefined
42 let popup: TippyInstance[] | undefined
43
44 const hide = () => {
45 popup?.[0]?.destroy()
46 component?.destroy()
47 }
48
49 return {
50 onStart: props => {
51 component = new ReactRenderer(MentionList, {
52 props: {...props, autocompleteRef, hide},
53 editor: props.editor,
54 })
55
56 if (!props.clientRect) {
57 return
58 }
59
60 // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
61 popup = tippy('body', {
62 getReferenceClientRect: props.clientRect,
63 appendTo: () => document.body,
64 content: component.element,
65 showOnCreate: true,
66 interactive: true,
67 trigger: 'manual',
68 placement: 'bottom-start',
69 })
70 },
71
72 onUpdate(props) {
73 component?.updateProps(props)
74
75 if (!props.clientRect) {
76 return
77 }
78
79 popup?.[0]?.setProps({
80 // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
81 getReferenceClientRect: props.clientRect,
82 })
83 },
84
85 onKeyDown(props) {
86 if (props.event.key === 'Escape') {
87 return false
88 }
89
90 return component?.ref?.onKeyDown(props) || false
91 },
92
93 onExit() {
94 hide()
95 },
96 }
97 },
98 }
99}
100
101const MentionList = forwardRef<
102 MentionListRef,
103 SuggestionProps & {
104 autocompleteRef: React.Ref<AutocompleteRef>
105 hide: () => void
106 }
107>(function MentionListImpl({items, command, hide, autocompleteRef}, ref) {
108 const [selectedIndex, setSelectedIndex] = useState(0)
109 const t = useTheme()
110 const moderationOpts = useModerationOpts()
111
112 const selectItem = (index: number) => {
113 const item = items[index]
114
115 if (item) {
116 command({id: item.handle})
117 }
118 }
119
120 const upHandler = () => {
121 setSelectedIndex((selectedIndex + items.length - 1) % items.length)
122 }
123
124 const downHandler = () => {
125 setSelectedIndex((selectedIndex + 1) % items.length)
126 }
127
128 const enterHandler = () => {
129 selectItem(selectedIndex)
130 }
131
132 useEffect(() => setSelectedIndex(0), [items])
133
134 useImperativeHandle(autocompleteRef, () => ({
135 maybeClose: () => {
136 hide()
137 return true
138 },
139 }))
140
141 useImperativeHandle(ref, () => ({
142 onKeyDown: ({event}) => {
143 if (event.key === 'ArrowUp') {
144 upHandler()
145 return true
146 }
147
148 if (event.key === 'ArrowDown') {
149 downHandler()
150 return true
151 }
152
153 if (event.key === 'Enter' || event.key === 'Tab') {
154 enterHandler()
155 return true
156 }
157
158 return false
159 },
160 }))
161
162 if (!moderationOpts) return null
163
164 return (
165 <div className="items">
166 <View
167 style={[
168 t.atoms.border_contrast_low,
169 t.atoms.bg,
170 a.rounded_sm,
171 a.border,
172 a.p_xs,
173 {width: 300},
174 ]}>
175 {items.length > 0 ? (
176 items.map((item, index) => {
177 const isSelected = selectedIndex === index
178
179 return (
180 <AutocompleteProfileCard
181 key={item.handle}
182 profile={item}
183 isSelected={isSelected}
184 onPress={() => selectItem(index)}
185 onHover={() => setSelectedIndex(index)}
186 moderationOpts={moderationOpts}
187 />
188 )
189 })
190 ) : (
191 <Text style={[a.text_sm, a.px_md, a.py_md]}>
192 <Trans>No result</Trans>
193 </Text>
194 )}
195 </View>
196 </div>
197 )
198})
199
200function AutocompleteProfileCard({
201 profile,
202 isSelected,
203 onPress,
204 onHover,
205 moderationOpts,
206}: {
207 profile: AppBskyActorDefs.ProfileViewBasic
208 isSelected: boolean
209 onPress: () => void
210 onHover: () => void
211 moderationOpts: ModerationOpts
212}) {
213 const t = useTheme()
214
215 return (
216 <Pressable
217 style={[
218 isSelected && t.atoms.bg_contrast_25,
219 a.align_center,
220 a.justify_between,
221 a.flex_row,
222 a.px_md,
223 a.py_sm,
224 a.gap_2xl,
225 a.rounded_xs,
226 a.transition_color,
227 ]}
228 onPress={onPress}
229 onPointerEnter={onHover}
230 accessibilityRole="button">
231 <View style={[a.flex_1]}>
232 <ProfileCard.Header>
233 <ProfileCard.Avatar
234 profile={profile}
235 moderationOpts={moderationOpts}
236 disabledPreview
237 />
238 <ProfileCard.NameAndHandle
239 profile={profile}
240 moderationOpts={moderationOpts}
241 />
242 </ProfileCard.Header>
243 </View>
244 </Pressable>
245 )
246}