From 10b027c35b094ec58fcb89cb05f28ef05fc5ee84 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:02:35 -0700 Subject: [PATCH] Sort follows and following behind gate (#11237) --- oxlint-suppressions.json | 10 ------ package.json | 2 +- pnpm-lock.yaml | 10 +++--- src/analytics/features/types.ts | 1 + src/analytics/metrics/types.ts | 5 +++ src/components/ProgressGuide/List.tsx | 16 ++++----- src/state/queries/profile-followers.ts | 25 ++++++++++++-- src/state/queries/profile-follows.ts | 22 +++++++++--- src/view/com/profile/ProfileFollowers.tsx | 42 +++++++++++++++-------- src/view/com/profile/ProfileFollows.tsx | 42 +++++++++++++++-------- 10 files changed, 114 insertions(+), 61 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index ddcd835fb7..0a3da7641f 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1736,16 +1736,6 @@ "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": { "typescript/no-explicit-any": { "count": 1 diff --git a/package.json b/package.json index edfec97c0f..199a7cb1d8 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "prettier": "prettier --check ." }, "dependencies": { - "@atproto/api": "0.20.31", + "@atproto/api": "0.20.32", "@atproto/common-web": "0.5.6", "@atproto/syntax": "0.7.2", "@bitdrift/react-native": "^0.6.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index face60e04b..1b937313bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,8 +242,8 @@ importers: .: dependencies: '@atproto/api': - specifier: 0.20.31 - version: 0.20.31 + specifier: 0.20.32 + version: 0.20.32 '@atproto/common-web': specifier: 0.5.6 version: 0.5.6 @@ -871,8 +871,8 @@ packages: graphql: optional: true - '@atproto/api@0.20.31': - resolution: {integrity: sha512-TovCQLQv5ti1jqh8UH6jJ0EFuWRjGdUtFyFR5xYC/IkIulwHDuyrVaXdCv7VLWiHftp92DevtZnFRm+BZsZZdw==} + '@atproto/api@0.20.32': + resolution: {integrity: sha512-P6Lh6+PR+x0/HHdEbmwZ5e2uvrzViEeobHWHoEc7KTaKJ/bQQu39z2wfBrG4dOTEC/OcOhFYhAhepo0VGIOeAQ==} engines: {node: '>=22'} '@atproto/common-web@0.5.6': @@ -9389,7 +9389,7 @@ snapshots: '@0no-co/graphql.web@1.2.0': {} - '@atproto/api@0.20.31': + '@atproto/api@0.20.32': dependencies: '@atproto/common-web': 0.5.6 '@atproto/lexicon': 0.7.7 diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index c48cdb90df..16cbe3a105 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -20,6 +20,7 @@ export enum Features { PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable', CustomLogoJapanEnable = 'custom_logo:japan:enable', SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable', + FollowSortEnable = 'follow_sort:enable', AATest = 'aa-test', } diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index b0445db587..1d1c88baca 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -475,25 +475,30 @@ export type Events = { 'profile:followers:view': { contextProfileDid: string isOwnProfile: boolean + sort?: 'latest' | 'top' } 'profile:followers:paginate': { contextProfileDid: string itemCount: number page: number + sort?: 'latest' | 'top' } 'profile:following:view': { contextProfileDid: string isOwnProfile: boolean + sort?: 'latest' | 'top' } 'profile:following:paginate': { contextProfileDid: string itemCount: number page: number + sort?: 'latest' | 'top' } 'profileCard:seen': { contextProfileDid?: string profileDid: string position?: number + sort?: 'latest' | 'top' } 'profile:mute': {} 'profile:unmute': {} diff --git a/src/components/ProgressGuide/List.tsx b/src/components/ProgressGuide/List.tsx index d6b0480443..71372a23ae 100644 --- a/src/components/ProgressGuide/List.tsx +++ b/src/components/ProgressGuide/List.tsx @@ -5,9 +5,7 @@ import { View, type ViewStyle, } from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useSession} from '#/state/session' @@ -29,7 +27,7 @@ const TOTAL_AVATARS = 10 export function ProgressGuideList({style}: {style?: StyleProp}) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const {gtPhone} = useBreakpoints() const {rightNavVisible} = useLayoutBreakpoints() const {currentAccount} = useSession() @@ -79,7 +77,7 @@ export function ProgressGuideList({style}: {style?: StyleProp}) { size="tiny" color="secondary" shape="round" - label={_(msg`Dismiss getting started guide`)} + label={l`Dismiss getting started guide`} onPress={endProgressGuide} style={[a.bg_transparent, {marginTop: -6, marginRight: -6}]}> @@ -107,14 +105,14 @@ export function ProgressGuideList({style}: {style?: StyleProp}) { )} diff --git a/src/state/queries/profile-followers.ts b/src/state/queries/profile-followers.ts index 9c4c5182a1..62968af2d8 100644 --- a/src/state/queries/profile-followers.ts +++ b/src/state/queries/profile-followers.ts @@ -10,15 +10,33 @@ import { } from '@tanstack/react-query' import {useAgent} from '#/state/session' +import {useAnalytics} from '#/analytics' +const DEFAULT_SORT = 'latest' const PAGE_SIZE = 30 type RQPageParam = string | undefined 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 sortParam = isSortEnabled ? sort || DEFAULT_SORT : undefined + return useInfiniteQuery< AppBskyGraphGetFollowers.OutputSchema, Error, @@ -26,12 +44,13 @@ export function useProfileFollowersQuery(did: string | undefined) { QueryKey, RQPageParam >({ - queryKey: RQKEY(did || ''), + queryKey: RQKEY(did || '', sortParam), async queryFn({pageParam}: {pageParam: RQPageParam}) { const res = await agent.app.bsky.graph.getFollowers({ actor: did || '', limit: PAGE_SIZE, cursor: pageParam, + sort: sortParam, }) return res.data }, diff --git a/src/state/queries/profile-follows.ts b/src/state/queries/profile-follows.ts index 1b154793d4..1c4d5dc69c 100644 --- a/src/state/queries/profile-follows.ts +++ b/src/state/queries/profile-follows.ts @@ -8,25 +8,36 @@ import { import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' +import {useAnalytics} from '#/analytics' +const DEFAULT_SORT = 'latest' const PAGE_SIZE = 30 type RQPageParam = string | undefined // TODO refactor invalidate on mutate? 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( did: string | undefined, { limit, + sort, }: { limit?: number - } = { - limit: PAGE_SIZE, - }, + sort?: 'latest' | 'top' + } = {}, ) { + const ax = useAnalytics() + const isSortEnabled = ax.features.enabled(ax.features.FollowSortEnable) const agent = useAgent() + + const sortParam = isSortEnabled ? sort || DEFAULT_SORT : undefined + return useInfiniteQuery< AppBskyGraphGetFollows.OutputSchema, Error, @@ -35,12 +46,13 @@ export function useProfileFollowsQuery( RQPageParam >({ staleTime: STALE.MINUTES.ONE, - queryKey: RQKEY(did || ''), + queryKey: RQKEY(did || '', sortParam), async queryFn({pageParam}: {pageParam: RQPageParam}) { const res = await agent.app.bsky.graph.getFollows({ actor: did || '', limit: limit || PAGE_SIZE, cursor: pageParam, + sort: sortParam, }) return res.data }, diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 07092caed6..b5040aae9d 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -1,7 +1,6 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {type AppBskyActorDefs as ActorDefs} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' @@ -48,18 +47,22 @@ function keyExtractor(item: ActorDefs.ProfileView) { } export function ProfileFollowers({name}: {name: string}) { - const {_} = useLingui() + const {t: l} = useLingui() const ax = useAnalytics() const navigation = useNavigation() const initialNumToRender = useInitialNumToRender() const {currentAccount} = useSession() + const isSortEnabled = ax.features.enabled(ax.features.FollowSortEnable) + const [isPTRing, setIsPTRing] = useState(false) const { data: resolvedDid, isLoading: isDidLoading, error: resolveError, } = useResolveDidQuery(name) + const isMe = resolvedDid === currentAccount?.did + const sort = isMe ? 'latest' : 'top' const { data, isLoading: isFollowersLoading, @@ -68,10 +71,11 @@ export function ProfileFollowers({name}: {name: string}) { fetchNextPage, error, refetch, - } = useProfileFollowersQuery(resolvedDid) + } = useProfileFollowersQuery(resolvedDid, { + sort, + }) const isError = !!resolveError || !!error - const isMe = resolvedDid === currentAccount?.did const followers = useMemo(() => { if (data?.pages) { @@ -101,10 +105,18 @@ export function ProfileFollowers({name}: {name: string}) { contextProfileDid: resolvedDid, itemCount: followers.length, page: currentPageCount, + sort: isSortEnabled ? sort : undefined, }) } paginationTrackingRef.current.page = currentPageCount - }, [ax, data?.pages?.length, resolvedDid, followers.length]) + }, [ + ax, + data?.pages?.length, + resolvedDid, + followers.length, + sort, + isSortEnabled, + ]) const onRefresh = useCallback(async () => { setIsPTRing(true) @@ -137,9 +149,10 @@ export function ProfileFollowers({name}: {name: string}) { ax.metric('profile:followers:view', { contextProfileDid: resolvedDid, isOwnProfile: isMe, + sort: isSortEnabled ? sort : undefined, }) } - }, [ax, resolvedDid, isMe]) + }, [ax, resolvedDid, isMe, sort, isSortEnabled]) // track seen items const seenItemsRef = useRef>(new Set()) @@ -160,9 +173,10 @@ export function ProfileFollowers({name}: {name: string}) { profileDid: item.did, position, ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), + sort: isSortEnabled ? sort : undefined, }) }, - [ax, followers, resolvedDid], + [ax, followers, resolvedDid, sort, isSortEnabled], ) const [followersPromoDismissed, setFollowersPromoDismissed] = @@ -199,8 +213,8 @@ export function ProfileFollowers({name}: {name: string}) { emptyType="results" emptyMessage={ isMe - ? _(msg`No followers yet`) - : _(msg`This user doesn't have any followers.`) + ? l`No followers yet` + : l`This user doesn't have any followers.` } errorMessage={cleanError(resolveError || error)} onRetry={isError ? refetch : undefined} @@ -208,8 +222,8 @@ export function ProfileFollowers({name}: {name: string}) { useEmptyState={true} emptyStateIcon={PeopleRemoveIcon} emptyStateButton={{ - label: _(msg`Go back`), - text: _(msg`Go back`), + label: l`Go back`, + text: l`Go back`, color: 'secondary', size: 'small', onPress: () => navigation.goBack(), @@ -221,8 +235,8 @@ export function ProfileFollowers({name}: {name: string}) { renderItem={renderItemWithContext} keyExtractor={keyExtractor} refreshing={isPTRing} - onRefresh={onRefresh} - onEndReached={onEndReached} + onRefresh={() => void onRefresh()} + onEndReached={() => void onEndReached()} onEndReachedThreshold={4} onItemSeen={onItemSeen} ListFooterComponent={ diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index 9371038aa6..b72161187c 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -1,7 +1,6 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {type AppBskyActorDefs as ActorDefs} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' @@ -44,12 +43,14 @@ function keyExtractor(item: ActorDefs.ProfileView) { } export function ProfileFollows({name}: {name: string}) { - const {_} = useLingui() + const {t: l} = useLingui() const ax = useAnalytics() const initialNumToRender = useInitialNumToRender() const {currentAccount} = useSession() const navigation = useNavigation() + const isSortEnabled = ax.features.enabled(ax.features.FollowSortEnable) + const onPressFindAccounts = useCallback(() => { if (IS_WEB) { navigation.navigate('Search', {}) @@ -65,6 +66,8 @@ export function ProfileFollows({name}: {name: string}) { isLoading: isDidLoading, error: resolveError, } = useResolveDidQuery(name) + const isMe = resolvedDid === currentAccount?.did + const sort = isMe ? 'latest' : 'top' const { data, isLoading: isFollowsLoading, @@ -73,10 +76,11 @@ export function ProfileFollows({name}: {name: string}) { fetchNextPage, error, refetch, - } = useProfileFollowsQuery(resolvedDid) + } = useProfileFollowsQuery(resolvedDid, { + sort, + }) const isError = !!resolveError || !!error - const isMe = resolvedDid === currentAccount?.did const follows = useMemo(() => { if (data?.pages) { @@ -106,10 +110,18 @@ export function ProfileFollows({name}: {name: string}) { contextProfileDid: resolvedDid, itemCount: follows.length, page: currentPageCount, + sort: isSortEnabled ? sort : undefined, }) } paginationTrackingRef.current.page = currentPageCount - }, [ax, data?.pages?.length, resolvedDid, follows.length]) + }, [ + ax, + data?.pages?.length, + resolvedDid, + follows.length, + sort, + isSortEnabled, + ]) const onRefresh = useCallback(async () => { setIsPTRing(true) @@ -142,9 +154,10 @@ export function ProfileFollows({name}: {name: string}) { ax.metric('profile:following:view', { contextProfileDid: resolvedDid, isOwnProfile: isMe, + sort: isSortEnabled ? sort : undefined, }) } - }, [ax, resolvedDid, isMe]) + }, [ax, resolvedDid, isMe, sort, isSortEnabled]) // track seen items const seenItemsRef = useRef>(new Set()) @@ -165,9 +178,10 @@ export function ProfileFollows({name}: {name: string}) { profileDid: item.did, position, ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), + sort: isSortEnabled ? sort : undefined, }) }, - [ax, follows, resolvedDid], + [ax, follows, resolvedDid, sort, isSortEnabled], ) if (follows.length < 1) { @@ -178,8 +192,8 @@ export function ProfileFollows({name}: {name: string}) { emptyType="results" emptyMessage={ isMe - ? _(msg`You are not following anyone yet`) - : _(msg`This user isn't following anyone.`) + ? l`You are not following anyone yet` + : l`This user isn't following anyone.` } errorMessage={cleanError(resolveError || error)} onRetry={isError ? refetch : undefined} @@ -187,8 +201,8 @@ export function ProfileFollows({name}: {name: string}) { useEmptyState={true} emptyStateIcon={PeopleRemoveIcon} emptyStateButton={{ - label: _(msg`See suggested accounts`), - text: _(msg`See suggested accounts`), + label: l`See suggested accounts`, + text: l`See suggested accounts`, onPress: onPressFindAccounts, size: 'tiny', color: 'primary', @@ -203,8 +217,8 @@ export function ProfileFollows({name}: {name: string}) { renderItem={renderItemWithContext} keyExtractor={keyExtractor} refreshing={isPTRing} - onRefresh={onRefresh} - onEndReached={onEndReached} + onRefresh={() => void onRefresh()} + onEndReached={() => void onEndReached()} onEndReachedThreshold={4} onItemSeen={onItemSeen} ListHeaderComponent={}