From 44b1ab08b58e9d7da5b5ebe4d7bce4391d247c28 Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Tue, 21 Jul 2026 13:40:30 -0700 Subject: [PATCH] Move known likers below stats row; restore original order in stats row (#11192) --- .../PostThread/components/LikesStat.tsx | 151 +++++++----------- .../components/ThreadItemAnchor.tsx | 5 +- 2 files changed, 62 insertions(+), 94 deletions(-) diff --git a/src/screens/PostThread/components/LikesStat.tsx b/src/screens/PostThread/components/LikesStat.tsx index 9a0c7139fb..eab32dc274 100644 --- a/src/screens/PostThread/components/LikesStat.tsx +++ b/src/screens/PostThread/components/LikesStat.tsx @@ -1,15 +1,13 @@ 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 {enforceLen} from '#/lib/strings/helpers' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useLikedBySampleQuery} from '#/state/queries/post-liked-by' import {useSession} from '#/state/session' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {AvatarStack} from '#/components/AvatarStack' import {InlineLinkText, Link} from '#/components/Link' import {useFormatPostStatCount} from '#/components/PostControls/util' @@ -18,27 +16,56 @@ import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' const AVI_SIZE = 20 -const MAX_NAME_LENGTH = 16 /** - * 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. + * The plain "N likes" stat for the expanded anchor post, linking to the likes + * list. Renders nothing when the post has no likes. */ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) { const t = useTheme() - const {gtMobile} = useBreakpoints() + const {t: l} = useLingui() + const formatPostStatCount = useFormatPostStatCount() + const ax = useAnalytics() + + const likeCount = post.likeCount ?? 0 + if (likeCount === 0) return null + + const urip = new AtUri(post.uri) + const likesHref = makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') + + return ( + ax.metric('post:likedBy:click', {})}> + + + + {formatPostStatCount(likeCount)} + {' '} + + + + + ) +} + +/** + * Social proof for the expanded anchor post. When the viewer follows some of + * the post's recent likers, renders a face pile plus "Liked by A and B" on + * its own row below the interaction stats line. Renders nothing 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. + */ +export function KnownLikers({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 @@ -78,67 +105,34 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) { knownLikersAndModeration.length > 0 && ax.features.enabled(ax.features.PostThreadKnownLikersEnable) - if (!showKnownLikers) { - return ( - - - - - {formatPostStatCount(likeCount)} - {' '} - - - - - ) - } + if (!showKnownLikers) return null const aviStackProfiles = knownLikersAndModeration .slice(0, 3) .map(({actor}) => actor) - const maxNames = gtMobile ? 2 : 1 const names = knownLikersAndModeration - .slice(0, maxNames) + .slice(0, 2) .map(({actor, moderation}) => { return { did: actor.did, href: makeProfileLink(actor), - displayName: enforceLen( - sanitizeDisplayName( - actor.displayName || actor.handle, - moderation.ui('displayName'), - ), - MAX_NAME_LENGTH, - true, + 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` + ? l`Liked by ${names[0].displayName} and ${names[1].displayName}` + : l`Liked by ${names[0].displayName}` - const textStyle = [a.text_md, t.atoms.text_contrast_medium] - const nameStyle = [a.text_md, a.font_semi_bold, t.atoms.text] + const textStyle = [a.text_sm, t.atoms.text_contrast_medium] + const nameStyle = [a.text_sm, a.font_semi_bold, t.atoms.text] /* * Nested inside the row link, but the deepest link claims the press, so @@ -160,10 +154,8 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) { 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. + * The full-width wrapper forces the social proof onto its own line below + * the count stats within the wrapping stats row. */ - + {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 + + Liked by {nameLink(names[0])} and {nameLink(names[1])} ) : ( - - {nameLink(names[0])} likes this + + Liked by {nameLink(names[0])} )} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index f88ad000c1..7973bc1a8a 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -28,7 +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 {KnownLikers, LikesStat} from '#/screens/PostThread/components/LikesStat' import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton' import { LINEAR_AVI_WIDTH, @@ -440,7 +440,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ a.py_md, t.atoms.border_contrast_low, ]}> - {post.repostCount != null && post.repostCount !== 0 ? ( ) : null} + {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( ) : null} + ) : null}