diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts
index 3d8d20a856..c856315e4c 100644
--- a/src/analytics/features/types.ts
+++ b/src/analytics/features/types.ts
@@ -13,6 +13,7 @@ export enum Features {
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
+ PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
SearchV2Enable = 'search_v2:enable',
AdvancedSearchV2Enable = 'advanced_search_v2:enable',
diff --git a/src/screens/PostThread/components/LikesStat.tsx b/src/screens/PostThread/components/LikesStat.tsx
index 1ad2034651..59d421c6b8 100644
--- a/src/screens/PostThread/components/LikesStat.tsx
+++ b/src/screens/PostThread/components/LikesStat.tsx
@@ -1,10 +1,11 @@
+import {View} from 'react-native'
import {type AppBskyFeedDefs, AtUri, moderateProfile} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
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 {atoms as a, useTheme} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack'
@@ -12,6 +13,7 @@ import {InlineLinkText, Link} from '#/components/Link'
import {useFormatPostStatCount} from '#/components/PostControls/util'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {Text} from '#/components/Typography'
+import {useAnalytics} from '#/analytics'
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,
* which it falls back to otherwise.
*
- * Known likers are sourced client-side from the first page of `getLikes`, so
- * they are a sample of the most recent likers, not an exhaustive list. Only
- * the faces and names are affected by sampling - the "N others" count is
- * derived from the post's total like count.
+ * Known likers are sourced client-side from a single `getLikes` request (100
+ * likes, the API max per page), so they are a sample of the most recent
+ * likers, not an exhaustive list. Only the faces and names are affected by
+ * sampling - the "N others" count is derived from the post's total like
+ * count.
*/
export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
const t = useTheme()
@@ -32,33 +35,33 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
const {hasSession, currentAccount} = useSession()
const moderationOpts = useModerationOpts()
const formatPostStatCount = useFormatPostStatCount()
+ const ax = useAnalytics()
+ const knownLikersEnabled = ax.features.enabled(
+ ax.features.PostThreadKnownLikersEnable,
+ )
const likeCount = post.likeCount ?? 0
- const enabled = hasSession && likeCount > 0
- const {data} = useLikedByQuery(enabled ? post.uri : undefined)
+ const enabled = knownLikersEnabled && hasSession && likeCount > 0
+ const {data} = useLikedBySampleQuery({uri: enabled ? post.uri : undefined})
if (likeCount === 0) return null
const urip = new AtUri(post.uri)
const likesHref = makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
- /*
- * Only the first page, even if the liked-by screen has loaded more into
- * this same query cache. This keeps the sample bounds consistent and
- * avoids reflowing the row as more pages arrive.
- */
- const knownLikers = moderationOpts
- ? (data?.pages[0]?.likes ?? [])
- .map(like => like.actor)
- .filter(
- actor =>
- actor.did !== currentAccount?.did &&
- actor.viewer?.following &&
- !actor.viewer.muted &&
- !actor.viewer.blocking &&
- !actor.viewer.blockedBy,
- )
- : []
+ const knownLikers =
+ knownLikersEnabled && moderationOpts
+ ? (data?.likes ?? [])
+ .map(like => like.actor)
+ .filter(
+ actor =>
+ actor.did !== currentAccount?.did &&
+ actor.viewer?.following &&
+ !actor.viewer.muted &&
+ !actor.viewer.blocking &&
+ !actor.viewer.blockedBy,
+ )
+ : []
if (knownLikers.length === 0) {
return (
@@ -112,24 +115,41 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
)
return (
-
-
-
- {names.length >= 2 ? (
- others > 0 ? (
-
- {nameLink(names[0])}, {nameLink(names[1])}, and{' '}
+ /*
+ * The full-width wrapper keeps the social proof on its own line within
+ * the wrapping stats row, rather than wrapping mid-row and orphaning
+ * whichever count stat comes last. The link itself hugs its content so
+ * the empty space to the right of the text is not pressable.
+ */
+
+
+
+
+ {names.length >= 2 ? (
+ others > 0 ? (
+
+ {nameLink(names[0])}, {nameLink(names[1])}, and{' '}
+ {' '}
+ like this
+
+ ) : (
+
+ {nameLink(names[0])} and {nameLink(names[1])} like this
+
+ )
+ ) : others > 0 ? (
+
+ {nameLink(names[0])} and{' '}
) : (
-
- {nameLink(names[0])} and {nameLink(names[1])} like this
+
+ {nameLink(names[0])} likes this
- )
- ) : others > 0 ? (
-
- {nameLink(names[0])} and{' '}
- {' '}
- like this
-
- ) : (
-
- {nameLink(names[0])} likes this
-
- )}
-
-
+ )}
+
+
+
)
}
diff --git a/src/state/queries/post-liked-by.ts b/src/state/queries/post-liked-by.ts
index e02de61531..6bf23118d9 100644
--- a/src/state/queries/post-liked-by.ts
+++ b/src/state/queries/post-liked-by.ts
@@ -4,8 +4,11 @@ import {
type QueryClient,
type QueryKey,
useInfiniteQuery,
+ useQuery,
} from '@tanstack/react-query'
+import {STALE} from '#/state/queries'
+import {createQueryKey} from '#/state/queries/util'
import {useAgent} from '#/state/session'
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(
queryClient: QueryClient,
did: string,
@@ -60,4 +92,18 @@ export function* findAllProfilesInQueryData(
}
}
}
+ const sampleQueryDatas =
+ queryClient.getQueriesData({
+ 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
+ }
+ }
+ }
}