diff --git a/src/analytics/features/index.ts b/src/analytics/features/index.ts index 6cbafeaa22..4f50415323 100644 --- a/src/analytics/features/index.ts +++ b/src/analytics/features/index.ts @@ -77,6 +77,22 @@ export function getFeatures() { export function getFeatureDescription(feature: Features, i18n: I18n) { switch (feature) { + case Features.PostFeedKnownLikersEnable: + return { + key: feature, + name: i18n._( + msg({ + message: 'Social proofing on posts', + comment: 'Name for a feature flag', + }), + ), + description: i18n._( + msg({ + message: 'Spot posts your friends and follows have liked.', + comment: 'Description of a feature flag (Social proofing on posts)', + }), + ), + } case Features.CanonicalPostNumberingEnable: return { key: feature, 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/AvatarStack.tsx b/src/components/AvatarStack.tsx index 8c5893ed76..486a025920 100644 --- a/src/components/AvatarStack.tsx +++ b/src/components/AvatarStack.tsx @@ -13,13 +13,17 @@ export function AvatarStack({ size = 26, numPending, backgroundColor, + borderWidth = 1, + overlap, }: { profiles: bsky.profile.AnyProfileView[] size?: number numPending?: number backgroundColor?: string + borderWidth?: number + overlap?: number }) { - const translation = size / 3 // overlap by 1/3 + const translation = overlap ?? size / 3 const t = useTheme() const moderationOpts = useModerationOpts() @@ -55,7 +59,7 @@ export function AvatarStack({ width: size, height: size, left: i * -translation, - borderWidth: 1, + borderWidth, borderColor: backgroundColor ?? t.atoms.bg.backgroundColor, borderRadius: 999, zIndex: 3 - i, @@ -63,7 +67,7 @@ export function AvatarStack({ ]}> {item.profile && ( ({ + 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 isFeed = variant === 'feed' + 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_medium, t.atoms.text_contrast_medium] + + 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/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx index 280cbd0cbb..5f5480d9fc 100644 --- a/src/components/WhoCanReply.tsx +++ b/src/components/WhoCanReply.tsx @@ -114,7 +114,7 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { style={[ a.flex_row, a.align_center, - a.gap_xs, + {gap: 3}, (hovered || focused || pressed) && native({opacity: 0.5}), style, ]}> @@ -122,16 +122,16 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { color={ isThreadAuthor ? t.palette.primary_500 : t.palette.contrast_400 } - width={16} + width={12} settings={settings} /> {description} diff --git a/src/screens/PostThread/components/LikesStat.tsx b/src/screens/PostThread/components/LikesStat.tsx index 15cbeba3ac..28d1f95735 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. @@ -40,9 +32,9 @@ export function LikesStat({post}: {post: app.bsky.feed.defs.PostView}) { onPress={() => ax.metric('post:likedBy:click', {})}> + style={[a.text_sm, t.atoms.text_contrast_high]}> - + {formatPostStatCount(likeCount)} {' '} @@ -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..350e71d210 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' @@ -451,7 +452,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ columnGap: a.gap_lg.gap, }, a.border_t, - a.border_b, a.mt_md, a.py_md, t.atoms.border_contrast_low, @@ -460,10 +460,10 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ + style={[a.text_sm, t.atoms.text_contrast_high]}> + style={[a.text_sm, a.font_semi_bold, t.atoms.text]}> {formatPostStatCount(post.repostCount)} {' '} + style={[a.text_sm, t.atoms.text_contrast_high]}> + style={[a.text_sm, a.font_semi_bold, t.atoms.text]}> {formatPostStatCount(post.quoteCount)} {' '} + style={[a.text_sm, t.atoms.text_contrast_high]}> - + {formatPostStatCount(post.bookmarkCount)} {' '} ) : null} - ) : null} + - + {niceDate(i18n, post.indexedAt, 'dot separated')} {isRootPost && ( diff --git a/src/state/queries/usePostThread/index.ts b/src/state/queries/usePostThread/index.ts index b4e217f4bd..cc42464a06 100644 --- a/src/state/queries/usePostThread/index.ts +++ b/src/state/queries/usePostThread/index.ts @@ -33,6 +33,7 @@ import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies import {useBreakpoints} from '#/alf' import {IS_WEB} from '#/env' import {app} from '#/lexicons' +import * as bsky from '#/types/bsky' export * from '#/state/queries/usePostThread/context' export {useUpdatePostThreadThreadgateQueryCache} from '#/state/queries/usePostThread/queryCache' @@ -73,6 +74,7 @@ export function usePostThread({anchor}: {anchor?: string}) { enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts, queryKey: postThreadQueryKey, async queryFn(ctx) { + const placeholder = getThreadPlaceholder(qc, anchor!) const data = await client.call(app.bsky.unspecced.getPostThreadV2, { anchor: anchor! as AtUriString, branchingFactor: view === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF, @@ -80,6 +82,28 @@ export function usePostThread({anchor}: {anchor?: string}) { sort: sort, }) + const cachedKnownLikers = + placeholder && + bsky.isType(app.bsky.unspecced.defs.threadItemPost, placeholder.value) + ? placeholder.value.post.viewer?.knownLikers + : undefined + if (cachedKnownLikers?.actors.length) { + const anchorItem = data.thread?.find(item => item.uri === anchor) + if ( + anchorItem && + bsky.isType( + app.bsky.unspecced.defs.threadItemPost, + anchorItem.value, + ) && + !anchorItem.value.post.viewer?.knownLikers?.actors.length + ) { + anchorItem.value.post.viewer = { + ...anchorItem.value.post.viewer, + knownLikers: cachedKnownLikers, + } + } + } + /* * Initialize `ctx.meta` to track if we know we have additional replies * we could fetch once we hit the end. diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 3a66564ed9..a5c16d5a94 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -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' @@ -456,6 +457,11 @@ let FeedItemInner = ({ onShowLess={onShowLess} viaRepost={viaRepost} /> +