Prototype: Show known likers on post thread page (#11080)
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
@@ -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',
|
||||
}
|
||||
|
||||
@@ -1461,4 +1461,6 @@ export type Events = {
|
||||
jobId?: string
|
||||
elapsedInPhaseMs: number
|
||||
}
|
||||
|
||||
'post:likedBy:click': {}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Link
|
||||
to={likesHref}
|
||||
label={l`Likes on this post`}
|
||||
onPress={onPressLikedBy}>
|
||||
<Text
|
||||
testID="likeCount-expanded"
|
||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
|
||||
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||
{formatPostStatCount(likeCount)}
|
||||
</Text>{' '}
|
||||
<Plural value={likeCount} one="like" other="likes" />
|
||||
</Trans>
|
||||
</Text>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
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]) => (
|
||||
<ProfileHoverCard key={name.did} did={name.did} inline>
|
||||
<InlineLinkText
|
||||
to={name.href}
|
||||
label={l`Go to ${name.displayName}'s profile`}
|
||||
disableMismatchWarning
|
||||
emoji
|
||||
style={nameStyle}>
|
||||
{name.displayName}
|
||||
</InlineLinkText>
|
||||
</ProfileHoverCard>
|
||||
)
|
||||
|
||||
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.
|
||||
*/
|
||||
<View style={[a.w_full, a.flex_row]}>
|
||||
<Link
|
||||
to={likesHref}
|
||||
label={rowLabel}
|
||||
style={[a.flex_row, a.align_center, a.gap_sm, a.flex_shrink]}
|
||||
onPress={onPressLikedBy}>
|
||||
<AvatarStack profiles={aviStackProfiles} size={AVI_SIZE} />
|
||||
<Text
|
||||
testID="knownLikersStat"
|
||||
numberOfLines={1}
|
||||
style={[a.flex_shrink, textStyle]}>
|
||||
{names.length >= 2 ? (
|
||||
others > 0 ? (
|
||||
<Trans comment="Social proof on the likes stat; the bolded names are people the viewer follows who liked the post, and the count is the remaining number of likes">
|
||||
{nameLink(names[0])}, {nameLink(names[1])}, and{' '}
|
||||
<Plural
|
||||
value={others}
|
||||
one={`${formatPostStatCount(others)} other`}
|
||||
other={`${formatPostStatCount(others)} others`}
|
||||
/>{' '}
|
||||
like this
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Social proof on the likes stat; the bolded names are people the viewer follows who liked the post and are its only likes">
|
||||
{nameLink(names[0])} and {nameLink(names[1])} like this
|
||||
</Trans>
|
||||
)
|
||||
) : others > 0 ? (
|
||||
<Trans comment="Social proof on the likes stat; the bolded name is a person the viewer follows who liked the post, and the count is the remaining number of likes">
|
||||
{nameLink(names[0])} and{' '}
|
||||
<Plural
|
||||
value={others}
|
||||
one={`${formatPostStatCount(others)} other`}
|
||||
other={`${formatPostStatCount(others)} others`}
|
||||
/>{' '}
|
||||
like this
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Social proof on the likes stat; the bolded name is a person the viewer follows who liked the post and is its only like">
|
||||
{nameLink(names[0])} likes this
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Link>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
]}>
|
||||
<LikesStat post={post} />
|
||||
{post.repostCount != null && post.repostCount !== 0 ? (
|
||||
<Link to={repostsHref} label={l`Reposts of this post`}>
|
||||
<Text
|
||||
@@ -483,25 +481,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
</Text>
|
||||
</Link>
|
||||
) : null}
|
||||
{post.likeCount != null && post.likeCount !== 0 ? (
|
||||
<Link to={likesHref} label={l`Likes on this post`}>
|
||||
<Text
|
||||
testID="likeCount-expanded"
|
||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
|
||||
<Text
|
||||
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||
{formatPostStatCount(post.likeCount)}
|
||||
</Text>{' '}
|
||||
<Plural
|
||||
value={post.likeCount}
|
||||
one="like"
|
||||
other="likes"
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
</Link>
|
||||
) : null}
|
||||
{post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
|
||||
<Text
|
||||
testID="bookmarkCount-expanded"
|
||||
|
||||
@@ -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,40 @@ 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,
|
||||
/*
|
||||
* 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<AppBskyFeedGetLikes.OutputSchema>({
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user