|
| 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 | +} |
0 commit comments