Bluesky app fork with some witchin' additions 💫

Adds a dismiss button to user suggestions (#9484)

* Add dismiss button to user suggestions

* Adds dismiss button to suggested user cards, behind a feature gate

* Reverse gate check, best practice

* Sync DISMISS_ANIMATION_DURATION

---------

Co-authored-by: Eric Bailey <git@esb.lol>

authored by Alex Benzer Eric Bailey and committed by GitHub e80e2f66 f02b9c32

Changed files
+493 -79
src
components
lib
statsig
logger
screens
Profile
+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
··· 12 12 | 'onboarding_suggested_starterpacks' 13 13 | 'remove_show_latest_button' 14 14 | 'show_composer_prompt' 15 + | 'suggested_users_dismiss' 15 16 | 'test_gate_1' 16 17 | 'test_gate_2'
+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
··· 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>