diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index 18da12b22c..07ad2d5011 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 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 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) => (
+
+
+
+ ))
+ : 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 f12d922fd3..5c99474a21 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 0000000000..146735aa65
--- /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 66134a4629..8ec86c9713 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 2f61ba4df8..1df35d5e0b 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 && }
-
-
- >
- ) : null}
-
-
-
-
-
- {sanitizeDisplayName(
- profile.displayName || sanitizeHandle(profile.handle),
- moderation.ui('displayName'),
- )}
-
-
-
-
-
-
-
- {!isPlaceholderProfile && !isBlockedUser && (
-
-
- {descriptionRT && !moderation.ui('profileView').blur ? (
-
-
-
- ) : undefined}
-
- {!isMe &&
- !isBlockedUser &&
- shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
-
-
+ ) : profile.viewer?.blocking ? (
+ profile.viewer?.blockingByList ? null : (
+
+ )
+ ) : !profile.viewer?.blockedBy ? (
+ <>
+ {hasSession && subscriptionsAllowed && (
+
-
- )}
+ )}
+ {hasSession && }
+
+
+ >
+ ) : null}
+
- )}
-
-
+
+
+ {sanitizeDisplayName(
+ profile.displayName || sanitizeHandle(profile.handle),
+ moderation.ui('displayName'),
+ )}
+
+
+
+
+
+
+
+ {!isPlaceholderProfile && !isBlockedUser && (
+
+
+ {descriptionRT && !moderation.ui('profileView').blur ? (
+
+
+
+ ) : undefined}
+
+ {!isMe &&
+ !isBlockedUser &&
+ shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
+
+
+
+ )}
+
+ )}
+
+
+
+
+
+
-
+ >
)
}
+
ProfileHeaderStandard = memo(ProfileHeaderStandard)
export {ProfileHeaderStandard}
diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx
index 167be0aa8c..cff0a707cd 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 0000000000..d005d888ea
--- /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 0a2343150c..c7a6e5f752 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({