forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React from 'react'
2import {type AppBskyActorDefs} from '@atproto/api'
3
4import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
5import {useModerationOpts} from '#/state/preferences/moderation-opts'
6import {
7 useSuggestedFollowsByActorQuery,
8 useSuggestedFollowsQuery,
9} from '#/state/queries/suggested-follows'
10import {useBreakpoints} from '#/alf'
11import {ProfileGrid} from '#/components/FeedInterstitials'
12import {IS_ANDROID} from '#/env'
13
14const DISMISS_ANIMATION_DURATION = 200
15
16export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) {
17 const {gtMobile} = useBreakpoints()
18 const moderationOpts = useModerationOpts()
19 const maxLength = gtMobile ? 4 : 12
20 const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
21 did: actorDid,
22 })
23 const {
24 data: moreSuggestions,
25 fetchNextPage,
26 hasNextPage,
27 isFetchingNextPage,
28 } = useSuggestedFollowsQuery({limit: 25})
29
30 const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
31 new Set(),
32 )
33 const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
34 new Set(),
35 )
36
37 const onDismiss = React.useCallback((did: string) => {
38 // Start the fade animation
39 setDismissingDids(prev => new Set(prev).add(did))
40 // After animation completes, actually remove from list
41 setTimeout(() => {
42 setDismissedDids(prev => new Set(prev).add(did))
43 setDismissingDids(prev => {
44 const next = new Set(prev)
45 next.delete(did)
46 return next
47 })
48 }, DISMISS_ANIMATION_DURATION)
49 }, [])
50
51 // Combine profiles from the actor-specific query with fallback suggestions
52 const allProfiles = React.useMemo(() => {
53 const actorProfiles = data?.suggestions ?? []
54 const fallbackProfiles =
55 moreSuggestions?.pages.flatMap(page => page.actors) ?? []
56
57 // Dedupe by did, preferring actor-specific profiles
58 const seen = new Set<string>()
59 const combined: AppBskyActorDefs.ProfileView[] = []
60
61 for (const profile of actorProfiles) {
62 if (!seen.has(profile.did)) {
63 seen.add(profile.did)
64 combined.push(profile)
65 }
66 }
67
68 for (const profile of fallbackProfiles) {
69 if (!seen.has(profile.did) && profile.did !== actorDid) {
70 seen.add(profile.did)
71 combined.push(profile)
72 }
73 }
74
75 return combined
76 }, [data?.suggestions, moreSuggestions?.pages, actorDid])
77
78 const filteredProfiles = React.useMemo(() => {
79 return allProfiles.filter(p => !dismissedDids.has(p.did))
80 }, [allProfiles, dismissedDids])
81
82 // Fetch more when running low
83 React.useEffect(() => {
84 if (
85 moderationOpts &&
86 filteredProfiles.length < maxLength &&
87 hasNextPage &&
88 !isFetchingNextPage
89 ) {
90 fetchNextPage()
91 }
92 }, [
93 filteredProfiles.length,
94 maxLength,
95 hasNextPage,
96 isFetchingNextPage,
97 fetchNextPage,
98 moderationOpts,
99 ])
100
101 return (
102 <ProfileGrid
103 isSuggestionsLoading={isLoading}
104 profiles={filteredProfiles}
105 totalProfileCount={allProfiles.length}
106 recId={data?.recId}
107 error={error}
108 viewContext="profileHeader"
109 onDismiss={onDismiss}
110 dismissingDids={dismissingDids}
111 />
112 )
113}
114
115export function AnimatedProfileHeaderSuggestedFollows({
116 isExpanded,
117 actorDid,
118}: {
119 isExpanded: boolean
120 actorDid: string
121}) {
122 const {gtMobile} = useBreakpoints()
123 const moderationOpts = useModerationOpts()
124 const maxLength = gtMobile ? 4 : 12
125 const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
126 did: actorDid,
127 })
128 const {
129 data: moreSuggestions,
130 fetchNextPage,
131 hasNextPage,
132 isFetchingNextPage,
133 } = useSuggestedFollowsQuery({limit: 25})
134
135 const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
136 new Set(),
137 )
138 const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
139 new Set(),
140 )
141
142 const onDismiss = React.useCallback((did: string) => {
143 // Start the fade animation
144 setDismissingDids(prev => new Set(prev).add(did))
145 // After animation completes, actually remove from list
146 setTimeout(() => {
147 setDismissedDids(prev => new Set(prev).add(did))
148 setDismissingDids(prev => {
149 const next = new Set(prev)
150 next.delete(did)
151 return next
152 })
153 }, DISMISS_ANIMATION_DURATION)
154 }, [])
155
156 // Combine profiles from the actor-specific query with fallback suggestions
157 const allProfiles = React.useMemo(() => {
158 const actorProfiles = data?.suggestions ?? []
159 const fallbackProfiles =
160 moreSuggestions?.pages.flatMap(page => page.actors) ?? []
161
162 // Dedupe by did, preferring actor-specific profiles
163 const seen = new Set<string>()
164 const combined: AppBskyActorDefs.ProfileView[] = []
165
166 for (const profile of actorProfiles) {
167 if (!seen.has(profile.did)) {
168 seen.add(profile.did)
169 combined.push(profile)
170 }
171 }
172
173 for (const profile of fallbackProfiles) {
174 if (!seen.has(profile.did) && profile.did !== actorDid) {
175 seen.add(profile.did)
176 combined.push(profile)
177 }
178 }
179
180 return combined
181 }, [data?.suggestions, moreSuggestions?.pages, actorDid])
182
183 const filteredProfiles = React.useMemo(() => {
184 return allProfiles.filter(p => !dismissedDids.has(p.did))
185 }, [allProfiles, dismissedDids])
186
187 // Fetch more when running low
188 React.useEffect(() => {
189 if (
190 moderationOpts &&
191 filteredProfiles.length < maxLength &&
192 hasNextPage &&
193 !isFetchingNextPage
194 ) {
195 fetchNextPage()
196 }
197 }, [
198 filteredProfiles.length,
199 maxLength,
200 hasNextPage,
201 isFetchingNextPage,
202 fetchNextPage,
203 moderationOpts,
204 ])
205
206 if (!allProfiles.length && !isLoading) return null
207
208 /* NOTE (caidanw):
209 * Android does not work well with this feature yet.
210 * This issue stems from Android not allowing dragging on clickable elements in the profile header.
211 * Blocking the ability to scroll on Android is too much of a trade-off for now.
212 **/
213 if (IS_ANDROID) return null
214
215 return (
216 <AccordionAnimation isExpanded={isExpanded}>
217 <ProfileGrid
218 isSuggestionsLoading={isLoading}
219 profiles={filteredProfiles}
220 totalProfileCount={allProfiles.length}
221 recId={data?.recId}
222 error={error}
223 viewContext="profileHeader"
224 onDismiss={onDismiss}
225 dismissingDids={dismissingDids}
226 isVisible={isExpanded}
227 />
228 </AccordionAnimation>
229 )
230}