From c54d0144fac803d272951d8f78f39b4a9d4f38be Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 26 Jan 2026 22:40:36 +0200 Subject: [PATCH] add recId to profile header suggestions --- .../Profile/Header/ProfileHeaderStandard.tsx | 4 +- .../Profile/Header/SuggestedFollows.tsx | 275 ++++++------------ 2 files changed, 88 insertions(+), 191 deletions(-) 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, + } +}