Bump app.bsky.feed.getLikes request sample size to 100

This commit is contained in:
Alex Benzer
2026-07-06 20:01:29 -07:00
parent 511fb7894b
commit 4534e4a577
3 changed files with 115 additions and 62 deletions
+1
View File
@@ -13,6 +13,7 @@ export enum Features {
ComposerLanguageDetectionEnable = 'composer:language_detection:enable', ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable', PostGalleryEmbedEnable = 'post_gallery_embed:enable',
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable', NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
SearchV2Enable = 'search_v2:enable', SearchV2Enable = 'search_v2:enable',
AdvancedSearchV2Enable = 'advanced_search_v2:enable', AdvancedSearchV2Enable = 'advanced_search_v2:enable',
+68 -62
View File
@@ -1,10 +1,11 @@
import {View} from 'react-native'
import {type AppBskyFeedDefs, AtUri, moderateProfile} from '@atproto/api' import {type AppBskyFeedDefs, AtUri, moderateProfile} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro' import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useLikedByQuery} from '#/state/queries/post-liked-by' import {useLikedBySampleQuery} from '#/state/queries/post-liked-by'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack' import {AvatarStack} from '#/components/AvatarStack'
@@ -12,6 +13,7 @@ import {InlineLinkText, Link} from '#/components/Link'
import {useFormatPostStatCount} from '#/components/PostControls/util' import {useFormatPostStatCount} from '#/components/PostControls/util'
import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
const AVI_SIZE = 20 const AVI_SIZE = 20
@@ -21,10 +23,11 @@ const AVI_SIZE = 20
* "Liked by A, B, and N others" - in place of the plain "N likes" text, * "Liked by A, B, and N others" - in place of the plain "N likes" text,
* which it falls back to otherwise. * which it falls back to otherwise.
* *
* Known likers are sourced client-side from the first page of `getLikes`, so * Known likers are sourced client-side from a single `getLikes` request (100
* they are a sample of the most recent likers, not an exhaustive list. Only * likes, the API max per page), so they are a sample of the most recent
* the faces and names are affected by sampling - the "N others" count is * likers, not an exhaustive list. Only the faces and names are affected by
* derived from the post's total like count. * sampling - the "N others" count is derived from the post's total like
* count.
*/ */
export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) { export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
const t = useTheme() const t = useTheme()
@@ -32,33 +35,33 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const formatPostStatCount = useFormatPostStatCount() const formatPostStatCount = useFormatPostStatCount()
const ax = useAnalytics()
const knownLikersEnabled = ax.features.enabled(
ax.features.PostThreadKnownLikersEnable,
)
const likeCount = post.likeCount ?? 0 const likeCount = post.likeCount ?? 0
const enabled = hasSession && likeCount > 0 const enabled = knownLikersEnabled && hasSession && likeCount > 0
const {data} = useLikedByQuery(enabled ? post.uri : undefined) const {data} = useLikedBySampleQuery({uri: enabled ? post.uri : undefined})
if (likeCount === 0) return null if (likeCount === 0) return null
const urip = new AtUri(post.uri) const urip = new AtUri(post.uri)
const likesHref = makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') const likesHref = makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
/* const knownLikers =
* Only the first page, even if the liked-by screen has loaded more into knownLikersEnabled && moderationOpts
* this same query cache. This keeps the sample bounds consistent and ? (data?.likes ?? [])
* avoids reflowing the row as more pages arrive. .map(like => like.actor)
*/ .filter(
const knownLikers = moderationOpts actor =>
? (data?.pages[0]?.likes ?? []) actor.did !== currentAccount?.did &&
.map(like => like.actor) actor.viewer?.following &&
.filter( !actor.viewer.muted &&
actor => !actor.viewer.blocking &&
actor.did !== currentAccount?.did && !actor.viewer.blockedBy,
actor.viewer?.following && )
!actor.viewer.muted && : []
!actor.viewer.blocking &&
!actor.viewer.blockedBy,
)
: []
if (knownLikers.length === 0) { if (knownLikers.length === 0) {
return ( return (
@@ -112,24 +115,41 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
) )
return ( return (
<Link /*
to={likesHref} * The full-width wrapper keeps the social proof on its own line within
label={l`Likes on this post`} * the wrapping stats row, rather than wrapping mid-row and orphaning
/* * whichever count stat comes last. The link itself hugs its content so
* Full width so the social proof always sits on its own line within * the empty space to the right of the text is not pressable.
* the wrapping stats row, rather than wrapping mid-row and orphaning */
* whichever count stat comes last. <View style={[a.w_full, a.flex_row]}>
*/ <Link
style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> to={likesHref}
<AvatarStack profiles={knownLikers.slice(0, 3)} size={AVI_SIZE} /> label={l`Likes on this post`}
<Text style={[a.flex_row, a.align_center, a.gap_sm, a.flex_shrink]}>
testID="knownLikersStat" <AvatarStack profiles={knownLikers.slice(0, 3)} size={AVI_SIZE} />
numberOfLines={1} <Text
style={[a.flex_shrink, textStyle]}> testID="knownLikersStat"
{names.length >= 2 ? ( numberOfLines={1}
others > 0 ? ( style={[a.flex_shrink, textStyle]}>
<Trans comment="Social proof on the likes stat; the bolded names are people the viewer follows who liked the post, and the count is the remaining number of likes"> {names.length >= 2 ? (
{nameLink(names[0])}, {nameLink(names[1])}, and{' '} others > 0 ? (
<Trans comment="Social proof on the likes stat; the bolded names are people the viewer follows who liked the post, and the count is the remaining number of likes">
{nameLink(names[0])}, {nameLink(names[1])}, and{' '}
<Plural
value={others}
one={`${formatPostStatCount(others)} other`}
other={`${formatPostStatCount(others)} others`}
/>{' '}
like this
</Trans>
) : (
<Trans comment="Social proof on the likes stat; the bolded names are people the viewer follows who liked the post and are its only likes">
{nameLink(names[0])} and {nameLink(names[1])} like this
</Trans>
)
) : others > 0 ? (
<Trans comment="Social proof on the likes stat; the bolded name is a person the viewer follows who liked the post, and the count is the remaining number of likes">
{nameLink(names[0])} and{' '}
<Plural <Plural
value={others} value={others}
one={`${formatPostStatCount(others)} other`} one={`${formatPostStatCount(others)} other`}
@@ -138,26 +158,12 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
like this like this
</Trans> </Trans>
) : ( ) : (
<Trans comment="Social proof on the likes stat; the bolded names are people the viewer follows who liked the post and are its only likes"> <Trans comment="Social proof on the likes stat; the bolded name is a person the viewer follows who liked the post and is its only like">
{nameLink(names[0])} and {nameLink(names[1])} like this {nameLink(names[0])} likes this
</Trans> </Trans>
) )}
) : others > 0 ? ( </Text>
<Trans comment="Social proof on the likes stat; the bolded name is a person the viewer follows who liked the post, and the count is the remaining number of likes"> </Link>
{nameLink(names[0])} and{' '} </View>
<Plural
value={others}
one={`${formatPostStatCount(others)} other`}
other={`${formatPostStatCount(others)} others`}
/>{' '}
like this
</Trans>
) : (
<Trans comment="Social proof on the likes stat; the bolded name is a person the viewer follows who liked the post and is its only like">
{nameLink(names[0])} likes this
</Trans>
)}
</Text>
</Link>
) )
} }
+46
View File
@@ -4,8 +4,11 @@ import {
type QueryClient, type QueryClient,
type QueryKey, type QueryKey,
useInfiniteQuery, useInfiniteQuery,
useQuery,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {STALE} from '#/state/queries'
import {createQueryKey} from '#/state/queries/util'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
@@ -39,6 +42,35 @@ export function useLikedByQuery(resolvedUri: string | undefined) {
}) })
} }
/**
* The maximum `limit` accepted by `app.bsky.feed.getLikes` in a single
* request.
*/
const SAMPLE_SIZE = 100
const likedBySampleQueryKeyRoot = 'liked-by-sample'
export const createLikedBySampleQueryKey = (args: {uri: string}) =>
createQueryKey(likedBySampleQueryKeyRoot, args)
/**
* A single-request sample of a post's most recent likers, as many as the API
* allows in one page (100). Used for the known-likers social proof on the
* post thread page. Kept separate from `useLikedByQuery` so it does not
* perturb the liked-by screen's pagination.
*/
export function useLikedBySampleQuery({uri}: {uri: string | undefined}) {
const agent = useAgent()
return useQuery({
queryKey: createLikedBySampleQueryKey({uri: uri ?? ''}),
queryFn: async () => {
const res = await agent.getLikes({uri: uri ?? '', limit: SAMPLE_SIZE})
return res.data
},
staleTime: STALE.MINUTES.FIVE,
enabled: !!uri,
})
}
export function* findAllProfilesInQueryData( export function* findAllProfilesInQueryData(
queryClient: QueryClient, queryClient: QueryClient,
did: string, did: string,
@@ -60,4 +92,18 @@ export function* findAllProfilesInQueryData(
} }
} }
} }
const sampleQueryDatas =
queryClient.getQueriesData<AppBskyFeedGetLikes.OutputSchema>({
queryKey: [likedBySampleQueryKeyRoot],
})
for (const [_queryKey, queryData] of sampleQueryDatas) {
if (!queryData?.likes) {
continue
}
for (const like of queryData.likes) {
if (like.actor.did === did) {
yield like.actor
}
}
}
} }