Add recId to suggestedUser:* events (#9764)

* add recId to onboarding

* add recId to follow dialog

* add recId to profile header suggestions

* add recId to feed interstitials, fix animation on native

* fix claude feedback

* fix yarn.lock ci
This commit is contained in:
Samuel Newman
2026-02-03 18:42:12 +02:00
committed by GitHub
parent 27d9462639
commit 017120b3e8
9 changed files with 376 additions and 439 deletions
+5 -5
View File
@@ -467,8 +467,8 @@ export type Events = {
| 'InterstitialProfile'
| 'Profile'
| 'Onboarding'
location: 'Card' | 'Profile'
recId?: number
location: 'Card' | 'Profile' | 'FollowAll'
recId?: number | string
position: number
suggestedDid: string
category: string | null
@@ -479,7 +479,7 @@ export type Events = {
| 'InterstitialDiscover'
| 'InterstitialProfile'
| 'Onboarding'
recId?: number
recId?: number | string
position: number
suggestedDid: string
category: string | null
@@ -492,7 +492,7 @@ export type Events = {
| 'Profile'
| 'Onboarding'
| 'ProgressGuide'
recId?: number
recId?: number | string
position: number
suggestedDid: string
category: string | null
@@ -507,7 +507,7 @@ export type Events = {
}
'suggestedUser:dismiss': {
logContext: 'InterstitialDiscover' | 'InterstitialProfile'
recId?: number
recId?: number | string
position: number
suggestedDid: string
}
+101 -120
View File
@@ -1,6 +1,12 @@
import React, {useCallback, useEffect, useRef} from 'react'
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {ScrollView, View} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated'
import Animated, {
Easing,
FadeIn,
FadeOut,
LayoutAnimationConfig,
LinearTransition,
} from 'react-native-reanimated'
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -21,6 +27,7 @@ import {type SeenPost} from '#/state/userActionHistory'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {
atoms as a,
native,
useBreakpoints,
useTheme,
type ViewStyleProp,
@@ -152,7 +159,7 @@ function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = React.useMemo(() => {
const dids = useMemo(() => {
const {likes, follows, followSuggestions, seen} = userActionSnapshot
const likeDids = likes
.map(l => new AtUri(l))
@@ -225,67 +232,54 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
isFetchingNextPage,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
const onDismiss = React.useCallback((dismissedDid: string) => {
// Start the fade animation
setDismissingDids(prev => new Set(prev).add(dismissedDid))
// After animation completes, actually remove from list
setTimeout(() => {
setDismissedDids(prev => new Set(prev).add(dismissedDid))
setDismissingDids(prev => {
const next = new Set(prev)
next.delete(dismissedDid)
return next
})
}, DISMISS_ANIMATION_DURATION)
const onDismiss = useCallback((dismissedDid: string) => {
setDismissedDids(prev => new Set(prev).add(dismissedDid))
}, [])
// Combine profiles from the actor-specific query with fallback suggestions
const allProfiles = React.useMemo(() => {
const allProfiles = useMemo(() => {
const actorProfiles = data?.suggestions ?? []
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
moreSuggestions?.pages.flatMap(page =>
page.actors.map(actor => ({actor, recId: page.recId})),
) ?? []
// Dedupe by did, preferring actor-specific profiles
const seen = new Set<string>()
const combined: bsky.profile.AnyProfileView[] = []
const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = []
for (const profile of actorProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push(profile)
combined.push({actor: profile, recId: data?.recId})
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.did) && profile.did !== did) {
seen.add(profile.did)
if (!seen.has(profile.actor.did) && profile.actor.did !== did) {
seen.add(profile.actor.did)
combined.push(profile)
}
}
return combined
}, [data?.suggestions, moreSuggestions?.pages, did])
}, [data?.suggestions, moreSuggestions?.pages, did, data?.recId])
const filteredProfiles = React.useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.did))
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
React.useEffect(() => {
useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage()
void fetchNextPage()
}
}, [
filteredProfiles.length,
@@ -301,11 +295,9 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
isSuggestionsLoading={isSuggestionsLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
recId={data?.recId}
error={error}
viewContext="profile"
onDismiss={onDismiss}
dismissingDids={dismissingDids}
/>
)
}
@@ -327,46 +319,36 @@ export function SuggestedFollowsHome() {
error: suggestionsError,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissedDids, setDismissedDids] = useState<Set<string>>(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)
const onDismiss = useCallback((did: string) => {
setDismissedDids(prev => new Set(prev).add(did))
}, [])
// Combine profiles from experimental query with paginated suggestions
const allProfiles = React.useMemo(() => {
const allProfiles = useMemo(() => {
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
moreSuggestions?.pages.flatMap(page =>
page.actors.map(actor => ({actor, recId: page.recId})),
) ?? []
// Dedupe by did, preferring experimental profiles
const seen = new Set<string>()
const combined: bsky.profile.AnyProfileView[] = []
const combined: Array<{
actor: bsky.profile.AnyProfileView
recId?: number
}> = []
for (const profile of experimentalProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push(profile)
combined.push({actor: profile, recId: undefined})
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
if (!seen.has(profile.actor.did)) {
seen.add(profile.actor.did)
combined.push(profile)
}
}
@@ -374,19 +356,19 @@ export function SuggestedFollowsHome() {
return combined
}, [experimentalProfiles, moreSuggestions?.pages])
const filteredProfiles = React.useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.did))
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
React.useEffect(() => {
useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage()
void fetchNextPage()
}
}, [
filteredProfiles.length,
@@ -405,7 +387,6 @@ export function SuggestedFollowsHome() {
error={experimentalError || suggestionsError}
viewContext="feed"
onDismiss={onDismiss}
dismissingDids={dismissingDids}
/>
)
}
@@ -415,18 +396,14 @@ export function ProfileGrid({
error,
profiles,
totalProfileCount,
recId,
viewContext = 'feed',
onDismiss,
dismissingDids,
isVisible = true,
}: {
isSuggestionsLoading: boolean
profiles: bsky.profile.AnyProfileView[]
profiles: {actor: bsky.profile.AnyProfileView; recId?: number}[]
totalProfileCount?: number
recId?: number
error: Error | null
dismissingDids?: Set<string>
viewContext: 'profile' | 'profileHeader' | 'feed'
onDismiss?: (did: string) => void
isVisible?: boolean
@@ -463,18 +440,18 @@ export function ProfileGrid({
const profilesToShow = profiles.slice(0, maxLength)
profilesToShow.forEach((profile, index) => {
if (!seenProfilesRef.current.has(profile.did)) {
seenProfilesRef.current.add(profile.did)
if (!seenProfilesRef.current.has(profile.actor.did)) {
seenProfilesRef.current.add(profile.actor.did)
ax.metric('suggestedUser:seen', {
logContext,
recId,
recId: profile.recId,
position: index,
suggestedDid: profile.did,
suggestedDid: profile.actor.did,
category: null,
})
}
})
}, [ax, isLoading, error, profiles, maxLength, logContext, recId])
}, [ax, isLoading, error, profiles, maxLength, logContext])
// For profile header, fire when isVisible becomes true
useEffect(() => {
@@ -540,8 +517,15 @@ export function ProfileGrid({
? null
: profiles.slice(0, maxLength).map((profile, index) => (
<Animated.View
key={profile.did}
layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)}
key={profile.actor.did}
layout={native(
LinearTransition.delay(DISMISS_ANIMATION_DURATION).easing(
Easing.out(Easing.exp),
),
)}
exiting={FadeOut.duration(DISMISS_ANIMATION_DURATION)}
// for web, as the cards are static, not in a list
entering={web(FadeIn.delay(DISMISS_ANIMATION_DURATION * 2))}
style={[
a.flex_1,
gtMobile &&
@@ -550,22 +534,17 @@ export function ProfileGrid({
a.flex_grow,
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
]),
{
opacity: dismissingDids?.has(profile.did) ? 0 : 1,
transitionProperty: 'opacity',
transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`,
},
]}>
<ProfileCard.Link
profile={profile}
profile={profile.actor}
onPress={() => {
ax.metric('suggestedUser:press', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
recId,
recId: profile.recId,
position: index,
suggestedDid: profile.did,
suggestedDid: profile.actor.did,
category: null,
})
}}
@@ -581,14 +560,14 @@ export function ProfileGrid({
label={_(msg`Dismiss this suggestion`)}
onPress={e => {
e.preventDefault()
onDismiss(profile.did)
onDismiss(profile.actor.did)
ax.metric('suggestedUser:dismiss', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
position: index,
suggestedDid: profile.did,
recId,
suggestedDid: profile.actor.did,
recId: profile.recId,
})
}}
style={[
@@ -621,18 +600,18 @@ export function ProfileGrid({
a.mb_auto,
]}>
<ProfileCard.Avatar
profile={profile}
profile={profile.actor}
moderationOpts={moderationOpts}
disabledPreview
size={88}
/>
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
<ProfileCard.Name
profile={profile}
profile={profile.actor}
moderationOpts={moderationOpts}
/>
<ProfileCard.Description
profile={profile}
profile={profile.actor}
numberOfLines={2}
style={[
t.atoms.text_contrast_medium,
@@ -644,7 +623,7 @@ export function ProfileGrid({
</View>
<ProfileCard.FollowButton
profile={profile}
profile={profile.actor}
moderationOpts={moderationOpts}
logContext="FeedInterstitial"
withIcon={false}
@@ -655,9 +634,9 @@ export function ProfileGrid({
? 'InterstitialDiscover'
: 'InterstitialProfile',
location: 'Card',
recId,
recId: profile.recId,
position: index,
suggestedDid: profile.did,
suggestedDid: profile.actor.did,
category: null,
})
}}
@@ -726,35 +705,37 @@ export function ProfileGrid({
<FollowDialogWithoutGuide control={followDialogControl} />
{gtMobile ? (
<View style={[a.p_lg, a.pt_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
{content}
<LayoutAnimationConfig skipExiting skipEntering>
{gtMobile ? (
<View style={[a.p_lg, a.pt_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
{content}
</View>
</View>
</View>
) : (
<BlockDrawerGesture>
<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}
) : (
<BlockDrawerGesture>
<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}
{!isProfileHeaderContext && (
<SeeMoreSuggestedProfilesCard
onPress={() => {
followDialogControl.open()
ax.metric('suggestedUser:seeMore', {
logContext: 'Explore',
})
}}
/>
)}
</ScrollView>
</BlockDrawerGesture>
)}
{!isProfileHeaderContext && (
<SeeMoreSuggestedProfilesCard
onPress={() => {
followDialogControl.open()
ax.metric('suggestedUser:seeMore', {
logContext: 'Explore',
})
}}
/>
)}
</ScrollView>
</BlockDrawerGesture>
)}
</LayoutAnimationConfig>
</View>
)
}
@@ -795,7 +776,7 @@ export function SuggestedFeeds() {
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const feeds = React.useMemo(() => {
const feeds = useMemo(() => {
const items: AppBskyFeedDefs.GeneratorView[] = []
if (!data) return items
+20 -15
View File
@@ -9,6 +9,7 @@ import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearch} from '#/state/queries/actor-search'
@@ -207,6 +208,15 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
}
}
if (
hasSearchText &&
!isFetchingSearchResults &&
!_items.length &&
!isSearchResultsError
) {
_items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
}
return _items
}, [
_,
@@ -219,17 +229,9 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
currentAccount?.did,
hasSearchText,
resultsKey,
isSearchResultsError,
])
if (
searchText &&
!isFetchingSearchResults &&
!items.length &&
!isSearchResultsError
) {
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
}
const renderItems = useCallback(
({item, index}: {item: Item; index: number}) => {
switch (item.type) {
@@ -262,7 +264,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const selectedInterestRef = useRef(selectedInterest)
selectedInterestRef.current = selectedInterest
const onViewableItemsChanged = useRef(
const onViewableItemsChanged = useNonReactiveCallback(
({viewableItems}: {viewableItems: ViewToken[]}) => {
for (const viewableItem of viewableItems) {
const item = viewableItem.item as Item
@@ -274,7 +276,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
)
ax.metric('suggestedUser:seen', {
logContext: 'ProgressGuide',
recId: undefined,
recId: hasSearchText ? undefined : suggestions?.recId,
position: position !== -1 ? position : 0,
suggestedDid: item.profile.did,
category: selectedInterestRef.current,
@@ -283,10 +285,13 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
}
}
},
).current
const viewabilityConfig = useRef({
itemVisiblePercentThreshold: 50,
}).current
)
const viewabilityConfig = useMemo(
() => ({
itemVisiblePercentThreshold: 50,
}),
[],
)
const onSelectTab = useCallback(
(interest: string) => {
@@ -97,6 +97,17 @@ export function StepSuggestedAccounts() {
tab: selectedInterest ?? 'all',
numAccounts: followableDids.length,
})
for (let i = 0; i < followableDids.length; i++) {
const did = followableDids[i]
ax.metric('suggestedUser:follow', {
logContext: 'Onboarding',
location: 'FollowAll',
recId: suggestedUsers?.recId,
position: i,
suggestedDid: did,
category: selectedInterest,
})
}
},
mutationFn: async () => {
for (const did of followableDids) {
@@ -135,14 +146,14 @@ export function StepSuggestedAccounts() {
seenProfilesRef.current.add(did)
ax.metric('suggestedUser:seen', {
logContext: 'Onboarding',
recId: undefined,
recId: suggestedUsers?.recId,
position,
suggestedDid: did,
category: selectedInterest,
})
}
},
[ax, selectedInterest],
[ax, selectedInterest, suggestedUsers?.recId],
)
return (
@@ -220,6 +231,7 @@ export function StepSuggestedAccounts() {
position={index}
category={selectedInterest}
onSeen={onProfileSeen}
recId={suggestedUsers.recId}
/>
))}
</View>
@@ -234,7 +246,7 @@ export function StepSuggestedAccounts() {
color="secondary"
size="large"
label={_(msg`Retry`)}
onPress={() => refetch()}>
onPress={() => void refetch()}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
@@ -329,12 +341,14 @@ function SuggestedProfileCard({
position,
category,
onSeen,
recId,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
position: number
category: string | null
onSeen: (did: string, position: number) => void
recId?: number | string
}) {
const t = useTheme()
const ax = useAnalytics()
@@ -401,7 +415,7 @@ function SuggestedProfileCard({
ax.metric('suggestedUser:follow', {
logContext: 'Onboarding',
location: 'Card',
recId: undefined,
recId,
position,
suggestedDid: profile.did,
category,
@@ -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 = ({
/>
</ProfileHeaderShell>
<AnimatedProfileHeaderSuggestedFollows
<ProfileHeaderSuggestedFollows
isExpanded={showSuggestedFollows}
actorDid={profile.did}
/>
+86 -189
View File
@@ -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<Set<string>>(
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
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<string>()
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 (
<ProfileGrid
isSuggestionsLoading={isLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
recId={data?.recId}
error={error}
viewContext="profileHeader"
onDismiss={onDismiss}
dismissingDids={dismissingDids}
/>
)
}
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<Set<string>>(
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
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<string>()
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}
/>
</AccordionAnimation>
)
}
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<Set<string>>(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<string>()
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,
}
}
@@ -45,6 +45,7 @@ export function useSuggestedUsers({
data: searched?.data
? {
actors: searched.data.pages.flatMap(p => p.actors) ?? [],
recId: undefined,
}
: undefined,
isLoading: searched.isLoading,