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