Remove getSuggestedFollowsByActor fallbacks and use recIdStr (#9988)

This commit is contained in:
DS Boyce
2026-03-12 16:57:38 -07:00
committed by GitHub
parent 35cb2bcf94
commit 0b6ff8000d
6 changed files with 148 additions and 336 deletions
+45 -122
View File
@@ -12,6 +12,7 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -19,8 +20,8 @@ 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 { import {
suggestedFollowsByActorQueryKey,
useSuggestedFollowsByActorQuery, useSuggestedFollowsByActorQuery,
useSuggestedFollowsQuery,
} from '#/state/queries/suggested-follows' } 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'
@@ -170,10 +171,12 @@ function useExperimentalSuggestedUsersQuery() {
if (followSuggestions.length > 0) { if (followSuggestions.length > 0) {
suggestedDids = [ suggestedDids = [
// It's ok if these will pick the same item (weighed by its frequency) // It's ok if these will pick the same item (weighed by its frequency)
/* eslint-disable react-hooks/purity */
followSuggestions[Math.floor(Math.random() * followSuggestions.length)], followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
followSuggestions[Math.floor(Math.random() * followSuggestions.length)], followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
followSuggestions[Math.floor(Math.random() * followSuggestions.length)], followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
followSuggestions[Math.floor(Math.random() * followSuggestions.length)], followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
/* eslint-enable react-hooks/purity */
] ]
} }
const seenDids = seen const seenDids = seen
@@ -216,9 +219,6 @@ 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,
@@ -226,76 +226,37 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
} = useSuggestedFollowsByActorQuery({ } = useSuggestedFollowsByActorQuery({
did, did,
}) })
const { const queryClient = useQueryClient()
data: moreSuggestions,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set()) const onDismiss = useCallback(
(dismissedDid: string) => {
queryClient.setQueryData(
suggestedFollowsByActorQueryKey(did),
(previous: typeof data) => {
if (!previous) return previous
return {
...previous,
suggestions: previous.suggestions.filter(
s => s.did !== dismissedDid,
),
}
},
)
},
[did, queryClient],
)
const onDismiss = useCallback((dismissedDid: string) => { const profiles = useMemo(() => {
setDismissedDids(prev => new Set(prev).add(dismissedDid)) return (data?.suggestions ?? []).map(profile => ({
}, []) actor: profile,
recId: data?.recId,
// Combine profiles from the actor-specific query with fallback suggestions }))
const allProfiles = useMemo(() => { }, [data?.suggestions, data?.recId])
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 !== did) {
seen.add(profile.actor.did)
combined.push(profile)
}
}
return combined
}, [data?.suggestions, moreSuggestions?.pages, did, 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 ( return (
<ProfileGrid <ProfileGrid
isSuggestionsLoading={isSuggestionsLoading} isSuggestionsLoading={isSuggestionsLoading}
profiles={filteredProfiles} profiles={profiles}
totalProfileCount={allProfiles.length}
error={error} error={error}
viewContext="profile" viewContext="profile"
onDismiss={onDismiss} onDismiss={onDismiss}
@@ -304,21 +265,11 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
} }
export function SuggestedFollowsHome() { export function SuggestedFollowsHome() {
const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const maxLength = gtMobile ? 4 : 6
const { const {
isLoading: isSuggestionsLoading, isLoading: isSuggestionsLoading,
profiles: experimentalProfiles, profiles: experimentalProfiles,
error: experimentalError, error: experimentalError,
} = useExperimentalSuggestedUsersQuery() } = useExperimentalSuggestedUsersQuery()
const {
data: moreSuggestions,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
error: suggestionsError,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set()) const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
@@ -326,66 +277,29 @@ export function SuggestedFollowsHome() {
setDismissedDids(prev => new Set(prev).add(did)) setDismissedDids(prev => new Set(prev).add(did))
}, []) }, [])
// Combine profiles from experimental query with paginated suggestions
const allProfiles = useMemo(() => { const allProfiles = useMemo(() => {
const fallbackProfiles = const result: Array<{
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: Array<{
actor: bsky.profile.AnyProfileView actor: bsky.profile.AnyProfileView
recId?: number recId?: string
}> = [] }> = []
for (const profile of experimentalProfiles) { for (const profile of experimentalProfiles) {
if (!seen.has(profile.did)) { result.push({actor: profile, recId: undefined})
seen.add(profile.did)
combined.push({actor: profile, recId: undefined})
}
} }
for (const profile of fallbackProfiles) { return result
if (!seen.has(profile.actor.did)) { }, [experimentalProfiles])
seen.add(profile.actor.did)
combined.push(profile)
}
}
return combined
}, [experimentalProfiles, moreSuggestions?.pages])
const filteredProfiles = useMemo(() => { const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did)) return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
}, [allProfiles, dismissedDids]) }, [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 ( return (
<ProfileGrid <ProfileGrid
isSuggestionsLoading={isSuggestionsLoading} isSuggestionsLoading={isSuggestionsLoading}
profiles={filteredProfiles} profiles={filteredProfiles}
totalProfileCount={allProfiles.length} totalProfileCount={allProfiles.length}
error={experimentalError || suggestionsError} error={experimentalError}
viewContext="feed" viewContext="feed"
onDismiss={onDismiss} onDismiss={onDismiss}
/> />
@@ -400,14 +314,16 @@ export function ProfileGrid({
viewContext = 'feed', viewContext = 'feed',
onDismiss, onDismiss,
isVisible = true, isVisible = true,
onRequestHide,
}: { }: {
isSuggestionsLoading: boolean isSuggestionsLoading: boolean
profiles: {actor: bsky.profile.AnyProfileView; recId?: number}[] profiles: {actor: bsky.profile.AnyProfileView; recId?: string}[]
totalProfileCount?: number totalProfileCount?: number
error: Error | null error: Error | null
viewContext: 'profile' | 'profileHeader' | 'feed' viewContext: 'profile' | 'profileHeader' | 'feed'
onDismiss?: (did: string) => void onDismiss?: (did: string) => void
isVisible?: boolean isVisible?: boolean
onRequestHide?: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const ax = useAnalytics() const ax = useAnalytics()
@@ -651,6 +567,13 @@ export function ProfileGrid({
// Use totalProfileCount (before dismissals) for minLength check on initial render. // Use totalProfileCount (before dismissals) for minLength check on initial render.
const profileCountForMinCheck = totalProfileCount ?? profiles.length const profileCountForMinCheck = totalProfileCount ?? profiles.length
useEffect(() => {
if (error || (!isLoading && profileCountForMinCheck < minLength)) {
onRequestHide?.()
}
}, [error, isLoading, onRequestHide, profileCountForMinCheck, minLength])
if (error || (!isLoading && profileCountForMinCheck < minLength)) { if (error || (!isLoading && profileCountForMinCheck < minLength)) {
ax.logger.debug(`Not enough profiles to show suggested follows`) ax.logger.debug(`Not enough profiles to show suggested follows`)
return null return null
@@ -75,6 +75,8 @@ let ProfileHeaderStandard = ({
const [, queueUnblock] = useProfileBlockMutationQueue(profile) const [, queueUnblock] = useProfileBlockMutationQueue(profile)
const unblockPromptControl = Prompt.usePromptControl() const unblockPromptControl = Prompt.usePromptControl()
const [showSuggestedFollows, setShowSuggestedFollows] = useState(false) const [showSuggestedFollows, setShowSuggestedFollows] = useState(false)
const [hasSeenAllSuggestedFollows, setHasSeenAllSuggestedFollows] =
useState(false)
const isBlockedUser = const isBlockedUser =
profile.viewer?.blocking || profile.viewer?.blocking ||
profile.viewer?.blockedBy || profile.viewer?.blockedBy ||
@@ -84,7 +86,8 @@ let ProfileHeaderStandard = ({
try { try {
await queueUnblock() await queueUnblock()
Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
logger.error('Failed to unblock account', {message: e}) logger.error('Failed to unblock account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'}) Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'})
@@ -92,6 +95,11 @@ let ProfileHeaderStandard = ({
} }
} }
const onRequestHide = () => {
setHasSeenAllSuggestedFollows(true)
setShowSuggestedFollows(false)
}
const isMe = currentAccount?.did === profile.did const isMe = currentAccount?.did === profile.did
const {isActive: live} = useActorStatus(profile) const {isActive: live} = useActorStatus(profile)
@@ -192,7 +200,9 @@ let ProfileHeaderStandard = ({
description={_( description={_(
msg`The account will be able to interact with you after unblocking.`, msg`The account will be able to interact with you after unblocking.`,
)} )}
onConfirm={unblockAccount} onConfirm={() => {
void unblockAccount()
}}
confirmButtonCta={ confirmButtonCta={
profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
} }
@@ -201,8 +211,9 @@ let ProfileHeaderStandard = ({
</ProfileHeaderShell> </ProfileHeaderShell>
<ProfileHeaderSuggestedFollows <ProfileHeaderSuggestedFollows
isExpanded={showSuggestedFollows} isExpanded={!hasSeenAllSuggestedFollows && showSuggestedFollows}
actorDid={profile.did} actorDid={profile.did}
onRequestHide={onRequestHide}
/> />
</> </>
) )
@@ -254,7 +265,8 @@ export function HeaderStandardButtons({
)}`, )}`,
), ),
) )
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
logger.error('Failed to follow', {message: String(e)}) logger.error('Failed to follow', {message: String(e)})
Toast.show(_(msg`There was an issue! ${e.toString()}`), { Toast.show(_(msg`There was an issue! ${e.toString()}`), {
@@ -280,7 +292,8 @@ export function HeaderStandardButtons({
), ),
{type: 'default'}, {type: 'default'},
) )
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
logger.error('Failed to unfollow', {message: String(e)}) logger.error('Failed to unfollow', {message: String(e)})
Toast.show(_(msg`There was an issue! ${e.toString()}`), { Toast.show(_(msg`There was an issue! ${e.toString()}`), {
@@ -295,7 +308,8 @@ export function HeaderStandardButtons({
try { try {
await queueUnblock() await queueUnblock()
Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
logger.error('Failed to unblock account', {message: e}) logger.error('Failed to unblock account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'}) Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'})
@@ -400,7 +414,9 @@ export function HeaderStandardButtons({
description={_( description={_(
msg`The account will be able to interact with you after unblocking.`, msg`The account will be able to interact with you after unblocking.`,
)} )}
onConfirm={unblockAccount} onConfirm={() => {
void unblockAccount()
}}
confirmButtonCta={_(msg`Unblock`)} confirmButtonCta={_(msg`Unblock`)}
confirmButtonColor="negative" confirmButtonColor="negative"
/> />
+34 -76
View File
@@ -1,12 +1,11 @@
import {useCallback, useEffect, useMemo, useState} from 'react' import {useCallback, useMemo} from 'react'
import {useQueryClient} from '@tanstack/react-query'
import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation' import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import { import {
suggestedFollowsByActorQueryKey,
useSuggestedFollowsByActorQuery, useSuggestedFollowsByActorQuery,
useSuggestedFollowsQuery,
} from '#/state/queries/suggested-follows' } from '#/state/queries/suggested-follows'
import {useBreakpoints} from '#/alf'
import {ProfileGrid} from '#/components/FeedInterstitials' import {ProfileGrid} from '#/components/FeedInterstitials'
import {IS_ANDROID} from '#/env' import {IS_ANDROID} from '#/env'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
@@ -14,15 +13,15 @@ import type * as bsky from '#/types/bsky'
export function ProfileHeaderSuggestedFollows({ export function ProfileHeaderSuggestedFollows({
isExpanded, isExpanded,
actorDid, actorDid,
onRequestHide,
}: { }: {
isExpanded: boolean isExpanded: boolean
actorDid: string actorDid: string
onRequestHide: () => void
}) { }) {
const {allProfiles, filteredProfiles, onDismiss, isLoading, error} = const {profiles, onDismiss, isLoading, error} =
useProfileHeaderSuggestions(actorDid) useProfileHeaderSuggestions(actorDid)
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.
* This issue stems from Android not allowing dragging on clickable elements in the profile header. * This issue stems from Android not allowing dragging on clickable elements in the profile header.
@@ -34,92 +33,51 @@ export function ProfileHeaderSuggestedFollows({
<AccordionAnimation isExpanded={isExpanded}> <AccordionAnimation isExpanded={isExpanded}>
<ProfileGrid <ProfileGrid
isSuggestionsLoading={isLoading} isSuggestionsLoading={isLoading}
profiles={filteredProfiles} profiles={profiles}
totalProfileCount={allProfiles.length} totalProfileCount={profiles.length}
error={error} error={error}
viewContext="profileHeader" viewContext="profileHeader"
onDismiss={onDismiss} onDismiss={onDismiss}
isVisible={isExpanded} isVisible={isExpanded}
onRequestHide={onRequestHide}
/> />
</AccordionAnimation> </AccordionAnimation>
) )
} }
function useProfileHeaderSuggestions(actorDid: string) { function useProfileHeaderSuggestions(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 { const queryClient = useQueryClient()
data: moreSuggestions,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set()) const onDismiss = useCallback(
(dismissedDid: string) => {
queryClient.setQueryData(
suggestedFollowsByActorQueryKey(actorDid),
(previous: typeof data) => {
if (!previous) return previous
return {
...previous,
suggestions: previous.suggestions.filter(
s => s.did !== dismissedDid,
),
}
},
)
},
[actorDid, queryClient],
)
const onDismiss = useCallback((did: string) => { const profiles = useMemo(() => {
setDismissedDids(prev => new Set(prev).add(did)) return (data?.suggestions ?? []).map(profile => ({
}, []) actor: profile as bsky.profile.AnyProfileView,
recId: data?.recId,
// Combine profiles from the actor-specific query with fallback suggestions }))
const allProfiles = useMemo(() => { }, [data?.suggestions, data?.recId])
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 { return {
allProfiles, profiles,
filteredProfiles,
onDismiss, onDismiss,
isLoading, isLoading,
error, error,
+13 -13
View File
@@ -5,8 +5,8 @@ import {
type AppBskyActorGetProfiles, type AppBskyActorGetProfiles,
type AppBskyActorProfile, type AppBskyActorProfile,
type AppBskyGraphGetFollows, type AppBskyGraphGetFollows,
type AtpAgent,
AtUri, AtUri,
type BskyAgent,
type ComAtprotoRepoUploadBlob, type ComAtprotoRepoUploadBlob,
type Un$Typed, type Un$Typed,
} from '@atproto/api' } from '@atproto/api'
@@ -203,19 +203,19 @@ export function useProfileUpdateMutation() {
(res => { (res => {
if (typeof newUserAvatar !== 'undefined') { if (typeof newUserAvatar !== 'undefined') {
if (newUserAvatar === null && res.data.avatar) { if (newUserAvatar === null && res.data.avatar) {
// url hasnt cleared yet // url hasn't cleared yet
return false return false
} else if (res.data.avatar === profile.avatar) { } else if (res.data.avatar === profile.avatar) {
// url hasnt changed yet // url hasn't changed yet
return false return false
} }
} }
if (typeof newUserBanner !== 'undefined') { if (typeof newUserBanner !== 'undefined') {
if (newUserBanner === null && res.data.banner) { if (newUserBanner === null && res.data.banner) {
// url hasnt cleared yet // url hasn't cleared yet
return false return false
} else if (res.data.banner === profile.banner) { } else if (res.data.banner === profile.banner) {
// url hasnt changed yet // url hasn't changed yet
return false return false
} }
} }
@@ -231,10 +231,10 @@ export function useProfileUpdateMutation() {
}, },
async onSuccess(_, variables) { async onSuccess(_, variables) {
// invalidate cache // invalidate cache
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: RQKEY(variables.profile.did), queryKey: RQKEY(variables.profile.did),
}) })
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [profilesQueryKeyRoot, [variables.profile.did]], queryKey: [profilesQueryKeyRoot, [variables.profile.did]],
}) })
await updateProfileVerificationCache({profile: variables.profile}) await updateProfileVerificationCache({profile: variables.profile})
@@ -329,7 +329,7 @@ export function useProfileFollowMutationQueue(
} }
if (finalFollowingUri) { if (finalFollowingUri) {
agent.app.bsky.graph void agent.app.bsky.graph
.getSuggestedFollowsByActor({ .getSuggestedFollowsByActor({
actor: did, actor: did,
}) })
@@ -474,7 +474,7 @@ function useProfileMuteMutation() {
await agent.mute(did) await agent.mute(did)
}, },
onSuccess() { onSuccess() {
queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) void queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
}, },
}) })
} }
@@ -487,7 +487,7 @@ function useProfileUnmuteMutation() {
await agent.unmute(did) await agent.unmute(did)
}, },
onSuccess() { onSuccess() {
queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) void queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
}, },
}) })
} }
@@ -527,7 +527,7 @@ export function useProfileBlockMutationQueue(
updateProfileShadow(queryClient, did, { updateProfileShadow(queryClient, did, {
blockingUri: finalBlockingUri, blockingUri: finalBlockingUri,
}) })
queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]}) void queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]})
}, },
}) })
@@ -565,7 +565,7 @@ function useProfileBlockMutation() {
) )
}, },
onSuccess(_, {did}) { onSuccess(_, {did}) {
queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()}) void queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()})
resetProfilePostsQueries(queryClient, did, 1000) resetProfilePostsQueries(queryClient, did, 1000)
}, },
}) })
@@ -593,7 +593,7 @@ function useProfileUnblockMutation() {
} }
async function whenAppViewReady( async function whenAppViewReady(
agent: BskyAgent, agent: AtpAgent,
actor: string, actor: string,
fn: (res: AppBskyActorGetProfile.Response) => boolean, fn: (res: AppBskyActorGetProfile.Response) => boolean,
) { ) {
+6 -89
View File
@@ -2,107 +2,24 @@ import {
type AppBskyActorDefs, type AppBskyActorDefs,
type AppBskyActorGetSuggestions, type AppBskyActorGetSuggestions,
type AppBskyGraphGetSuggestedFollowsByActor, type AppBskyGraphGetSuggestedFollowsByActor,
moderateProfile,
} from '@atproto/api' } from '@atproto/api'
import { import {
type InfiniteData, type InfiniteData,
type QueryClient, type QueryClient,
type QueryKey,
useInfiniteQuery,
useQuery, useQuery,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {
aggregateUserInterests,
createBskyTopicsHeader,
} from '#/lib/api/feed/utils'
import {getContentLanguages} from '#/state/preferences/languages'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences' import {useAgent} from '#/state/session'
import {useAgent, useSession} from '#/state/session'
import {useModerationOpts} from '../preferences/moderation-opts'
const suggestedFollowsQueryKeyRoot = 'suggested-follows' const suggestedFollowsQueryKeyRoot = 'suggested-follows'
const suggestedFollowsQueryKey = (options?: SuggestedFollowsOptions) => [
suggestedFollowsQueryKeyRoot,
options,
]
const suggestedFollowsByActorQueryKeyRoot = 'suggested-follows-by-actor' const suggestedFollowsByActorQueryKeyRoot = 'suggested-follows-by-actor'
const suggestedFollowsByActorQueryKey = (did: string) => [ export const suggestedFollowsByActorQueryKey = (did: string) => [
suggestedFollowsByActorQueryKeyRoot, suggestedFollowsByActorQueryKeyRoot,
did, did,
] ]
type SuggestedFollowsOptions = {limit?: number; subsequentPageLimit?: number}
export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
const {currentAccount} = useSession()
const agent = useAgent()
const moderationOpts = useModerationOpts()
const {data: preferences} = usePreferencesQuery()
const limit = options?.limit || 25
return useInfiniteQuery<
AppBskyActorGetSuggestions.OutputSchema,
Error,
InfiniteData<AppBskyActorGetSuggestions.OutputSchema>,
QueryKey,
string | undefined
>({
enabled: !!moderationOpts && !!preferences,
staleTime: STALE.HOURS.ONE,
queryKey: suggestedFollowsQueryKey(options),
queryFn: async ({pageParam}) => {
const contentLangs = getContentLanguages().join(',')
const maybeDifferentLimit =
options?.subsequentPageLimit && pageParam
? options.subsequentPageLimit
: limit
const res = await agent.app.bsky.actor.getSuggestions(
{
limit: maybeDifferentLimit,
cursor: pageParam,
},
{
headers: {
...createBskyTopicsHeader(aggregateUserInterests(preferences)),
'Accept-Language': contentLangs,
},
},
)
res.data.actors = res.data.actors
.filter(
actor =>
!moderateProfile(actor, moderationOpts!).ui('profileList').filter,
)
.filter(actor => {
const viewer = actor.viewer
if (viewer) {
if (
viewer.following ||
viewer.muted ||
viewer.mutedByList ||
viewer.blockedBy ||
viewer.blocking
) {
return false
}
}
if (actor.did === currentAccount?.did) {
return false
}
return true
})
return res.data
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
})
}
export function useSuggestedFollowsByActorQuery({ export function useSuggestedFollowsByActorQuery({
did, did,
enabled, enabled,
@@ -120,10 +37,10 @@ export function useSuggestedFollowsByActorQuery({
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
actor: did, actor: did,
}) })
const suggestions = res.data.isFallback const suggestions = res.data.suggestions.filter(
? [] profile => !profile.viewer?.following,
: res.data.suggestions.filter(profile => !profile.viewer?.following) )
return {suggestions, recId: res.data.recId} return {suggestions, recId: res.data.recIdStr}
}, },
enabled, enabled,
}) })
+27 -29
View File
@@ -15,8 +15,7 @@ import {
AppBskyEmbedVideo, AppBskyEmbedVideo,
type AppBskyFeedDefs, type AppBskyFeedDefs,
} from '@atproto/api' } from '@atproto/api'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
@@ -226,14 +225,16 @@ let PostFeed = ({
savedFeedConfig?: AppBskyActorDefs.SavedFeed savedFeedConfig?: AppBskyActorDefs.SavedFeed
initialNumToRender?: number initialNumToRender?: number
isVideoFeed?: boolean isVideoFeed?: boolean
lastFetchDate?: () => number
}): React.ReactNode => { }): React.ReactNode => {
const ax = useAnalytics() const ax = useAnalytics()
const {_} = useLingui() const {t: l} = useLingui()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {currentAccount, hasSession} = useSession() const {currentAccount, hasSession} = useSession()
const initialNumToRender = useInitialNumToRender() const initialNumToRender = useInitialNumToRender()
const feedFeedback = useFeedFeedbackContext() const feedFeedback = useFeedFeedbackContext()
const [isPTRing, setIsPTRing] = useState(false) const [isPTRing, setIsPTRing] = useState(false)
// eslint-disable-next-line react-hooks/purity
const lastFetchRef = useRef<number>(Date.now()) const lastFetchRef = useRef<number>(Date.now())
const [feedType, feedUriOrActorDid, feedTab] = feed.split('|') const [feedType, feedUriOrActorDid, feedTab] = feed.split('|')
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
@@ -271,14 +272,17 @@ let PostFeed = ({
fetchNextPage, fetchNextPage,
} = usePostFeedQuery(feed, feedParams, opts) } = usePostFeedQuery(feed, feedParams, opts)
const lastFetchedAt = data?.pages[0].fetchedAt const lastFetchedAt = data?.pages[0].fetchedAt
if (lastFetchedAt) {
lastFetchRef.current = lastFetchedAt
}
const isEmpty = useMemo( const isEmpty = useMemo(
() => !isFetching && !data?.pages?.some(page => page.slices.length), () => !isFetching && !data?.pages?.some(page => page.slices.length),
[isFetching, data], [isFetching, data],
) )
useEffect(() => {
if (lastFetchedAt) {
lastFetchRef.current = lastFetchedAt
}
}, [lastFetchedAt])
const checkForNew = useNonReactiveCallback(async () => { const checkForNew = useNonReactiveCallback(async () => {
if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) { if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) {
return return
@@ -292,7 +296,7 @@ let PostFeed = ({
try { try {
if (await pollLatest(data.pages[0])) { if (await pollLatest(data.pages[0])) {
if (isEmpty) { if (isEmpty) {
refetch() void refetch()
} else { } else {
onHasNew(true) onHasNew(true)
} }
@@ -333,7 +337,7 @@ let PostFeed = ({
const timeSinceFirstLoad = Date.now() - lastFetchRef.current const timeSinceFirstLoad = Date.now() - lastFetchRef.current
if (isEmpty || timeSinceFirstLoad > CHECK_LATEST_AFTER) { if (isEmpty || timeSinceFirstLoad > CHECK_LATEST_AFTER) {
// check for new on enable (aka on focus) // check for new on enable (aka on focus)
checkForNew() void checkForNew()
} }
} }
}, [enabled, isEmpty, disablePoll, checkForNew]) }, [enabled, isEmpty, disablePoll, checkForNew])
@@ -343,14 +347,14 @@ let PostFeed = ({
const subscription = AppState.addEventListener('change', nextAppState => { const subscription = AppState.addEventListener('change', nextAppState => {
// check for new on app foreground // check for new on app foreground
if (nextAppState === 'active') { if (nextAppState === 'active') {
checkForNew() void checkForNew()
} }
}) })
cleanup1 = () => subscription.remove() cleanup1 = () => subscription.remove()
if (pollInterval) { if (pollInterval) {
// check for new on interval // check for new on interval
const i = setInterval(() => { const i = setInterval(() => {
checkForNew() void checkForNew()
}, pollInterval) }, pollInterval)
cleanup2 = () => clearInterval(i) cleanup2 = () => clearInterval(i)
} }
@@ -363,7 +367,7 @@ let PostFeed = ({
const followProgressGuide = useProgressGuide('follow-10') const followProgressGuide = useProgressGuide('follow-10')
const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7')
const showProgressIntersitial = const showProgressInterstitial =
(followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible (followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible
const {trendingVideoDisabled} = useTrendingSettings() const {trendingVideoDisabled} = useTrendingSettings()
@@ -494,7 +498,7 @@ let PostFeed = ({
if (hasSession) { if (hasSession) {
if (feedKind === 'discover') { if (feedKind === 'discover') {
if (sliceIndex === 0) { if (sliceIndex === 0) {
if (showProgressIntersitial) { if (showProgressInterstitial) {
arr.push({ arr.push({
type: 'interstitialProgressGuide', type: 'interstitialProgressGuide',
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
@@ -672,7 +676,7 @@ let PostFeed = ({
feedUriOrActorDid, feedUriOrActorDid,
feedTab, feedTab,
hasSession, hasSession,
showProgressIntersitial, showProgressInterstitial,
trendingVideoDisabled, trendingVideoDisabled,
gtMobile, gtMobile,
isVideoFeed, isVideoFeed,
@@ -683,16 +687,12 @@ let PostFeed = ({
blockedOrMutedAuthors, blockedOrMutedAuthors,
]) ])
useEffect(() => {
if (enabled === false) {
setIsPTRing(false)
}
}, [enabled])
// events // events
// = // =
const onRefresh = useCallback(async () => { const onRefresh = useCallback(async () => {
if (!enabled) return
ax.metric('feed:refresh', { ax.metric('feed:refresh', {
feedType: feedType, feedType: feedType,
feedUrl: feed, feedUrl: feed,
@@ -706,7 +706,7 @@ let PostFeed = ({
logger.error('Failed to refresh posts feed', {message: err}) logger.error('Failed to refresh posts feed', {message: err})
} }
setIsPTRing(false) setIsPTRing(false)
}, [ax, refetch, setIsPTRing, onHasNew, feed, feedType]) }, [ax, refetch, setIsPTRing, onHasNew, feed, feedType, enabled])
const onEndReached = useCallback(async () => { const onEndReached = useCallback(async () => {
if (isFetching || !hasNextPage || isError) return if (isFetching || !hasNextPage || isError) return
@@ -733,12 +733,12 @@ let PostFeed = ({
]) ])
const onPressTryAgain = useCallback(() => { const onPressTryAgain = useCallback(() => {
refetch() void refetch()
onHasNew?.(false) onHasNew?.(false)
}, [refetch, onHasNew]) }, [refetch, onHasNew])
const onPressRetryLoadMore = useCallback(() => { const onPressRetryLoadMore = useCallback(() => {
fetchNextPage() void fetchNextPage()
}, [fetchNextPage]) }, [fetchNextPage])
// rendering // rendering
@@ -760,9 +760,7 @@ let PostFeed = ({
} else if (row.type === 'loadMoreError') { } else if (row.type === 'loadMoreError') {
return ( return (
<LoadMoreRetryBtn <LoadMoreRetryBtn
label={_( label={l`There was an issue fetching posts. Tap here to try again.`}
msg`There was an issue fetching posts. Tap here to try again.`,
)}
onPress={onPressRetryLoadMore} onPress={onPressRetryLoadMore}
/> />
) )
@@ -861,7 +859,7 @@ let PostFeed = ({
error, error,
onPressTryAgain, onPressTryAgain,
savedFeedConfig, savedFeedConfig,
_, l,
onPressRetryLoadMore, onPressRetryLoadMore,
feedType, feedType,
feedUriOrActorDid, feedUriOrActorDid,
@@ -997,19 +995,19 @@ let PostFeed = ({
testID={testID ? `${testID}-flatlist` : undefined} testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef} ref={scrollElRef}
data={feedItems} data={feedItems}
keyExtractor={item => item.key} keyExtractor={(item: FeedRow) => item.key}
renderItem={renderItem} renderItem={renderItem}
ListFooterComponent={FeedFooter} ListFooterComponent={FeedFooter}
ListHeaderComponent={ListHeaderComponent} ListHeaderComponent={ListHeaderComponent}
refreshing={isPTRing} refreshing={isPTRing}
onRefresh={onRefresh} onRefresh={() => void onRefresh()}
headerOffset={headerOffset} headerOffset={headerOffset}
progressViewOffset={progressViewOffset} progressViewOffset={progressViewOffset}
contentContainerStyle={{ contentContainerStyle={{
minHeight: Dimensions.get('window').height * 1.5, minHeight: Dimensions.get('window').height * 1.5,
}} }}
onScrolledDownChange={handleScrolledDownChange} onScrolledDownChange={handleScrolledDownChange}
onEndReached={onEndReached} onEndReached={() => void onEndReached()}
onEndReachedThreshold={2} // number of posts left to trigger load more onEndReachedThreshold={2} // number of posts left to trigger load more
removeClippedSubviews={true} removeClippedSubviews={true}
extraData={extraData} extraData={extraData}