Skip to content

Commit 04ebd02

Browse files
Prototype: Show known likers on post thread page (#11080)
Co-authored-by: Eric Bailey <git@esb.lol>
1 parent 0701a08 commit 04ebd02

5 files changed

Lines changed: 263 additions & 23 deletions

File tree

src/analytics/features/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export enum Features {
1212
GroupChatsDisable = 'group_chats:disable',
1313
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
1414
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
15+
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
16+
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
1517

1618
AATest = 'aa-test',
1719
}

src/analytics/metrics/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1461,4 +1461,6 @@ export type Events = {
14611461
jobId?: string
14621462
elapsedInPhaseMs: number
14631463
}
1464+
1465+
'post:likedBy:click': {}
14641466
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import {View} from 'react-native'
2+
import {type AppBskyFeedDefs, AtUri, moderateProfile} from '@atproto/api'
3+
import {plural} from '@lingui/core/macro'
4+
import {Plural, Trans, useLingui} from '@lingui/react/macro'
5+
6+
import {makeProfileLink} from '#/lib/routes/links'
7+
import {sanitizeDisplayName} from '#/lib/strings/display-names'
8+
import {useModerationOpts} from '#/state/preferences/moderation-opts'
9+
import {useLikedBySampleQuery} from '#/state/queries/post-liked-by'
10+
import {useSession} from '#/state/session'
11+
import {atoms as a, useTheme} from '#/alf'
12+
import {AvatarStack} from '#/components/AvatarStack'
13+
import {InlineLinkText, Link} from '#/components/Link'
14+
import {useFormatPostStatCount} from '#/components/PostControls/util'
15+
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
16+
import {Text} from '#/components/Typography'
17+
import {useAnalytics} from '#/analytics'
18+
19+
const AVI_SIZE = 20
20+
21+
/**
22+
* The likes stat for the expanded anchor post. When the viewer follows some
23+
* of the post's recent likers, renders social proof - a face pile plus
24+
* "Liked by A, B, and N others" - in place of the plain "N likes" text,
25+
* which it falls back to otherwise.
26+
*
27+
* Known likers are sourced client-side from a single `getLikes` request (100
28+
* likes, the API max per page), so they are a sample of the most recent
29+
* likers, not an exhaustive list. Only the faces and names are affected by
30+
* sampling - the "N others" count is derived from the post's total like
31+
* count.
32+
*/
33+
export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
34+
const t = useTheme()
35+
const {t: l} = useLingui()
36+
const {hasSession, currentAccount} = useSession()
37+
const moderationOpts = useModerationOpts()
38+
const formatPostStatCount = useFormatPostStatCount()
39+
const ax = useAnalytics()
40+
41+
const likeCount = post.likeCount ?? 0
42+
/*
43+
* Kill switch for the getLikes sample request itself, separate from the
44+
* display gate below.
45+
*/
46+
const fetchEnabled = ax.features.enabled(
47+
ax.features.PostThreadKnownLikersFetchEnable,
48+
)
49+
const {data} = useLikedBySampleQuery({
50+
uri: fetchEnabled && hasSession && likeCount > 0 ? post.uri : undefined,
51+
})
52+
53+
if (likeCount === 0) return null
54+
55+
const urip = new AtUri(post.uri)
56+
const likesHref = makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
57+
const onPressLikedBy = () => ax.metric('post:likedBy:click', {})
58+
59+
const knownLikersAndModeration = moderationOpts
60+
? (data?.likes ?? [])
61+
.map(like => like.actor)
62+
.map(actor => ({
63+
actor,
64+
moderation: moderateProfile(actor, moderationOpts),
65+
}))
66+
.filter(({actor, moderation}) => {
67+
const modui = moderation.ui('profileList')
68+
const isMe = actor.did === currentAccount?.did
69+
const following = actor.viewer?.following
70+
return !isMe && following && !modui.filter
71+
})
72+
: []
73+
74+
const showKnownLikers =
75+
knownLikersAndModeration.length > 0 &&
76+
ax.features.enabled(ax.features.PostThreadKnownLikersEnable)
77+
78+
if (!showKnownLikers) {
79+
return (
80+
<Link
81+
to={likesHref}
82+
label={l`Likes on this post`}
83+
onPress={onPressLikedBy}>
84+
<Text
85+
testID="likeCount-expanded"
86+
style={[a.text_md, t.atoms.text_contrast_medium]}>
87+
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
88+
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
89+
{formatPostStatCount(likeCount)}
90+
</Text>{' '}
91+
<Plural value={likeCount} one="like" other="likes" />
92+
</Trans>
93+
</Text>
94+
</Link>
95+
)
96+
}
97+
98+
const aviStackProfiles = knownLikersAndModeration
99+
.slice(0, 3)
100+
.map(({actor}) => actor)
101+
const names = knownLikersAndModeration
102+
.slice(0, 2)
103+
.map(({actor, moderation}) => {
104+
return {
105+
did: actor.did,
106+
href: makeProfileLink(actor),
107+
displayName: sanitizeDisplayName(
108+
actor.displayName || actor.handle,
109+
moderation.ui('displayName'),
110+
),
111+
}
112+
})
113+
const others = likeCount - names.length
114+
115+
/*
116+
* The row link's a11y label mirrors the visible sentence so screen readers
117+
* announce the social proof.
118+
*/
119+
const othersLabel = plural(others, {
120+
one: `${formatPostStatCount(others)} other`,
121+
other: `${formatPostStatCount(others)} others`,
122+
})
123+
const rowLabel =
124+
names.length >= 2
125+
? others > 0
126+
? l`${names[0].displayName}, ${names[1].displayName}, and ${othersLabel} like this`
127+
: l`${names[0].displayName} and ${names[1].displayName} like this`
128+
: others > 0
129+
? l`${names[0].displayName} and ${othersLabel} like this`
130+
: l`${names[0].displayName} likes this`
131+
132+
const textStyle = [a.text_md, t.atoms.text_contrast_medium]
133+
const nameStyle = [a.text_md, a.font_semi_bold, t.atoms.text]
134+
135+
/*
136+
* Nested inside the row link, but the deepest link claims the press, so
137+
* tapping a name goes to that profile while the rest of the row goes to
138+
* the likes list. Same pattern as NotificationFeedItem's author links.
139+
*/
140+
const nameLink = (name: (typeof names)[number]) => (
141+
<ProfileHoverCard key={name.did} did={name.did} inline>
142+
<InlineLinkText
143+
to={name.href}
144+
label={l`Go to ${name.displayName}'s profile`}
145+
disableMismatchWarning
146+
emoji
147+
style={nameStyle}>
148+
{name.displayName}
149+
</InlineLinkText>
150+
</ProfileHoverCard>
151+
)
152+
153+
return (
154+
/*
155+
* The full-width wrapper keeps the social proof on its own line within
156+
* the wrapping stats row, rather than wrapping mid-row and orphaning
157+
* whichever count stat comes last. The link itself hugs its content so
158+
* the empty space to the right of the text is not pressable.
159+
*/
160+
<View style={[a.w_full, a.flex_row]}>
161+
<Link
162+
to={likesHref}
163+
label={rowLabel}
164+
style={[a.flex_row, a.align_center, a.gap_sm, a.flex_shrink]}
165+
onPress={onPressLikedBy}>
166+
<AvatarStack profiles={aviStackProfiles} size={AVI_SIZE} />
167+
<Text
168+
testID="knownLikersStat"
169+
numberOfLines={1}
170+
style={[a.flex_shrink, textStyle]}>
171+
{names.length >= 2 ? (
172+
others > 0 ? (
173+
<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">
174+
{nameLink(names[0])}, {nameLink(names[1])}, and{' '}
175+
<Plural
176+
value={others}
177+
one={`${formatPostStatCount(others)} other`}
178+
other={`${formatPostStatCount(others)} others`}
179+
/>{' '}
180+
like this
181+
</Trans>
182+
) : (
183+
<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">
184+
{nameLink(names[0])} and {nameLink(names[1])} like this
185+
</Trans>
186+
)
187+
) : others > 0 ? (
188+
<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">
189+
{nameLink(names[0])} and{' '}
190+
<Plural
191+
value={others}
192+
one={`${formatPostStatCount(others)} other`}
193+
other={`${formatPostStatCount(others)} others`}
194+
/>{' '}
195+
like this
196+
</Trans>
197+
) : (
198+
<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">
199+
{nameLink(names[0])} likes this
200+
</Trans>
201+
)}
202+
</Text>
203+
</Link>
204+
</View>
205+
)
206+
}

