[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
+46 -56
View File
@@ -1,6 +1,5 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {ScrollView, View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {type AppBskyFeedDefs, AtUri} from '@atproto/api' import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -9,6 +8,7 @@ import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {type FeedDescriptor} from '#/state/queries/post-feed' import {type FeedDescriptor} from '#/state/queries/post-feed'
@@ -25,7 +25,7 @@ import {
type ViewStyleProp, type ViewStyleProp,
web, web,
} from '#/alf' } from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard' import * as FeedCard from '#/components/FeedCard'
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
@@ -46,11 +46,13 @@ function CardOuter({
return ( return (
<View <View
style={[ style={[
a.flex_1,
a.w_full, a.w_full,
a.p_md, a.p_md,
a.rounded_lg, a.rounded_lg,
a.border, a.border,
t.atoms.bg, t.atoms.bg,
t.atoms.shadow_sm,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
!gtMobile && { !gtMobile && {
width: MOBILE_CARD_WIDTH, width: MOBILE_CARD_WIDTH,
@@ -63,11 +65,8 @@ function CardOuter({
} }
export function SuggestedFollowPlaceholder() { export function SuggestedFollowPlaceholder() {
const t = useTheme()
return ( return (
<CardOuter <CardOuter>
style={[a.gap_md, t.atoms.border_contrast_low, t.atoms.shadow_sm]}>
<ProfileCard.Outer> <ProfileCard.Outer>
<View <View
style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}> 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>
</View> </View>
<Button <ProfileCard.FollowButtonPlaceholder />
label=""
size="small"
variant="solid"
color="secondary"
disabled
style={[a.w_full, a.rounded_sm]}>
<ButtonText>Follow</ButtonText>
</Button>
</ProfileCard.Outer> </ProfileCard.Outer>
</CardOuter> </CardOuter>
) )
} }
export function SuggestedFeedsCardPlaceholder() { export function SuggestedFeedsCardPlaceholder() {
const t = useTheme()
return ( return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}> <CardOuter style={[a.gap_sm]}>
<FeedCard.Header> <FeedCard.Header>
<FeedCard.AvatarPlaceholder /> <FeedCard.AvatarPlaceholder />
<FeedCard.TitleAndBylinePlaceholder creator /> <FeedCard.TitleAndBylinePlaceholder creator />
@@ -253,22 +243,28 @@ export function ProfileGrid({
profiles: bsky.profile.AnyProfileView[] profiles: bsky.profile.AnyProfileView[]
recId?: number recId?: number
error: Error | null error: Error | null
viewContext: 'profile' | 'feed' viewContext: 'profile' | 'profileHeader' | 'feed'
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const isLoading = isSuggestionsLoading || !moderationOpts
const maxLength = gtMobile ? 3 : 6
const content = isLoading ? ( const isLoading = isSuggestionsLoading || !moderationOpts
Array(maxLength) 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) .fill(0)
.map((_, i) => ( .map((_, i) => (
<View <View
key={i} key={i}
style={[ style={[
a.flex_1,
gtMobile && gtMobile &&
web([ web([
a.flex_0, a.flex_0,
@@ -279,16 +275,15 @@ export function ProfileGrid({
<SuggestedFollowPlaceholder /> <SuggestedFollowPlaceholder />
</View> </View>
)) ))
) : error || !profiles.length ? null : ( : error || !profiles.length
<> ? null
{profiles.slice(0, maxLength).map((profile, index) => ( : profiles.slice(0, maxLength).map((profile, index) => (
<ProfileCard.Link <ProfileCard.Link
key={profile.did} key={profile.did}
profile={profile} profile={profile}
onPress={() => { onPress={() => {
logEvent('suggestedUser:press', { logEvent('suggestedUser:press', {
logContext: logContext: isFeedContext
viewContext === 'feed'
? 'InterstitialDiscover' ? 'InterstitialDiscover'
: 'InterstitialProfile', : 'InterstitialProfile',
recId, recId,
@@ -306,11 +301,7 @@ export function ProfileGrid({
]}> ]}>
{({hovered, pressed}) => ( {({hovered, pressed}) => (
<CardOuter <CardOuter
style={[ style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
a.flex_1,
t.atoms.shadow_sm,
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<ProfileCard.Outer> <ProfileCard.Outer>
<View <View
style={[ style={[
@@ -323,6 +314,7 @@ export function ProfileGrid({
<ProfileCard.Avatar <ProfileCard.Avatar
profile={profile} profile={profile}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
disabledPreview
size={88} size={88}
/> />
<View style={[a.flex_col, a.align_center, a.max_w_full]}> <View style={[a.flex_col, a.align_center, a.max_w_full]}>
@@ -350,8 +342,7 @@ export function ProfileGrid({
style={[a.rounded_sm]} style={[a.rounded_sm]}
onFollow={() => { onFollow={() => {
logEvent('suggestedUser:follow', { logEvent('suggestedUser:follow', {
logContext: logContext: isFeedContext
viewContext === 'feed'
? 'InterstitialDiscover' ? 'InterstitialDiscover'
: 'InterstitialProfile', : 'InterstitialProfile',
location: 'Card', location: 'Card',
@@ -364,18 +355,21 @@ export function ProfileGrid({
</CardOuter> </CardOuter>
)} )}
</ProfileCard.Link> </ProfileCard.Link>
))} ))
</>
)
if (error || (!isLoading && profiles.length < 4)) { if (error || (!isLoading && profiles.length < minLength)) {
logger.debug(`Not enough profiles to show suggested follows`) logger.debug(`Not enough profiles to show suggested follows`)
return null return null
} }
return ( return (
<View <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 <View
style={[ style={[
a.px_lg, a.px_lg,
@@ -383,19 +377,22 @@ export function ProfileGrid({
a.flex_row, a.flex_row,
a.align_center, a.align_center,
a.justify_between, a.justify_between,
]}> ]}
pointerEvents={isIOS ? 'auto' : 'box-none'}>
<Text style={[a.text_sm, a.font_bold, t.atoms.text]}> <Text style={[a.text_sm, a.font_bold, t.atoms.text]}>
{viewContext === 'profile' ? ( {isFeedContext ? (
<Trans>Similar accounts</Trans>
) : (
<Trans>Suggested for you</Trans> <Trans>Suggested for you</Trans>
) : (
<Trans>Similar accounts</Trans>
)} )}
</Text> </Text>
{!isProfileHeaderContext && (
<InlineLinkText <InlineLinkText
label={_(msg`See more suggested profiles on the Explore page`)} label={_(msg`See more suggested profiles on the Explore page`)}
to="/search"> to="/search">
<Trans>See more</Trans> <Trans>See more</Trans>
</InlineLinkText> </InlineLinkText>
)}
</View> </View>
{gtMobile ? ( {gtMobile ? (
@@ -406,19 +403,16 @@ export function ProfileGrid({
</View> </View>
) : ( ) : (
<BlockDrawerGesture> <BlockDrawerGesture>
<View>
<ScrollView <ScrollView
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast"> decelerationRate="fast">
<View style={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}>
{content} {content}
<SeeMoreSuggestedProfilesCard /> {!isProfileHeaderContext && <SeeMoreSuggestedProfilesCard />}
</View>
</ScrollView> </ScrollView>
</View>
</BlockDrawerGesture> </BlockDrawerGesture>
)} )}
</View> </View>
@@ -427,7 +421,6 @@ export function ProfileGrid({
function SeeMoreSuggestedProfilesCard() { function SeeMoreSuggestedProfilesCard() {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
return ( return (
@@ -437,7 +430,7 @@ function SeeMoreSuggestedProfilesCard() {
onPress={() => { onPress={() => {
navigation.navigate('SearchTab') 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_1, a.justify_center]}>
<View style={[a.flex_col, a.align_center, a.gap_md]}> <View style={[a.flex_col, a.align_center, a.gap_md]}>
<Text style={[a.leading_snug, a.text_center]}> <Text style={[a.leading_snug, a.text_center]}>
@@ -491,10 +484,7 @@ export function SuggestedFeeds() {
}}> }}>
{({hovered, pressed}) => ( {({hovered, pressed}) => (
<CardOuter <CardOuter
style={[ style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
a.flex_1,
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<FeedCard.Outer> <FeedCard.Outer>
<FeedCard.Header> <FeedCard.Header>
<FeedCard.Avatar src={feed.avatar} /> <FeedCard.Avatar src={feed.avatar} />
@@ -568,7 +558,7 @@ export function SuggestedFeeds() {
navigation.navigate('SearchTab') navigation.navigate('SearchTab')
}} }}
style={[a.flex_col]}> style={[a.flex_col]}>
<CardOuter style={[a.flex_1]}> <CardOuter>
<View style={[a.flex_1, a.justify_center]}> <View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}> <View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}> <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({ export function Labels({
profile, profile,
moderationOpts, 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' | 'handle_suggestions'
| 'old_postonboarding' | 'old_postonboarding'
| 'onboarding_add_video_feed' | 'onboarding_add_video_feed'
| 'post_follow_profile_suggested_accounts'
| 'post_threads_v2_unspecced' | 'post_threads_v2_unspecced'
| 'remove_show_latest_button' | 'remove_show_latest_button'
| 'test_gate_1' | '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 {View} from 'react-native'
import { import {
type AppBskyActorDefs, type AppBskyActorDefs,
@@ -40,6 +40,7 @@ import {EditProfileDialog} from './EditProfileDialog'
import {ProfileHeaderHandle} from './Handle' import {ProfileHeaderHandle} from './Handle'
import {ProfileHeaderMetrics} from './Metrics' import {ProfileHeaderMetrics} from './Metrics'
import {ProfileHeaderShell} from './Shell' import {ProfileHeaderShell} from './Shell'
import {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows'
interface Props { interface Props {
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
@@ -73,6 +74,7 @@ let ProfileHeaderStandard = ({
const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const unblockPromptControl = Prompt.usePromptControl() const unblockPromptControl = Prompt.usePromptControl()
const requireAuth = useRequireAuth() const requireAuth = useRequireAuth()
const [showSuggestedFollows, setShowSuggestedFollows] = useState(false)
const isBlockedUser = const isBlockedUser =
profile.viewer?.blocking || profile.viewer?.blocking ||
profile.viewer?.blockedBy || profile.viewer?.blockedBy ||
@@ -81,6 +83,7 @@ let ProfileHeaderStandard = ({
const editProfileControl = useDialogControl() const editProfileControl = useDialogControl()
const onPressFollow = () => { const onPressFollow = () => {
setShowSuggestedFollows(true)
requireAuth(async () => { requireAuth(async () => {
try { try {
await queueFollow() await queueFollow()
@@ -102,6 +105,7 @@ let ProfileHeaderStandard = ({
} }
const onPressUnfollow = () => { const onPressUnfollow = () => {
setShowSuggestedFollows(false)
requireAuth(async () => { requireAuth(async () => {
try { try {
await queueUnfollow() await queueUnfollow()
@@ -122,7 +126,7 @@ let ProfileHeaderStandard = ({
}) })
} }
const unblockAccount = React.useCallback(async () => { const unblockAccount = useCallback(async () => {
try { try {
await queueUnblock() await queueUnblock()
Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
@@ -155,6 +159,7 @@ let ProfileHeaderStandard = ({
}, [profile]) }, [profile])
return ( return (
<>
<ProfileHeaderShell <ProfileHeaderShell
profile={profile} profile={profile}
moderation={moderation} moderation={moderation}
@@ -220,7 +225,9 @@ let ProfileHeaderStandard = ({
{hasSession && <MessageProfileButton profile={profile} />} {hasSession && <MessageProfileButton profile={profile} />}
<Button <Button
testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'} testID={
profile.viewer?.following ? 'unfollowBtn' : 'followBtn'
}
size="small" size="small"
color={profile.viewer?.following ? 'secondary' : 'primary'} color={profile.viewer?.following ? 'secondary' : 'primary'}
variant="solid" variant="solid"
@@ -309,6 +316,7 @@ let ProfileHeaderStandard = ({
</View> </View>
)} )}
</View> </View>
<Prompt.Basic <Prompt.Basic
control={unblockPromptControl} control={unblockPromptControl}
title={_(msg`Unblock Account?`)} title={_(msg`Unblock Account?`)}
@@ -322,7 +330,14 @@ let ProfileHeaderStandard = ({
confirmButtonColor="negative" confirmButtonColor="negative"
/> />
</ProfileHeaderShell> </ProfileHeaderShell>
<AnimatedProfileHeaderSuggestedFollows
isExpanded={showSuggestedFollows}
actorDid={profile.did}
/>
</>
) )
} }
ProfileHeaderStandard = memo(ProfileHeaderStandard) ProfileHeaderStandard = memo(ProfileHeaderStandard)
export {ProfileHeaderStandard} export {ProfileHeaderStandard}
+1 -1
View File
@@ -211,7 +211,7 @@ let ProfileHeaderShell = ({
{!isPlaceholderProfile && ( {!isPlaceholderProfile && (
<View <View
style={[a.px_lg, a.py_xs]} style={[a.px_lg, a.pt_xs, a.pb_sm]}
pointerEvents={isIOS ? 'auto' : 'box-none'}> pointerEvents={isIOS ? 'auto' : 'box-none'}>
{isMe ? ( {isMe ? (
<LabelsOnMe type="account" labels={profile.labels} /> <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 { import {
AppBskyActorDefs, type AppBskyActorDefs,
AppBskyActorGetSuggestions, type AppBskyActorGetSuggestions,
AppBskyGraphGetSuggestedFollowsByActor, type AppBskyGraphGetSuggestedFollowsByActor,
moderateProfile, moderateProfile,
} from '@atproto/api' } from '@atproto/api'
import { import {
InfiniteData, type InfiniteData,
QueryClient, type QueryClient,
QueryKey, type QueryKey,
useInfiniteQuery, useInfiniteQuery,
useQuery, useQuery,
} from '@tanstack/react-query' } from '@tanstack/react-query'
@@ -106,12 +106,15 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
export function useSuggestedFollowsByActorQuery({ export function useSuggestedFollowsByActorQuery({
did, did,
enabled, enabled,
staleTime = STALE.MINUTES.FIVE,
}: { }: {
did: string did: string
enabled?: boolean enabled?: boolean
staleTime?: number
}) { }) {
const agent = useAgent() const agent = useAgent()
return useQuery({ return useQuery({
staleTime,
queryKey: suggestedFollowsByActorQueryKey(did), queryKey: suggestedFollowsByActorQueryKey(did),
queryFn: async () => { queryFn: async () => {
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({