From eabcd9150d3513988f5b3c47b95a601d5f1bf738 Mon Sep 17 00:00:00 2001 From: Caidan Date: Thu, 21 Aug 2025 11:56:17 -0700 Subject: [APP-1357] profile header follow recommendations (#8784) --- src/components/FeedInterstitials.tsx | 286 +++++++++--------- src/components/ProfileCard.tsx | 18 ++ src/lib/custom-animations/AccordionAnimation.tsx | 77 +++++ src/lib/statsig/gates.ts | 1 + .../Profile/Header/ProfileHeaderStandard.tsx | 329 +++++++++++---------- src/screens/Profile/Header/Shell.tsx | 2 +- src/screens/Profile/Header/SuggestedFollows.tsx | 45 +++ src/state/queries/suggested-follows.ts | 15 +- 8 files changed, 461 insertions(+), 312 deletions(-) create mode 100644 src/lib/custom-animations/AccordionAnimation.tsx create mode 100644 src/screens/Profile/Header/SuggestedFollows.tsx (limited to 'src') diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 18da12b22..07ad2d501 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -1,6 +1,5 @@ import React from 'react' -import {View} from 'react-native' -import {ScrollView} from 'react-native-gesture-handler' +import {ScrollView, View} from 'react-native' import {type AppBskyFeedDefs, AtUri} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -9,6 +8,7 @@ import {useNavigation} from '@react-navigation/native' import {type NavigationProp} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' +import {isIOS} from '#/platform/detection' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {type FeedDescriptor} from '#/state/queries/post-feed' @@ -25,7 +25,7 @@ import { type ViewStyleProp, web, } from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {Button} from '#/components/Button' import * as FeedCard from '#/components/FeedCard' import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' @@ -46,11 +46,13 @@ function CardOuter({ return ( + @@ -78,24 +77,15 @@ export function SuggestedFollowPlaceholder() { - + ) } export function SuggestedFeedsCardPlaceholder() { - const t = useTheme() return ( - + @@ -253,129 +243,133 @@ export function ProfileGrid({ profiles: bsky.profile.AnyProfileView[] recId?: number error: Error | null - viewContext: 'profile' | 'feed' + viewContext: 'profile' | 'profileHeader' | 'feed' }) { const t = useTheme() const {_} = useLingui() const moderationOpts = useModerationOpts() const {gtMobile} = useBreakpoints() + const isLoading = isSuggestionsLoading || !moderationOpts - const maxLength = gtMobile ? 3 : 6 + const isProfileHeaderContext = viewContext === 'profileHeader' + const isFeedContext = viewContext === 'feed' - const content = isLoading ? ( - Array(maxLength) - .fill(0) - .map((_, i) => ( - - - - )) - ) : error || !profiles.length ? null : ( - <> - {profiles.slice(0, maxLength).map((profile, index) => ( - { - logEvent('suggestedUser:press', { - logContext: - viewContext === 'feed' + const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6 + const minLength = gtMobile ? 3 : 4 + + const content = isLoading + ? Array(maxLength) + .fill(0) + .map((_, i) => ( + + + + )) + : error || !profiles.length + ? null + : profiles.slice(0, maxLength).map((profile, index) => ( + { + logEvent('suggestedUser:press', { + logContext: isFeedContext ? 'InterstitialDiscover' : 'InterstitialProfile', - recId, - position: index, - }) - }} - style={[ - a.flex_1, - gtMobile && - web([ - a.flex_0, - a.flex_grow, - {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, - ]), - ]}> - {({hovered, pressed}) => ( - - - - - - + {({hovered, pressed}) => ( + + + + - + + + + - - - { - logEvent('suggestedUser:follow', { - logContext: - viewContext === 'feed' + + { + logEvent('suggestedUser:follow', { + logContext: isFeedContext ? 'InterstitialDiscover' : 'InterstitialProfile', - location: 'Card', - recId, - position: index, - }) - }} - /> - - - )} - - ))} - - ) + location: 'Card', + recId, + position: index, + }) + }} + /> + + + )} + + )) - if (error || (!isLoading && profiles.length < 4)) { + if (error || (!isLoading && profiles.length < minLength)) { logger.debug(`Not enough profiles to show suggested follows`) return null } return ( + style={[ + !isProfileHeaderContext && a.border_t, + t.atoms.border_contrast_low, + t.atoms.bg_contrast_25, + ]} + pointerEvents={isIOS ? 'auto' : 'box-none'}> + ]} + pointerEvents={isIOS ? 'auto' : 'box-none'}> - {viewContext === 'profile' ? ( - Similar accounts - ) : ( + {isFeedContext ? ( Suggested for you + ) : ( + Similar accounts )} - - See more - + {!isProfileHeaderContext && ( + + See more + + )} {gtMobile ? ( @@ -406,19 +403,16 @@ export function ProfileGrid({ ) : ( - - - - {content} - - - - - + + {content} + + {!isProfileHeaderContext && } + )} @@ -427,7 +421,6 @@ export function ProfileGrid({ function SeeMoreSuggestedProfilesCard() { const navigation = useNavigation() - const t = useTheme() const {_} = useLingui() return ( @@ -437,7 +430,7 @@ function SeeMoreSuggestedProfilesCard() { onPress={() => { navigation.navigate('SearchTab') }}> - + @@ -491,10 +484,7 @@ export function SuggestedFeeds() { }}> {({hovered, pressed}) => ( + style={[(hovered || pressed) && t.atoms.border_contrast_high]}> @@ -568,7 +558,7 @@ export function SuggestedFeeds() { navigation.navigate('SearchTab') }} style={[a.flex_col]}> - + diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index f12d922fd..5c99474a2 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -561,6 +561,24 @@ export function FollowButtonInner({ ) } +export function FollowButtonPlaceholder({style}: ViewStyleProp) { + const t = useTheme() + + return ( + + ) +} + export function Labels({ profile, moderationOpts, diff --git a/src/lib/custom-animations/AccordionAnimation.tsx b/src/lib/custom-animations/AccordionAnimation.tsx new file mode 100644 index 000000000..146735aa6 --- /dev/null +++ b/src/lib/custom-animations/AccordionAnimation.tsx @@ -0,0 +1,77 @@ +import { + type LayoutChangeEvent, + type StyleProp, + View, + type ViewStyle, +} from 'react-native' +import Animated, { + Easing, + FadeInUp, + FadeOutUp, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' + +import {isIOS, isWeb} from '#/platform/detection' + +type AccordionAnimationProps = React.PropsWithChildren<{ + isExpanded: boolean + duration?: number + style?: StyleProp +}> + +function WebAccordion({ + isExpanded, + duration = 300, + style, + children, +}: AccordionAnimationProps) { + const heightValue = useSharedValue(0) + + const animatedStyle = useAnimatedStyle(() => { + const targetHeight = isExpanded ? heightValue.get() : 0 + return { + height: withTiming(targetHeight, { + duration, + easing: Easing.out(Easing.cubic), + }), + overflow: 'hidden', + } + }) + + const onLayout = (e: LayoutChangeEvent) => { + if (heightValue.get() === 0) { + heightValue.set(e.nativeEvent.layout.height) + } + } + + return ( + + {children} + + ) +} + +function MobileAccordion({ + isExpanded, + duration = 200, + style, + children, +}: AccordionAnimationProps) { + if (!isExpanded) return null + + return ( + + {children} + + ) +} + +export function AccordionAnimation(props: AccordionAnimationProps) { + return isWeb ? : +} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 66134a462..8ec86c971 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -8,6 +8,7 @@ export type Gate = | 'handle_suggestions' | 'old_postonboarding' | 'onboarding_add_video_feed' + | 'post_follow_profile_suggested_accounts' | 'post_threads_v2_unspecced' | 'remove_show_latest_button' | 'test_gate_1' diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 2f61ba4df..1df35d5e0 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -1,4 +1,4 @@ -import React, {memo, useMemo} from 'react' +import {memo, useCallback, useMemo, useState} from 'react' import {View} from 'react-native' import { type AppBskyActorDefs, @@ -40,6 +40,7 @@ import {EditProfileDialog} from './EditProfileDialog' import {ProfileHeaderHandle} from './Handle' import {ProfileHeaderMetrics} from './Metrics' import {ProfileHeaderShell} from './Shell' +import {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows' interface Props { profile: AppBskyActorDefs.ProfileViewDetailed @@ -73,6 +74,7 @@ let ProfileHeaderStandard = ({ const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) const unblockPromptControl = Prompt.usePromptControl() const requireAuth = useRequireAuth() + const [showSuggestedFollows, setShowSuggestedFollows] = useState(false) const isBlockedUser = profile.viewer?.blocking || profile.viewer?.blockedBy || @@ -81,6 +83,7 @@ let ProfileHeaderStandard = ({ const editProfileControl = useDialogControl() const onPressFollow = () => { + setShowSuggestedFollows(true) requireAuth(async () => { try { await queueFollow() @@ -102,6 +105,7 @@ let ProfileHeaderStandard = ({ } const onPressUnfollow = () => { + setShowSuggestedFollows(false) requireAuth(async () => { try { await queueUnfollow() @@ -122,7 +126,7 @@ let ProfileHeaderStandard = ({ }) } - const unblockAccount = React.useCallback(async () => { + const unblockAccount = useCallback(async () => { try { await queueUnblock() Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) @@ -155,174 +159,185 @@ let ProfileHeaderStandard = ({ }, [profile]) return ( - - + <> + - {isMe ? ( - <> - - - - ) : profile.viewer?.blocking ? ( - profile.viewer?.blockingByList ? null : ( - - ) - ) : !profile.viewer?.blockedBy ? ( - <> - {hasSession && subscriptionsAllowed && ( - + {isMe ? ( + <> + + - )} - {hasSession && } - - + ) + ) : !profile.viewer?.blockedBy ? ( + <> + {hasSession && subscriptionsAllowed && ( + )} - - {profile.viewer?.following ? ( - Following - ) : profile.viewer?.followedBy ? ( - Follow Back - ) : ( - Follow + {hasSession && } + + - - ) : null} - - - - - - {sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), - )} - + {profile.viewer?.following ? ( + Following + ) : profile.viewer?.followedBy ? ( + Follow Back + ) : ( + Follow + )} + + + + ) : null} + + + + + - - - + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + + + + + + - - - {!isPlaceholderProfile && !isBlockedUser && ( - - - {descriptionRT && !moderation.ui('profileView').blur ? ( - - - - ) : undefined} - - {!isMe && - !isBlockedUser && - shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( - - + + {descriptionRT && !moderation.ui('profileView').blur ? ( + + - )} - - )} - - + + + )} + + )} + + + + + + - + ) } + ProfileHeaderStandard = memo(ProfileHeaderStandard) export {ProfileHeaderStandard} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 167be0aa8..cff0a707c 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -211,7 +211,7 @@ let ProfileHeaderShell = ({ {!isPlaceholderProfile && ( {isMe ? ( diff --git a/src/screens/Profile/Header/SuggestedFollows.tsx b/src/screens/Profile/Header/SuggestedFollows.tsx new file mode 100644 index 000000000..d005d888e --- /dev/null +++ b/src/screens/Profile/Header/SuggestedFollows.tsx @@ -0,0 +1,45 @@ +import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation' +import {useGate} from '#/lib/statsig/statsig' +import {isAndroid} from '#/platform/detection' +import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' +import {ProfileGrid} from '#/components/FeedInterstitials' + +export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) { + const {isLoading, data, error} = useSuggestedFollowsByActorQuery({ + did: actorDid, + }) + + return ( + + ) +} + +export function AnimatedProfileHeaderSuggestedFollows({ + isExpanded, + actorDid, +}: { + isExpanded: boolean + actorDid: string +}) { + const gate = useGate() + if (!gate('post_follow_profile_suggested_accounts')) return null + + /* NOTE (caidanw): + * Android does not work well with this feature yet. + * This issue stems from Android not allowing dragging on clickable elements in the profile header. + * Blocking the ability to scroll on Android is too much of a trade-off for now. + **/ + if (isAndroid) return null + + return ( + + + + ) +} diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index 0a2343150..c7a6e5f75 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -1,13 +1,13 @@ import { - AppBskyActorDefs, - AppBskyActorGetSuggestions, - AppBskyGraphGetSuggestedFollowsByActor, + type AppBskyActorDefs, + type AppBskyActorGetSuggestions, + type AppBskyGraphGetSuggestedFollowsByActor, moderateProfile, } from '@atproto/api' import { - InfiniteData, - QueryClient, - QueryKey, + type InfiniteData, + type QueryClient, + type QueryKey, useInfiniteQuery, useQuery, } from '@tanstack/react-query' @@ -106,12 +106,15 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) { export function useSuggestedFollowsByActorQuery({ did, enabled, + staleTime = STALE.MINUTES.FIVE, }: { did: string enabled?: boolean + staleTime?: number }) { const agent = useAgent() return useQuery({ + staleTime, queryKey: suggestedFollowsByActorQueryKey(did), queryFn: async () => { const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ -- cgit 1.4.1