src/screens/PostThread/components/ThreadItemAnchor.tsx

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {type OnPostSuccessData} from '#/state/shell/composer'
2828
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
2929
import {type PostSource} from '#/state/unstable-post-source'
3030
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
31+
import {LikesStat} from '#/screens/PostThread/components/LikesStat'
3132
import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton'
3233
import {
3334
LINEAR_AVI_WIDTH,
@@ -199,10 +200,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
199200
const authorHref = makeProfileLink(post.author)
200201
const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
201202

202-
const likesHref = useMemo(() => {
203-
const urip = new AtUri(post.uri)
204-
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
205-
}, [post.uri, post.author])
206203
const repostsHref = useMemo(() => {
207204
const urip = new AtUri(post.uri)
208205
return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
@@ -443,6 +440,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
443440
a.py_md,
444441
t.atoms.border_contrast_low,
445442
]}>
443+
<LikesStat post={post} />
446444
{post.repostCount != null && post.repostCount !== 0 ? (
447445
<Link to={repostsHref} label={l`Reposts of this post`}>
448446
<Text
@@ -483,25 +481,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
483481
</Text>
484482
</Link>
485483
) : null}
486-
{post.likeCount != null && post.likeCount !== 0 ? (
487-
<Link to={likesHref} label={l`Likes on this post`}>
488-
<Text
489-
testID="likeCount-expanded"
490-
style={[a.text_md, t.atoms.text_contrast_medium]}>
491-
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
492-
<Text
493-
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
494-
{formatPostStatCount(post.likeCount)}
495-
</Text>{' '}
496-
<Plural
497-
value={post.likeCount}
498-
one="like"
499-
other="likes"
500-
/>
501-
</Trans>
502-
</Text>
503-
</Link>
504-
) : null}
505484
{post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
506485
<Text
507486
testID="bookmarkCount-expanded"

src/state/queries/post-liked-by.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import {
44
type QueryClient,
55
type QueryKey,
66
useInfiniteQuery,
7+
useQuery,
78
} from '@tanstack/react-query'
89

10+
import {STALE} from '#/state/queries'
11+
import {createQueryKey} from '#/state/queries/util'
912
import {useAgent} from '#/state/session'
1013

1114
const PAGE_SIZE = 30
@@ -39,6 +42,40 @@ export function useLikedByQuery(resolvedUri: string | undefined) {
3942
})
4043
}
4144

