Sort follows and following behind gate (#11237)

This commit is contained in:
DS Boyce
2026-07-23 09:02:35 -07:00
committed by GitHub
parent 8d64ab9d4b
commit 10b027c35b
10 changed files with 114 additions and 61 deletions
-10
View File
@@ -1736,16 +1736,6 @@
"count": 9 "count": 9
} }
}, },
"src/view/com/profile/ProfileFollowers.tsx": {
"typescript/no-misused-promises": {
"count": 2
}
},
"src/view/com/profile/ProfileFollows.tsx": {
"typescript/no-misused-promises": {
"count": 2
}
},
"src/view/com/util/EmptyState.tsx": { "src/view/com/util/EmptyState.tsx": {
"typescript/no-explicit-any": { "typescript/no-explicit-any": {
"count": 1 "count": 1
+1 -1
View File
@@ -96,7 +96,7 @@
"prettier": "prettier --check ." "prettier": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.20.31", "@atproto/api": "0.20.32",
"@atproto/common-web": "0.5.6", "@atproto/common-web": "0.5.6",
"@atproto/syntax": "0.7.2", "@atproto/syntax": "0.7.2",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
+5 -5
View File
@@ -242,8 +242,8 @@ importers:
.: .:
dependencies: dependencies:
'@atproto/api': '@atproto/api':
specifier: 0.20.31 specifier: 0.20.32
version: 0.20.31 version: 0.20.32
'@atproto/common-web': '@atproto/common-web':
specifier: 0.5.6 specifier: 0.5.6
version: 0.5.6 version: 0.5.6
@@ -871,8 +871,8 @@ packages:
graphql: graphql:
optional: true optional: true
'@atproto/api@0.20.31': '@atproto/api@0.20.32':
resolution: {integrity: sha512-TovCQLQv5ti1jqh8UH6jJ0EFuWRjGdUtFyFR5xYC/IkIulwHDuyrVaXdCv7VLWiHftp92DevtZnFRm+BZsZZdw==} resolution: {integrity: sha512-P6Lh6+PR+x0/HHdEbmwZ5e2uvrzViEeobHWHoEc7KTaKJ/bQQu39z2wfBrG4dOTEC/OcOhFYhAhepo0VGIOeAQ==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/common-web@0.5.6': '@atproto/common-web@0.5.6':
@@ -9389,7 +9389,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {} '@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.31': '@atproto/api@0.20.32':
dependencies: dependencies:
'@atproto/common-web': 0.5.6 '@atproto/common-web': 0.5.6
'@atproto/lexicon': 0.7.7 '@atproto/lexicon': 0.7.7
+1
View File
@@ -20,6 +20,7 @@ export enum Features {
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable', PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
CustomLogoJapanEnable = 'custom_logo:japan:enable', CustomLogoJapanEnable = 'custom_logo:japan:enable',
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable', SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
FollowSortEnable = 'follow_sort:enable',
AATest = 'aa-test', AATest = 'aa-test',
} }
+5
View File
@@ -475,25 +475,30 @@ export type Events = {
'profile:followers:view': { 'profile:followers:view': {
contextProfileDid: string contextProfileDid: string
isOwnProfile: boolean isOwnProfile: boolean
sort?: 'latest' | 'top'
} }
'profile:followers:paginate': { 'profile:followers:paginate': {
contextProfileDid: string contextProfileDid: string
itemCount: number itemCount: number
page: number page: number
sort?: 'latest' | 'top'
} }
'profile:following:view': { 'profile:following:view': {
contextProfileDid: string contextProfileDid: string
isOwnProfile: boolean isOwnProfile: boolean
sort?: 'latest' | 'top'
} }
'profile:following:paginate': { 'profile:following:paginate': {
contextProfileDid: string contextProfileDid: string
itemCount: number itemCount: number
page: number page: number
sort?: 'latest' | 'top'
} }
'profileCard:seen': { 'profileCard:seen': {
contextProfileDid?: string contextProfileDid?: string
profileDid: string profileDid: string
position?: number position?: number
sort?: 'latest' | 'top'
} }
'profile:mute': {} 'profile:mute': {}
'profile:unmute': {} 'profile:unmute': {}
+7 -9
View File
@@ -5,9 +5,7 @@ import {
View, View,
type ViewStyle, type ViewStyle,
} from 'react-native' } from 'react-native'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@@ -29,7 +27,7 @@ const TOTAL_AVATARS = 10
export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) { export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const {gtPhone} = useBreakpoints() const {gtPhone} = useBreakpoints()
const {rightNavVisible} = useLayoutBreakpoints() const {rightNavVisible} = useLayoutBreakpoints()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -79,7 +77,7 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
size="tiny" size="tiny"
color="secondary" color="secondary"
shape="round" shape="round"
label={_(msg`Dismiss getting started guide`)} label={l`Dismiss getting started guide`}
onPress={endProgressGuide} onPress={endProgressGuide}
style={[a.bg_transparent, {marginTop: -6, marginRight: -6}]}> style={[a.bg_transparent, {marginTop: -6, marginRight: -6}]}>
<ButtonIcon icon={Times} size="xs" /> <ButtonIcon icon={Times} size="xs" />
@@ -107,14 +105,14 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
<ProgressGuideTask <ProgressGuideTask
current={guide.numLikes + 1} current={guide.numLikes + 1}
total={10 + 1} total={10 + 1}
title={_(msg`Like 10 posts`)} title={l`Like 10 posts`}
subtitle={_(msg`Teach our algorithm what you like`)} subtitle={l`Teach our algorithm what you like`}
/> />
<ProgressGuideTask <ProgressGuideTask
current={guide.numFollows + 1} current={guide.numFollows + 1}
total={7 + 1} total={7 + 1}
title={_(msg`Follow 7 accounts`)} title={l`Follow 7 accounts`}
subtitle={_(msg`Bluesky is better with friends!`)} subtitle={l`Bluesky is better with friends!`}
/> />
</> </>
)} )}
+22 -3
View File
@@ -10,15 +10,33 @@ import {
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useAnalytics} from '#/analytics'
const DEFAULT_SORT = 'latest'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
type RQPageParam = string | undefined type RQPageParam = string | undefined
const RQKEY_ROOT = 'profile-followers' const RQKEY_ROOT = 'profile-followers'
export const RQKEY = (did: string) => [RQKEY_ROOT, did] export const RQKEY = (did: string, sort: 'latest' | 'top' = DEFAULT_SORT) => [
RQKEY_ROOT,
did,
sort,
]
export function useProfileFollowersQuery(did: string | undefined) { export function useProfileFollowersQuery(
did?: string,
{
sort,
}: {
sort?: 'latest' | 'top'
} = {},
) {
const ax = useAnalytics()
const isSortEnabled = ax.features.enabled(ax.features.FollowSortEnable)
const agent = useAgent() const agent = useAgent()
const sortParam = isSortEnabled ? sort || DEFAULT_SORT : undefined
return useInfiniteQuery< return useInfiniteQuery<
AppBskyGraphGetFollowers.OutputSchema, AppBskyGraphGetFollowers.OutputSchema,
Error, Error,
@@ -26,12 +44,13 @@ export function useProfileFollowersQuery(did: string | undefined) {
QueryKey, QueryKey,
RQPageParam RQPageParam
>({ >({
queryKey: RQKEY(did || ''), queryKey: RQKEY(did || '', sortParam),
async queryFn({pageParam}: {pageParam: RQPageParam}) { async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await agent.app.bsky.graph.getFollowers({ const res = await agent.app.bsky.graph.getFollowers({
actor: did || '', actor: did || '',
limit: PAGE_SIZE, limit: PAGE_SIZE,
cursor: pageParam, cursor: pageParam,
sort: sortParam,
}) })
return res.data return res.data
}, },
+17 -5
View File
@@ -8,25 +8,36 @@ import {
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useAnalytics} from '#/analytics'
const DEFAULT_SORT = 'latest'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
type RQPageParam = string | undefined type RQPageParam = string | undefined
// TODO refactor invalidate on mutate? // TODO refactor invalidate on mutate?
const RQKEY_ROOT = 'profile-follows' const RQKEY_ROOT = 'profile-follows'
export const RQKEY = (did: string) => [RQKEY_ROOT, did] export const RQKEY = (did: string, sort: 'latest' | 'top' = DEFAULT_SORT) => [
RQKEY_ROOT,
did,
sort,
]
export function useProfileFollowsQuery( export function useProfileFollowsQuery(
did: string | undefined, did: string | undefined,
{ {
limit, limit,
sort,
}: { }: {
limit?: number limit?: number
} = { sort?: 'latest' | 'top'
limit: PAGE_SIZE, } = {},
},
) { ) {
const ax = useAnalytics()
const isSortEnabled = ax.features.enabled(ax.features.FollowSortEnable)
const agent = useAgent() const agent = useAgent()
const sortParam = isSortEnabled ? sort || DEFAULT_SORT : undefined
return useInfiniteQuery< return useInfiniteQuery<
AppBskyGraphGetFollows.OutputSchema, AppBskyGraphGetFollows.OutputSchema,
Error, Error,
@@ -35,12 +46,13 @@ export function useProfileFollowsQuery(
RQPageParam RQPageParam
>({ >({
staleTime: STALE.MINUTES.ONE, staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(did || ''), queryKey: RQKEY(did || '', sortParam),
async queryFn({pageParam}: {pageParam: RQPageParam}) { async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await agent.app.bsky.graph.getFollows({ const res = await agent.app.bsky.graph.getFollows({
actor: did || '', actor: did || '',
limit: limit || PAGE_SIZE, limit: limit || PAGE_SIZE,
cursor: pageParam, cursor: pageParam,
sort: sortParam,
}) })
return res.data return res.data
}, },
+28 -14
View File
@@ -1,7 +1,6 @@
import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {type AppBskyActorDefs as ActorDefs} from '@atproto/api' import {type AppBskyActorDefs as ActorDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
@@ -48,18 +47,22 @@ function keyExtractor(item: ActorDefs.ProfileView) {
} }
export function ProfileFollowers({name}: {name: string}) { export function ProfileFollowers({name}: {name: string}) {
const {_} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const initialNumToRender = useInitialNumToRender() const initialNumToRender = useInitialNumToRender()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const isSortEnabled = ax.features.enabled(ax.features.FollowSortEnable)
const [isPTRing, setIsPTRing] = useState(false) const [isPTRing, setIsPTRing] = useState(false)
const { const {
data: resolvedDid, data: resolvedDid,
isLoading: isDidLoading, isLoading: isDidLoading,
error: resolveError, error: resolveError,
} = useResolveDidQuery(name) } = useResolveDidQuery(name)
const isMe = resolvedDid === currentAccount?.did
const sort = isMe ? 'latest' : 'top'
const { const {
data, data,
isLoading: isFollowersLoading, isLoading: isFollowersLoading,
@@ -68,10 +71,11 @@ export function ProfileFollowers({name}: {name: string}) {
fetchNextPage, fetchNextPage,
error, error,
refetch, refetch,
} = useProfileFollowersQuery(resolvedDid) } = useProfileFollowersQuery(resolvedDid, {
sort,
})
const isError = !!resolveError || !!error const isError = !!resolveError || !!error
const isMe = resolvedDid === currentAccount?.did
const followers = useMemo(() => { const followers = useMemo(() => {
if (data?.pages) { if (data?.pages) {
@@ -101,10 +105,18 @@ export function ProfileFollowers({name}: {name: string}) {
contextProfileDid: resolvedDid, contextProfileDid: resolvedDid,
itemCount: followers.length, itemCount: followers.length,
page: currentPageCount, page: currentPageCount,
sort: isSortEnabled ? sort : undefined,
}) })
} }
paginationTrackingRef.current.page = currentPageCount paginationTrackingRef.current.page = currentPageCount
}, [ax, data?.pages?.length, resolvedDid, followers.length]) }, [
ax,
data?.pages?.length,
resolvedDid,
followers.length,
sort,
isSortEnabled,
])
const onRefresh = useCallback(async () => { const onRefresh = useCallback(async () => {
setIsPTRing(true) setIsPTRing(true)
@@ -137,9 +149,10 @@ export function ProfileFollowers({name}: {name: string}) {
ax.metric('profile:followers:view', { ax.metric('profile:followers:view', {
contextProfileDid: resolvedDid, contextProfileDid: resolvedDid,
isOwnProfile: isMe, isOwnProfile: isMe,
sort: isSortEnabled ? sort : undefined,
}) })
} }
}, [ax, resolvedDid, isMe]) }, [ax, resolvedDid, isMe, sort, isSortEnabled])
// track seen items // track seen items
const seenItemsRef = useRef<Set<string>>(new Set()) const seenItemsRef = useRef<Set<string>>(new Set())
@@ -160,9 +173,10 @@ export function ProfileFollowers({name}: {name: string}) {
profileDid: item.did, profileDid: item.did,
position, position,
...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}),
sort: isSortEnabled ? sort : undefined,
}) })
}, },
[ax, followers, resolvedDid], [ax, followers, resolvedDid, sort, isSortEnabled],
) )
const [followersPromoDismissed, setFollowersPromoDismissed] = const [followersPromoDismissed, setFollowersPromoDismissed] =
@@ -199,8 +213,8 @@ export function ProfileFollowers({name}: {name: string}) {
emptyType="results" emptyType="results"
emptyMessage={ emptyMessage={
isMe isMe
? _(msg`No followers yet`) ? l`No followers yet`
: _(msg`This user doesn't have any followers.`) : l`This user doesn't have any followers.`
} }
errorMessage={cleanError(resolveError || error)} errorMessage={cleanError(resolveError || error)}
onRetry={isError ? refetch : undefined} onRetry={isError ? refetch : undefined}
@@ -208,8 +222,8 @@ export function ProfileFollowers({name}: {name: string}) {
useEmptyState={true} useEmptyState={true}
emptyStateIcon={PeopleRemoveIcon} emptyStateIcon={PeopleRemoveIcon}
emptyStateButton={{ emptyStateButton={{
label: _(msg`Go back`), label: l`Go back`,
text: _(msg`Go back`), text: l`Go back`,
color: 'secondary', color: 'secondary',
size: 'small', size: 'small',
onPress: () => navigation.goBack(), onPress: () => navigation.goBack(),
@@ -221,8 +235,8 @@ export function ProfileFollowers({name}: {name: string}) {
renderItem={renderItemWithContext} renderItem={renderItemWithContext}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
refreshing={isPTRing} refreshing={isPTRing}
onRefresh={onRefresh} onRefresh={() => void onRefresh()}
onEndReached={onEndReached} onEndReached={() => void onEndReached()}
onEndReachedThreshold={4} onEndReachedThreshold={4}
onItemSeen={onItemSeen} onItemSeen={onItemSeen}
ListFooterComponent={ ListFooterComponent={
+28 -14
View File
@@ -1,7 +1,6 @@
import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {type AppBskyActorDefs as ActorDefs} from '@atproto/api' import {type AppBskyActorDefs as ActorDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
@@ -44,12 +43,14 @@ function keyExtractor(item: ActorDefs.ProfileView) {
} }
export function ProfileFollows({name}: {name: string}) { export function ProfileFollows({name}: {name: string}) {
const {_} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const initialNumToRender = useInitialNumToRender() const initialNumToRender = useInitialNumToRender()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const isSortEnabled = ax.features.enabled(ax.features.FollowSortEnable)
const onPressFindAccounts = useCallback(() => { const onPressFindAccounts = useCallback(() => {
if (IS_WEB) { if (IS_WEB) {
navigation.navigate('Search', {}) navigation.navigate('Search', {})
@@ -65,6 +66,8 @@ export function ProfileFollows({name}: {name: string}) {
isLoading: isDidLoading, isLoading: isDidLoading,
error: resolveError, error: resolveError,
} = useResolveDidQuery(name) } = useResolveDidQuery(name)
const isMe = resolvedDid === currentAccount?.did
const sort = isMe ? 'latest' : 'top'
const { const {
data, data,
isLoading: isFollowsLoading, isLoading: isFollowsLoading,
@@ -73,10 +76,11 @@ export function ProfileFollows({name}: {name: string}) {
fetchNextPage, fetchNextPage,
error, error,
refetch, refetch,
} = useProfileFollowsQuery(resolvedDid) } = useProfileFollowsQuery(resolvedDid, {
sort,
})
const isError = !!resolveError || !!error const isError = !!resolveError || !!error
const isMe = resolvedDid === currentAccount?.did
const follows = useMemo(() => { const follows = useMemo(() => {
if (data?.pages) { if (data?.pages) {
@@ -106,10 +110,18 @@ export function ProfileFollows({name}: {name: string}) {
contextProfileDid: resolvedDid, contextProfileDid: resolvedDid,
itemCount: follows.length, itemCount: follows.length,
page: currentPageCount, page: currentPageCount,
sort: isSortEnabled ? sort : undefined,
}) })
} }
paginationTrackingRef.current.page = currentPageCount paginationTrackingRef.current.page = currentPageCount
}, [ax, data?.pages?.length, resolvedDid, follows.length]) }, [
ax,
data?.pages?.length,
resolvedDid,
follows.length,
sort,
isSortEnabled,
])
const onRefresh = useCallback(async () => { const onRefresh = useCallback(async () => {
setIsPTRing(true) setIsPTRing(true)
@@ -142,9 +154,10 @@ export function ProfileFollows({name}: {name: string}) {
ax.metric('profile:following:view', { ax.metric('profile:following:view', {
contextProfileDid: resolvedDid, contextProfileDid: resolvedDid,
isOwnProfile: isMe, isOwnProfile: isMe,
sort: isSortEnabled ? sort : undefined,
}) })
} }
}, [ax, resolvedDid, isMe]) }, [ax, resolvedDid, isMe, sort, isSortEnabled])
// track seen items // track seen items
const seenItemsRef = useRef<Set<string>>(new Set()) const seenItemsRef = useRef<Set<string>>(new Set())
@@ -165,9 +178,10 @@ export function ProfileFollows({name}: {name: string}) {
profileDid: item.did, profileDid: item.did,
position, position,
...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}),
sort: isSortEnabled ? sort : undefined,
}) })
}, },
[ax, follows, resolvedDid], [ax, follows, resolvedDid, sort, isSortEnabled],
) )
if (follows.length < 1) { if (follows.length < 1) {
@@ -178,8 +192,8 @@ export function ProfileFollows({name}: {name: string}) {
emptyType="results" emptyType="results"
emptyMessage={ emptyMessage={
isMe isMe
? _(msg`You are not following anyone yet`) ? l`You are not following anyone yet`
: _(msg`This user isn't following anyone.`) : l`This user isn't following anyone.`
} }
errorMessage={cleanError(resolveError || error)} errorMessage={cleanError(resolveError || error)}
onRetry={isError ? refetch : undefined} onRetry={isError ? refetch : undefined}
@@ -187,8 +201,8 @@ export function ProfileFollows({name}: {name: string}) {
useEmptyState={true} useEmptyState={true}
emptyStateIcon={PeopleRemoveIcon} emptyStateIcon={PeopleRemoveIcon}
emptyStateButton={{ emptyStateButton={{
label: _(msg`See suggested accounts`), label: l`See suggested accounts`,
text: _(msg`See suggested accounts`), text: l`See suggested accounts`,
onPress: onPressFindAccounts, onPress: onPressFindAccounts,
size: 'tiny', size: 'tiny',
color: 'primary', color: 'primary',
@@ -203,8 +217,8 @@ export function ProfileFollows({name}: {name: string}) {
renderItem={renderItemWithContext} renderItem={renderItemWithContext}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
refreshing={isPTRing} refreshing={isPTRing}
onRefresh={onRefresh} onRefresh={() => void onRefresh()}
onEndReached={onEndReached} onEndReached={() => void onEndReached()}
onEndReachedThreshold={4} onEndReachedThreshold={4}
onItemSeen={onItemSeen} onItemSeen={onItemSeen}
ListHeaderComponent={<FindContactsBannerNUX />} ListHeaderComponent={<FindContactsBannerNUX />}