Bluesky app fork with some witchin' additions 馃挮
1import {useCallback} 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 if (showSearchFallback && q) {
94 results.unshift({
95 key: `search-${q}`,
96 type: 'search' as const,
97 value: q,
98 })
99 }
100
101 return results
102 },
103 [q, showSearchFallback, moderationOpts],
104 ),
105 placeholderData: keepPreviousData,
106 })
107
108 return {
109 query: q,
110 items: query.data || [],
111 }
112}
113
114function moderateProfileItem({
115 query,
116 item,
117 moderationOpts,
118}: {
119 query: string
120 item: AutocompleteProfile
121 moderationOpts: ModerationOpts
122}) {
123 const modui = moderateProfile(item.profile, moderationOpts).ui('profileList')
124 const isExactMatch = query && item.profile.handle.toLowerCase() === query
125
126 if (
127 (isExactMatch && !moduiContainsHideableOffense(modui)) ||
128 !modui.filter ||
129 isJustAMute(modui)
130 ) {
131 return item
132 }
133
134 return null
135}