Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/analytics/features/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
2 changes: 2 additions & 0 deletions src/analytics/metrics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1461,4 +1461,6 @@ export type Events = {
jobId?: string
elapsedInPhaseMs: number
}

'post:likedBy:click': {}
}
206 changes: 206 additions & 0 deletions src/screens/PostThread/components/LikesStat.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
25 changes: 2 additions & 23 deletions src/screens/PostThread/components/ThreadItemAnchor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -444,6 +441,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
Expand Down Expand Up @@ -484,25 +482,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"
Expand Down
51 changes: 51 additions & 0 deletions src/state/queries/post-liked-by.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
}
}
}
Loading