forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useMemo} from 'react'
2import {moderateProfile, type ModerationOpts} from '@atproto/api'
3import {keepPreviousData, useQuery} from '@tanstack/react-query'
4
5import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation'
6import {useModerationOpts} from '#/state/preferences/moderation-opts'
7import {STALE} from '#/state/queries'
8import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences'
9import {useAgent} from '#/state/session'
10import {
11 type AutocompleteApi,
12 type AutocompleteItem,
13 type AutocompleteItemType,
14 type AutocompleteProfile,
15} from '#/components/Autocomplete/types'
16import {useEmojiSearch} from './useEmojiSearch'
17
18const DEFAULT_MOD_OPTS = {
19 userDid: undefined,
20 prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
21}
22
23export function useAutocomplete({
24 type,
25 query: q,
26 limit,
27 showSearchFallback = false,
28}: {
29 type: AutocompleteItemType
30 query: string
31 limit?: number
32 showSearchFallback?: boolean
33}): AutocompleteApi {
34 const agent = useAgent()
35 const moderationOpts = useModerationOpts()
36 const emojiSearch = useEmojiSearch()
37
38 const query = useQuery({
39 staleTime: STALE.MINUTES.ONE,
40 queryKey: [
41 'autocomplete',
42 {
43 type,
44 query: q,
45 },
46 ],
47 async queryFn() {
48 if (type === 'profile') {
49 // TODO return recents
50 if (!q) return []
51
52 // Going from "foo" to "foo." should not clear matches.
53 q = q.toLowerCase().trim().replace(/\.$/, '')
54
55 const res = await agent.searchActorsTypeahead({
56 q,
57 limit: limit || 8,
58 })
59
60 return (res?.data.actors || []).map(profile => ({
61 key: profile.did,
62 type: 'profile' as const,
63 value: '@' + profile.handle,
64 profile,
65 }))
66 } else if (type === 'emoji') {
67 return emojiSearch(q, limit || 8)
68 }
69
70 return []
71 },
72 select: useCallback(
73 (items: AutocompleteItem[]) => {
74 const seen = new Set<string>()
75 let results: AutocompleteItem[] = []
76
77 for (const item of items) {
78 if (seen.has(item.key)) continue
79 seen.add(item.key)
80
81 if (item.type === 'profile') {
82 const moderated = moderateProfileItem({
83 query: q,
84 item,
85 moderationOpts: moderationOpts || DEFAULT_MOD_OPTS,
86 })
87 if (moderated) results.push(moderated)
88 } else {
89 results.push(item)
90 }
91 }
92
93 return results
94 },
95 [q, moderationOpts],
96 ),
97 placeholderData: keepPreviousData,
98 })
99
100 const items = useMemo(() => {
101 if (!query.data) {
102 return []
103 }
104
105 const results = [...query.data]
106
107 if (showSearchFallback && q) {
108 results.unshift({
109 key: `search-${q}`,
110 type: 'search' as const,
111 value: q,
112 })
113 }
114
115 return results
116 }, [query.data, showSearchFallback, q])
117
118 return {
119 query: q,
120 items,
121 }
122}
123
124function moderateProfileItem({
125 query,
126 item,
127 moderationOpts,
128}: {
129 query: string
130 item: AutocompleteProfile
131 moderationOpts: ModerationOpts
132}) {
133 const modui = moderateProfile(item.profile, moderationOpts).ui('profileList')
134 const isExactMatch = query && item.profile.handle.toLowerCase() === query
135
136 if (
137 (isExactMatch && !moduiContainsHideableOffense(modui)) ||
138 !modui.filter ||
139 isJustAMute(modui)
140 ) {
141 return item
142 }
143
144 return null
145}