known likers in feeds
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: Keep pull request descriptions limited to a concise summary of the changes
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Pull Request Descriptions
|
||||
|
||||
When creating or editing a pull request description:
|
||||
|
||||
- Include only a concise summary of the changes.
|
||||
- Do not mention Linear tickets or issue identifiers.
|
||||
- Do not include validation, testing, or verification steps.
|
||||
- Do not add boilerplate sections that duplicate information available elsewhere.
|
||||
@@ -16,6 +16,7 @@ export enum Features {
|
||||
GroupChatsDisable = 'group_chats:disable',
|
||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||
PostFeedKnownLikersEnable = 'post_feed:known_likers:enable',
|
||||
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
|
||||
CustomLogoJapanEnable = 'custom_logo:japan:enable',
|
||||
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import {View} from 'react-native'
|
||||
import {AtUri} from '@atproto/syntax'
|
||||
import {moderateProfile} from '@bsky/sdk/moderation'
|
||||
import {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 {atoms as a, useTheme} from '#/alf'
|
||||
import {AvatarStack} from '#/components/AvatarStack'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {type Features, useAnalytics} from '#/analytics'
|
||||
import {type app} from '#/lexicons'
|
||||
|
||||
const AVI_SIZE = 20
|
||||
|
||||
/**
|
||||
* Social proof for a post. When the viewer follows some of the post's recent
|
||||
* likers, renders a face pile plus "Liked by A and B". Renders nothing when
|
||||
* the feature is disabled or no visible known likers are available.
|
||||
*/
|
||||
export function KnownLikers({
|
||||
post,
|
||||
feature,
|
||||
}: {
|
||||
post: app.bsky.feed.defs.PostView
|
||||
feature: Features
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const ax = useAnalytics()
|
||||
|
||||
const likeCount = post.likeCount ?? 0
|
||||
if (likeCount === 0) return null
|
||||
|
||||
const knownLikersAndModeration = moderationOpts
|
||||
? (post.viewer?.knownLikers?.actors ?? [])
|
||||
.map(actor => ({
|
||||
actor,
|
||||
moderation: moderateProfile(actor, moderationOpts),
|
||||
}))
|
||||
.filter(({moderation}) => !moderation.ui('profileList').filter)
|
||||
: []
|
||||
|
||||
if (knownLikersAndModeration.length === 0 || !ax.features.enabled(feature)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const urip = new AtUri(post.uri)
|
||||
const likesHref = makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
|
||||
const aviStackProfiles = knownLikersAndModeration
|
||||
.slice(0, 3)
|
||||
.map(({actor}) => actor)
|
||||
const names = knownLikersAndModeration
|
||||
.slice(0, 2)
|
||||
.map(({actor, moderation}) => ({
|
||||
did: actor.did,
|
||||
href: makeProfileLink(actor),
|
||||
displayName: sanitizeDisplayName(
|
||||
actor.displayName || actor.handle,
|
||||
moderation.ui('displayName'),
|
||||
),
|
||||
}))
|
||||
const rowLabel =
|
||||
names.length >= 2
|
||||
? l`Liked by ${names[0].displayName} and ${names[1].displayName}`
|
||||
: l`Liked by ${names[0].displayName}`
|
||||
const textStyle = [a.text_sm, t.atoms.text_contrast_medium]
|
||||
const nameStyle = [a.text_sm, a.font_semi_bold, t.atoms.text]
|
||||
|
||||
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 (
|
||||
<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={() => ax.metric('post:likedBy:click', {})}>
|
||||
<AvatarStack profiles={aviStackProfiles} size={AVI_SIZE} />
|
||||
<Text testID="knownLikersStat" style={[a.flex_shrink, textStyle]}>
|
||||
{names.length >= 2 ? (
|
||||
<Trans comment="Social proof below a post; the bolded names are people the viewer follows who liked the post">
|
||||
Liked by {nameLink(names[0])} and {nameLink(names[1])}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Social proof below a post; the bolded name is a person the viewer follows who liked the post">
|
||||
Liked by {nameLink(names[0])}
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Link>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,14 @@
|
||||
import {View} from 'react-native'
|
||||
import {AtUri} from '@atproto/syntax'
|
||||
import {moderateProfile} from '@bsky/sdk/moderation'
|
||||
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 {atoms as a, useTheme} from '#/alf'
|
||||
import {AvatarStack} from '#/components/AvatarStack'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import {Link} from '#/components/Link'
|
||||
import {useFormatPostStatCount} from '#/components/PostControls/util'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {type app} from '#/lexicons'
|
||||
|
||||
const AVI_SIZE = 20
|
||||
|
||||
/**
|
||||
* The plain "N likes" stat for the expanded anchor post, linking to the likes
|
||||
* list. Renders nothing when the post has no likes.
|
||||
@@ -51,115 +43,3 @@ export function LikesStat({post}: {post: app.bsky.feed.defs.PostView}) {
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 included in the post viewer state so this can render with
|
||||
* the rest of the thread, without a separate request or layout shift.
|
||||
*/
|
||||
export function KnownLikers({post}: {post: app.bsky.feed.defs.PostView}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
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')
|
||||
const onPressLikedBy = () => ax.metric('post:likedBy:click', {})
|
||||
|
||||
const knownLikersAndModeration = moderationOpts
|
||||
? (post.viewer?.knownLikers?.actors ?? [])
|
||||
.map(actor => ({
|
||||
actor,
|
||||
moderation: moderateProfile(actor, moderationOpts),
|
||||
}))
|
||||
.filter(({moderation}) => {
|
||||
const modui = moderation.ui('profileList')
|
||||
return !modui.filter
|
||||
})
|
||||
: []
|
||||
|
||||
const showKnownLikers =
|
||||
knownLikersAndModeration.length > 0 &&
|
||||
ax.features.enabled(ax.features.PostThreadKnownLikersEnable)
|
||||
|
||||
if (!showKnownLikers) return null
|
||||
|
||||
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'),
|
||||
),
|
||||
}
|
||||
})
|
||||
/*
|
||||
* The row link's a11y label mirrors the visible sentence so screen readers
|
||||
* announce the social proof.
|
||||
*/
|
||||
const rowLabel =
|
||||
names.length >= 2
|
||||
? l`Liked by ${names[0].displayName} and ${names[1].displayName}`
|
||||
: l`Liked by ${names[0].displayName}`
|
||||
|
||||
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
|
||||
* 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 forces the social proof onto its own line below
|
||||
* the count stats within the wrapping stats row.
|
||||
*/
|
||||
<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" style={[a.flex_shrink, textStyle]}>
|
||||
{names.length >= 2 ? (
|
||||
<Trans comment="Social proof below the post stats; the bolded names are people the viewer follows who liked the post">
|
||||
Liked by {nameLink(names[0])} and {nameLink(names[1])}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Social proof below the post stats; the bolded name is a person the viewer follows who liked the post">
|
||||
Liked by {nameLink(names[0])}
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Link>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,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 {KnownLikers, LikesStat} from '#/screens/PostThread/components/LikesStat'
|
||||
import {LikesStat} from '#/screens/PostThread/components/LikesStat'
|
||||
import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton'
|
||||
import {
|
||||
POST_NUMBER_INLINE_OFFSET,
|
||||
@@ -47,6 +47,7 @@ import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||
import {KnownLikers} from '#/components/Post/KnownLikers'
|
||||
import {TranslatedPost} from '#/components/Post/Translated'
|
||||
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
|
||||
import {useFormatPostStatCount} from '#/components/PostControls/util'
|
||||
@@ -57,7 +58,7 @@ import {RichText} from '#/components/RichText'
|
||||
import * as Skele from '#/components/Skeleton'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {WhoCanReply} from '#/components/WhoCanReply'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {Features, useAnalytics} from '#/analytics'
|
||||
import {useActorStatus} from '#/features/liveNow'
|
||||
import {app} from '#/lexicons'
|
||||
import * as bsky from '#/types/bsky'
|
||||
@@ -513,7 +514,10 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
</Trans>
|
||||
</Text>
|
||||
) : null}
|
||||
<KnownLikers post={post} />
|
||||
<KnownLikers
|
||||
post={post}
|
||||
feature={Features.PostThreadKnownLikersEnable}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<View
|
||||
|
||||
@@ -44,6 +44,7 @@ import * as ReportDialogMetadataContext from '#/components/moderation/ReportDial
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
import {Embed} from '#/components/Post/Embed'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {KnownLikers} from '#/components/Post/KnownLikers'
|
||||
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
|
||||
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
||||
import {TranslatedPost} from '#/components/Post/Translated'
|
||||
@@ -51,7 +52,7 @@ import {PostControls} from '#/components/PostControls'
|
||||
import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {SubtleHover} from '#/components/SubtleHover'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {Features, useAnalytics} from '#/analytics'
|
||||
import {useActorStatus} from '#/features/liveNow'
|
||||
import {app} from '#/lexicons'
|
||||
import * as bsky from '#/types/bsky'
|
||||
@@ -444,6 +445,10 @@ let FeedItemInner = ({
|
||||
additionalPostAlerts={additionalPostAlerts}
|
||||
feedDescriptor={feedDescriptor}
|
||||
/>
|
||||
<KnownLikers
|
||||
post={rootPost}
|
||||
feature={Features.PostFeedKnownLikersEnable}
|
||||
/>
|
||||
<PostControls
|
||||
post={post}
|
||||
record={record}
|
||||
|
||||
Reference in New Issue
Block a user