diff --git a/src/screens/PostThread/components/LikesStat.tsx b/src/screens/PostThread/components/LikesStat.tsx new file mode 100644 index 0000000000..1ad2034651 --- /dev/null +++ b/src/screens/PostThread/components/LikesStat.tsx @@ -0,0 +1,163 @@ +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 {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' + +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 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. + */ +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 likeCount = post.likeCount ?? 0 + const enabled = hasSession && likeCount > 0 + const {data} = useLikedByQuery(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, + ) + : [] + + if (knownLikers.length === 0) { + return ( + + + + + {formatPostStatCount(likeCount)} + {' '} + + + + + ) + } + + const names = knownLikers.slice(0, 2).map(actor => { + const moderation = moderateProfile(actor, moderationOpts!) + return { + did: actor.did, + href: makeProfileLink(actor), + displayName: sanitizeDisplayName( + actor.displayName || actor.handle, + moderation.ui('displayName'), + ), + } + }) + const others = likeCount - names.length + + 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 ( + + + + {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 dc3c55f125..1014e0e1a4 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, @@ -200,10 +201,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') @@ -444,6 +441,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 ? (