diff options
-rw-r--r-- | src/components/FeedInterstitials.tsx | 286 | ||||
-rw-r--r-- | src/components/ProfileCard.tsx | 18 | ||||
-rw-r--r-- | src/lib/custom-animations/AccordionAnimation.tsx | 77 | ||||
-rw-r--r-- | src/lib/statsig/gates.ts | 1 | ||||
-rw-r--r-- | src/screens/Profile/Header/ProfileHeaderStandard.tsx | 329 | ||||
-rw-r--r-- | src/screens/Profile/Header/Shell.tsx | 2 | ||||
-rw-r--r-- | src/screens/Profile/Header/SuggestedFollows.tsx | 45 | ||||
-rw-r--r-- | src/state/queries/suggested-follows.ts | 15 |
8 files changed, 461 insertions, 312 deletions
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 ( <View style={[ + a.flex_1, a.w_full, a.p_md, a.rounded_lg, a.border, t.atoms.bg, + t.atoms.shadow_sm, t.atoms.border_contrast_low, !gtMobile && { width: MOBILE_CARD_WIDTH, @@ -63,11 +65,8 @@ function CardOuter({ } export function SuggestedFollowPlaceholder() { - const t = useTheme() - return ( - <CardOuter - style={[a.gap_md, t.atoms.border_contrast_low, t.atoms.shadow_sm]}> + <CardOuter> <ProfileCard.Outer> <View style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}> @@ -78,24 +77,15 @@ export function SuggestedFollowPlaceholder() { </View> </View> - <Button - label="" - size="small" - variant="solid" - color="secondary" - disabled - style={[a.w_full, a.rounded_sm]}> - <ButtonText>Follow</ButtonText> - </Button> + <ProfileCard.FollowButtonPlaceholder /> </ProfileCard.Outer> </CardOuter> ) } export function SuggestedFeedsCardPlaceholder() { - const t = useTheme() return ( - <CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}> + <CardOuter style={[a.gap_sm]}> <FeedCard.Header> <FeedCard.AvatarPlaceholder /> <FeedCard.TitleAndBylinePlaceholder creator /> @@ -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) => ( - <View - key={i} - style={[ - gtMobile && - web([ - a.flex_0, - a.flex_grow, - {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, - ]), - ]}> - <SuggestedFollowPlaceholder /> - </View> - )) - ) : error || !profiles.length ? null : ( - <> - {profiles.slice(0, maxLength).map((profile, index) => ( - <ProfileCard.Link - key={profile.did} - profile={profile} - onPress={() => { - 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) => ( + <View + key={i} + style={[ + a.flex_1, + gtMobile && + web([ + a.flex_0, + a.flex_grow, + {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, + ]), + ]}> + <SuggestedFollowPlaceholder /> + </View> + )) + : error || !profiles.length + ? null + : profiles.slice(0, maxLength).map((profile, index) => ( + <ProfileCard.Link + key={profile.did} + profile={profile} + onPress={() => { + 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}) => ( - <CardOuter - style={[ - a.flex_1, - t.atoms.shadow_sm, - (hovered || pressed) && t.atoms.border_contrast_high, - ]}> - <ProfileCard.Outer> - <View - style={[ - a.flex_col, - a.align_center, - a.gap_sm, - a.pb_sm, - a.mb_auto, - ]}> - <ProfileCard.Avatar - profile={profile} - moderationOpts={moderationOpts} - size={88} - /> - <View style={[a.flex_col, a.align_center, a.max_w_full]}> - <ProfileCard.Name + 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}) => ( + <CardOuter + style={[(hovered || pressed) && t.atoms.border_contrast_high]}> + <ProfileCard.Outer> + <View + style={[ + a.flex_col, + a.align_center, + a.gap_sm, + a.pb_sm, + a.mb_auto, + ]}> + <ProfileCard.Avatar profile={profile} moderationOpts={moderationOpts} + disabledPreview + size={88} /> - <ProfileCard.Description - profile={profile} - numberOfLines={2} - style={[ - t.atoms.text_contrast_medium, - a.text_center, - a.text_xs, - ]} - /> + <View style={[a.flex_col, a.align_center, a.max_w_full]}> + <ProfileCard.Name + profile={profile} + moderationOpts={moderationOpts} + /> + <ProfileCard.Description + profile={profile} + numberOfLines={2} + style={[ + t.atoms.text_contrast_medium, + a.text_center, + a.text_xs, + ]} + /> + </View> </View> - </View> - - <ProfileCard.FollowButton - profile={profile} - moderationOpts={moderationOpts} - logContext="FeedInterstitial" - withIcon={false} - style={[a.rounded_sm]} - onFollow={() => { - logEvent('suggestedUser:follow', { - logContext: - viewContext === 'feed' + + <ProfileCard.FollowButton + profile={profile} + moderationOpts={moderationOpts} + logContext="FeedInterstitial" + withIcon={false} + style={[a.rounded_sm]} + onFollow={() => { + logEvent('suggestedUser:follow', { + logContext: isFeedContext ? 'InterstitialDiscover' : 'InterstitialProfile', - location: 'Card', - recId, - position: index, - }) - }} - /> - </ProfileCard.Outer> - </CardOuter> - )} - </ProfileCard.Link> - ))} - </> - ) + location: 'Card', + recId, + position: index, + }) + }} + /> + </ProfileCard.Outer> + </CardOuter> + )} + </ProfileCard.Link> + )) - if (error || (!isLoading && profiles.length < 4)) { + if (error || (!isLoading && profiles.length < minLength)) { logger.debug(`Not enough profiles to show suggested follows`) return null } return ( <View - style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> + style={[ + !isProfileHeaderContext && a.border_t, + t.atoms.border_contrast_low, + t.atoms.bg_contrast_25, + ]} + pointerEvents={isIOS ? 'auto' : 'box-none'}> <View style={[ a.px_lg, @@ -383,19 +377,22 @@ export function ProfileGrid({ a.flex_row, a.align_center, a.justify_between, - ]}> + ]} + pointerEvents={isIOS ? 'auto' : 'box-none'}> <Text style={[a.text_sm, a.font_bold, t.atoms.text]}> - {viewContext === 'profile' ? ( - <Trans>Similar accounts</Trans> - ) : ( + {isFeedContext ? ( <Trans>Suggested for you</Trans> + ) : ( + <Trans>Similar accounts</Trans> )} </Text> - <InlineLinkText - label={_(msg`See more suggested profiles on the Explore page`)} - to="/search"> - <Trans>See more</Trans> - </InlineLinkText> + {!isProfileHeaderContext && ( + <InlineLinkText + label={_(msg`See more suggested profiles on the Explore page`)} + to="/search"> + <Trans>See more</Trans> + </InlineLinkText> + )} </View> {gtMobile ? ( @@ -406,19 +403,16 @@ export function ProfileGrid({ </View> ) : ( <BlockDrawerGesture> - <View> - <ScrollView - horizontal - showsHorizontalScrollIndicator={false} - snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} - decelerationRate="fast"> - <View style={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}> - {content} - - <SeeMoreSuggestedProfilesCard /> - </View> - </ScrollView> - </View> + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]} + snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} + decelerationRate="fast"> + {content} + + {!isProfileHeaderContext && <SeeMoreSuggestedProfilesCard />} + </ScrollView> </BlockDrawerGesture> )} </View> @@ -427,7 +421,6 @@ export function ProfileGrid({ function SeeMoreSuggestedProfilesCard() { const navigation = useNavigation<NavigationProp>() - const t = useTheme() const {_} = useLingui() return ( @@ -437,7 +430,7 @@ function SeeMoreSuggestedProfilesCard() { onPress={() => { navigation.navigate('SearchTab') }}> - <CardOuter style={[a.flex_1, t.atoms.shadow_sm]}> + <CardOuter> <View style={[a.flex_1, a.justify_center]}> <View style={[a.flex_col, a.align_center, a.gap_md]}> <Text style={[a.leading_snug, a.text_center]}> @@ -491,10 +484,7 @@ export function SuggestedFeeds() { }}> {({hovered, pressed}) => ( <CardOuter - style={[ - a.flex_1, - (hovered || pressed) && t.atoms.border_contrast_high, - ]}> + style={[(hovered || pressed) && t.atoms.border_contrast_high]}> <FeedCard.Outer> <FeedCard.Header> <FeedCard.Avatar src={feed.avatar} /> @@ -568,7 +558,7 @@ export function SuggestedFeeds() { navigation.navigate('SearchTab') }} style={[a.flex_col]}> - <CardOuter style={[a.flex_1]}> + <CardOuter> <View style={[a.flex_1, a.justify_center]}> <View style={[a.flex_row, a.px_lg]}> <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}> 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 ( + <View + style={[ + a.rounded_sm, + t.atoms.bg_contrast_25, + a.w_full, + { + height: 33, + }, + style, + ]} + /> + ) +} + 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<ViewStyle> +}> + +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 ( + <Animated.View style={[animatedStyle, style]}> + <View onLayout={onLayout}>{children}</View> + </Animated.View> + ) +} + +function MobileAccordion({ + isExpanded, + duration = 200, + style, + children, +}: AccordionAnimationProps) { + if (!isExpanded) return null + + return ( + <Animated.View + style={style} + entering={FadeInUp.duration(duration)} + exiting={FadeOutUp.duration(duration / 2)} + pointerEvents={isIOS ? 'auto' : 'box-none'}> + {children} + </Animated.View> + ) +} + +export function AccordionAnimation(props: AccordionAnimationProps) { + return isWeb ? <WebAccordion {...props} /> : <MobileAccordion {...props} /> +} 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 ( - <ProfileHeaderShell - profile={profile} - moderation={moderation} - hideBackButton={hideBackButton} - isPlaceholderProfile={isPlaceholderProfile}> - <View - style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]} - pointerEvents={isIOS ? 'auto' : 'box-none'}> + <> + <ProfileHeaderShell + profile={profile} + moderation={moderation} + hideBackButton={hideBackButton} + isPlaceholderProfile={isPlaceholderProfile}> <View - style={[ - {paddingLeft: 90}, - a.flex_row, - a.align_center, - a.justify_end, - a.gap_xs, - a.pb_sm, - a.flex_wrap, - ]} + style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]} pointerEvents={isIOS ? 'auto' : 'box-none'}> - {isMe ? ( - <> - <Button - testID="profileHeaderEditProfileButton" - size="small" - color="secondary" - variant="solid" - onPress={editProfileControl.open} - label={_(msg`Edit profile`)} - style={[a.rounded_full]}> - <ButtonText> - <Trans>Edit Profile</Trans> - </ButtonText> - </Button> - <EditProfileDialog - profile={profile} - control={editProfileControl} - /> - </> - ) : profile.viewer?.blocking ? ( - profile.viewer?.blockingByList ? null : ( - <Button - testID="unblockBtn" - size="small" - color="secondary" - variant="solid" - label={_(msg`Unblock`)} - disabled={!hasSession} - onPress={() => unblockPromptControl.open()} - style={[a.rounded_full]}> - <ButtonText> - <Trans context="action">Unblock</Trans> - </ButtonText> - </Button> - ) - ) : !profile.viewer?.blockedBy ? ( - <> - {hasSession && subscriptionsAllowed && ( - <SubscribeProfileButton + <View + style={[ + {paddingLeft: 90}, + a.flex_row, + a.align_center, + a.justify_end, + a.gap_xs, + a.pb_sm, + a.flex_wrap, + ]} + pointerEvents={isIOS ? 'auto' : 'box-none'}> + {isMe ? ( + <> + <Button + testID="profileHeaderEditProfileButton" + size="small" + color="secondary" + variant="solid" + onPress={editProfileControl.open} + label={_(msg`Edit profile`)} + style={[a.rounded_full]}> + <ButtonText> + <Trans>Edit Profile</Trans> + </ButtonText> + </Button> + <EditProfileDialog profile={profile} - moderationOpts={moderationOpts} + control={editProfileControl} /> - )} - {hasSession && <MessageProfileButton profile={profile} />} - - <Button - testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'} - size="small" - color={profile.viewer?.following ? 'secondary' : 'primary'} - variant="solid" - label={ - profile.viewer?.following - ? _(msg`Unfollow ${profile.handle}`) - : _(msg`Follow ${profile.handle}`) - } - onPress={ - profile.viewer?.following ? onPressUnfollow : onPressFollow - } - style={[a.rounded_full]}> - {!profile.viewer?.following && ( - <ButtonIcon position="left" icon={Plus} /> + </> + ) : profile.viewer?.blocking ? ( + profile.viewer?.blockingByList ? null : ( + <Button + testID="unblockBtn" + size="small" + color="secondary" + variant="solid" + label={_(msg`Unblock`)} + disabled={!hasSession} + onPress={() => unblockPromptControl.open()} + style={[a.rounded_full]}> + <ButtonText> + <Trans context="action">Unblock</Trans> + </ButtonText> + </Button> + ) + ) : !profile.viewer?.blockedBy ? ( + <> + {hasSession && subscriptionsAllowed && ( + <SubscribeProfileButton + profile={profile} + moderationOpts={moderationOpts} + /> )} - <ButtonText> - {profile.viewer?.following ? ( - <Trans>Following</Trans> - ) : profile.viewer?.followedBy ? ( - <Trans>Follow Back</Trans> - ) : ( - <Trans>Follow</Trans> + {hasSession && <MessageProfileButton profile={profile} />} + + <Button + testID={ + profile.viewer?.following ? 'unfollowBtn' : 'followBtn' + } + size="small" + color={profile.viewer?.following ? 'secondary' : 'primary'} + variant="solid" + label={ + profile.viewer?.following + ? _(msg`Unfollow ${profile.handle}`) + : _(msg`Follow ${profile.handle}`) + } + onPress={ + profile.viewer?.following ? onPressUnfollow : onPressFollow + } + style={[a.rounded_full]}> + {!profile.viewer?.following && ( + <ButtonIcon position="left" icon={Plus} /> )} - </ButtonText> - </Button> - </> - ) : null} - <ProfileMenu profile={profile} /> - </View> - <View - style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}> - <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> - <Text - emoji - testID="profileHeaderDisplayName" - style={[ - t.atoms.text, - gtMobile ? a.text_4xl : a.text_3xl, - a.self_start, - a.font_heavy, - a.leading_tight, - ]}> - {sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), - )} - <View + <ButtonText> + {profile.viewer?.following ? ( + <Trans>Following</Trans> + ) : profile.viewer?.followedBy ? ( + <Trans>Follow Back</Trans> + ) : ( + <Trans>Follow</Trans> + )} + </ButtonText> + </Button> + </> + ) : null} + <ProfileMenu profile={profile} /> + </View> + <View + style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}> + <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> + <Text + emoji + testID="profileHeaderDisplayName" style={[ - a.pl_xs, - { - marginTop: platform({ios: 2}), - }, + t.atoms.text, + gtMobile ? a.text_4xl : a.text_3xl, + a.self_start, + a.font_heavy, + a.leading_tight, ]}> - <VerificationCheckButton profile={profile} size="lg" /> - </View> - </Text> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + <View + style={[ + a.pl_xs, + { + marginTop: platform({ios: 2}), + }, + ]}> + <VerificationCheckButton profile={profile} size="lg" /> + </View> + </Text> + </View> + <ProfileHeaderHandle profile={profile} /> </View> - <ProfileHeaderHandle profile={profile} /> - </View> - {!isPlaceholderProfile && !isBlockedUser && ( - <View style={a.gap_md}> - <ProfileHeaderMetrics profile={profile} /> - {descriptionRT && !moderation.ui('profileView').blur ? ( - <View pointerEvents="auto"> - <RichText - testID="profileHeaderDescription" - style={[a.text_md]} - numberOfLines={15} - value={descriptionRT} - enableTags - authorHandle={profile.handle} - /> - </View> - ) : undefined} - - {!isMe && - !isBlockedUser && - shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( - <View style={[a.flex_row, a.align_center, a.gap_sm]}> - <KnownFollowers - profile={profile} - moderationOpts={moderationOpts} + {!isPlaceholderProfile && !isBlockedUser && ( + <View style={a.gap_md}> + <ProfileHeaderMetrics profile={profile} /> + {descriptionRT && !moderation.ui('profileView').blur ? ( + <View pointerEvents="auto"> + <RichText + testID="profileHeaderDescription" + style={[a.text_md]} + numberOfLines={15} + value={descriptionRT} + enableTags + authorHandle={profile.handle} /> </View> - )} - </View> - )} - </View> - <Prompt.Basic - control={unblockPromptControl} - title={_(msg`Unblock Account?`)} - description={_( - msg`The account will be able to interact with you after unblocking.`, - )} - onConfirm={unblockAccount} - confirmButtonCta={ - profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) - } - confirmButtonColor="negative" + ) : undefined} + + {!isMe && + !isBlockedUser && + shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + <KnownFollowers + profile={profile} + moderationOpts={moderationOpts} + /> + </View> + )} + </View> + )} + </View> + + <Prompt.Basic + control={unblockPromptControl} + title={_(msg`Unblock Account?`)} + description={_( + msg`The account will be able to interact with you after unblocking.`, + )} + onConfirm={unblockAccount} + confirmButtonCta={ + profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) + } + confirmButtonColor="negative" + /> + </ProfileHeaderShell> + + <AnimatedProfileHeaderSuggestedFollows + isExpanded={showSuggestedFollows} + actorDid={profile.did} /> - </ProfileHeaderShell> + </> ) } + 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 && ( <View - style={[a.px_lg, a.py_xs]} + style={[a.px_lg, a.pt_xs, a.pb_sm]} pointerEvents={isIOS ? 'auto' : 'box-none'}> {isMe ? ( <LabelsOnMe type="account" labels={profile.labels} /> 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 ( + <ProfileGrid + isSuggestionsLoading={isLoading} + profiles={data?.suggestions ?? []} + recId={data?.recId} + error={error} + viewContext="profileHeader" + /> + ) +} + +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 ( + <AccordionAnimation isExpanded={isExpanded}> + <ProfileHeaderSuggestedFollows actorDid={actorDid} /> + </AccordionAnimation> + ) +} 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({ |