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 + } + } + } }