add recId to feed interstitials, fix animation on native

This commit is contained in:
Samuel Newman
2026-01-26 22:41:10 +02:00
parent c54d0144fa
commit c71db3172c
+72 -90
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 {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 {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'
@@ -21,6 +27,7 @@ import {type SeenPost} from '#/state/userActionHistory'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import { import {
atoms as a, atoms as a,
native,
useBreakpoints, useBreakpoints,
useTheme, useTheme,
type ViewStyleProp, type ViewStyleProp,
@@ -152,7 +159,7 @@ function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
function useExperimentalSuggestedUsersQuery() { function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot() const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = React.useMemo(() => { const dids = useMemo(() => {
const {likes, follows, followSuggestions, seen} = userActionSnapshot const {likes, follows, followSuggestions, seen} = userActionSnapshot
const likeDids = likes const likeDids = likes
.map(l => new AtUri(l)) .map(l => new AtUri(l))
@@ -225,67 +232,54 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
isFetchingNextPage, isFetchingNextPage,
} = useSuggestedFollowsQuery({limit: 25}) } = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>( const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
new Set(),
)
const onDismiss = React.useCallback((dismissedDid: string) => { const onDismiss = 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)) 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 // Combine profiles from the actor-specific query with fallback suggestions
const allProfiles = React.useMemo(() => { const allProfiles = useMemo(() => {
const actorProfiles = data?.suggestions ?? [] const actorProfiles = data?.suggestions ?? []
const fallbackProfiles = 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 // Dedupe by did, preferring actor-specific profiles
const seen = new Set<string>() const seen = new Set<string>()
const combined: bsky.profile.AnyProfileView[] = [] const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = []
for (const profile of actorProfiles) { for (const profile of actorProfiles) {
if (!seen.has(profile.did)) { if (!seen.has(profile.did)) {
seen.add(profile.did) seen.add(profile.did)
combined.push(profile) combined.push({actor: profile, recId: data?.recId})
} }
} }
for (const profile of fallbackProfiles) { for (const profile of fallbackProfiles) {
if (!seen.has(profile.did) && profile.did !== did) { if (!seen.has(profile.actor.did) && profile.actor.did !== did) {
seen.add(profile.did) seen.add(profile.actor.did)
combined.push(profile) combined.push(profile)
} }
} }
return combined return combined
}, [data?.suggestions, moreSuggestions?.pages, did]) }, [data?.suggestions, moreSuggestions?.pages, did, data?.recId])
const filteredProfiles = React.useMemo(() => { const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.did)) return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
}, [allProfiles, dismissedDids]) }, [allProfiles, dismissedDids])
// Fetch more when running low // Fetch more when running low
React.useEffect(() => { useEffect(() => {
if ( if (
moderationOpts && moderationOpts &&
filteredProfiles.length < maxLength && filteredProfiles.length < maxLength &&
hasNextPage && hasNextPage &&
!isFetchingNextPage !isFetchingNextPage
) { ) {
fetchNextPage() void fetchNextPage()
} }
}, [ }, [
filteredProfiles.length, filteredProfiles.length,
@@ -301,11 +295,9 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
isSuggestionsLoading={isSuggestionsLoading} isSuggestionsLoading={isSuggestionsLoading}
profiles={filteredProfiles} profiles={filteredProfiles}
totalProfileCount={allProfiles.length} totalProfileCount={allProfiles.length}
recId={data?.recId}
error={error} error={error}
viewContext="profile" viewContext="profile"
onDismiss={onDismiss} onDismiss={onDismiss}
dismissingDids={dismissingDids}
/> />
) )
} }
@@ -327,46 +319,36 @@ export function SuggestedFollowsHome() {
error: suggestionsError, error: suggestionsError,
} = useSuggestedFollowsQuery({limit: 25}) } = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>( const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
new Set(),
)
const onDismiss = React.useCallback((did: string) => { const onDismiss = 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)) 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 // Combine profiles from experimental query with paginated suggestions
const allProfiles = React.useMemo(() => { const allProfiles = useMemo(() => {
const fallbackProfiles = 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 // Dedupe by did, preferring experimental profiles
const seen = new Set<string>() const seen = new Set<string>()
const combined: bsky.profile.AnyProfileView[] = [] const combined: Array<{
actor: bsky.profile.AnyProfileView
recId?: number
}> = []
for (const profile of experimentalProfiles) { for (const profile of experimentalProfiles) {
if (!seen.has(profile.did)) { if (!seen.has(profile.did)) {
seen.add(profile.did) seen.add(profile.did)
combined.push(profile) combined.push({actor: profile, recId: undefined})
} }
} }
for (const profile of fallbackProfiles) { for (const profile of fallbackProfiles) {
if (!seen.has(profile.did)) { if (!seen.has(profile.actor.did)) {
seen.add(profile.did) seen.add(profile.actor.did)
combined.push(profile) combined.push(profile)
} }
} }
@@ -374,19 +356,19 @@ export function SuggestedFollowsHome() {
return combined return combined
}, [experimentalProfiles, moreSuggestions?.pages]) }, [experimentalProfiles, moreSuggestions?.pages])
const filteredProfiles = React.useMemo(() => { const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.did)) return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
}, [allProfiles, dismissedDids]) }, [allProfiles, dismissedDids])
// Fetch more when running low // Fetch more when running low
React.useEffect(() => { useEffect(() => {
if ( if (
moderationOpts && moderationOpts &&
filteredProfiles.length < maxLength && filteredProfiles.length < maxLength &&
hasNextPage && hasNextPage &&
!isFetchingNextPage !isFetchingNextPage
) { ) {
fetchNextPage() void fetchNextPage()
} }
}, [ }, [
filteredProfiles.length, filteredProfiles.length,
@@ -405,7 +387,6 @@ export function SuggestedFollowsHome() {
error={experimentalError || suggestionsError} error={experimentalError || suggestionsError}
viewContext="feed" viewContext="feed"
onDismiss={onDismiss} onDismiss={onDismiss}
dismissingDids={dismissingDids}
/> />
) )
} }
@@ -415,16 +396,13 @@ export function ProfileGrid({
error, error,
profiles, profiles,
totalProfileCount, totalProfileCount,
recId,
viewContext = 'feed', viewContext = 'feed',
onDismiss, onDismiss,
dismissingDids,
isVisible = true, isVisible = true,
}: { }: {
isSuggestionsLoading: boolean isSuggestionsLoading: boolean
profiles: bsky.profile.AnyProfileView[] profiles: {actor: bsky.profile.AnyProfileView; recId?: number}[]
totalProfileCount?: number totalProfileCount?: number
recId?: number
error: Error | null error: Error | null
dismissingDids?: Set<string> dismissingDids?: Set<string>
viewContext: 'profile' | 'profileHeader' | 'feed' viewContext: 'profile' | 'profileHeader' | 'feed'
@@ -463,18 +441,18 @@ export function ProfileGrid({
const profilesToShow = profiles.slice(0, maxLength) const profilesToShow = profiles.slice(0, maxLength)
profilesToShow.forEach((profile, index) => { profilesToShow.forEach((profile, index) => {
if (!seenProfilesRef.current.has(profile.did)) { if (!seenProfilesRef.current.has(profile.actor.did)) {
seenProfilesRef.current.add(profile.did) seenProfilesRef.current.add(profile.actor.did)
ax.metric('suggestedUser:seen', { ax.metric('suggestedUser:seen', {
logContext, logContext,
recId, recId: profile.recId,
position: index, position: index,
suggestedDid: profile.did, suggestedDid: profile.actor.did,
category: null, category: null,
}) })
} }
}) })
}, [ax, isLoading, error, profiles, maxLength, logContext, recId]) }, [ax, isLoading, error, profiles, maxLength, logContext])
// For profile header, fire when isVisible becomes true // For profile header, fire when isVisible becomes true
useEffect(() => { useEffect(() => {
@@ -540,8 +518,15 @@ export function ProfileGrid({
? null ? null
: profiles.slice(0, maxLength).map((profile, index) => ( : profiles.slice(0, maxLength).map((profile, index) => (
<Animated.View <Animated.View
key={profile.did} key={profile.actor.did}
layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)} 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={[ style={[
a.flex_1, a.flex_1,
gtMobile && gtMobile &&
@@ -550,22 +535,17 @@ 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`,
},
]}> ]}>
<ProfileCard.Link <ProfileCard.Link
profile={profile} profile={profile.actor}
onPress={() => { onPress={() => {
ax.metric('suggestedUser:press', { ax.metric('suggestedUser:press', {
logContext: isFeedContext logContext: isFeedContext
? 'InterstitialDiscover' ? 'InterstitialDiscover'
: 'InterstitialProfile', : 'InterstitialProfile',
recId, recId: profile.recId,
position: index, position: index,
suggestedDid: profile.did, suggestedDid: profile.actor.did,
category: null, category: null,
}) })
}} }}
@@ -581,14 +561,14 @@ export function ProfileGrid({
label={_(msg`Dismiss this suggestion`)} label={_(msg`Dismiss this suggestion`)}
onPress={e => { onPress={e => {
e.preventDefault() e.preventDefault()
onDismiss(profile.did) onDismiss(profile.actor.did)
ax.metric('suggestedUser:dismiss', { ax.metric('suggestedUser:dismiss', {
logContext: isFeedContext logContext: isFeedContext
? 'InterstitialDiscover' ? 'InterstitialDiscover'
: 'InterstitialProfile', : 'InterstitialProfile',
position: index, position: index,
suggestedDid: profile.did, suggestedDid: profile.actor.did,
recId, recId: profile.recId,
}) })
}} }}
style={[ style={[
@@ -621,18 +601,18 @@ export function ProfileGrid({
a.mb_auto, a.mb_auto,
]}> ]}>
<ProfileCard.Avatar <ProfileCard.Avatar
profile={profile} profile={profile.actor}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
disabledPreview disabledPreview
size={88} size={88}
/> />
<View style={[a.flex_col, a.align_center, a.max_w_full]}> <View style={[a.flex_col, a.align_center, a.max_w_full]}>
<ProfileCard.Name <ProfileCard.Name
profile={profile} profile={profile.actor}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
/> />
<ProfileCard.Description <ProfileCard.Description
profile={profile} profile={profile.actor}
numberOfLines={2} numberOfLines={2}
style={[ style={[
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
@@ -644,7 +624,7 @@ export function ProfileGrid({
</View> </View>
<ProfileCard.FollowButton <ProfileCard.FollowButton
profile={profile} profile={profile.actor}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
logContext="FeedInterstitial" logContext="FeedInterstitial"
withIcon={false} withIcon={false}
@@ -655,9 +635,9 @@ export function ProfileGrid({
? 'InterstitialDiscover' ? 'InterstitialDiscover'
: 'InterstitialProfile', : 'InterstitialProfile',
location: 'Card', location: 'Card',
recId, recId: profile.recId,
position: index, position: index,
suggestedDid: profile.did, suggestedDid: profile.actor.did,
category: null, category: null,
}) })
}} }}
@@ -726,6 +706,7 @@ export function ProfileGrid({
<FollowDialogWithoutGuide control={followDialogControl} /> <FollowDialogWithoutGuide control={followDialogControl} />
<LayoutAnimationConfig skipExiting skipEntering>
{gtMobile ? ( {gtMobile ? (
<View style={[a.p_lg, a.pt_md]}> <View style={[a.p_lg, a.pt_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}> <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
@@ -755,6 +736,7 @@ export function ProfileGrid({
</ScrollView> </ScrollView>
</BlockDrawerGesture> </BlockDrawerGesture>
)} )}
</LayoutAnimationConfig>
</View> </View>
) )
} }
@@ -795,7 +777,7 @@ export function SuggestedFeeds() {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const feeds = React.useMemo(() => { const feeds = useMemo(() => {
const items: AppBskyFeedDefs.GeneratorView[] = [] const items: AppBskyFeedDefs.GeneratorView[] = []
if (!data) return items if (!data) return items