[APP-1357] profile header follow recommendations (#8784)

This commit is contained in:
Caidan
2025-08-21 11:56:17 -07:00
committed by GitHub
parent d900d0b7a7
commit eabcd9150d
8 changed files with 463 additions and 314 deletions
+137 -147
View File
@@ -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 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 isLoading = isSuggestionsLoading || !moderationOpts
const isProfileHeaderContext = viewContext === 'profileHeader'
const isFeedContext = 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}
<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}
<SeeMoreSuggestedProfilesCard />
</View>
</ScrollView>
</View>
{!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]}>
+18
View File
@@ -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,
@@ -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} />
}
+1
View File
@@ -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'
@@ -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} />
)}
<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={[
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
style={[
a.pl_xs,
{
marginTop: platform({ios: 2}),
},
]}>
<VerificationCheckButton profile={profile} size="lg" />
</View>
</Text>
</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.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}
/>
</View>
)}
)}
{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>
{profile.viewer?.following ? (
<Trans>Following</Trans>
) : profile.viewer?.followedBy ? (
<Trans>Follow Back</Trans>
) : (
<Trans>Follow</Trans>
)}
</ButtonText>
</Button>
</>
) : null}
<ProfileMenu profile={profile} />
</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"
<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
style={[
a.pl_xs,
{
marginTop: platform({ios: 2}),
},
]}>
<VerificationCheckButton profile={profile} size="lg" />
</View>
</Text>
</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}
/>
</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}
+1 -1
View File
@@ -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} />
@@ -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>
)
}
+9 -6
View File
@@ -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({