+305
-74
src/components/FeedInterstitials.tsx
+305
-74
src/components/FeedInterstitials.tsx
···
1
1
import React, {useCallback, useEffect, useRef} from 'react'
2
2
import {ScrollView, View} from 'react-native'
3
+
import Animated, {LinearTransition} from 'react-native-reanimated'
3
4
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
4
5
import {msg, Trans} from '@lingui/macro'
5
6
import {useLingui} from '@lingui/react'
6
7
import {useNavigation} from '@react-navigation/native'
7
8
8
9
import {type NavigationProp} from '#/lib/routes/types'
9
-
import {logEvent} from '#/lib/statsig/statsig'
10
+
import {logEvent, useGate} from '#/lib/statsig/statsig'
10
11
import {logger} from '#/logger'
11
12
import {type MetricEvents} from '#/logger/metrics'
12
13
import {isIOS} from '#/platform/detection'
···
14
15
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
15
16
import {type FeedDescriptor} from '#/state/queries/post-feed'
16
17
import {useProfilesQuery} from '#/state/queries/profile'
17
-
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
18
+
import {
19
+
useSuggestedFollowsByActorQuery,
20
+
useSuggestedFollowsQuery,
21
+
} from '#/state/queries/suggested-follows'
18
22
import {useSession} from '#/state/session'
19
23
import * as userActionHistory from '#/state/userActionHistory'
20
24
import {type SeenPost} from '#/state/userActionHistory'
···
31
35
import * as FeedCard from '#/components/FeedCard'
32
36
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
33
37
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
38
+
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
34
39
import {InlineLinkText} from '#/components/Link'
35
40
import * as ProfileCard from '#/components/ProfileCard'
36
41
import {Text} from '#/components/Typography'
37
42
import type * as bsky from '#/types/bsky'
38
43
import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
39
44
import {ProgressGuideList} from './ProgressGuide/List'
45
+
46
+
const DISMISS_ANIMATION_DURATION = 200
40
47
41
48
const MOBILE_CARD_WIDTH = 165
42
49
const FINAL_CARD_WIDTH = 120
···
202
209
}
203
210
204
211
export function SuggestedFollowsProfile({did}: {did: string}) {
212
+
const {gtMobile} = useBreakpoints()
213
+
const moderationOpts = useModerationOpts()
214
+
const maxLength = gtMobile ? 4 : 6
205
215
const {
206
216
isLoading: isSuggestionsLoading,
207
217
data,
···
209
219
} = useSuggestedFollowsByActorQuery({
210
220
did,
211
221
})
222
+
const {
223
+
data: moreSuggestions,
224
+
fetchNextPage,
225
+
hasNextPage,
226
+
isFetchingNextPage,
227
+
} = useSuggestedFollowsQuery({limit: 25})
228
+
229
+
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
230
+
new Set(),
231
+
)
232
+
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
233
+
new Set(),
234
+
)
235
+
236
+
const onDismiss = React.useCallback((dismissedDid: string) => {
237
+
// Start the fade animation
238
+
setDismissingDids(prev => new Set(prev).add(dismissedDid))
239
+
// After animation completes, actually remove from list
240
+
setTimeout(() => {
241
+
setDismissedDids(prev => new Set(prev).add(dismissedDid))
242
+
setDismissingDids(prev => {
243
+
const next = new Set(prev)
244
+
next.delete(dismissedDid)
245
+
return next
246
+
})
247
+
}, DISMISS_ANIMATION_DURATION)
248
+
}, [])
249
+
250
+
// Combine profiles from the actor-specific query with fallback suggestions
251
+
const allProfiles = React.useMemo(() => {
252
+
const actorProfiles = data?.suggestions ?? []
253
+
const fallbackProfiles =
254
+
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
255
+
256
+
// Dedupe by did, preferring actor-specific profiles
257
+
const seen = new Set<string>()
258
+
const combined: bsky.profile.AnyProfileView[] = []
259
+
260
+
for (const profile of actorProfiles) {
261
+
if (!seen.has(profile.did)) {
262
+
seen.add(profile.did)
263
+
combined.push(profile)
264
+
}
265
+
}
266
+
267
+
for (const profile of fallbackProfiles) {
268
+
if (!seen.has(profile.did) && profile.did !== did) {
269
+
seen.add(profile.did)
270
+
combined.push(profile)
271
+
}
272
+
}
273
+
274
+
return combined
275
+
}, [data?.suggestions, moreSuggestions?.pages, did])
276
+
277
+
const filteredProfiles = React.useMemo(() => {
278
+
return allProfiles.filter(p => !dismissedDids.has(p.did))
279
+
}, [allProfiles, dismissedDids])
280
+
281
+
// Fetch more when running low
282
+
React.useEffect(() => {
283
+
if (
284
+
moderationOpts &&
285
+
filteredProfiles.length < maxLength &&
286
+
hasNextPage &&
287
+
!isFetchingNextPage
288
+
) {
289
+
fetchNextPage()
290
+
}
291
+
}, [
292
+
filteredProfiles.length,
293
+
maxLength,
294
+
hasNextPage,
295
+
isFetchingNextPage,
296
+
fetchNextPage,
297
+
moderationOpts,
298
+
])
299
+
212
300
return (
213
301
<ProfileGrid
214
302
isSuggestionsLoading={isSuggestionsLoading}
215
-
profiles={data?.suggestions ?? []}
303
+
profiles={filteredProfiles}
304
+
totalProfileCount={allProfiles.length}
216
305
recId={data?.recId}
217
306
error={error}
218
307
viewContext="profile"
308
+
onDismiss={onDismiss}
309
+
dismissingDids={dismissingDids}
219
310
/>
220
311
)
221
312
}
222
313
223
314
export function SuggestedFollowsHome() {
315
+
const {gtMobile} = useBreakpoints()
316
+
const moderationOpts = useModerationOpts()
317
+
const maxLength = gtMobile ? 4 : 6
224
318
const {
225
319
isLoading: isSuggestionsLoading,
226
-
profiles,
227
-
error,
320
+
profiles: experimentalProfiles,
321
+
error: experimentalError,
228
322
} = useExperimentalSuggestedUsersQuery()
323
+
const {
324
+
data: moreSuggestions,
325
+
fetchNextPage,
326
+
hasNextPage,
327
+
isFetchingNextPage,
328
+
error: suggestionsError,
329
+
} = useSuggestedFollowsQuery({limit: 25})
330
+
331
+
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
332
+
new Set(),
333
+
)
334
+
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
335
+
new Set(),
336
+
)
337
+
338
+
const onDismiss = React.useCallback((did: string) => {
339
+
// Start the fade animation
340
+
setDismissingDids(prev => new Set(prev).add(did))
341
+
// After animation completes, actually remove from list
342
+
setTimeout(() => {
343
+
setDismissedDids(prev => new Set(prev).add(did))
344
+
setDismissingDids(prev => {
345
+
const next = new Set(prev)
346
+
next.delete(did)
347
+
return next
348
+
})
349
+
}, DISMISS_ANIMATION_DURATION)
350
+
}, [])
351
+
352
+
// Combine profiles from experimental query with paginated suggestions
353
+
const allProfiles = React.useMemo(() => {
354
+
const fallbackProfiles =
355
+
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
356
+
357
+
// Dedupe by did, preferring experimental profiles
358
+
const seen = new Set<string>()
359
+
const combined: bsky.profile.AnyProfileView[] = []
360
+
361
+
for (const profile of experimentalProfiles) {
362
+
if (!seen.has(profile.did)) {
363
+
seen.add(profile.did)
364
+
combined.push(profile)
365
+
}
366
+
}
367
+
368
+
for (const profile of fallbackProfiles) {
369
+
if (!seen.has(profile.did)) {
370
+
seen.add(profile.did)
371
+
combined.push(profile)
372
+
}
373
+
}
374
+
375
+
return combined
376
+
}, [experimentalProfiles, moreSuggestions?.pages])
377
+
378
+
const filteredProfiles = React.useMemo(() => {
379
+
return allProfiles.filter(p => !dismissedDids.has(p.did))
380
+
}, [allProfiles, dismissedDids])
381
+
382
+
// Fetch more when running low
383
+
React.useEffect(() => {
384
+
if (
385
+
moderationOpts &&
386
+
filteredProfiles.length < maxLength &&
387
+
hasNextPage &&
388
+
!isFetchingNextPage
389
+
) {
390
+
fetchNextPage()
391
+
}
392
+
}, [
393
+
filteredProfiles.length,
394
+
maxLength,
395
+
hasNextPage,
396
+
isFetchingNextPage,
397
+
fetchNextPage,
398
+
moderationOpts,
399
+
])
400
+
229
401
return (
230
402
<ProfileGrid
231
403
isSuggestionsLoading={isSuggestionsLoading}
232
-
profiles={profiles}
233
-
error={error}
404
+
profiles={filteredProfiles}
405
+
totalProfileCount={allProfiles.length}
406
+
error={experimentalError || suggestionsError}
234
407
viewContext="feed"
408
+
onDismiss={onDismiss}
409
+
dismissingDids={dismissingDids}
235
410
/>
236
411
)
237
412
}
···
240
415
isSuggestionsLoading,
241
416
error,
242
417
profiles,
418
+
totalProfileCount,
243
419
recId,
244
420
viewContext = 'feed',
421
+
onDismiss,
422
+
dismissingDids,
245
423
isVisible = true,
246
424
}: {
247
425
isSuggestionsLoading: boolean
248
426
profiles: bsky.profile.AnyProfileView[]
427
+
totalProfileCount?: number
249
428
recId?: number
250
429
error: Error | null
430
+
dismissingDids?: Set<string>
251
431
viewContext: 'profile' | 'profileHeader' | 'feed'
432
+
onDismiss?: (did: string) => void
252
433
isVisible?: boolean
253
434
}) {
254
435
const t = useTheme()
255
436
const {_} = useLingui()
437
+
const gate = useGate()
256
438
const moderationOpts = useModerationOpts()
257
439
const {gtMobile} = useBreakpoints()
258
440
const followDialogControl = useDialogControl()
···
260
442
const isLoading = isSuggestionsLoading || !moderationOpts
261
443
const isProfileHeaderContext = viewContext === 'profileHeader'
262
444
const isFeedContext = viewContext === 'feed'
445
+
const showDismissButton = onDismiss && gate('suggested_users_dismiss')
263
446
264
447
const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
265
448
const minLength = gtMobile ? 3 : 4
···
363
546
: error || !profiles.length
364
547
? null
365
548
: profiles.slice(0, maxLength).map((profile, index) => (
366
-
<ProfileCard.Link
549
+
<Animated.View
367
550
key={profile.did}
368
-
profile={profile}
369
-
onPress={() => {
370
-
logEvent('suggestedUser:press', {
371
-
logContext: isFeedContext
372
-
? 'InterstitialDiscover'
373
-
: 'InterstitialProfile',
374
-
recId,
375
-
position: index,
376
-
suggestedDid: profile.did,
377
-
category: null,
378
-
})
379
-
}}
551
+
layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)}
380
552
style={[
381
553
a.flex_1,
382
554
gtMobile &&
···
385
557
a.flex_grow,
386
558
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
387
559
]),
560
+
{
561
+
opacity: dismissingDids?.has(profile.did) ? 0 : 1,
562
+
transitionProperty: 'opacity',
563
+
transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`,
564
+
},
388
565
]}>
389
-
{({hovered, pressed}) => (
390
-
<CardOuter
391
-
style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
392
-
<ProfileCard.Outer>
393
-
<View
394
-
style={[
395
-
a.flex_col,
396
-
a.align_center,
397
-
a.gap_sm,
398
-
a.pb_sm,
399
-
a.mb_auto,
400
-
]}>
401
-
<ProfileCard.Avatar
402
-
profile={profile}
403
-
moderationOpts={moderationOpts}
404
-
disabledPreview
405
-
size={88}
406
-
/>
407
-
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
408
-
<ProfileCard.Name
566
+
<ProfileCard.Link
567
+
profile={profile}
568
+
onPress={() => {
569
+
logEvent('suggestedUser:press', {
570
+
logContext: isFeedContext
571
+
? 'InterstitialDiscover'
572
+
: 'InterstitialProfile',
573
+
recId,
574
+
position: index,
575
+
suggestedDid: profile.did,
576
+
category: null,
577
+
})
578
+
}}>
579
+
{({hovered, pressed}) => (
580
+
<CardOuter
581
+
style={[
582
+
(hovered || pressed) && t.atoms.border_contrast_high,
583
+
]}>
584
+
<ProfileCard.Outer>
585
+
{showDismissButton && (
586
+
<Button
587
+
label={_(msg`Dismiss this suggestion`)}
588
+
onPress={e => {
589
+
e.preventDefault()
590
+
onDismiss!(profile.did)
591
+
logEvent('suggestedUser:dismiss', {
592
+
logContext: isFeedContext
593
+
? 'InterstitialDiscover'
594
+
: 'InterstitialProfile',
595
+
position: index,
596
+
suggestedDid: profile.did,
597
+
recId,
598
+
})
599
+
}}
600
+
style={[
601
+
a.absolute,
602
+
a.z_10,
603
+
a.p_xs,
604
+
{top: -4, right: -4},
605
+
]}>
606
+
{({
607
+
hovered: dismissHovered,
608
+
pressed: dismissPressed,
609
+
}) => (
610
+
<X
611
+
size="xs"
612
+
fill={
613
+
dismissHovered || dismissPressed
614
+
? t.atoms.text.color
615
+
: t.atoms.text_contrast_medium.color
616
+
}
617
+
/>
618
+
)}
619
+
</Button>
620
+
)}
621
+
<View
622
+
style={[
623
+
a.flex_col,
624
+
a.align_center,
625
+
a.gap_sm,
626
+
a.pb_sm,
627
+
a.mb_auto,
628
+
]}>
629
+
<ProfileCard.Avatar
409
630
profile={profile}
410
631
moderationOpts={moderationOpts}
632
+
disabledPreview
633
+
size={88}
411
634
/>
412
-
<ProfileCard.Description
413
-
profile={profile}
414
-
numberOfLines={2}
415
-
style={[
416
-
t.atoms.text_contrast_medium,
417
-
a.text_center,
418
-
a.text_xs,
419
-
]}
420
-
/>
635
+
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
636
+
<ProfileCard.Name
637
+
profile={profile}
638
+
moderationOpts={moderationOpts}
639
+
/>
640
+
<ProfileCard.Description
641
+
profile={profile}
642
+
numberOfLines={2}
643
+
style={[
644
+
t.atoms.text_contrast_medium,
645
+
a.text_center,
646
+
a.text_xs,
647
+
]}
648
+
/>
649
+
</View>
421
650
</View>
422
-
</View>
423
651
424
-
<ProfileCard.FollowButton
425
-
profile={profile}
426
-
moderationOpts={moderationOpts}
427
-
logContext="FeedInterstitial"
428
-
withIcon={false}
429
-
style={[a.rounded_sm]}
430
-
onFollow={() => {
431
-
logEvent('suggestedUser:follow', {
432
-
logContext: isFeedContext
433
-
? 'InterstitialDiscover'
434
-
: 'InterstitialProfile',
435
-
location: 'Card',
436
-
recId,
437
-
position: index,
438
-
suggestedDid: profile.did,
439
-
category: null,
440
-
})
441
-
}}
442
-
/>
443
-
</ProfileCard.Outer>
444
-
</CardOuter>
445
-
)}
446
-
</ProfileCard.Link>
652
+
<ProfileCard.FollowButton
653
+
profile={profile}
654
+
moderationOpts={moderationOpts}
655
+
logContext="FeedInterstitial"
656
+
withIcon={false}
657
+
style={[a.rounded_sm]}
658
+
onFollow={() => {
659
+
logEvent('suggestedUser:follow', {
660
+
logContext: isFeedContext
661
+
? 'InterstitialDiscover'
662
+
: 'InterstitialProfile',
663
+
location: 'Card',
664
+
recId,
665
+
position: index,
666
+
suggestedDid: profile.did,
667
+
category: null,
668
+
})
669
+
}}
670
+
/>
671
+
</ProfileCard.Outer>
672
+
</CardOuter>
673
+
)}
674
+
</ProfileCard.Link>
675
+
</Animated.View>
447
676
))
448
677
449
-
if (error || (!isLoading && profiles.length < minLength)) {
678
+
// Use totalProfileCount (before dismissals) for minLength check on initial render.
679
+
const profileCountForMinCheck = totalProfileCount ?? profiles.length
680
+
if (error || (!isLoading && profileCountForMinCheck < minLength)) {
450
681
logger.debug(`Not enough profiles to show suggested follows`)
451
682
return null
452
683
}
+1
src/lib/statsig/gates.ts
+1
src/lib/statsig/gates.ts
+6
src/logger/metrics.ts
+6
src/logger/metrics.ts
···
379
379
| 'Profile'
380
380
| 'Onboarding'
381
381
}
382
+
'suggestedUser:dismiss': {
383
+
logContext: 'InterstitialDiscover' | 'InterstitialProfile'
384
+
recId?: number
385
+
position: number
386
+
suggestedDid: string
387
+
}
382
388
'profile:unfollow': {
383
389
logContext:
384
390
| 'RecommendedFollowsItem'
+181
-5
src/screens/Profile/Header/SuggestedFollows.tsx
+181
-5
src/screens/Profile/Header/SuggestedFollows.tsx
···
1
+
import React from 'react'
2
+
import {type AppBskyActorDefs} from '@atproto/api'
3
+
1
4
import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
2
5
import {isAndroid} from '#/platform/detection'
3
-
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
6
+
import {useModerationOpts} from '#/state/preferences/moderation-opts'
7
+
import {
8
+
useSuggestedFollowsByActorQuery,
9
+
useSuggestedFollowsQuery,
10
+
} from '#/state/queries/suggested-follows'
11
+
import {useBreakpoints} from '#/alf'
4
12
import {ProfileGrid} from '#/components/FeedInterstitials'
13
+
14
+
const DISMISS_ANIMATION_DURATION = 200
5
15
6
16
export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) {
17
+
const {gtMobile} = useBreakpoints()
18
+
const moderationOpts = useModerationOpts()
19
+
const maxLength = gtMobile ? 4 : 12
7
20
const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
8
21
did: actorDid,
9
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
+
])
10
100
11
101
return (
12
102
<ProfileGrid
13
103
isSuggestionsLoading={isLoading}
14
-
profiles={data?.suggestions ?? []}
104
+
profiles={filteredProfiles}
105
+
totalProfileCount={allProfiles.length}
15
106
recId={data?.recId}
16
107
error={error}
17
108
viewContext="profileHeader"
109
+
onDismiss={onDismiss}
110
+
dismissingDids={dismissingDids}
18
111
/>
19
112
)
20
113
}
···
26
119
isExpanded: boolean
27
120
actorDid: string
28
121
}) {
122
+
const {gtMobile} = useBreakpoints()
123
+
const moderationOpts = useModerationOpts()
124
+
const maxLength = gtMobile ? 4 : 12
29
125
const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
30
126
did: actorDid,
31
127
})
128
+
const {
129
+
data: moreSuggestions,
130
+
fetchNextPage,
131
+
hasNextPage,
132
+
isFetchingNextPage,
133
+
} = useSuggestedFollowsQuery({limit: 25})
32
134
33
-
if (!data?.suggestions?.length) return null
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
34
207
35
208
/* NOTE (caidanw):
36
209
* Android does not work well with this feature yet.
···
43
216
<AccordionAnimation isExpanded={isExpanded}>
44
217
<ProfileGrid
45
218
isSuggestionsLoading={isLoading}
46
-
profiles={data.suggestions}
47
-
recId={data.recId}
219
+
profiles={filteredProfiles}
220
+
totalProfileCount={allProfiles.length}
221
+
recId={data?.recId}
48
222
error={error}
49
223
viewContext="profileHeader"
224
+
onDismiss={onDismiss}
225
+
dismissingDids={dismissingDids}
50
226
isVisible={isExpanded}
51
227
/>
52
228
</AccordionAnimation>