Adds a dismiss button to user suggestions (#9484)
* Add dismiss button to user suggestions * Adds dismiss button to suggested user cards, behind a feature gate * Reverse gate check, best practice * Sync DISMISS_ANIMATION_DURATION --------- Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
@@ -1,12 +1,13 @@
|
|||||||
import React, {useCallback, useEffect, useRef} from 'react'
|
import React, {useCallback, useEffect, useRef} from 'react'
|
||||||
import {ScrollView, View} from 'react-native'
|
import {ScrollView, View} from 'react-native'
|
||||||
|
import Animated, {LinearTransition} from 'react-native-reanimated'
|
||||||
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'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
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, useGate} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {type MetricEvents} from '#/logger/metrics'
|
import {type MetricEvents} from '#/logger/metrics'
|
||||||
import {isIOS} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
@@ -14,7 +15,10 @@ 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'
|
||||||
import {useProfilesQuery} from '#/state/queries/profile'
|
import {useProfilesQuery} from '#/state/queries/profile'
|
||||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
import {
|
||||||
|
useSuggestedFollowsByActorQuery,
|
||||||
|
useSuggestedFollowsQuery,
|
||||||
|
} from '#/state/queries/suggested-follows'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import * as userActionHistory from '#/state/userActionHistory'
|
import * as userActionHistory from '#/state/userActionHistory'
|
||||||
import {type SeenPost} from '#/state/userActionHistory'
|
import {type SeenPost} from '#/state/userActionHistory'
|
||||||
@@ -31,6 +35,7 @@ import {useDialogControl} from '#/components/Dialog'
|
|||||||
import * as FeedCard from '#/components/FeedCard'
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
|
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} 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'
|
||||||
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText} from '#/components/Link'
|
||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
@@ -38,6 +43,8 @@ import type * as bsky from '#/types/bsky'
|
|||||||
import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
|
import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
|
||||||
import {ProgressGuideList} from './ProgressGuide/List'
|
import {ProgressGuideList} from './ProgressGuide/List'
|
||||||
|
|
||||||
|
const DISMISS_ANIMATION_DURATION = 200
|
||||||
|
|
||||||
const MOBILE_CARD_WIDTH = 165
|
const MOBILE_CARD_WIDTH = 165
|
||||||
const FINAL_CARD_WIDTH = 120
|
const FINAL_CARD_WIDTH = 120
|
||||||
|
|
||||||
@@ -202,6 +209,9 @@ export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestedFollowsProfile({did}: {did: string}) {
|
export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const maxLength = gtMobile ? 4 : 6
|
||||||
const {
|
const {
|
||||||
isLoading: isSuggestionsLoading,
|
isLoading: isSuggestionsLoading,
|
||||||
data,
|
data,
|
||||||
@@ -209,29 +219,194 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
|
|||||||
} = useSuggestedFollowsByActorQuery({
|
} = useSuggestedFollowsByActorQuery({
|
||||||
did,
|
did,
|
||||||
})
|
})
|
||||||
|
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((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)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 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: bsky.profile.AnyProfileView[] = []
|
||||||
|
|
||||||
|
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 !== did) {
|
||||||
|
seen.add(profile.did)
|
||||||
|
combined.push(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return combined
|
||||||
|
}, [data?.suggestions, moreSuggestions?.pages, did])
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<ProfileGrid
|
<ProfileGrid
|
||||||
isSuggestionsLoading={isSuggestionsLoading}
|
isSuggestionsLoading={isSuggestionsLoading}
|
||||||
profiles={data?.suggestions ?? []}
|
profiles={filteredProfiles}
|
||||||
|
totalProfileCount={allProfiles.length}
|
||||||
recId={data?.recId}
|
recId={data?.recId}
|
||||||
error={error}
|
error={error}
|
||||||
viewContext="profile"
|
viewContext="profile"
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
dismissingDids={dismissingDids}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestedFollowsHome() {
|
export function SuggestedFollowsHome() {
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const maxLength = gtMobile ? 4 : 6
|
||||||
const {
|
const {
|
||||||
isLoading: isSuggestionsLoading,
|
isLoading: isSuggestionsLoading,
|
||||||
profiles,
|
profiles: experimentalProfiles,
|
||||||
error,
|
error: experimentalError,
|
||||||
} = useExperimentalSuggestedUsersQuery()
|
} = useExperimentalSuggestedUsersQuery()
|
||||||
|
const {
|
||||||
|
data: moreSuggestions,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
error: suggestionsError,
|
||||||
|
} = 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 experimental query with paginated suggestions
|
||||||
|
const allProfiles = React.useMemo(() => {
|
||||||
|
const fallbackProfiles =
|
||||||
|
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
|
||||||
|
|
||||||
|
// Dedupe by did, preferring experimental profiles
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const combined: bsky.profile.AnyProfileView[] = []
|
||||||
|
|
||||||
|
for (const profile of experimentalProfiles) {
|
||||||
|
if (!seen.has(profile.did)) {
|
||||||
|
seen.add(profile.did)
|
||||||
|
combined.push(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const profile of fallbackProfiles) {
|
||||||
|
if (!seen.has(profile.did)) {
|
||||||
|
seen.add(profile.did)
|
||||||
|
combined.push(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return combined
|
||||||
|
}, [experimentalProfiles, moreSuggestions?.pages])
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<ProfileGrid
|
<ProfileGrid
|
||||||
isSuggestionsLoading={isSuggestionsLoading}
|
isSuggestionsLoading={isSuggestionsLoading}
|
||||||
profiles={profiles}
|
profiles={filteredProfiles}
|
||||||
error={error}
|
totalProfileCount={allProfiles.length}
|
||||||
|
error={experimentalError || suggestionsError}
|
||||||
viewContext="feed"
|
viewContext="feed"
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
dismissingDids={dismissingDids}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -240,19 +415,26 @@ export function ProfileGrid({
|
|||||||
isSuggestionsLoading,
|
isSuggestionsLoading,
|
||||||
error,
|
error,
|
||||||
profiles,
|
profiles,
|
||||||
|
totalProfileCount,
|
||||||
recId,
|
recId,
|
||||||
viewContext = 'feed',
|
viewContext = 'feed',
|
||||||
|
onDismiss,
|
||||||
|
dismissingDids,
|
||||||
isVisible = true,
|
isVisible = true,
|
||||||
}: {
|
}: {
|
||||||
isSuggestionsLoading: boolean
|
isSuggestionsLoading: boolean
|
||||||
profiles: bsky.profile.AnyProfileView[]
|
profiles: bsky.profile.AnyProfileView[]
|
||||||
|
totalProfileCount?: number
|
||||||
recId?: number
|
recId?: number
|
||||||
error: Error | null
|
error: Error | null
|
||||||
|
dismissingDids?: Set<string>
|
||||||
viewContext: 'profile' | 'profileHeader' | 'feed'
|
viewContext: 'profile' | 'profileHeader' | 'feed'
|
||||||
|
onDismiss?: (did: string) => void
|
||||||
isVisible?: boolean
|
isVisible?: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const gate = useGate()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const followDialogControl = useDialogControl()
|
const followDialogControl = useDialogControl()
|
||||||
@@ -260,6 +442,7 @@ export function ProfileGrid({
|
|||||||
const isLoading = isSuggestionsLoading || !moderationOpts
|
const isLoading = isSuggestionsLoading || !moderationOpts
|
||||||
const isProfileHeaderContext = viewContext === 'profileHeader'
|
const isProfileHeaderContext = viewContext === 'profileHeader'
|
||||||
const isFeedContext = viewContext === 'feed'
|
const isFeedContext = viewContext === 'feed'
|
||||||
|
const showDismissButton = onDismiss && gate('suggested_users_dismiss')
|
||||||
|
|
||||||
const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
|
const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
|
||||||
const minLength = gtMobile ? 3 : 4
|
const minLength = gtMobile ? 3 : 4
|
||||||
@@ -363,20 +546,9 @@ export function ProfileGrid({
|
|||||||
: error || !profiles.length
|
: error || !profiles.length
|
||||||
? null
|
? null
|
||||||
: profiles.slice(0, maxLength).map((profile, index) => (
|
: profiles.slice(0, maxLength).map((profile, index) => (
|
||||||
<ProfileCard.Link
|
<Animated.View
|
||||||
key={profile.did}
|
key={profile.did}
|
||||||
profile={profile}
|
layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)}
|
||||||
onPress={() => {
|
|
||||||
logEvent('suggestedUser:press', {
|
|
||||||
logContext: isFeedContext
|
|
||||||
? 'InterstitialDiscover'
|
|
||||||
: 'InterstitialProfile',
|
|
||||||
recId,
|
|
||||||
position: index,
|
|
||||||
suggestedDid: profile.did,
|
|
||||||
category: null,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
gtMobile &&
|
gtMobile &&
|
||||||
@@ -385,68 +557,127 @@ export function ProfileGrid({
|
|||||||
a.flex_grow,
|
a.flex_grow,
|
||||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||||
]),
|
]),
|
||||||
|
{
|
||||||
|
opacity: dismissingDids?.has(profile.did) ? 0 : 1,
|
||||||
|
transitionProperty: 'opacity',
|
||||||
|
transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`,
|
||||||
|
},
|
||||||
]}>
|
]}>
|
||||||
{({hovered, pressed}) => (
|
<ProfileCard.Link
|
||||||
<CardOuter
|
profile={profile}
|
||||||
style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
|
onPress={() => {
|
||||||
<ProfileCard.Outer>
|
logEvent('suggestedUser:press', {
|
||||||
<View
|
logContext: isFeedContext
|
||||||
style={[
|
? 'InterstitialDiscover'
|
||||||
a.flex_col,
|
: 'InterstitialProfile',
|
||||||
a.align_center,
|
recId,
|
||||||
a.gap_sm,
|
position: index,
|
||||||
a.pb_sm,
|
suggestedDid: profile.did,
|
||||||
a.mb_auto,
|
category: null,
|
||||||
]}>
|
})
|
||||||
<ProfileCard.Avatar
|
}}>
|
||||||
profile={profile}
|
{({hovered, pressed}) => (
|
||||||
moderationOpts={moderationOpts}
|
<CardOuter
|
||||||
disabledPreview
|
style={[
|
||||||
size={88}
|
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||||
/>
|
]}>
|
||||||
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
<ProfileCard.Outer>
|
||||||
<ProfileCard.Name
|
{showDismissButton && (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Dismiss this suggestion`)}
|
||||||
|
onPress={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
onDismiss!(profile.did)
|
||||||
|
logEvent('suggestedUser:dismiss', {
|
||||||
|
logContext: isFeedContext
|
||||||
|
? 'InterstitialDiscover'
|
||||||
|
: 'InterstitialProfile',
|
||||||
|
position: index,
|
||||||
|
suggestedDid: profile.did,
|
||||||
|
recId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.z_10,
|
||||||
|
a.p_xs,
|
||||||
|
{top: -4, right: -4},
|
||||||
|
]}>
|
||||||
|
{({
|
||||||
|
hovered: dismissHovered,
|
||||||
|
pressed: dismissPressed,
|
||||||
|
}) => (
|
||||||
|
<X
|
||||||
|
size="xs"
|
||||||
|
fill={
|
||||||
|
dismissHovered || dismissPressed
|
||||||
|
? t.atoms.text.color
|
||||||
|
: t.atoms.text_contrast_medium.color
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_col,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
a.pb_sm,
|
||||||
|
a.mb_auto,
|
||||||
|
]}>
|
||||||
|
<ProfileCard.Avatar
|
||||||
profile={profile}
|
profile={profile}
|
||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
|
disabledPreview
|
||||||
|
size={88}
|
||||||
/>
|
/>
|
||||||
<ProfileCard.Description
|
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
||||||
profile={profile}
|
<ProfileCard.Name
|
||||||
numberOfLines={2}
|
profile={profile}
|
||||||
style={[
|
moderationOpts={moderationOpts}
|
||||||
t.atoms.text_contrast_medium,
|
/>
|
||||||
a.text_center,
|
<ProfileCard.Description
|
||||||
a.text_xs,
|
profile={profile}
|
||||||
]}
|
numberOfLines={2}
|
||||||
/>
|
style={[
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
a.text_center,
|
||||||
|
a.text_xs,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<ProfileCard.FollowButton
|
<ProfileCard.FollowButton
|
||||||
profile={profile}
|
profile={profile}
|
||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
logContext="FeedInterstitial"
|
logContext="FeedInterstitial"
|
||||||
withIcon={false}
|
withIcon={false}
|
||||||
style={[a.rounded_sm]}
|
style={[a.rounded_sm]}
|
||||||
onFollow={() => {
|
onFollow={() => {
|
||||||
logEvent('suggestedUser:follow', {
|
logEvent('suggestedUser:follow', {
|
||||||
logContext: isFeedContext
|
logContext: isFeedContext
|
||||||
? 'InterstitialDiscover'
|
? 'InterstitialDiscover'
|
||||||
: 'InterstitialProfile',
|
: 'InterstitialProfile',
|
||||||
location: 'Card',
|
location: 'Card',
|
||||||
recId,
|
recId,
|
||||||
position: index,
|
position: index,
|
||||||
suggestedDid: profile.did,
|
suggestedDid: profile.did,
|
||||||
category: null,
|
category: null,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ProfileCard.Outer>
|
</ProfileCard.Outer>
|
||||||
</CardOuter>
|
</CardOuter>
|
||||||
)}
|
)}
|
||||||
</ProfileCard.Link>
|
</ProfileCard.Link>
|
||||||
|
</Animated.View>
|
||||||
))
|
))
|
||||||
|
|
||||||
if (error || (!isLoading && profiles.length < minLength)) {
|
// Use totalProfileCount (before dismissals) for minLength check on initial render.
|
||||||
|
const profileCountForMinCheck = totalProfileCount ?? profiles.length
|
||||||
|
if (error || (!isLoading && profileCountForMinCheck < minLength)) {
|
||||||
logger.debug(`Not enough profiles to show suggested follows`)
|
logger.debug(`Not enough profiles to show suggested follows`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ export type Gate =
|
|||||||
| 'onboarding_suggested_starterpacks'
|
| 'onboarding_suggested_starterpacks'
|
||||||
| 'remove_show_latest_button'
|
| 'remove_show_latest_button'
|
||||||
| 'show_composer_prompt'
|
| 'show_composer_prompt'
|
||||||
|
| 'suggested_users_dismiss'
|
||||||
| 'test_gate_1'
|
| 'test_gate_1'
|
||||||
| 'test_gate_2'
|
| 'test_gate_2'
|
||||||
|
|||||||
@@ -379,6 +379,12 @@ export type MetricEvents = {
|
|||||||
| 'Profile'
|
| 'Profile'
|
||||||
| 'Onboarding'
|
| 'Onboarding'
|
||||||
}
|
}
|
||||||
|
'suggestedUser:dismiss': {
|
||||||
|
logContext: 'InterstitialDiscover' | 'InterstitialProfile'
|
||||||
|
recId?: number
|
||||||
|
position: number
|
||||||
|
suggestedDid: string
|
||||||
|
}
|
||||||
'profile:unfollow': {
|
'profile:unfollow': {
|
||||||
logContext:
|
logContext:
|
||||||
| 'RecommendedFollowsItem'
|
| 'RecommendedFollowsItem'
|
||||||
|
|||||||
@@ -1,20 +1,113 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {type AppBskyActorDefs} from '@atproto/api'
|
||||||
|
|
||||||
import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
|
import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
|
||||||
import {isAndroid} from '#/platform/detection'
|
import {isAndroid} from '#/platform/detection'
|
||||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {
|
||||||
|
useSuggestedFollowsByActorQuery,
|
||||||
|
useSuggestedFollowsQuery,
|
||||||
|
} from '#/state/queries/suggested-follows'
|
||||||
|
import {useBreakpoints} from '#/alf'
|
||||||
import {ProfileGrid} from '#/components/FeedInterstitials'
|
import {ProfileGrid} from '#/components/FeedInterstitials'
|
||||||
|
|
||||||
|
const DISMISS_ANIMATION_DURATION = 200
|
||||||
|
|
||||||
export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) {
|
export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) {
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const maxLength = gtMobile ? 4 : 12
|
||||||
const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
|
const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
|
||||||
did: actorDid,
|
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 (
|
return (
|
||||||
<ProfileGrid
|
<ProfileGrid
|
||||||
isSuggestionsLoading={isLoading}
|
isSuggestionsLoading={isLoading}
|
||||||
profiles={data?.suggestions ?? []}
|
profiles={filteredProfiles}
|
||||||
|
totalProfileCount={allProfiles.length}
|
||||||
recId={data?.recId}
|
recId={data?.recId}
|
||||||
error={error}
|
error={error}
|
||||||
viewContext="profileHeader"
|
viewContext="profileHeader"
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
dismissingDids={dismissingDids}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -26,11 +119,91 @@ export function AnimatedProfileHeaderSuggestedFollows({
|
|||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
actorDid: string
|
actorDid: string
|
||||||
}) {
|
}) {
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const maxLength = gtMobile ? 4 : 12
|
||||||
const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
|
const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
|
||||||
did: actorDid,
|
did: actorDid,
|
||||||
})
|
})
|
||||||
|
const {
|
||||||
|
data: moreSuggestions,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useSuggestedFollowsQuery({limit: 25})
|
||||||
|
|
||||||
if (!data?.suggestions?.length) return null
|
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,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!allProfiles.length && !isLoading) return null
|
||||||
|
|
||||||
/* NOTE (caidanw):
|
/* NOTE (caidanw):
|
||||||
* Android does not work well with this feature yet.
|
* Android does not work well with this feature yet.
|
||||||
@@ -43,10 +216,13 @@ export function AnimatedProfileHeaderSuggestedFollows({
|
|||||||
<AccordionAnimation isExpanded={isExpanded}>
|
<AccordionAnimation isExpanded={isExpanded}>
|
||||||
<ProfileGrid
|
<ProfileGrid
|
||||||
isSuggestionsLoading={isLoading}
|
isSuggestionsLoading={isLoading}
|
||||||
profiles={data.suggestions}
|
profiles={filteredProfiles}
|
||||||
recId={data.recId}
|
totalProfileCount={allProfiles.length}
|
||||||
|
recId={data?.recId}
|
||||||
error={error}
|
error={error}
|
||||||
viewContext="profileHeader"
|
viewContext="profileHeader"
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
dismissingDids={dismissingDids}
|
||||||
isVisible={isExpanded}
|
isVisible={isExpanded}
|
||||||
/>
|
/>
|
||||||
</AccordionAnimation>
|
</AccordionAnimation>
|
||||||
|
|||||||
Reference in New Issue
Block a user