diff --git a/.cursor/rules/pr-descriptions.mdc b/.cursor/rules/pr-descriptions.mdc new file mode 100644 index 0000000000..c1199b2a13 --- /dev/null +++ b/.cursor/rules/pr-descriptions.mdc @@ -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. diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 4d3cf5e197..c059eca8b6 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -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', diff --git a/src/components/Post/KnownLikers.tsx b/src/components/Post/KnownLikers.tsx new file mode 100644 index 0000000000..7a10e91d19 --- /dev/null +++ b/src/components/Post/KnownLikers.tsx @@ -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]) => ( + + + {name.displayName} + + + ) + + return ( + + ax.metric('post:likedBy:click', {})}> + + + {names.length >= 2 ? ( + + Liked by {nameLink(names[0])} and {nameLink(names[1])} + + ) : ( + + Liked by {nameLink(names[0])} + + )} + + + + ) +} diff --git a/src/screens/PostThread/components/LikesStat.tsx b/src/screens/PostThread/components/LikesStat.tsx index 15cbeba3ac..0b2fa30b53 100644 --- a/src/screens/PostThread/components/LikesStat.tsx +++ b/src/screens/PostThread/components/LikesStat.tsx @@ -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}) { ) } - -/** - * 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]) => ( - - - {name.displayName} - - - ) - - return ( - /* - * The full-width wrapper forces the social proof onto its own line below - * the count stats within the wrapping stats row. - */ - - - - - {names.length >= 2 ? ( - - Liked by {nameLink(names[0])} and {nameLink(names[1])} - - ) : ( - - Liked by {nameLink(names[0])} - - )} - - - - ) -} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 6a1ee4bb55..8a4f714157 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -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({ ) : null} - + ) : null} +