diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 7418c8d766..267d303be8 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -1,12 +1,13 @@ import React, {useCallback, useEffect, useRef} 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' import {useNavigation} from '@react-navigation/native' import {type NavigationProp} from '#/lib/routes/types' -import {logEvent} from '#/lib/statsig/statsig' +import {logEvent, useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {type MetricEvents} from '#/logger/metrics' import {isIOS} from '#/platform/detection' @@ -14,7 +15,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' @@ -31,6 +35,7 @@ import {useDialogControl} from '#/components/Dialog' 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} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' @@ -38,6 +43,8 @@ import type * as bsky from '#/types/bsky' import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog' import {ProgressGuideList} from './ProgressGuide/List' +const DISMISS_ANIMATION_DURATION = 200 + const MOBILE_CARD_WIDTH = 165 const FINAL_CARD_WIDTH = 120 @@ -202,6 +209,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, @@ -209,29 +219,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 ( ) } @@ -240,19 +415,26 @@ export function ProfileGrid({ isSuggestionsLoading, error, profiles, + totalProfileCount, recId, viewContext = 'feed', + onDismiss, + dismissingDids, isVisible = true, }: { isSuggestionsLoading: boolean profiles: bsky.profile.AnyProfileView[] + totalProfileCount?: number recId?: number error: Error | null + dismissingDids?: Set viewContext: 'profile' | 'profileHeader' | 'feed' + onDismiss?: (did: string) => void isVisible?: boolean }) { const t = useTheme() const {_} = useLingui() + const gate = useGate() const moderationOpts = useModerationOpts() const {gtMobile} = useBreakpoints() const followDialogControl = useDialogControl() @@ -260,6 +442,7 @@ export function ProfileGrid({ const isLoading = isSuggestionsLoading || !moderationOpts const isProfileHeaderContext = viewContext === 'profileHeader' const isFeedContext = viewContext === 'feed' + const showDismissButton = onDismiss && gate('suggested_users_dismiss') const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6 const minLength = gtMobile ? 3 : 4 @@ -363,20 +546,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, - suggestedDid: profile.did, - category: null, - }) - }} + layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)} style={[ a.flex_1, gtMobile && @@ -385,68 +557,127 @@ 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, + suggestedDid: profile.did, + category: null, + }) + }}> + {({hovered, pressed}) => ( + + + {showDismissButton && ( + + )} + + - + + + + - - { - logEvent('suggestedUser:follow', { - logContext: isFeedContext - ? 'InterstitialDiscover' - : 'InterstitialProfile', - location: 'Card', - recId, - position: index, - suggestedDid: profile.did, - category: null, - }) - }} - /> - - - )} - + { + logEvent('suggestedUser:follow', { + logContext: isFeedContext + ? 'InterstitialDiscover' + : 'InterstitialProfile', + location: 'Card', + recId, + position: index, + suggestedDid: profile.did, + category: null, + }) + }} + /> + + + )} + + )) - 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/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index ea67ac01bb..357548d3b1 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -12,5 +12,6 @@ export type Gate = | 'onboarding_suggested_starterpacks' | 'remove_show_latest_button' | 'show_composer_prompt' + | 'suggested_users_dismiss' | 'test_gate_1' | 'test_gate_2' diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 6908f3a05e..949c883b7c 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -379,6 +379,12 @@ export type MetricEvents = { | 'Profile' | 'Onboarding' } + 'suggestedUser:dismiss': { + logContext: 'InterstitialDiscover' | 'InterstitialProfile' + recId?: number + position: number + suggestedDid: string + } 'profile:unfollow': { logContext: | 'RecommendedFollowsItem' diff --git a/src/screens/Profile/Header/SuggestedFollows.tsx b/src/screens/Profile/Header/SuggestedFollows.tsx index 48856cef76..239ad7d9f4 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({