diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 39e4895ba6..6467756d4e 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -43,7 +43,7 @@ import {EditProfileDialog} from './EditProfileDialog'
import {ProfileHeaderHandle} from './Handle'
import {ProfileHeaderMetrics} from './Metrics'
import {ProfileHeaderShell} from './Shell'
-import {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows'
+import {ProfileHeaderSuggestedFollows} from './SuggestedFollows'
interface Props {
profile: AppBskyActorDefs.ProfileViewDetailed
@@ -193,7 +193,7 @@ let ProfileHeaderStandard = ({
/>
-
diff --git a/src/screens/Profile/Header/SuggestedFollows.tsx b/src/screens/Profile/Header/SuggestedFollows.tsx
index de5d35107e..f8bec7d142 100644
--- a/src/screens/Profile/Header/SuggestedFollows.tsx
+++ b/src/screens/Profile/Header/SuggestedFollows.tsx
@@ -1,5 +1,4 @@
-import React from 'react'
-import {type AppBskyActorDefs} from '@atproto/api'
+import {useCallback, useEffect, useMemo, useState} from 'react'
import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -10,198 +9,17 @@ import {
import {useBreakpoints} from '#/alf'
import {ProfileGrid} from '#/components/FeedInterstitials'
import {IS_ANDROID} from '#/env'
+import type * as bsky from '#/types/bsky'
-const DISMISS_ANIMATION_DURATION = 200
-
-export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) {
- const {gtMobile} = useBreakpoints()
- const moderationOpts = useModerationOpts()
- const maxLength = gtMobile ? 4 : 12
- const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
- did: actorDid,
- })
- const {
- data: moreSuggestions,
- fetchNextPage,
- hasNextPage,
- isFetchingNextPage,
- } = useSuggestedFollowsQuery({limit: 25})
-
- const [dismissedDids, setDismissedDids] = React.useState>(
- new Set(),
- )
- const [dismissingDids, setDismissingDids] = React.useState>(
- new Set(),
- )
-
- const onDismiss = React.useCallback((did: string) => {
- // Start the fade animation
- setDismissingDids(prev => new Set(prev).add(did))
- // After animation completes, actually remove from list
- setTimeout(() => {
- setDismissedDids(prev => new Set(prev).add(did))
- setDismissingDids(prev => {
- const next = new Set(prev)
- next.delete(did)
- return next
- })
- }, DISMISS_ANIMATION_DURATION)
- }, [])
-
- // Combine profiles from the actor-specific query with fallback suggestions
- const allProfiles = React.useMemo(() => {
- const actorProfiles = data?.suggestions ?? []
- const fallbackProfiles =
- moreSuggestions?.pages.flatMap(page => page.actors) ?? []
-
- // Dedupe by did, preferring actor-specific profiles
- const seen = new Set()
- const combined: AppBskyActorDefs.ProfileView[] = []
-
- for (const profile of actorProfiles) {
- if (!seen.has(profile.did)) {
- seen.add(profile.did)
- combined.push(profile)
- }
- }
-
- for (const profile of fallbackProfiles) {
- if (!seen.has(profile.did) && profile.did !== actorDid) {
- seen.add(profile.did)
- combined.push(profile)
- }
- }
-
- return combined
- }, [data?.suggestions, moreSuggestions?.pages, actorDid])
-
- const filteredProfiles = React.useMemo(() => {
- return allProfiles.filter(p => !dismissedDids.has(p.did))
- }, [allProfiles, dismissedDids])
-
- // Fetch more when running low
- React.useEffect(() => {
- if (
- moderationOpts &&
- filteredProfiles.length < maxLength &&
- hasNextPage &&
- !isFetchingNextPage
- ) {
- fetchNextPage()
- }
- }, [
- filteredProfiles.length,
- maxLength,
- hasNextPage,
- isFetchingNextPage,
- fetchNextPage,
- moderationOpts,
- ])
-
- return (
-
- )
-}
-
-export function AnimatedProfileHeaderSuggestedFollows({
+export function ProfileHeaderSuggestedFollows({
isExpanded,
actorDid,
}: {
isExpanded: boolean
actorDid: string
}) {
- const {gtMobile} = useBreakpoints()
- const moderationOpts = useModerationOpts()
- const maxLength = gtMobile ? 4 : 12
- const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
- did: actorDid,
- })
- const {
- data: moreSuggestions,
- fetchNextPage,
- hasNextPage,
- isFetchingNextPage,
- } = useSuggestedFollowsQuery({limit: 25})
-
- const [dismissedDids, setDismissedDids] = React.useState>(
- new Set(),
- )
- const [dismissingDids, setDismissingDids] = React.useState>(
- new Set(),
- )
-
- const onDismiss = React.useCallback((did: string) => {
- // Start the fade animation
- setDismissingDids(prev => new Set(prev).add(did))
- // After animation completes, actually remove from list
- setTimeout(() => {
- setDismissedDids(prev => new Set(prev).add(did))
- setDismissingDids(prev => {
- const next = new Set(prev)
- next.delete(did)
- return next
- })
- }, DISMISS_ANIMATION_DURATION)
- }, [])
-
- // Combine profiles from the actor-specific query with fallback suggestions
- const allProfiles = React.useMemo(() => {
- const actorProfiles = data?.suggestions ?? []
- const fallbackProfiles =
- moreSuggestions?.pages.flatMap(page => page.actors) ?? []
-
- // Dedupe by did, preferring actor-specific profiles
- const seen = new Set()
- const combined: AppBskyActorDefs.ProfileView[] = []
-
- for (const profile of actorProfiles) {
- if (!seen.has(profile.did)) {
- seen.add(profile.did)
- combined.push(profile)
- }
- }
-
- for (const profile of fallbackProfiles) {
- if (!seen.has(profile.did) && profile.did !== actorDid) {
- seen.add(profile.did)
- combined.push(profile)
- }
- }
-
- return combined
- }, [data?.suggestions, moreSuggestions?.pages, actorDid])
-
- const filteredProfiles = React.useMemo(() => {
- return allProfiles.filter(p => !dismissedDids.has(p.did))
- }, [allProfiles, dismissedDids])
-
- // Fetch more when running low
- React.useEffect(() => {
- if (
- moderationOpts &&
- filteredProfiles.length < maxLength &&
- hasNextPage &&
- !isFetchingNextPage
- ) {
- fetchNextPage()
- }
- }, [
- filteredProfiles.length,
- maxLength,
- hasNextPage,
- isFetchingNextPage,
- fetchNextPage,
- moderationOpts,
- ])
+ const {allProfiles, filteredProfiles, onDismiss, isLoading, error} =
+ useProfileHeaderSuggestions(actorDid)
if (!allProfiles.length && !isLoading) return null
@@ -218,13 +36,92 @@ export function AnimatedProfileHeaderSuggestedFollows({
isSuggestionsLoading={isLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
- recId={data?.recId}
error={error}
viewContext="profileHeader"
onDismiss={onDismiss}
- dismissingDids={dismissingDids}
isVisible={isExpanded}
/>
)
}
+
+function useProfileHeaderSuggestions(actorDid: string) {
+ const {gtMobile} = useBreakpoints()
+ const moderationOpts = useModerationOpts()
+ const maxLength = gtMobile ? 4 : 12
+ const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
+ did: actorDid,
+ })
+ const {
+ data: moreSuggestions,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ } = useSuggestedFollowsQuery({limit: 25})
+
+ const [dismissedDids, setDismissedDids] = useState>(new Set())
+
+ const onDismiss = useCallback((did: string) => {
+ setDismissedDids(prev => new Set(prev).add(did))
+ }, [])
+
+ // Combine profiles from the actor-specific query with fallback suggestions
+ const allProfiles = useMemo(() => {
+ const actorProfiles = data?.suggestions ?? []
+ const fallbackProfiles =
+ moreSuggestions?.pages.flatMap(page =>
+ page.actors.map(actor => ({actor, recId: page.recId})),
+ ) ?? []
+
+ // Dedupe by did, preferring actor-specific profiles
+ const seen = new Set()
+ const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = []
+
+ for (const profile of actorProfiles) {
+ if (!seen.has(profile.did)) {
+ seen.add(profile.did)
+ combined.push({actor: profile, recId: data?.recId})
+ }
+ }
+
+ for (const profile of fallbackProfiles) {
+ if (!seen.has(profile.actor.did) && profile.actor.did !== actorDid) {
+ seen.add(profile.actor.did)
+ combined.push(profile)
+ }
+ }
+
+ return combined
+ }, [data?.suggestions, moreSuggestions?.pages, actorDid, data?.recId])
+
+ const filteredProfiles = useMemo(() => {
+ return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
+ }, [allProfiles, dismissedDids])
+
+ // Fetch more when running low
+ useEffect(() => {
+ if (
+ moderationOpts &&
+ filteredProfiles.length < maxLength &&
+ hasNextPage &&
+ !isFetchingNextPage
+ ) {
+ void fetchNextPage()
+ }
+ }, [
+ filteredProfiles.length,
+ maxLength,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ moderationOpts,
+ ])
+
+ return {
+ allProfiles,
+ filteredProfiles,
+ onDismiss,
+ isLoading,
+ error,
+ }
+}