From 931bcd63e30116530d53abebbd660f80868a65b8 Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Thu, 4 Dec 2025 14:35:32 -0800 Subject: [PATCH] Add dismiss button to user suggestions --- src/components/FeedInterstitials.tsx | 368 ++++++++++++++---- src/logger/metrics.ts | 4 + .../Profile/Header/SuggestedFollows.tsx | 186 ++++++++- 3 files changed, 484 insertions(+), 74 deletions(-) diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index dd02d1a6e4..8f6405b94a 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -1,5 +1,6 @@ import React from 'react' import {ScrollView, View} from 'react-native' +import Animated, {LinearTransition} from 'react-native-reanimated' import {type AppBskyFeedDefs, AtUri} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -13,7 +14,10 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {type FeedDescriptor} from '#/state/queries/post-feed' import {useProfilesQuery} from '#/state/queries/profile' -import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' +import { + useSuggestedFollowsByActorQuery, + useSuggestedFollowsQuery, +} from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' import * as userActionHistory from '#/state/userActionHistory' import {type SeenPost} from '#/state/userActionHistory' @@ -29,12 +33,15 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as FeedCard from '#/components/FeedCard' import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {InlineLinkText, Link} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' import type * as bsky from '#/types/bsky' import {ProgressGuideList} from './ProgressGuide/List' +const DISMISS_ANIMATION_DURATION = 200 + const MOBILE_CARD_WIDTH = 165 const FINAL_CARD_WIDTH = 120 @@ -199,6 +206,9 @@ export function SuggestedFollows({feed}: {feed: FeedDescriptor}) { } export function SuggestedFollowsProfile({did}: {did: string}) { + const {gtMobile} = useBreakpoints() + const moderationOpts = useModerationOpts() + const maxLength = gtMobile ? 4 : 6 const { isLoading: isSuggestionsLoading, data, @@ -206,29 +216,194 @@ export function SuggestedFollowsProfile({did}: {did: string}) { } = useSuggestedFollowsByActorQuery({ did, }) + const { + data: moreSuggestions, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useSuggestedFollowsQuery({limit: 25}) + + const [dismissedDids, setDismissedDids] = React.useState>( + new Set(), + ) + const [dismissingDids, setDismissingDids] = React.useState>( + new Set(), + ) + + const onDismiss = React.useCallback((dismissedDid: string) => { + // Start the fade animation + setDismissingDids(prev => new Set(prev).add(dismissedDid)) + // After animation completes, actually remove from list + setTimeout(() => { + setDismissedDids(prev => new Set(prev).add(dismissedDid)) + setDismissingDids(prev => { + const next = new Set(prev) + next.delete(dismissedDid) + return next + }) + }, DISMISS_ANIMATION_DURATION) + }, []) + + // Combine profiles from the actor-specific query with fallback suggestions + const allProfiles = React.useMemo(() => { + const actorProfiles = data?.suggestions ?? [] + const fallbackProfiles = + moreSuggestions?.pages.flatMap(page => page.actors) ?? [] + + // Dedupe by did, preferring actor-specific profiles + const seen = new Set() + const combined: bsky.profile.AnyProfileView[] = [] + + for (const profile of actorProfiles) { + if (!seen.has(profile.did)) { + seen.add(profile.did) + combined.push(profile) + } + } + + for (const profile of fallbackProfiles) { + if (!seen.has(profile.did) && profile.did !== did) { + seen.add(profile.did) + combined.push(profile) + } + } + + return combined + }, [data?.suggestions, moreSuggestions?.pages, did]) + + const filteredProfiles = React.useMemo(() => { + return allProfiles.filter(p => !dismissedDids.has(p.did)) + }, [allProfiles, dismissedDids]) + + // Fetch more when running low + React.useEffect(() => { + if ( + moderationOpts && + filteredProfiles.length < maxLength && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage() + } + }, [ + filteredProfiles.length, + maxLength, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + moderationOpts, + ]) + return ( ) } export function SuggestedFollowsHome() { + const {gtMobile} = useBreakpoints() + const moderationOpts = useModerationOpts() + const maxLength = gtMobile ? 4 : 6 const { isLoading: isSuggestionsLoading, - profiles, - error, + profiles: experimentalProfiles, + error: experimentalError, } = useExperimentalSuggestedUsersQuery() + const { + data: moreSuggestions, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + error: suggestionsError, + } = useSuggestedFollowsQuery({limit: 25}) + + const [dismissedDids, setDismissedDids] = React.useState>( + new Set(), + ) + const [dismissingDids, setDismissingDids] = React.useState>( + new Set(), + ) + + const onDismiss = React.useCallback((did: string) => { + // Start the fade animation + setDismissingDids(prev => new Set(prev).add(did)) + // After animation completes, actually remove from list + setTimeout(() => { + setDismissedDids(prev => new Set(prev).add(did)) + setDismissingDids(prev => { + const next = new Set(prev) + next.delete(did) + return next + }) + }, DISMISS_ANIMATION_DURATION) + }, []) + + // Combine profiles from experimental query with paginated suggestions + const allProfiles = React.useMemo(() => { + const fallbackProfiles = + moreSuggestions?.pages.flatMap(page => page.actors) ?? [] + + // Dedupe by did, preferring experimental profiles + const seen = new Set() + const combined: bsky.profile.AnyProfileView[] = [] + + for (const profile of experimentalProfiles) { + if (!seen.has(profile.did)) { + seen.add(profile.did) + combined.push(profile) + } + } + + for (const profile of fallbackProfiles) { + if (!seen.has(profile.did)) { + seen.add(profile.did) + combined.push(profile) + } + } + + return combined + }, [experimentalProfiles, moreSuggestions?.pages]) + + const filteredProfiles = React.useMemo(() => { + return allProfiles.filter(p => !dismissedDids.has(p.did)) + }, [allProfiles, dismissedDids]) + + // Fetch more when running low + React.useEffect(() => { + if ( + moderationOpts && + filteredProfiles.length < maxLength && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage() + } + }, [ + filteredProfiles.length, + maxLength, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + moderationOpts, + ]) + return ( ) } @@ -237,14 +412,20 @@ export function ProfileGrid({ isSuggestionsLoading, error, profiles, + totalProfileCount, recId, viewContext = 'feed', + onDismiss, + dismissingDids, }: { isSuggestionsLoading: boolean profiles: bsky.profile.AnyProfileView[] + totalProfileCount?: number recId?: number error: Error | null + dismissingDids?: Set viewContext: 'profile' | 'profileHeader' | 'feed' + onDismiss?: (did: string) => void }) { const t = useTheme() const {_} = useLingui() @@ -279,18 +460,9 @@ export function ProfileGrid({ : error || !profiles.length ? null : profiles.slice(0, maxLength).map((profile, index) => ( - { - logEvent('suggestedUser:press', { - logContext: isFeedContext - ? 'InterstitialDiscover' - : 'InterstitialProfile', - recId, - position: index, - }) - }} + layout={LinearTransition.duration(200)} style={[ a.flex_1, gtMobile && @@ -299,66 +471,124 @@ export function ProfileGrid({ a.flex_grow, {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, ]), + { + opacity: dismissingDids?.has(profile.did) ? 0 : 1, + transitionProperty: 'opacity', + transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`, + }, ]}> - {({hovered, pressed}) => ( - - - - - - { + logEvent('suggestedUser:press', { + logContext: isFeedContext + ? 'InterstitialDiscover' + : 'InterstitialProfile', + recId, + position: index, + }) + }}> + {({hovered, pressed}) => ( + + + {onDismiss && ( + + )} + + - + + + + - - { - logEvent('suggestedUser:follow', { - logContext: isFeedContext - ? 'InterstitialDiscover' - : 'InterstitialProfile', - location: 'Card', - recId, - position: index, - }) - }} - /> - - - )} - + { + logEvent('suggestedUser:follow', { + logContext: isFeedContext + ? 'InterstitialDiscover' + : 'InterstitialProfile', + location: 'Card', + recId, + position: index, + }) + }} + /> + + + )} + + )) - if (error || (!isLoading && profiles.length < minLength)) { + // Use totalProfileCount (before dismissals) for minLength check on initial render. + const profileCountForMinCheck = totalProfileCount ?? profiles.length + if (error || (!isLoading && profileCountForMinCheck < minLength)) { logger.debug(`Not enough profiles to show suggested follows`) return null } diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index dc38453d81..8eb9d7b5b2 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -334,6 +334,10 @@ export type MetricEvents = { | 'Profile' | 'Onboarding' } + 'suggestedUser:dismiss': { + logContext: 'InterstitialDiscover' | 'InterstitialProfile' + position: number + } 'profile:unfollow': { logContext: | 'RecommendedFollowsItem' diff --git a/src/screens/Profile/Header/SuggestedFollows.tsx b/src/screens/Profile/Header/SuggestedFollows.tsx index 3a9eb170a6..96f6002ba2 100644 --- a/src/screens/Profile/Header/SuggestedFollows.tsx +++ b/src/screens/Profile/Header/SuggestedFollows.tsx @@ -1,20 +1,113 @@ +import React from 'react' +import {type AppBskyActorDefs} from '@atproto/api' + import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation' import {isAndroid} from '#/platform/detection' -import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import { + useSuggestedFollowsByActorQuery, + useSuggestedFollowsQuery, +} from '#/state/queries/suggested-follows' +import {useBreakpoints} from '#/alf' import {ProfileGrid} from '#/components/FeedInterstitials' +const DISMISS_ANIMATION_DURATION = 200 + export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) { + const {gtMobile} = useBreakpoints() + const moderationOpts = useModerationOpts() + const maxLength = gtMobile ? 4 : 12 const {isLoading, data, error} = useSuggestedFollowsByActorQuery({ did: actorDid, }) + const { + data: moreSuggestions, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useSuggestedFollowsQuery({limit: 25}) + + const [dismissedDids, setDismissedDids] = React.useState>( + new Set(), + ) + const [dismissingDids, setDismissingDids] = React.useState>( + new Set(), + ) + + const onDismiss = React.useCallback((did: string) => { + // Start the fade animation + setDismissingDids(prev => new Set(prev).add(did)) + // After animation completes, actually remove from list + setTimeout(() => { + setDismissedDids(prev => new Set(prev).add(did)) + setDismissingDids(prev => { + const next = new Set(prev) + next.delete(did) + return next + }) + }, DISMISS_ANIMATION_DURATION) + }, []) + + // Combine profiles from the actor-specific query with fallback suggestions + const allProfiles = React.useMemo(() => { + const actorProfiles = data?.suggestions ?? [] + const fallbackProfiles = + moreSuggestions?.pages.flatMap(page => page.actors) ?? [] + + // Dedupe by did, preferring actor-specific profiles + const seen = new Set() + const combined: AppBskyActorDefs.ProfileView[] = [] + + for (const profile of actorProfiles) { + if (!seen.has(profile.did)) { + seen.add(profile.did) + combined.push(profile) + } + } + + for (const profile of fallbackProfiles) { + if (!seen.has(profile.did) && profile.did !== actorDid) { + seen.add(profile.did) + combined.push(profile) + } + } + + return combined + }, [data?.suggestions, moreSuggestions?.pages, actorDid]) + + const filteredProfiles = React.useMemo(() => { + return allProfiles.filter(p => !dismissedDids.has(p.did)) + }, [allProfiles, dismissedDids]) + + // Fetch more when running low + React.useEffect(() => { + if ( + moderationOpts && + filteredProfiles.length < maxLength && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage() + } + }, [ + filteredProfiles.length, + maxLength, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + moderationOpts, + ]) return ( ) } @@ -26,11 +119,91 @@ export function AnimatedProfileHeaderSuggestedFollows({ isExpanded: boolean actorDid: string }) { + const {gtMobile} = useBreakpoints() + const moderationOpts = useModerationOpts() + const maxLength = gtMobile ? 4 : 12 const {isLoading, data, error} = useSuggestedFollowsByActorQuery({ did: actorDid, }) + const { + data: moreSuggestions, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useSuggestedFollowsQuery({limit: 25}) - if (!data?.suggestions?.length) return null + const [dismissedDids, setDismissedDids] = React.useState>( + new Set(), + ) + const [dismissingDids, setDismissingDids] = React.useState>( + new Set(), + ) + + const onDismiss = React.useCallback((did: string) => { + // Start the fade animation + setDismissingDids(prev => new Set(prev).add(did)) + // After animation completes, actually remove from list + setTimeout(() => { + setDismissedDids(prev => new Set(prev).add(did)) + setDismissingDids(prev => { + const next = new Set(prev) + next.delete(did) + return next + }) + }, DISMISS_ANIMATION_DURATION) + }, []) + + // Combine profiles from the actor-specific query with fallback suggestions + const allProfiles = React.useMemo(() => { + const actorProfiles = data?.suggestions ?? [] + const fallbackProfiles = + moreSuggestions?.pages.flatMap(page => page.actors) ?? [] + + // Dedupe by did, preferring actor-specific profiles + const seen = new Set() + const combined: AppBskyActorDefs.ProfileView[] = [] + + for (const profile of actorProfiles) { + if (!seen.has(profile.did)) { + seen.add(profile.did) + combined.push(profile) + } + } + + for (const profile of fallbackProfiles) { + if (!seen.has(profile.did) && profile.did !== actorDid) { + seen.add(profile.did) + combined.push(profile) + } + } + + return combined + }, [data?.suggestions, moreSuggestions?.pages, actorDid]) + + const filteredProfiles = React.useMemo(() => { + return allProfiles.filter(p => !dismissedDids.has(p.did)) + }, [allProfiles, dismissedDids]) + + // Fetch more when running low + React.useEffect(() => { + if ( + moderationOpts && + filteredProfiles.length < maxLength && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage() + } + }, [ + filteredProfiles.length, + maxLength, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + moderationOpts, + ]) + + if (!allProfiles.length && !isLoading) return null /* NOTE (caidanw): * Android does not work well with this feature yet. @@ -43,10 +216,13 @@ export function AnimatedProfileHeaderSuggestedFollows({ )