From 04ebd02ce3dda50571827a91b491d38183ce235a Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Mon, 13 Jul 2026 12:55:15 -0700 Subject: [PATCH] Prototype: Show known likers on post thread page (#11080) Co-authored-by: Eric Bailey --- src/analytics/features/types.ts | 2 + src/analytics/metrics/types.ts | 2 + .../PostThread/components/LikesStat.tsx | 206 ++++++++++++++++++ .../components/ThreadItemAnchor.tsx | 25 +-- src/state/queries/post-liked-by.ts | 51 +++++ 5 files changed, 263 insertions(+), 23 deletions(-) create mode 100644 src/screens/PostThread/components/LikesStat.tsx diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 63d1ab0da4..b890873a37 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -12,6 +12,8 @@ export enum Features { GroupChatsDisable = 'group_chats:disable', ComposerLanguageDetectionEnable = 'composer:language_detection:enable', PostGalleryEmbedEnable = 'post_gallery_embed:enable', + PostThreadKnownLikersEnable = 'post_thread:known_likers:enable', + PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable', AATest = 'aa-test', } diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index b988595bb2..ef875ff5bf 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -1461,4 +1461,6 @@ export type Events = { jobId?: string elapsedInPhaseMs: number } + + 'post:likedBy:click': {} } diff --git a/src/screens/PostThread/components/LikesStat.tsx b/src/screens/PostThread/components/LikesStat.tsx new file mode 100644 index 0000000000..7b6182d3c0 --- /dev/null +++ b/src/screens/PostThread/components/LikesStat.tsx @@ -0,0 +1,206 @@ +import {View} from 'react-native' +import {type AppBskyFeedDefs, AtUri, moderateProfile} from '@atproto/api' +import {plural} from '@lingui/core/macro' +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 {useLikedBySampleQuery} from '#/state/queries/post-liked-by' +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {AvatarStack} from '#/components/AvatarStack' +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 + +/** + * The likes stat for the expanded anchor post. When the viewer follows some + * of the post's recent likers, renders social proof - a face pile plus + * "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 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() + const {t: l} = useLingui() + const {hasSession, currentAccount} = useSession() + const moderationOpts = useModerationOpts() + const formatPostStatCount = useFormatPostStatCount() + const ax = useAnalytics() + + const likeCount = post.likeCount ?? 0 + /* + * Kill switch for the getLikes sample request itself, separate from the + * display gate below. + */ + const fetchEnabled = ax.features.enabled( + ax.features.PostThreadKnownLikersFetchEnable, + ) + const {data} = useLikedBySampleQuery({ + uri: fetchEnabled && hasSession && likeCount > 0 ? post.uri : undefined, + }) + + if (likeCount === 0) return null + + const urip = new AtUri(post.uri) + const likesHref = makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') + const onPressLikedBy = () => ax.metric('post:likedBy:click', {}) + + const knownLikersAndModeration = moderationOpts + ? (data?.likes ?? []) + .map(like => like.actor) + .map(actor => ({ + actor, + moderation: moderateProfile(actor, moderationOpts), + })) + .filter(({actor, moderation}) => { + const modui = moderation.ui('profileList') + const isMe = actor.did === currentAccount?.did + const following = actor.viewer?.following + return !isMe && following && !modui.filter + }) + : [] + + const showKnownLikers = + knownLikersAndModeration.length > 0 && + ax.features.enabled(ax.features.PostThreadKnownLikersEnable) + + if (!showKnownLikers) { + return ( + + + + + {formatPostStatCount(likeCount)} + {' '} + + + + + ) + } + + const aviStackProfiles = knownLikersAndModeration + .slice(0, 3) + .map(({actor}) => actor) + const names = knownLikersAndModeration + .slice(0, 2) + .map(({actor, moderation}) => { + return { + did: actor.did, + href: makeProfileLink(actor), + displayName: sanitizeDisplayName( + actor.displayName || actor.handle, + moderation.ui('displayName'), + ), + } + }) + const others = likeCount - names.length + + /* + * The row link's a11y label mirrors the visible sentence so screen readers + * announce the social proof. + */ + const othersLabel = plural(others, { + one: `${formatPostStatCount(others)} other`, + other: `${formatPostStatCount(others)} others`, + }) + const rowLabel = + names.length >= 2 + ? others > 0 + ? l`${names[0].displayName}, ${names[1].displayName}, and ${othersLabel} like this` + : l`${names[0].displayName} and ${names[1].displayName} like this` + : others > 0 + ? l`${names[0].displayName} and ${othersLabel} like this` + : l`${names[0].displayName} likes this` + + const textStyle = [a.text_md, t.atoms.text_contrast_medium] + const nameStyle = [a.text_md, a.font_semi_bold, t.atoms.text] + + /* + * Nested inside the row link, but the deepest link claims the press, so + * tapping a name goes to that profile while the rest of the row goes to + * the likes list. Same pattern as NotificationFeedItem's author links. + */ + const nameLink = (name: (typeof names)[number]) => ( + + + {name.displayName} + + + ) + + return ( + /* + * 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{' '} + {' '} + like this + + ) : ( + + {nameLink(names[0])} likes this + + )} + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index e7f3b5a1cb..f88ad000c1 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -28,6 +28,7 @@ import {type OnPostSuccessData} from '#/state/shell/composer' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {type PostSource} from '#/state/unstable-post-source' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' +import {LikesStat} from '#/screens/PostThread/components/LikesStat' import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton' import { LINEAR_AVI_WIDTH, @@ -199,10 +200,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ const authorHref = makeProfileLink(post.author) const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did - const likesHref = useMemo(() => { - const urip = new AtUri(post.uri) - return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') - }, [post.uri, post.author]) const repostsHref = useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') @@ -443,6 +440,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ a.py_md, t.atoms.border_contrast_low, ]}> + {post.repostCount != null && post.repostCount !== 0 ? ( ) : null} - {post.likeCount != null && post.likeCount !== 0 ? ( - - - - - {formatPostStatCount(post.likeCount)} - {' '} - - - - - ) : null} {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( + 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, + /* + * Consumers fall back to a plain like count when this query fails, so + * failing fast is preferable to amplifying getLikes load with retries. + */ + retry: 1, + }) +} + export function* findAllProfilesInQueryData( queryClient: QueryClient, did: string, @@ -60,4 +97,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 + } + } + } }