45+
/**
46+
* The maximum `limit` accepted by `app.bsky.feed.getLikes` in a single
47+
* request.
48+
*/
49+
const SAMPLE_SIZE = 100
50+
51+
const likedBySampleQueryKeyRoot = 'liked-by-sample'
52+
export const createLikedBySampleQueryKey = (args: {uri: string}) =>
53+
createQueryKey(likedBySampleQueryKeyRoot, args)
54+
55+
/**
56+
* A single-request sample of a post's most recent likers, as many as the API
57+
* allows in one page (100). Used for the known-likers social proof on the
58+
* post thread page. Kept separate from `useLikedByQuery` so it does not
59+
* perturb the liked-by screen's pagination.
60+
*/
61+
export function useLikedBySampleQuery({uri}: {uri: string | undefined}) {
62+
const agent = useAgent()
63+
return useQuery({
64+
queryKey: createLikedBySampleQueryKey({uri: uri ?? ''}),
65+
queryFn: async () => {
66+
const res = await agent.getLikes({uri: uri ?? '', limit: SAMPLE_SIZE})
67+
return res.data
68+
},
69+
staleTime: STALE.MINUTES.FIVE,
70+
enabled: !!uri,
71+
/*
72+
* Consumers fall back to a plain like count when this query fails, so
73+
* failing fast is preferable to amplifying getLikes load with retries.
74+
*/
75+
retry: 1,
76+
})
77+
}
78+
4279
export function* findAllProfilesInQueryData(
4380
queryClient: QueryClient,
4481
did: string,
@@ -60,4 +97,18 @@ export function* findAllProfilesInQueryData(
6097
}
6198
}
6299
}
100+
const sampleQueryDatas =
101+
queryClient.getQueriesData<AppBskyFeedGetLikes.OutputSchema>({
102+
queryKey: [likedBySampleQueryKeyRoot],
103+
})
104+
for (const [_queryKey, queryData] of sampleQueryDatas) {
105+
if (!queryData?.likes) {
106+
continue
107+
}
108+
for (const like of queryData.likes) {
109+
if (like.actor.did === did) {
110+
yield like.actor
111+
}
112+
}
113+
}
63114
}

0 commit comments

Comments
 (0)