From 58dbbc173824521b19a164450582c78dd4945af5 Mon Sep 17 00:00:00 2001 From: "xan.lol" Date: Tue, 2 Jun 2026 22:36:01 -0700 Subject: [PATCH] feat: granular impressions visibility options also fix for starter pack cards avatar background shapes not matching the avatars --- src/components/KnownFollowers.tsx | 214 +++++--- .../PostControls/MetricCountLabel.tsx | 41 ++ src/components/PostControls/RepostButton.tsx | 28 +- .../PostControls/RepostButton.web.tsx | 46 +- src/components/PostControls/index.tsx | 120 +++-- src/components/PostControls/util.ts | 25 +- src/components/ProfileHoverCard/index.web.tsx | 112 ++-- src/lib/metrics-display.ts | 156 ++++++ .../components/ThreadItemAnchor.tsx | 192 ++++--- src/screens/Profile/Header/Metrics.tsx | 202 ++++--- .../Profile/Header/ProfileHeaderStandard.tsx | 8 +- .../Search/components/StarterPackCard.tsx | 13 +- .../RunesSettings/ImpressionsSettings.tsx | 502 ++++++++++++------ src/state/persisted/__tests__/schema.test.ts | 29 + src/state/persisted/schema.ts | 40 +- src/state/persisted/util.ts | 48 +- .../disable-followed-by-metrics.tsx | 57 -- .../preferences/disable-followers-metrics.tsx | 57 -- .../preferences/disable-following-metrics.tsx | 57 -- .../preferences/disable-likes-metrics.tsx | 57 -- .../preferences/disable-posts-metrics.tsx | 57 -- .../preferences/disable-quotes-metrics.tsx | 57 -- .../preferences/disable-reply-metrics.tsx | 57 -- .../preferences/disable-reposts-metrics.tsx | 57 -- .../preferences/disable-saves-metrics.tsx | 57 -- src/state/preferences/index.tsx | 120 ++--- .../metrics-display-preference.tsx | 225 ++++++++ src/state/preferences/settings-sync.tsx | 18 +- .../preferences/show-follows-you-badge.tsx | 25 +- src/view/shell/Drawer.tsx | 73 ++- 30 files changed, 1580 insertions(+), 1170 deletions(-) create mode 100644 src/components/PostControls/MetricCountLabel.tsx create mode 100644 src/lib/metrics-display.ts delete mode 100644 src/state/preferences/disable-followed-by-metrics.tsx delete mode 100644 src/state/preferences/disable-followers-metrics.tsx delete mode 100644 src/state/preferences/disable-following-metrics.tsx delete mode 100644 src/state/preferences/disable-likes-metrics.tsx delete mode 100644 src/state/preferences/disable-posts-metrics.tsx delete mode 100644 src/state/preferences/disable-quotes-metrics.tsx delete mode 100644 src/state/preferences/disable-reply-metrics.tsx delete mode 100644 src/state/preferences/disable-reposts-metrics.tsx delete mode 100644 src/state/preferences/disable-saves-metrics.tsx create mode 100644 src/state/preferences/metrics-display-preference.tsx diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx index 502b08252..63eedabb9 100644 --- a/src/components/KnownFollowers.tsx +++ b/src/components/KnownFollowers.tsx @@ -1,4 +1,4 @@ -import {useRef} from 'react' +import {useLayoutEffect, useRef, useState} from 'react' import {View} from 'react-native' import { type AppBskyActorDefs, @@ -7,10 +7,18 @@ import { } from '@atproto/api' import {Plural, Trans, useLingui} from '@lingui/react/macro' +import { + type FollowedByMetricsDisplay, + shouldShowFollowedByOverflowCount, + shouldShowFollowedByOverflowPlus, + shouldShowFollowedByText, +} from '#/lib/metrics-display' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' +import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {Link, type LinkProps} from '#/components/Link' import {Text} from '#/components/Typography' import type * as bsky from '#/types/bsky' @@ -19,6 +27,13 @@ const AVI_SIZE = 30 const AVI_SIZE_SMALL = 20 const AVI_BORDER = 1 +function avatarBorderRadius(size: number, square: boolean) { + if (square) { + return size > 32 ? 8 : 3 + } + return (size + AVI_BORDER * 2) / 2 +} + /** * Shared logic to determine if `KnownFollowers` should be shown. * @@ -37,14 +52,22 @@ export function KnownFollowers({ onLinkPress, minimal, showIfEmpty, + followedByDisplay = 'names', }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts onLinkPress?: LinkProps['onPress'] minimal?: boolean showIfEmpty?: boolean + followedByDisplay?: FollowedByMetricsDisplay }) { - const cache = useRef>(new Map()) + const cacheRef = useRef>( + new Map(), + ) + const [cachedKnownFollowers, setCachedKnownFollowers] = useState< + AppBskyActorDefs.KnownFollowers | undefined + >() + const knownFollowers = profile.viewer?.knownFollowers /* * Results for `knownFollowers` are not sorted consistently, so when @@ -53,11 +76,12 @@ export function KnownFollowers({ * screen, or once this one is popped, this cache is empty, so new data is * displayed. */ - if (profile.viewer?.knownFollowers && !cache.current.has(profile.did)) { - cache.current.set(profile.did, profile.viewer.knownFollowers) - } - - const cachedKnownFollowers = cache.current.get(profile.did) + useLayoutEffect(() => { + if (knownFollowers && !cacheRef.current.has(profile.did)) { + cacheRef.current.set(profile.did, knownFollowers) + } + setCachedKnownFollowers(cacheRef.current.get(profile.did)) + }, [profile.did, knownFollowers]) if (cachedKnownFollowers && shouldShowKnownFollowers(cachedKnownFollowers)) { return ( @@ -68,6 +92,7 @@ export function KnownFollowers({ onLinkPress={onLinkPress} minimal={minimal} showIfEmpty={showIfEmpty} + followedByDisplay={followedByDisplay} /> ) } @@ -82,6 +107,7 @@ function KnownFollowersInner({ onLinkPress, minimal, showIfEmpty, + followedByDisplay, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts @@ -89,11 +115,14 @@ function KnownFollowersInner({ onLinkPress?: LinkProps['onPress'] minimal?: boolean showIfEmpty?: boolean + followedByDisplay: FollowedByMetricsDisplay }) { const t = useTheme() const {t: l} = useLingui() + const enableSquareAvatars = useEnableSquareAvatars() const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium] + const showText = shouldShowFollowedByText(followedByDisplay) const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => { const moderation = moderateProfile(f, moderationOpts) @@ -119,6 +148,20 @@ function KnownFollowersInner({ if (slice.length === 0) return const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE + const dim = SIZE + AVI_BORDER * 2 + const radius = avatarBorderRadius(SIZE, enableSquareAvatars) + const overflowCount = serverCount - slice.length + const showOverflowCount = shouldShowFollowedByOverflowCount( + followedByDisplay, + serverCount, + slice.length, + ) + const showOverflowPlus = shouldShowFollowedByOverflowPlus( + followedByDisplay, + serverCount, + slice.length, + ) + const showEndCap = showOverflowCount || showOverflowPlus return ( 0 ? -8 : 0, + overflow: 'hidden', }, ]}> ))} + {showEndCap ? ( + + {showOverflowCount && overflowCount > 0 ? ( + + + +{overflowCount} + + + ) : ( + + )} + + ) : null} - - {slice.length >= 2 ? ( - // 2-n followers, including blocks - // only 2 - serverCount > 2 ? ( + {showText ? ( + + {slice.length >= 2 ? ( + // 2-n followers, including blocks + // only 2 + serverCount > 2 ? ( + + Followed by{' '} + + {slice[0].profile.displayName} + + ,{' '} + + {slice[1].profile.displayName} + + , and{' '} + + + ) : ( + + Followed by{' '} + + {slice[0].profile.displayName} + {' '} + and{' '} + + {slice[1].profile.displayName} + + + ) + ) : serverCount > 1 ? ( + // 1-n followers, including blocks Followed by{' '} {slice[0].profile.displayName} - - ,{' '} - - {slice[1].profile.displayName} - - , and{' '} + {' '} + and{' '} ) : ( + // only 1 Followed by{' '} {slice[0].profile.displayName} - {' '} - and{' '} - - {slice[1].profile.displayName} - ) - ) : serverCount > 1 ? ( - // 1-n followers, including blocks - - Followed by{' '} - - {slice[0].profile.displayName} - {' '} - and{' '} - - - ) : ( - // only 1 - - Followed by{' '} - - {slice[0].profile.displayName} - - - )} - + )} + + ) : null} )} diff --git a/src/components/PostControls/MetricCountLabel.tsx b/src/components/PostControls/MetricCountLabel.tsx new file mode 100644 index 000000000..1cf3a6ee1 --- /dev/null +++ b/src/components/PostControls/MetricCountLabel.tsx @@ -0,0 +1,41 @@ +import {type ReactNode} from 'react' + +import { + type CountsMetricsDisplay, + shouldShowCountsMetricLabelOnly, + shouldShowCountsMetricNumber, + shouldShowCountsMetricRow, +} from '#/lib/metrics-display' +import {PostControlButtonText} from '#/components/PostControls/PostControlButton' +import {useFormatPostStatCount} from '#/components/PostControls/util' + +export function MetricCountLabel({ + display, + count, + testID, + labelOnly, +}: { + display: CountsMetricsDisplay + count: number + testID?: string + labelOnly: ReactNode +}) { + const formatPostStatCount = useFormatPostStatCount(display) + + if (!shouldShowCountsMetricRow(display)) { + return null + } + if (shouldShowCountsMetricLabelOnly(display, count)) { + return ( + {labelOnly} + ) + } + if (shouldShowCountsMetricNumber(display, count) && count > 0) { + return ( + + {formatPostStatCount(count)} + + ) + } + return null +} diff --git a/src/components/PostControls/RepostButton.tsx b/src/components/PostControls/RepostButton.tsx index abfba9d1f..e7443aae3 100644 --- a/src/components/PostControls/RepostButton.tsx +++ b/src/components/PostControls/RepostButton.tsx @@ -5,23 +5,21 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useHaptics} from '#/lib/haptics' +import {type CountsMetricsDisplay} from '#/lib/metrics-display' import {useRequireAuth} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote' import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost' -import {useFormatPostStatCount} from '#/components/PostControls/util' +import {MetricCountLabel} from '#/components/PostControls/MetricCountLabel' import {Text} from '#/components/Typography' -import { - PostControlButton, - PostControlButtonIcon, - PostControlButtonText, -} from './PostControlButton' +import {PostControlButton, PostControlButtonIcon} from './PostControlButton' interface Props { isReposted: boolean repostCount?: number + metricsDisplay?: CountsMetricsDisplay onRepost: () => void onQuote: () => void onLongPress?: () => void @@ -32,6 +30,7 @@ interface Props { let RepostButton = ({ isReposted, repostCount, + metricsDisplay = 'visible', onRepost, onQuote, onLongPress, @@ -42,7 +41,6 @@ let RepostButton = ({ const {_} = useLingui() const requireAuth = useRequireAuth() const dialogControl = Dialog.useDialogControl() - const formatPostStatCount = useFormatPostStatCount() const onPress = () => requireAuth(() => dialogControl.open()) @@ -88,11 +86,17 @@ let RepostButton = ({ ) }> - {typeof repostCount !== 'undefined' && repostCount > 0 && ( - - {formatPostStatCount(repostCount)} - - )} + {typeof repostCount !== 'undefined' ? ( + + ) : null} void onQuote: () => void onLongPress?: () => void @@ -27,6 +25,7 @@ interface Props { export const RepostButton = ({ isReposted, repostCount, + metricsDisplay = 'visible', onRepost, onQuote, onLongPress, @@ -37,7 +36,6 @@ export const RepostButton = ({ const {_} = useLingui() const {hasSession} = useSession() const requireAuth = useRequireAuth() - const formatPostStatCount = useFormatPostStatCount() return hasSession ? ( @@ -54,11 +52,17 @@ export const RepostButton = ({ onLongPress={onLongPress} {...props}> - {typeof repostCount !== 'undefined' && repostCount > 0 && ( - - {formatPostStatCount(repostCount)} - - )} + {typeof repostCount !== 'undefined' ? ( + + ) : null} ) }} @@ -106,11 +110,17 @@ export const RepostButton = ({ label={_(msg`Repost or quote post`)} big={big}> - {typeof repostCount !== 'undefined' && repostCount > 0 && ( - - {formatPostStatCount(repostCount)} - - )} + {typeof repostCount !== 'undefined' ? ( + + ) : null} ) } diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx index c16e3906d..82b5f098a 100644 --- a/src/components/PostControls/index.tsx +++ b/src/components/PostControls/index.tsx @@ -12,12 +12,18 @@ import {useLingui} from '@lingui/react/macro' import {CountWheel} from '#/lib/custom-animations/CountWheel' import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import { + shouldShowCountsMetricLabelOnly, + shouldShowCountsMetricRow, +} from '#/lib/metrics-display' import {type Shadow} from '#/state/cache/types' import {useFeedFeedbackContext} from '#/state/feed-feedback' -import {useDisableLikesMetrics} from '#/state/preferences/disable-likes-metrics' -import {useDisableQuotesMetrics} from '#/state/preferences/disable-quotes-metrics' -import {useDisableReplyMetrics} from '#/state/preferences/disable-reply-metrics' -import {useDisableRepostsMetrics} from '#/state/preferences/disable-reposts-metrics' +import { + useLikesMetricsDisplay, + useQuotesMetricsDisplay, + useReplyMetricsDisplay, + useRepostsMetricsDisplay, +} from '#/state/preferences/metrics-display-preference' import { useGetPost, usePostLikeMutationQueue, @@ -39,6 +45,7 @@ import {useAnalytics} from '#/analytics' import {useAutoLikeOnRepost} from '../../state/preferences/auto-like-on-repost.tsx' import {useRunWithEphemeralAgent} from '../hooks/useRunWithEphemeralAgent' import {BookmarkButton} from './BookmarkButton' +import {MetricCountLabel} from './MetricCountLabel' import { PostControlButton, PostControlButtonIcon, @@ -111,16 +118,14 @@ let PostControls = ({ ) const replyDisabled = post.viewer?.replyDisabled const {gtPhone} = useBreakpoints() - const formatPostStatCount = useFormatPostStatCount() + const likesMetricsDisplay = useLikesMetricsDisplay() + const repostsMetricsDisplay = useRepostsMetricsDisplay() + const replyMetricsDisplay = useReplyMetricsDisplay() + const quotesMetricsDisplay = useQuotesMetricsDisplay() + const formatPostStatCount = useFormatPostStatCount(likesMetricsDisplay) const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false) - // disable metrics - const disableLikesMetrics = useDisableLikesMetrics() - const disableRepostsMetrics = useDisableRepostsMetrics() - const disableReplyMetrics = useDisableReplyMetrics() - const disableQuotesMetrics = useDisableQuotesMetrics() - const autoLikeOnRepost = useAutoLikeOnRepost() const shouldAutoLikeOnRepost = async () => { @@ -399,17 +404,32 @@ let PostControls = ({ big={big} hasBeenToggled={hasLikeIconBeenToggled} /> - {!disableLikesMetrics ? ( - ( - - {formatPostStatCount(count)} - - )} - /> + {shouldShowCountsMetricRow(likesMetricsDisplay) ? ( + shouldShowCountsMetricLabelOnly( + likesMetricsDisplay, + post.likeCount ?? 0, + ) ? ( + + ) : ( + ( + + {formatPostStatCount(count)} + + )} + /> + ) ) : null} ) @@ -466,13 +486,14 @@ let PostControls = ({ })} big={big}> - {typeof post.replyCount !== 'undefined' && - post.replyCount > 0 && - !disableReplyMetrics && ( - - {formatPostStatCount(post.replyCount)} - - )} + )} /> @@ -503,13 +524,14 @@ let PostControls = ({ })} big={big}> - {typeof post.replyCount !== 'undefined' && - post.replyCount > 0 && - !disableReplyMetrics && ( - - {formatPostStatCount(post.replyCount)} - - )} + )} @@ -526,8 +548,17 @@ let PostControls = ({ 0 + ? repostsMetricsDisplay + : quotesMetricsDisplay } onRepost={() => void onRepost()} onQuote={onQuote} @@ -541,8 +572,17 @@ let PostControls = ({ 0 + ? repostsMetricsDisplay + : quotesMetricsDisplay } onRepost={() => void onRepost()} onQuote={onQuote} diff --git a/src/components/PostControls/util.ts b/src/components/PostControls/util.ts index 6dc395882..b781e64ed 100644 --- a/src/components/PostControls/util.ts +++ b/src/components/PostControls/util.ts @@ -1,23 +1,22 @@ import {useCallback} from 'react' import {useLingui} from '@lingui/react' +import { + type CountsMetricsDisplay, + formatCountsMetricNumber, +} from '#/lib/metrics-display' + /** - * This matches `formatCount` from `view/com/util/numeric/format.ts`, but has - * additional truncation logic for large numbers. `roundingMode` should always - * match the original impl, regardless of if we add more formatting here. + * Formats post stat counts using the user's impressions display preference. */ -export function useFormatPostStatCount() { +export function useFormatPostStatCount( + display: CountsMetricsDisplay = 'visible', +) { const {i18n} = useLingui() return useCallback( - (postStatCount: number) => { - const isOver10k = postStatCount >= 10_000 - return i18n.number(postStatCount, { - notation: 'compact', - maximumFractionDigits: isOver10k ? 0 : 1, - roundingMode: 'trunc', - }) - }, - [i18n], + (postStatCount: number) => + formatCountsMetricNumber(i18n, display, postStatCount), + [display, i18n], ) } diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index b64c24b1d..e49e7da17 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -1,4 +1,12 @@ -import {memo, useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react' +import { + memo, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react' import {View} from 'react-native' import { type AppBskyActorDefs, @@ -10,6 +18,10 @@ import {msg, plural} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' +import { + isFollowedByMetricHidden, + shouldShowProfileCountsMetric, +} from '#/lib/metrics-display' import {getModerationCauseKey} from '#/lib/moderation' import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' @@ -17,16 +29,18 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useConfirmFollowUnfollow} from '#/state/preferences/confirm-follow-unfollow' -import {useDisableFollowedByMetrics} from '#/state/preferences/disable-followed-by-metrics' -import {useDisableFollowersMetrics} from '#/state/preferences/disable-followers-metrics' -import {useDisableFollowingMetrics} from '#/state/preferences/disable-following-metrics' import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' +import { + useFollowedByMetricsDisplay, + useFollowersMetricsDisplay, + useFollowingMetricsDisplay, +} from '#/state/preferences/metrics-display-preference' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' -import {formatCount} from '#/view/com/util/numeric/format' import {UserAvatar} from '#/view/com/util/UserAvatar' import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle' +import {ProfileCountLink} from '#/screens/Profile/Header/Metrics' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import { @@ -44,7 +58,7 @@ import { KnownFollowers, shouldShowKnownFollowers, } from '#/components/KnownFollowers' -import {InlineLinkText, Link} from '#/components/Link' +import {Link} from '#/components/Link' import {Loader} from '#/components/Loader' import * as Pills from '#/components/Pills' import {Portal} from '#/components/Portal' @@ -292,7 +306,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) { const prefetchProfileQuery = usePrefetchProfileQuery() const prefetchedProfile = useRef(false) - /* eslint-disable react-hooks/preserve-manual-memoization -- restored stable handlers */ + const prefetchIfNeeded = useCallback(async () => { if (!prefetchedProfile.current) { prefetchedProfile.current = true @@ -329,8 +343,6 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) { dispatch('pressed') }, [dispatch]) - /* eslint-enable react-hooks/preserve-manual-memoization */ - const isVisible = currentState.stage === 'showing' || currentState.stage === 'might-hide' || @@ -413,7 +425,6 @@ let Card = ({ const status = useActorStatus(data) - /* eslint-disable react-hooks/preserve-manual-memoization -- restored stable handler */ const onPressOpenProfile = useCallback(() => { if (!status.isActive || !data) return hide() @@ -422,8 +433,6 @@ let Card = ({ }) }, [hide, navigation, status, data]) - /* eslint-enable react-hooks/preserve-manual-memoization */ - return ( void }) { - const t = useTheme() - const {_, i18n} = useLingui() + const {_} = useLingui() const {currentAccount} = useSession() const moderation = useMemo( () => moderateProfile(profile, moderationOpts), @@ -504,8 +512,11 @@ function Inner({ profile.viewer?.blocking || profile.viewer?.blockedBy || profile.viewer?.blockingByList - const following = formatCount(i18n, profile.followsCount || 0) - const followers = formatCount(i18n, profile.followersCount || 0) + const followersMetricsDisplay = useFollowersMetricsDisplay() + const followingMetricsDisplay = useFollowingMetricsDisplay() + const followedByMetricsDisplay = useFollowedByMetricsDisplay() + const followersCount = profile.followersCount || 0 + const followingCount = profile.followsCount || 0 const pluralizedFollowers = plural(profile.followersCount || 0, { one: 'follower', other: 'followers', @@ -526,11 +537,6 @@ function Inner({ const enableSquareButtons = useEnableSquareButtons() - // disable metrics - const disableFollowersMetrics = useDisableFollowersMetrics() - const disableFollowingMetrics = useDisableFollowingMetrics() - const disableFollowedByMetrics = useDisableFollowedByMetrics() - const handleFollow = useCallback(() => { if (confirmFollowUnfollow && onRequestFollowConfirmation) { onRequestFollowConfirmation({ @@ -617,7 +623,9 @@ function Inner({ : _(msg`Follow`) } style={enableSquareButtons ? [a.rounded_sm] : [a.rounded_full]} - onPress={profileShadow.viewer?.following ? handleUnfollow : handleFollow}> + onPress={ + profileShadow.viewer?.following ? handleUnfollow : handleFollow + }> - {disableFollowersMetrics && disableFollowingMetrics ? null : ( + {!shouldShowProfileCountsMetric( + followersMetricsDisplay, + followersCount, + ) && + !shouldShowProfileCountsMetric( + followingMetricsDisplay, + followingCount, + ) ? null : ( - {!disableFollowersMetrics ? ( - - - {followers}{' '} - - - {pluralizedFollowers} - - + label={`${followersCount} ${pluralizedFollowers}`} + display={followersMetricsDisplay} + count={followersCount} + labelText={pluralizedFollowers} + onPress={hide} + /> ) : null} - {!disableFollowingMetrics ? ( - - - {following}{' '} - - - {pluralizedFollowings} - - + label={_(msg`${followingCount} following`)} + display={followingMetricsDisplay} + count={followingCount} + labelText={pluralizedFollowings} + onPress={hide} + /> ) : null} )} @@ -738,13 +753,14 @@ function Inner({ ) : undefined} {!isMe && - !disableFollowedByMetrics && + !isFollowedByMetricHidden(followedByMetricsDisplay) && shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( )} diff --git a/src/lib/metrics-display.ts b/src/lib/metrics-display.ts new file mode 100644 index 000000000..ca9789c20 --- /dev/null +++ b/src/lib/metrics-display.ts @@ -0,0 +1,156 @@ +import {type I18n} from '@lingui/core' +import {z} from 'zod' + +const countsMetricsDisplayValues = [ + 'hidden', + 'lite', + 'visible', + 'exact', +] as const + +export const countsMetricsDisplaySchema = z.preprocess( + val => (val === 'accessible' ? 'lite' : val), + z.enum(countsMetricsDisplayValues), +) +export type CountsMetricsDisplay = z.infer + +const followedByMetricsDisplayValues = [ + 'hidden', + 'lite', + 'visible', + 'names', +] as const + +export const followedByMetricsDisplaySchema = z.preprocess( + val => (val === 'accessible' ? 'lite' : val), + z.enum(followedByMetricsDisplayValues), +) +export type FollowedByMetricsDisplay = z.infer< + typeof followedByMetricsDisplaySchema +> + +export function migrateCountsMetricsDisplay( + display: CountsMetricsDisplay | undefined, + legacyDisabled: boolean | undefined, +): CountsMetricsDisplay { + if (display !== undefined) { + return display + } + if (legacyDisabled === true) { + return 'hidden' + } + if (legacyDisabled === false) { + return 'visible' + } + return 'visible' +} + +export function migrateFollowedByMetricsDisplay( + display: FollowedByMetricsDisplay | undefined, + legacyDisabled: boolean | undefined, +): FollowedByMetricsDisplay { + if (display !== undefined) { + return display + } + if (legacyDisabled === true) { + return 'hidden' + } + return 'names' +} + +export function isCountsMetricHidden(mode: CountsMetricsDisplay): boolean { + return mode === 'hidden' +} + +/** Whether a metric row or control may appear at all. */ +export function shouldShowCountsMetricRow(mode: CountsMetricsDisplay): boolean { + return mode !== 'hidden' +} + +/** Whether to show numeric count text (visible = compact, exact = full number). */ +export function shouldShowCountsMetricNumber( + mode: CountsMetricsDisplay, + _count: number, +): boolean { + if (mode === 'hidden' || mode === 'lite') { + return false + } + return mode === 'visible' || mode === 'exact' +} + +/** Show label text without the numeric count (lite mode). */ +export function shouldShowCountsMetricLabelOnly( + mode: CountsMetricsDisplay, + count: number, +): boolean { + return mode === 'lite' && count > 0 +} + +/** Profile header metrics: hide lite rows when count is zero. */ +export function shouldShowProfileCountsMetric( + mode: CountsMetricsDisplay, + count: number, +): boolean { + if (mode === 'hidden') { + return false + } + if (mode === 'lite') { + return count > 0 + } + return true +} + +/** Expanded thread engagement row when count is non-zero. */ +export function shouldShowThreadExpandedMetric( + mode: CountsMetricsDisplay, + count: number | null | undefined, +): boolean { + if (count == null || count === 0) { + return false + } + return shouldShowProfileCountsMetric(mode, count) +} + +export function formatCountsMetricNumber( + i18n: I18n, + mode: CountsMetricsDisplay, + count: number, +): string { + if (mode === 'exact') { + return i18n.number(count) + } + const isOver10k = count >= 10_000 + return i18n.number(count, { + notation: 'compact', + maximumFractionDigits: isOver10k ? 0 : 1, + roundingMode: 'trunc', + }) +} + +export function isFollowedByMetricHidden( + mode: FollowedByMetricsDisplay, +): boolean { + return mode === 'hidden' +} + +export function shouldShowFollowedByText( + mode: FollowedByMetricsDisplay, +): boolean { + return mode === 'names' +} + +export function shouldShowFollowedByOverflowCount( + mode: FollowedByMetricsDisplay, + serverCount: number, + shownCount: number, +): boolean { + return mode === 'visible' && serverCount > shownCount +} + +export function shouldShowFollowedByOverflowPlus( + mode: FollowedByMetricsDisplay, + serverCount: number, + shownCount: number, +): boolean { + return mode === 'lite' && serverCount > shownCount +} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index c66cf8126..aed3d0373 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -11,6 +11,12 @@ import {Plural, Trans, useLingui} from '@lingui/react/macro' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import { + type CountsMetricsDisplay, + formatCountsMetricNumber, + shouldShowCountsMetricLabelOnly, + shouldShowThreadExpandedMetric, +} from '#/lib/metrics-display' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' @@ -22,14 +28,16 @@ import { } from '#/state/cache/post-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow' import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' -import {useDisableLikesMetrics} from '#/state/preferences/disable-likes-metrics' -import {useDisableQuotesMetrics} from '#/state/preferences/disable-quotes-metrics' -import {useDisableRepostsMetrics} from '#/state/preferences/disable-reposts-metrics' -import {useDisableSavesMetrics} from '#/state/preferences/disable-saves-metrics' import {useCompactPosts} from '#/state/preferences/compact-posts' import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' import {useHideScaryFollowButtons} from '#/state/preferences/hide-scary-follow-buttons' +import { + useLikesMetricsDisplay, + useQuotesMetricsDisplay, + useRepostsMetricsDisplay, + useSavesMetricsDisplay, +} from '#/state/preferences/metrics-display-preference' import {useShowViaClient} from '#/state/preferences/show-via-client' import {type ThreadItem} from '#/state/queries/usePostThread/types' import {useSession} from '#/state/session' @@ -57,7 +65,6 @@ import {type AppModerationCause} from '#/components/Pills' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {TranslatedPost} from '#/components/Post/Translated' import {PostControls, PostControlsSkeleton} from '#/components/PostControls' -import {useFormatPostStatCount} from '#/components/PostControls/util' import {ProfileBadges} from '#/components/ProfileBadges' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import * as Prompt from '#/components/Prompt' @@ -205,7 +212,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ const {openComposer} = useOpenComposer() const {currentAccount, hasSession} = useSession() const feedFeedback = useFeedFeedback(postSource?.feedSourceInfo, hasSession) - const formatPostStatCount = useFormatPostStatCount() const compactPosts = useCompactPosts() const isCompactPosts = !!compactPosts const avatarSize = isCompactPosts ? 34 : 42 @@ -235,11 +241,10 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ displayName === displayName.toLowerCase() && /[a-z]/.test(displayName) const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did - // disable metrics - const disableLikesMetrics = useDisableLikesMetrics() - const disableRepostsMetrics = useDisableRepostsMetrics() - const disableQuotesMetrics = useDisableQuotesMetrics() - const disableSavesMetrics = useDisableSavesMetrics() + const likesMetricsDisplay = useLikesMetricsDisplay() + const repostsMetricsDisplay = useRepostsMetricsDisplay() + const quotesMetricsDisplay = useQuotesMetricsDisplay() + const savesMetricsDisplay = useSavesMetricsDisplay() const likesHref = useMemo(() => { const urip = new AtUri(post.uri) @@ -490,10 +495,22 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ isThreadAuthor={isThreadAuthor} compactPosts={isCompactPosts} /> - {(post.repostCount !== 0 && !disableRepostsMetrics) || - (post.likeCount !== 0 && !disableLikesMetrics) || - (post.quoteCount !== 0 && !disableQuotesMetrics) || - (post.bookmarkCount !== 0 && !disableSavesMetrics) ? ( + {shouldShowThreadExpandedMetric( + repostsMetricsDisplay, + post.repostCount, + ) || + shouldShowThreadExpandedMetric( + likesMetricsDisplay, + post.likeCount, + ) || + shouldShowThreadExpandedMetric( + quotesMetricsDisplay, + post.quoteCount, + ) || + shouldShowThreadExpandedMetric( + savesMetricsDisplay, + post.bookmarkCount, + ) ? ( // Show this section unless we're *sure* it has no engagement. - {post.repostCount != null && - post.repostCount !== 0 && - !disableRepostsMetrics ? ( + {shouldShowThreadExpandedMetric( + repostsMetricsDisplay, + post.repostCount, + ) ? ( - - - - {formatPostStatCount(post.repostCount)} - {' '} - - - + display={repostsMetricsDisplay} + count={post.repostCount!} + one="repost" + other="reposts" + /> ) : null} - {post.quoteCount != null && - post.quoteCount !== 0 && - !post.viewer?.embeddingDisabled && - !disableQuotesMetrics ? ( + {shouldShowThreadExpandedMetric( + quotesMetricsDisplay, + post.quoteCount, + ) && !post.viewer?.embeddingDisabled ? ( - - - - {formatPostStatCount(post.quoteCount)} - {' '} - - - + display={quotesMetricsDisplay} + count={post.quoteCount!} + one="quote" + other="quotes" + /> ) : null} - {post.likeCount != null && - post.likeCount !== 0 && - !disableLikesMetrics ? ( + {shouldShowThreadExpandedMetric( + likesMetricsDisplay, + post.likeCount, + ) ? ( - - - - {formatPostStatCount(post.likeCount)} - {' '} - - - + display={likesMetricsDisplay} + count={post.likeCount!} + one="like" + other="likes" + /> ) : null} - {post.bookmarkCount != null && - post.bookmarkCount !== 0 && - !disableSavesMetrics ? ( - - - - {formatPostStatCount(post.bookmarkCount)} - {' '} - - - + display={savesMetricsDisplay} + count={post.bookmarkCount!} + one="save" + other="saves" + /> ) : null} ) : null} @@ -756,6 +745,43 @@ function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { ) } +function ThreadExpandedMetricText({ + testID, + display, + count, + one, + other, +}: { + testID: string + display: CountsMetricsDisplay + count: number + one: string + other: string +}) { + const t = useTheme() + const {i18n} = useLingui() + const labelOnly = shouldShowCountsMetricLabelOnly(display, count) + + if (labelOnly) { + return ( + + + + ) + } + + return ( + + + + {formatCountsMetricNumber(i18n, display, count)} + {' '} + + + + ) +} + function getThreadAuthor( post: AppBskyFeedDefs.PostView, record: AppBskyFeedPost.Record, diff --git a/src/screens/Profile/Header/Metrics.tsx b/src/screens/Profile/Header/Metrics.tsx index 59f09cded..bfd8160db 100644 --- a/src/screens/Profile/Header/Metrics.tsx +++ b/src/screens/Profile/Header/Metrics.tsx @@ -3,12 +3,19 @@ import {type AppBskyActorDefs} from '@atproto/api' import {msg, plural} from '@lingui/core/macro' import {useLingui} from '@lingui/react' +import { + type CountsMetricsDisplay, + formatCountsMetricNumber, + shouldShowCountsMetricLabelOnly, + shouldShowProfileCountsMetric, +} from '#/lib/metrics-display' import {makeProfileLink} from '#/lib/routes/links' import {type Shadow} from '#/state/cache/types' -import {useDisableFollowersMetrics} from '#/state/preferences/disable-followers-metrics' -import {useDisableFollowingMetrics} from '#/state/preferences/disable-following-metrics' -import {useDisablePostsMetrics} from '#/state/preferences/disable-posts-metrics' -import {formatCount} from '#/view/com/util/numeric/format' +import { + useFollowersMetricsDisplay, + useFollowingMetricsDisplay, + usePostsMetricsDisplay, +} from '#/state/preferences/metrics-display-preference' import {atoms as a, useTheme} from '#/alf' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' @@ -17,72 +24,135 @@ export function ProfileHeaderMetrics({ profile, }: { profile: Shadow +}) { + const {_} = useLingui() + const followersMetricsDisplay = useFollowersMetricsDisplay() + const followingMetricsDisplay = useFollowingMetricsDisplay() + const postsMetricsDisplay = usePostsMetricsDisplay() + + const followersCount = profile.followersCount || 0 + const followingCount = profile.followsCount || 0 + const postsCount = profile.postsCount || 0 + + const showFollowers = shouldShowProfileCountsMetric( + followersMetricsDisplay, + followersCount, + ) + const showFollowing = shouldShowProfileCountsMetric( + followingMetricsDisplay, + followingCount, + ) + const showPosts = shouldShowProfileCountsMetric( + postsMetricsDisplay, + postsCount, + ) + + if (!showFollowers && !showFollowing && !showPosts) { + return null + } + + return ( + + {showFollowers ? ( + + ) : null} + {showFollowing ? ( + + ) : null} + {showPosts ? ( + + ) : null} + + ) +} + +export function ProfileCountLink({ + testID, + to, + label, + display, + count, + labelText, + onPress, +}: { + testID: string + to: string + label: string + display: CountsMetricsDisplay + count: number + labelText: string + onPress?: () => void +}) { + const t = useTheme() + + return ( + + + + ) +} + +export function ProfileCountText({ + display, + count, + labelText, +}: { + display: CountsMetricsDisplay + count: number + labelText: string }) { const t = useTheme() - const {_, i18n} = useLingui() - const following = formatCount(i18n, profile.followsCount || 0) - const followers = formatCount(i18n, profile.followersCount || 0) - const pluralizedFollowers = plural(profile.followersCount || 0, { - one: 'follower', - other: 'followers', - }) - const pluralizedFollowings = plural(profile.followsCount || 0, { - one: 'following', - other: 'following', - }) + const {i18n} = useLingui() + const labelOnly = shouldShowCountsMetricLabelOnly(display, count) - // disable metrics - const disableFollowersMetrics = useDisableFollowersMetrics() - const disableFollowingMetrics = useDisableFollowingMetrics() - const disablePostsMetrics = useDisablePostsMetrics() + if (labelOnly) { + return ( + + {labelText} + + ) + } return ( - <> - {disableFollowersMetrics && - disableFollowingMetrics && - disablePostsMetrics ? null : ( - - {!disableFollowersMetrics ? ( - - {followers} - - {pluralizedFollowers} - - - ) : null} - {!disableFollowingMetrics ? ( - - {following} - - {pluralizedFollowings} - - - ) : null} - {!disablePostsMetrics ? ( - - {formatCount(i18n, profile.postsCount || 0)}{' '} - - {plural(profile.postsCount || 0, {one: 'post', other: 'posts'})} - - - ) : null} - - )} - + + {formatCountsMetricNumber(i18n, display, count)}{' '} + + {labelText} + + ) } diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 4242689d5..af257ca32 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -12,6 +12,7 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useHaptics} from '#/lib/haptics' +import {isFollowedByMetricHidden} from '#/lib/metrics-display' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {formatJoinDate, niceDate} from '#/lib/strings/time' @@ -23,8 +24,8 @@ import {logger} from '#/logger' import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow' import {useShowGermDmButton} from '#/state/preferences' import {useConfirmFollowUnfollow} from '#/state/preferences/confirm-follow-unfollow' -import {useDisableFollowedByMetrics} from '#/state/preferences/disable-followed-by-metrics' import {useHideScaryFollowButtons} from '#/state/preferences/hide-scary-follow-buttons' +import {useFollowedByMetricsDisplay} from '#/state/preferences/metrics-display-preference' import { useProfileBlockMutationQueue, useProfileFollowMutationQueue, @@ -153,7 +154,7 @@ let ProfileHeaderStandard = ({ const {isActive: live} = useActorStatus(profile) // disable metrics - const disableFollowedByMetrics = useDisableFollowedByMetrics() + const followedByMetricsDisplay = useFollowedByMetricsDisplay() return ( <> @@ -243,13 +244,14 @@ let ProfileHeaderStandard = ({ )} {!isMe && - !disableFollowedByMetrics && + !isFollowedByMetricHidden(followedByMetricsDisplay) && !isBlockedUser && shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( )} diff --git a/src/screens/Search/components/StarterPackCard.tsx b/src/screens/Search/components/StarterPackCard.tsx index 52dd0c0c5..4d5905027 100644 --- a/src/screens/Search/components/StarterPackCard.tsx +++ b/src/screens/Search/components/StarterPackCard.tsx @@ -10,7 +10,7 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {sanitizeHandle} from '#/lib/strings/handles' -import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' +import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useSession} from '#/state/session' import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' @@ -146,7 +146,8 @@ export function AvatarStack({ const widthPerc = 100 / circlesCount const [size, setSize] = useState(null) - const enableSquareButtons = useEnableSquareButtons() + const enableSquareAvatars = useEnableSquareAvatars() + const avatarRounding = enableSquareAvatars ? a.rounded_sm : a.rounded_full const isPending = (numPending && profiles.length === 0) || !moderationOpts @@ -190,7 +191,7 @@ export function AvatarStack({ setSize(e.nativeEvent.layout.width)} style={[ - enableSquareButtons ? a.rounded_sm : a.rounded_full, + avatarRounding, t.atoms.bg_contrast_25, { paddingTop: '100%', @@ -205,9 +206,7 @@ export function AvatarStack({ style={[a.absolute, a.inset_0]} /> ) : ( - + )} @@ -237,7 +236,7 @@ export function AvatarStack({ style={[ a.absolute, a.inset_0, - enableSquareButtons ? a.rounded_sm : a.rounded_full, + avatarRounding, a.align_center, a.justify_center, { diff --git a/src/screens/Settings/RunesSettings/ImpressionsSettings.tsx b/src/screens/Settings/RunesSettings/ImpressionsSettings.tsx index 5c1c255ef..e467c32a5 100644 --- a/src/screens/Settings/RunesSettings/ImpressionsSettings.tsx +++ b/src/screens/Settings/RunesSettings/ImpressionsSettings.tsx @@ -1,200 +1,352 @@ +import {type ComponentProps, type ReactNode} from 'react' +import {View} from 'react-native' import {Trans, useLingui} from '@lingui/react/macro' import { - useDisableFollowedByMetrics, - useSetDisableFollowedByMetrics, -} from '#/state/preferences/disable-followed-by-metrics' + type CountsMetricsDisplay, + type FollowedByMetricsDisplay, +} from '#/lib/metrics-display' import { - useDisableFollowersMetrics, - useSetDisableFollowersMetrics, -} from '#/state/preferences/disable-followers-metrics' -import { - useDisableFollowingMetrics, - useSetDisableFollowingMetrics, -} from '#/state/preferences/disable-following-metrics' -import { - useDisableLikesMetrics, - useSetDisableLikesMetrics, -} from '#/state/preferences/disable-likes-metrics' -import { - useDisablePostsMetrics, - useSetDisablePostsMetrics, -} from '#/state/preferences/disable-posts-metrics' -import { - useDisableQuotesMetrics, - useSetDisableQuotesMetrics, -} from '#/state/preferences/disable-quotes-metrics' -import { - useDisableReplyMetrics, - useSetDisableReplyMetrics, -} from '#/state/preferences/disable-reply-metrics' -import { - useDisableRepostsMetrics, - useSetDisableRepostsMetrics, -} from '#/state/preferences/disable-reposts-metrics' -import { - useDisableSavesMetrics, - useSetDisableSavesMetrics, -} from '#/state/preferences/disable-saves-metrics' + useFollowedByMetricsDisplay, + useFollowersMetricsDisplay, + useFollowingMetricsDisplay, + useLikesMetricsDisplay, + usePostsMetricsDisplay, + useQuotesMetricsDisplay, + useReplyMetricsDisplay, + useRepostsMetricsDisplay, + useSavesMetricsDisplay, + useSetFollowedByMetricsDisplay, + useSetFollowersMetricsDisplay, + useSetFollowingMetricsDisplay, + useSetLikesMetricsDisplay, + useSetPostsMetricsDisplay, + useSetQuotesMetricsDisplay, + useSetReplyMetricsDisplay, + useSetRepostsMetricsDisplay, + useSetSavesMetricsDisplay, +} from '#/state/preferences/metrics-display-preference' import { useSetShowFollowsYouBadge, useShowFollowsYouBadge, } from '#/state/preferences/show-follows-you-badge' import * as SettingsList from '#/screens/Settings/components/SettingsList' -import {atoms as a} from '#/alf' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' import * as Toggle from '#/components/forms/Toggle' +import * as ToggleButton from '#/components/forms/ToggleButton' import {Person_Stroke2_Corner0_Rounded as PersonIcon} from '#/components/icons/Person' import {Reply as ReplyIcon} from '#/components/icons/Reply' +import {Text} from '#/components/Typography' import {RunesScreenLayout} from './components/RunesScreenLayout' export function RunesImpressionsSettingsScreen() { const {t: l} = useLingui() - const disableLikesMetrics = useDisableLikesMetrics() - const setDisableLikesMetrics = useSetDisableLikesMetrics() - const disableRepostsMetrics = useDisableRepostsMetrics() - const setDisableRepostsMetrics = useSetDisableRepostsMetrics() - const disableQuotesMetrics = useDisableQuotesMetrics() - const setDisableQuotesMetrics = useSetDisableQuotesMetrics() - const disableSavesMetrics = useDisableSavesMetrics() - const setDisableSavesMetrics = useSetDisableSavesMetrics() - const disableReplyMetrics = useDisableReplyMetrics() - const setDisableReplyMetrics = useSetDisableReplyMetrics() - const disableFollowersMetrics = useDisableFollowersMetrics() - const setDisableFollowersMetrics = useSetDisableFollowersMetrics() - const disableFollowingMetrics = useDisableFollowingMetrics() - const setDisableFollowingMetrics = useSetDisableFollowingMetrics() - const disableFollowedByMetrics = useDisableFollowedByMetrics() - const setDisableFollowedByMetrics = useSetDisableFollowedByMetrics() - const disablePostsMetrics = useDisablePostsMetrics() - const setDisablePostsMetrics = useSetDisablePostsMetrics() + const likesMetricsDisplay = useLikesMetricsDisplay() + const setLikesMetricsDisplay = useSetLikesMetricsDisplay() + const repostsMetricsDisplay = useRepostsMetricsDisplay() + const setRepostsMetricsDisplay = useSetRepostsMetricsDisplay() + const quotesMetricsDisplay = useQuotesMetricsDisplay() + const setQuotesMetricsDisplay = useSetQuotesMetricsDisplay() + const savesMetricsDisplay = useSavesMetricsDisplay() + const setSavesMetricsDisplay = useSetSavesMetricsDisplay() + const replyMetricsDisplay = useReplyMetricsDisplay() + const setReplyMetricsDisplay = useSetReplyMetricsDisplay() + const followersMetricsDisplay = useFollowersMetricsDisplay() + const setFollowersMetricsDisplay = useSetFollowersMetricsDisplay() + const followingMetricsDisplay = useFollowingMetricsDisplay() + const setFollowingMetricsDisplay = useSetFollowingMetricsDisplay() + const postsMetricsDisplay = usePostsMetricsDisplay() + const setPostsMetricsDisplay = useSetPostsMetricsDisplay() + const followedByMetricsDisplay = useFollowedByMetricsDisplay() + const setFollowedByMetricsDisplay = useSetFollowedByMetricsDisplay() const showFollowsYouBadge = useShowFollowsYouBadge() const setShowFollowsYouBadge = useSetShowFollowsYouBadge() + const countsLabels = useCountsDisplayLabels() + const followedByLabels = useFollowedByDisplayLabels() + return ( - - - - Posts - - setDisableLikesMetrics(value)} - style={[a.w_full]}> - - Remove likes counts - - - - setDisableRepostsMetrics(value)} - style={[a.w_full]}> - - Remove reposts counts - - - - setDisableQuotesMetrics(value)} - style={[a.w_full]}> - - Remove quotes counts - - - - setDisableSavesMetrics(value)} - style={[a.w_full]}> - - Remove saves counts - - - - setDisableReplyMetrics(value)} - style={[a.w_full]}> - - Remove reply counts - - - - - - - - Profiles - - setDisableFollowersMetrics(value)} - style={[a.w_full]}> - - Remove followers counts - - - - setDisableFollowingMetrics(value)} - style={[a.w_full]}> - - Remove following counts - - - - setDisablePostsMetrics(value)} - style={[a.w_full]}> - - Remove post counts - - - - setDisableFollowedByMetrics(value)} - style={[a.w_full]}> - - Remove "followed by" avatars - - - - setShowFollowsYouBadge(value)} - style={[a.w_full]}> - - Show "Follows you" badge - - - - + + + + + + + + + + + + + + + ) } + +function ImpressionsSectionHeader({ + icon, + label, +}: { + icon: ComponentProps['icon'] + label: string +}) { + return ( + + + {label} + + ) +} + +function FollowsYouLabelToggle({ + enabled, + onChange, +}: { + enabled: boolean + onChange: (value: boolean) => void +}) { + const {t: l} = useLingui() + const t = useTheme() + + return ( + + + + + Enable extra "Follows you" label + + + + + {enabled ? Enabled : Disabled} + + + + + + + + ) +} + +function CountsMetricRow({ + name, + value, + labels, + onChange, +}: { + name: string + value: CountsMetricsDisplay + labels: Record + onChange: (value: CountsMetricsDisplay) => void +}) { + const {t: l} = useLingui() + const {gtPhone} = useBreakpoints() + + const handleChange = (values: string[]) => { + const next = values[0] as CountsMetricsDisplay | undefined + if ( + next === 'hidden' || + next === 'lite' || + next === 'visible' || + next === 'exact' + ) { + onChange(next) + } + } + + return ( + + + + {name} + + + + + + {labels.hidden} + + + {labels.lite} + + + {labels.visible} + + + {labels.exact} + + + + + ) +} + +function FollowedByMetricRow({ + name, + value, + labels, + onChange, +}: { + name: string + value: FollowedByMetricsDisplay + labels: Record + onChange: (value: FollowedByMetricsDisplay) => void +}) { + const {t: l} = useLingui() + const {gtPhone} = useBreakpoints() + + const handleChange = (values: string[]) => { + const next = values[0] as FollowedByMetricsDisplay | undefined + if ( + next === 'hidden' || + next === 'lite' || + next === 'visible' || + next === 'names' + ) { + onChange(next) + } + } + + return ( + + + + {name} + + + + + + {labels.hidden} + + + {labels.lite} + + + {labels.visible} + + + {labels.names} + + + + + ) +} + +function MetricRowLayout({children}: {children: ReactNode}) { + return ( + + {children} + + ) +} + +function useCountsDisplayLabels(): Record { + const {t: l} = useLingui() + return { + hidden: l`Hidden`, + lite: l`Lite`, + visible: l`Visible`, + exact: l`Exact`, + } +} + +function useFollowedByDisplayLabels(): Record< + FollowedByMetricsDisplay, + string +> { + const {t: l} = useLingui() + return { + hidden: l`Hidden`, + lite: l`Lite`, + visible: l`Visible`, + names: l`Names`, + } +} diff --git a/src/state/persisted/__tests__/schema.test.ts b/src/state/persisted/__tests__/schema.test.ts index 4eb6f9433..f08da4ccb 100644 --- a/src/state/persisted/__tests__/schema.test.ts +++ b/src/state/persisted/__tests__/schema.test.ts @@ -49,4 +49,33 @@ describe('persisted schema helpers', () => { ) expect(normalizeData(parsed!).downloadFormat).toBe(defaults.downloadFormat) }) + + it('migrates legacy disable metrics booleans to display modes', () => { + const parsed = tryParse( + JSON.stringify({ + ...partialState, + disableLikesMetrics: true, + disableFollowedByMetrics: true, + }), + ) + + const normalized = normalizeData(parsed!) + expect(normalized.likesMetricsDisplay).toBe('hidden') + expect(normalized.followedByMetricsDisplay).toBe('hidden') + expect(normalized.repostsMetricsDisplay).toBe('visible') + }) + + it('migrates legacy accessible display mode to lite', () => { + const parsed = tryParse( + JSON.stringify({ + ...partialState, + likesMetricsDisplay: 'accessible', + followedByMetricsDisplay: 'accessible', + }), + ) + + expect(parsed).toBeDefined() + expect(parsed!.likesMetricsDisplay).toBe('lite') + expect(parsed!.followedByMetricsDisplay).toBe('lite') + }) }) diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index f5d069533..b10fc781d 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -2,6 +2,10 @@ import {BSKY_LABELER_DID} from '@atproto/api' import {z} from 'zod' import {DEFAULT_ALT_TEXT_AI_MODEL} from '#/lib/constants' +import { + countsMetricsDisplaySchema, + followedByMetricsDisplaySchema, +} from '#/lib/metrics-display' import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales' import {findSupportedAppLanguage} from '#/locale/helpers' import {logger} from '#/logger' @@ -183,15 +187,33 @@ const schema = z.object({ disableComposerPrompt: z.boolean().optional(), disableTopOfFeedButton: z.boolean().optional(), showAvatarFollowButton: z.boolean().optional(), + /** @deprecated Migrated to likesMetricsDisplay */ disableLikesMetrics: z.boolean().optional(), + /** @deprecated Migrated to repostsMetricsDisplay */ disableRepostsMetrics: z.boolean().optional(), + /** @deprecated Migrated to quotesMetricsDisplay */ disableQuotesMetrics: z.boolean().optional(), + /** @deprecated Migrated to savesMetricsDisplay */ disableSavesMetrics: z.boolean().optional(), + /** @deprecated Migrated to replyMetricsDisplay */ disableReplyMetrics: z.boolean().optional(), + /** @deprecated Migrated to followersMetricsDisplay */ disableFollowersMetrics: z.boolean().optional(), + /** @deprecated Migrated to followingMetricsDisplay */ disableFollowingMetrics: z.boolean().optional(), + /** @deprecated Migrated to followedByMetricsDisplay */ disableFollowedByMetrics: z.boolean().optional(), + /** @deprecated Migrated to postsMetricsDisplay */ disablePostsMetrics: z.boolean().optional(), + likesMetricsDisplay: countsMetricsDisplaySchema.optional(), + repostsMetricsDisplay: countsMetricsDisplaySchema.optional(), + quotesMetricsDisplay: countsMetricsDisplaySchema.optional(), + savesMetricsDisplay: countsMetricsDisplaySchema.optional(), + replyMetricsDisplay: countsMetricsDisplaySchema.optional(), + followersMetricsDisplay: countsMetricsDisplaySchema.optional(), + followingMetricsDisplay: countsMetricsDisplaySchema.optional(), + followedByMetricsDisplay: followedByMetricsDisplaySchema.optional(), + postsMetricsDisplay: countsMetricsDisplaySchema.optional(), showFollowsYouBadge: z.boolean().optional(), hideSimilarAccountsRecomm: z.boolean().optional(), hideScaryFollowButtons: z.boolean().optional(), @@ -333,15 +355,15 @@ export const defaults: Schema = { disableComposerPrompt: true, disableTopOfFeedButton: false, showAvatarFollowButton: false, - disableLikesMetrics: false, - disableRepostsMetrics: false, - disableQuotesMetrics: false, - disableSavesMetrics: false, - disableReplyMetrics: false, - disableFollowersMetrics: false, - disableFollowingMetrics: false, - disableFollowedByMetrics: false, - disablePostsMetrics: false, + likesMetricsDisplay: 'visible', + repostsMetricsDisplay: 'visible', + quotesMetricsDisplay: 'visible', + savesMetricsDisplay: 'visible', + replyMetricsDisplay: 'visible', + followersMetricsDisplay: 'visible', + followingMetricsDisplay: 'visible', + followedByMetricsDisplay: 'names', + postsMetricsDisplay: 'visible', showFollowsYouBadge: false, hideSimilarAccountsRecomm: true, hideScaryFollowButtons: false, diff --git a/src/state/persisted/util.ts b/src/state/persisted/util.ts index 5af629041..e06b06e56 100644 --- a/src/state/persisted/util.ts +++ b/src/state/persisted/util.ts @@ -1,6 +1,10 @@ import {parse} from 'bcp-47' import {dedupArray} from '#/lib/functions' +import { + migrateCountsMetricsDisplay, + migrateFollowedByMetricsDisplay, +} from '#/lib/metrics-display' import {logger} from '#/logger' import {defaults, type Schema} from '#/state/persisted/schema' @@ -37,8 +41,50 @@ export function hydrateWithDefaults(data: Schema): Schema { return hydrateRecord(defaults, data) } +function migrateMetricsDisplayPrefs(data: Schema): Schema { + return { + ...data, + likesMetricsDisplay: migrateCountsMetricsDisplay( + data.likesMetricsDisplay, + data.disableLikesMetrics, + ), + repostsMetricsDisplay: migrateCountsMetricsDisplay( + data.repostsMetricsDisplay, + data.disableRepostsMetrics, + ), + quotesMetricsDisplay: migrateCountsMetricsDisplay( + data.quotesMetricsDisplay, + data.disableQuotesMetrics, + ), + savesMetricsDisplay: migrateCountsMetricsDisplay( + data.savesMetricsDisplay, + data.disableSavesMetrics, + ), + replyMetricsDisplay: migrateCountsMetricsDisplay( + data.replyMetricsDisplay, + data.disableReplyMetrics, + ), + followersMetricsDisplay: migrateCountsMetricsDisplay( + data.followersMetricsDisplay, + data.disableFollowersMetrics, + ), + followingMetricsDisplay: migrateCountsMetricsDisplay( + data.followingMetricsDisplay, + data.disableFollowingMetrics, + ), + postsMetricsDisplay: migrateCountsMetricsDisplay( + data.postsMetricsDisplay, + data.disablePostsMetrics, + ), + followedByMetricsDisplay: migrateFollowedByMetricsDisplay( + data.followedByMetricsDisplay, + data.disableFollowedByMetrics, + ), + } +} + export function normalizeData(data: Schema) { - const next = hydrateWithDefaults(data) + const next = hydrateWithDefaults(migrateMetricsDisplayPrefs(data)) /** * Normalize language prefs to ensure that these values only contain 2-letter diff --git a/src/state/preferences/disable-followed-by-metrics.tsx b/src/state/preferences/disable-followed-by-metrics.tsx deleted file mode 100644 index 0d18a9187..000000000 --- a/src/state/preferences/disable-followed-by-metrics.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { - createContext, - type PropsWithChildren, - useCallback, - useContext, - useEffect, - useState, -} from 'react' - -import * as persisted from '#/state/persisted' - -// Preference: disableFollowedByMetrics – when true, disables following metrics on profiles - -type StateContext = persisted.Schema['disableFollowedByMetrics'] -// Same setter signature used across other preference modules -type SetContext = (v: persisted.Schema['disableFollowedByMetrics']) => void - -const stateContext = createContext( - persisted.defaults.disableFollowedByMetrics, -) -const setContext = createContext( - (_: persisted.Schema['disableFollowedByMetrics']) => {}, -) - -export function Provider({children}: PropsWithChildren<{}>) { - const [state, setState] = useState(persisted.get('disableFollowedByMetrics')) - - const setStateWrapped = useCallback( - (value: persisted.Schema['disableFollowedByMetrics']) => { - setState(value) - persisted.write('disableFollowedByMetrics', value) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate('disableFollowedByMetrics', next => { - setState(next) - }) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) -} - -export function useDisableFollowedByMetrics() { - return useContext(stateContext) -} - -export function useSetDisableFollowedByMetrics() { - return useContext(setContext) -} diff --git a/src/state/preferences/disable-followers-metrics.tsx b/src/state/preferences/disable-followers-metrics.tsx deleted file mode 100644 index 7c74a606f..000000000 --- a/src/state/preferences/disable-followers-metrics.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { - createContext, - type PropsWithChildren, - useCallback, - useContext, - useEffect, - useState, -} from 'react' - -import * as persisted from '#/state/persisted' - -// Preference: disableFollowersMetrics – when true, disables followers metrics on profiles - -type StateContext = persisted.Schema['disableFollowersMetrics'] -// Same setter signature used across other preference modules -type SetContext = (v: persisted.Schema['disableFollowersMetrics']) => void - -const stateContext = createContext( - persisted.defaults.disableFollowersMetrics, -) -const setContext = createContext( - (_: persisted.Schema['disableFollowersMetrics']) => {}, -) - -export function Provider({children}: PropsWithChildren<{}>) { - const [state, setState] = useState(persisted.get('disableFollowersMetrics')) - - const setStateWrapped = useCallback( - (value: persisted.Schema['disableFollowersMetrics']) => { - setState(value) - persisted.write('disableFollowersMetrics', value) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate('disableFollowersMetrics', next => { - setState(next) - }) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) -} - -export function useDisableFollowersMetrics() { - return useContext(stateContext) -} - -export function useSetDisableFollowersMetrics() { - return useContext(setContext) -} diff --git a/src/state/preferences/disable-following-metrics.tsx b/src/state/preferences/disable-following-metrics.tsx deleted file mode 100644 index 019b6317a..000000000 --- a/src/state/preferences/disable-following-metrics.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { - createContext, - type PropsWithChildren, - useCallback, - useContext, - useEffect, - useState, -} from 'react' - -import * as persisted from '#/state/persisted' - -// Preference: disableFollowingMetrics – when true, disables following metrics on profiles - -type StateContext = persisted.Schema['disableFollowingMetrics'] -// Same setter signature used across other preference modules -type SetContext = (v: persisted.Schema['disableFollowingMetrics']) => void - -const stateContext = createContext( - persisted.defaults.disableFollowingMetrics, -) -const setContext = createContext( - (_: persisted.Schema['disableFollowingMetrics']) => {}, -) - -export function Provider({children}: PropsWithChildren<{}>) { - const [state, setState] = useState(persisted.get('disableFollowingMetrics')) - - const setStateWrapped = useCallback( - (value: persisted.Schema['disableFollowingMetrics']) => { - setState(value) - persisted.write('disableFollowingMetrics', value) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate('disableFollowingMetrics', next => { - setState(next) - }) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) -} - -export function useDisableFollowingMetrics() { - return useContext(stateContext) -} - -export function useSetDisableFollowingMetrics() { - return useContext(setContext) -} diff --git a/src/state/preferences/disable-likes-metrics.tsx b/src/state/preferences/disable-likes-metrics.tsx deleted file mode 100644 index 91bf52d21..000000000 --- a/src/state/preferences/disable-likes-metrics.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { - createContext, - type PropsWithChildren, - useCallback, - useContext, - useEffect, - useState, -} from 'react' - -import * as persisted from '#/state/persisted' - -// Preference: disableLikesMetrics – when true, disables likes metrics on posts - -type StateContext = persisted.Schema['disableLikesMetrics'] -// Same setter signature used across other preference modules -type SetContext = (v: persisted.Schema['disableLikesMetrics']) => void - -const stateContext = createContext( - persisted.defaults.disableLikesMetrics, -) -const setContext = createContext( - (_: persisted.Schema['disableLikesMetrics']) => {}, -) - -export function Provider({children}: PropsWithChildren<{}>) { - const [state, setState] = useState(persisted.get('disableLikesMetrics')) - - const setStateWrapped = useCallback( - (value: persisted.Schema['disableLikesMetrics']) => { - setState(value) - persisted.write('disableLikesMetrics', value) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate('disableLikesMetrics', next => { - setState(next) - }) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) -} - -export function useDisableLikesMetrics() { - return useContext(stateContext) -} - -export function useSetDisableLikesMetrics() { - return useContext(setContext) -} diff --git a/src/state/preferences/disable-posts-metrics.tsx b/src/state/preferences/disable-posts-metrics.tsx deleted file mode 100644 index b7b3137b7..000000000 --- a/src/state/preferences/disable-posts-metrics.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { - createContext, - type PropsWithChildren, - useCallback, - useContext, - useEffect, - useState, -} from 'react' - -import * as persisted from '#/state/persisted' - -// Preference: disablePostsMetrics – when true, disables posts metrics on profiles - -type StateContext = persisted.Schema['disablePostsMetrics'] -// Same setter signature used across other preference modules -type SetContext = (v: persisted.Schema['disablePostsMetrics']) => void - -const stateContext = createContext( - persisted.defaults.disablePostsMetrics, -) -const setContext = createContext( - (_: persisted.Schema['disablePostsMetrics']) => {}, -) - -export function Provider({children}: PropsWithChildren<{}>) { - const [state, setState] = useState(persisted.get('disablePostsMetrics')) - - const setStateWrapped = useCallback( - (value: persisted.Schema['disablePostsMetrics']) => { - setState(value) - persisted.write('disablePostsMetrics', value) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate('disablePostsMetrics', next => { - setState(next) - }) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) -} - -export function useDisablePostsMetrics() { - return useContext(stateContext) -} - -export function useSetDisablePostsMetrics() { - return useContext(setContext) -} diff --git a/src/state/preferences/disable-quotes-metrics.tsx b/src/state/preferences/disable-quotes-metrics.tsx deleted file mode 100644 index 05e9d5707..000000000 --- a/src/state/preferences/disable-quotes-metrics.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { - createContext, - type PropsWithChildren, - useCallback, - useContext, - useEffect, - useState, -} from 'react' - -import * as persisted from '#/state/persisted' - -// Preference: disableQuotesMetrics – when true, disables quotes metrics on posts - -type StateContext = persisted.Schema['disableQuotesMetrics'] -// Same setter signature used across other preference modules -type SetContext = (v: persisted.Schema['disableQuotesMetrics']) => void - -const stateContext = createContext( - persisted.defaults.disableQuotesMetrics, -) -const setContext = createContext( - (_: persisted.Schema['disableQuotesMetrics']) => {}, -) - -export function Provider({children}: PropsWithChildren<{}>) { - const [state, setState] = useState(persisted.get('disableQuotesMetrics')) - - const setStateWrapped = useCallback( - (value: persisted.Schema['disableQuotesMetrics']) => { - setState(value) - persisted.write('disableQuotesMetrics', value) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate('disableQuotesMetrics', next => { - setState(next) - }) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) -} - -export function useDisableQuotesMetrics() { - return useContext(stateContext) -} - -export function useSetDisableQuotesMetrics() { - return useContext(setContext) -} diff --git a/src/state/preferences/disable-reply-metrics.tsx b/src/state/preferences/disable-reply-metrics.tsx deleted file mode 100644 index 4ae9f31b9..000000000 --- a/src/state/preferences/disable-reply-metrics.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { - createContext, - type PropsWithChildren, - useCallback, - useContext, - useEffect, - useState, -} from 'react' - -import * as persisted from '#/state/persisted' - -// Preference: disableReplyMetrics – when true, disables reply metrics on posts - -type StateContext = persisted.Schema['disableReplyMetrics'] -// Same setter signature used across other preference modules -type SetContext = (v: persisted.Schema['disableReplyMetrics']) => void - -const stateContext = createContext( - persisted.defaults.disableReplyMetrics, -) -const setContext = createContext( - (_: persisted.Schema['disableReplyMetrics']) => {}, -) - -export function Provider({children}: PropsWithChildren<{}>) { - const [state, setState] = useState(persisted.get('disableReplyMetrics')) - - const setStateWrapped = useCallback( - (value: persisted.Schema['disableReplyMetrics']) => { - setState(value) - persisted.write('disableReplyMetrics', value) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate('disableReplyMetrics', next => { - setState(next) - }) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) -} - -export function useDisableReplyMetrics() { - return useContext(stateContext) -} - -export function useSetDisableReplyMetrics() { - return useContext(setContext) -} diff --git a/src/state/preferences/disable-reposts-metrics.tsx b/src/state/preferences/disable-reposts-metrics.tsx deleted file mode 100644 index ca5132dd9..000000000 --- a/src/state/preferences/disable-reposts-metrics.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { - createContext, - type PropsWithChildren, - useCallback, - useContext, - useEffect, - useState, -} from 'react' - -import * as persisted from '#/state/persisted' - -// Preference: disableRepostsMetrics – when true, disables reposts metrics on posts - -type StateContext = persisted.Schema['disableRepostsMetrics'] -// Same setter signature used across other preference modules -type SetContext = (v: persisted.Schema['disableRepostsMetrics']) => void - -const stateContext = createContext( - persisted.defaults.disableRepostsMetrics, -) -const setContext = createContext( - (_: persisted.Schema['disableRepostsMetrics']) => {}, -) - -export function Provider({children}: PropsWithChildren<{}>) { - const [state, setState] = useState(persisted.get('disableRepostsMetrics')) - - const setStateWrapped = useCallback( - (value: persisted.Schema['disableRepostsMetrics']) => { - setState(value) - persisted.write('disableRepostsMetrics', value) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate('disableRepostsMetrics', next => { - setState(next) - }) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) -} - -export function useDisableRepostsMetrics() { - return useContext(stateContext) -} - -export function useSetDisableRepostsMetrics() { - return useContext(setContext) -} diff --git a/src/state/preferences/disable-saves-metrics.tsx b/src/state/preferences/disable-saves-metrics.tsx deleted file mode 100644 index 42b78fbde..000000000 --- a/src/state/preferences/disable-saves-metrics.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { - createContext, - type PropsWithChildren, - useCallback, - useContext, - useEffect, - useState, -} from 'react' - -import * as persisted from '#/state/persisted' - -// Preference: disableSavesMetrics – when true, disables saves metrics on posts - -type StateContext = persisted.Schema['disableSavesMetrics'] -// Same setter signature used across other preference modules -type SetContext = (v: persisted.Schema['disableSavesMetrics']) => void - -const stateContext = createContext( - persisted.defaults.disableSavesMetrics, -) -const setContext = createContext( - (_: persisted.Schema['disableSavesMetrics']) => {}, -) - -export function Provider({children}: PropsWithChildren<{}>) { - const [state, setState] = useState(persisted.get('disableSavesMetrics')) - - const setStateWrapped = useCallback( - (value: persisted.Schema['disableSavesMetrics']) => { - setState(value) - persisted.write('disableSavesMetrics', value) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate('disableSavesMetrics', next => { - setState(next) - }) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) -} - -export function useDisableSavesMetrics() { - return useContext(stateContext) -} - -export function useSetDisableSavesMetrics() { - return useContext(setContext) -} diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index 77aebef60..5ae805ea4 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -14,16 +14,7 @@ import {Provider as ConstellationInstanceProvider} from './constellation-instanc import {Provider as DeerVerificationProvider} from './deer-verification' import {Provider as DirectFetchRecordsProvider} from './direct-fetch-records' import {Provider as DisableComposerPromptProvider} from './disable-composer-prompt' -import {Provider as DisableFollowedByMetricsProvider} from './disable-followed-by-metrics' -import {Provider as DisableFollowersMetricsProvider} from './disable-followers-metrics' -import {Provider as DisableFollowingMetricsProvider} from './disable-following-metrics' import {Provider as DisableHapticsProvider} from './disable-haptics' -import {Provider as DisableLikesMetricsProvider} from './disable-likes-metrics' -import {Provider as DisablePostsMetricsProvider} from './disable-posts-metrics' -import {Provider as DisableQuotesMetricsProvider} from './disable-quotes-metrics' -import {Provider as DisableReplyMetricsProvider} from './disable-reply-metrics' -import {Provider as DisableRepostsMetricsProvider} from './disable-reposts-metrics' -import {Provider as DisableSavesMetricsProvider} from './disable-saves-metrics' import {Provider as DisableTopOfFeedButtonProvider} from './disable-top-of-feed-button' import {Provider as DisableVerifyEmailReminderProvider} from './disable-verify-email-reminder' import {Provider as DisableViaRepostNotificationProvider} from './disable-via-repost-notification' @@ -48,6 +39,7 @@ import {Provider as KawaiiProvider} from './kawaii' import {Provider as LanguagesProvider} from './languages' import {Provider as LargeAltBadgeProvider} from './large-alt-badge' import {Provider as LoadSmallPNGsProvider} from './load-small-pngs' +import {MetricsDisplayPreferencesProvider} from './metrics-display-preference' import {Provider as NoDiscoverProvider} from './no-discover-fallback' import {Provider as OmitViaFieldProvider} from './omit-via-field' import {Provider as OpenRouterProvider} from './openrouter' @@ -189,69 +181,53 @@ export function Provider({children}: PropsWithChildren<{}>) { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { - children - } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + { + children + } + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/state/preferences/metrics-display-preference.tsx b/src/state/preferences/metrics-display-preference.tsx new file mode 100644 index 000000000..0408e3efb --- /dev/null +++ b/src/state/preferences/metrics-display-preference.tsx @@ -0,0 +1,225 @@ +import { + createContext, + type PropsWithChildren, + useCallback, + useContext, + useEffect, + useState, +} from 'react' + +import { + type CountsMetricsDisplay, + type FollowedByMetricsDisplay, + migrateCountsMetricsDisplay, + migrateFollowedByMetricsDisplay, +} from '#/lib/metrics-display' +import * as persisted from '#/state/persisted' + +type CountsPrefs = { + displayKey: + | 'likesMetricsDisplay' + | 'repostsMetricsDisplay' + | 'quotesMetricsDisplay' + | 'savesMetricsDisplay' + | 'replyMetricsDisplay' + | 'followersMetricsDisplay' + | 'followingMetricsDisplay' + | 'postsMetricsDisplay' + legacyDisableKey: + | 'disableLikesMetrics' + | 'disableRepostsMetrics' + | 'disableQuotesMetrics' + | 'disableSavesMetrics' + | 'disableReplyMetrics' + | 'disableFollowersMetrics' + | 'disableFollowingMetrics' + | 'disablePostsMetrics' +} + +function createCountsMetricsDisplayPreference({ + displayKey, + legacyDisableKey, +}: CountsPrefs) { + const stateContext = createContext( + migrateCountsMetricsDisplay( + persisted.defaults[displayKey], + persisted.defaults[legacyDisableKey], + ), + ) + const setContext = createContext<(v: CountsMetricsDisplay) => void>(() => {}) + + function Provider({children}: PropsWithChildren<{}>) { + const [state, setState] = useState(() => + migrateCountsMetricsDisplay( + persisted.get(displayKey), + persisted.get(legacyDisableKey), + ), + ) + + const setStateWrapped = useCallback((value: CountsMetricsDisplay) => { + setState(value) + persisted.write(displayKey, value) + }, []) + + useEffect(() => { + return persisted.onUpdate(displayKey, next => { + setState( + migrateCountsMetricsDisplay(next, persisted.get(legacyDisableKey)), + ) + }) + }, []) + + return ( + + + {children} + + + ) + } + + function useMetricsDisplay() { + return useContext(stateContext) + } + + function useSetMetricsDisplay() { + return useContext(setContext) + } + + return {Provider, useMetricsDisplay, useSetMetricsDisplay} +} + +function createFollowedByMetricsDisplayPreference() { + const displayKey = 'followedByMetricsDisplay' as const + const legacyDisableKey = 'disableFollowedByMetrics' as const + + const stateContext = createContext( + migrateFollowedByMetricsDisplay( + persisted.defaults[displayKey], + persisted.defaults[legacyDisableKey], + ), + ) + const setContext = createContext<(v: FollowedByMetricsDisplay) => void>( + () => {}, + ) + + function Provider({children}: PropsWithChildren<{}>) { + const [state, setState] = useState(() => + migrateFollowedByMetricsDisplay( + persisted.get(displayKey), + persisted.get(legacyDisableKey), + ), + ) + + const setStateWrapped = useCallback((value: FollowedByMetricsDisplay) => { + setState(value) + persisted.write(displayKey, value) + }, []) + + useEffect(() => { + return persisted.onUpdate(displayKey, next => { + setState( + migrateFollowedByMetricsDisplay( + next, + persisted.get(legacyDisableKey), + ), + ) + }) + }, []) + + return ( + + + {children} + + + ) + } + + function useMetricsDisplay() { + return useContext(stateContext) + } + + function useSetMetricsDisplay() { + return useContext(setContext) + } + + return {Provider, useMetricsDisplay, useSetMetricsDisplay} +} + +const likes = createCountsMetricsDisplayPreference({ + displayKey: 'likesMetricsDisplay', + legacyDisableKey: 'disableLikesMetrics', +}) +const reposts = createCountsMetricsDisplayPreference({ + displayKey: 'repostsMetricsDisplay', + legacyDisableKey: 'disableRepostsMetrics', +}) +const quotes = createCountsMetricsDisplayPreference({ + displayKey: 'quotesMetricsDisplay', + legacyDisableKey: 'disableQuotesMetrics', +}) +const saves = createCountsMetricsDisplayPreference({ + displayKey: 'savesMetricsDisplay', + legacyDisableKey: 'disableSavesMetrics', +}) +const reply = createCountsMetricsDisplayPreference({ + displayKey: 'replyMetricsDisplay', + legacyDisableKey: 'disableReplyMetrics', +}) +const followers = createCountsMetricsDisplayPreference({ + displayKey: 'followersMetricsDisplay', + legacyDisableKey: 'disableFollowersMetrics', +}) +const following = createCountsMetricsDisplayPreference({ + displayKey: 'followingMetricsDisplay', + legacyDisableKey: 'disableFollowingMetrics', +}) +const posts = createCountsMetricsDisplayPreference({ + displayKey: 'postsMetricsDisplay', + legacyDisableKey: 'disablePostsMetrics', +}) +const followedBy = createFollowedByMetricsDisplayPreference() + +export const useLikesMetricsDisplay = likes.useMetricsDisplay +export const useSetLikesMetricsDisplay = likes.useSetMetricsDisplay +export const useRepostsMetricsDisplay = reposts.useMetricsDisplay +export const useSetRepostsMetricsDisplay = reposts.useSetMetricsDisplay +export const useQuotesMetricsDisplay = quotes.useMetricsDisplay +export const useSetQuotesMetricsDisplay = quotes.useSetMetricsDisplay +export const useSavesMetricsDisplay = saves.useMetricsDisplay +export const useSetSavesMetricsDisplay = saves.useSetMetricsDisplay +export const useReplyMetricsDisplay = reply.useMetricsDisplay +export const useSetReplyMetricsDisplay = reply.useSetMetricsDisplay +export const useFollowersMetricsDisplay = followers.useMetricsDisplay +export const useSetFollowersMetricsDisplay = followers.useSetMetricsDisplay +export const useFollowingMetricsDisplay = following.useMetricsDisplay +export const useSetFollowingMetricsDisplay = following.useSetMetricsDisplay +export const usePostsMetricsDisplay = posts.useMetricsDisplay +export const useSetPostsMetricsDisplay = posts.useSetMetricsDisplay +export const useFollowedByMetricsDisplay = followedBy.useMetricsDisplay +export const useSetFollowedByMetricsDisplay = followedBy.useSetMetricsDisplay + +export function MetricsDisplayPreferencesProvider({ + children, +}: PropsWithChildren<{}>) { + return ( + + + + + + + + + {children} + + + + + + + + + ) +} diff --git a/src/state/preferences/settings-sync.tsx b/src/state/preferences/settings-sync.tsx index 75c78b6e6..728e90637 100644 --- a/src/state/preferences/settings-sync.tsx +++ b/src/state/preferences/settings-sync.tsx @@ -51,15 +51,15 @@ export const SYNCED_PREFS_KEYS = [ 'disableComposerPrompt', 'disableTopOfFeedButton', 'showAvatarFollowButton', - 'disableLikesMetrics', - 'disableRepostsMetrics', - 'disableQuotesMetrics', - 'disableSavesMetrics', - 'disableReplyMetrics', - 'disableFollowersMetrics', - 'disableFollowingMetrics', - 'disableFollowedByMetrics', - 'disablePostsMetrics', + 'likesMetricsDisplay', + 'repostsMetricsDisplay', + 'quotesMetricsDisplay', + 'savesMetricsDisplay', + 'replyMetricsDisplay', + 'followersMetricsDisplay', + 'followingMetricsDisplay', + 'followedByMetricsDisplay', + 'postsMetricsDisplay', 'showFollowsYouBadge', 'hideSimilarAccountsRecomm', 'hideScaryFollowButtons', diff --git a/src/state/preferences/show-follows-you-badge.tsx b/src/state/preferences/show-follows-you-badge.tsx index 66ffc9528..d1c915164 100644 --- a/src/state/preferences/show-follows-you-badge.tsx +++ b/src/state/preferences/show-follows-you-badge.tsx @@ -9,21 +9,21 @@ import { import * as persisted from '#/state/persisted' -type StateContext = persisted.Schema['showFollowsYouBadge'] -type SetContext = (v: persisted.Schema['showFollowsYouBadge']) => void +type StateContext = boolean +type SetContext = (v: boolean) => void const stateContext = createContext( - persisted.defaults.showFollowsYouBadge, -) -const setContext = createContext( - (_: persisted.Schema['showFollowsYouBadge']) => {}, + Boolean(persisted.defaults.showFollowsYouBadge), ) +const setContext = createContext((_: boolean) => {}) export function Provider({children}: PropsWithChildren<{}>) { - const [state, setState] = useState(persisted.get('showFollowsYouBadge')) + const [state, setState] = useState( + Boolean(persisted.get('showFollowsYouBadge')), + ) const setStateWrapped = useCallback( - (showFollowsYouBadge: persisted.Schema['showFollowsYouBadge']) => { + (showFollowsYouBadge: boolean) => { setState(showFollowsYouBadge) persisted.write('showFollowsYouBadge', showFollowsYouBadge) }, @@ -31,12 +31,9 @@ export function Provider({children}: PropsWithChildren<{}>) { ) useEffect(() => { - return persisted.onUpdate( - 'showFollowsYouBadge', - nextShowFollowsYouBadge => { - setState(nextShowFollowsYouBadge) - }, - ) + return persisted.onUpdate('showFollowsYouBadge', next => { + setState(Boolean(next)) + }) }, [setStateWrapped]) return ( diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 067c1cad3..32b8dfb27 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -9,21 +9,27 @@ import {StackActions, useNavigation} from '@react-navigation/native' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants' import {type PressableScale} from '#/lib/custom-animations/PressableScale' import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState' +import { + formatCountsMetricNumber, + shouldShowCountsMetricLabelOnly, + shouldShowProfileCountsMetric, +} from '#/lib/metrics-display' import {getTabState, TabState} from '#/lib/routes/helpers' import {type SharedNavTab, TAB_TO_NAV_ITEM} from '#/lib/routes/tab-to-nav-item' import {type NavigationProp} from '#/lib/routes/types' import {sanitizeHandle} from '#/lib/strings/handles' import {colors} from '#/lib/styles' import {emitSoftReset} from '#/state/events' -import {useDisableFollowersMetrics} from '#/state/preferences/disable-followers-metrics' -import {useDisableFollowingMetrics} from '#/state/preferences/disable-following-metrics' import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' import {useKawaiiMode} from '#/state/preferences/kawaii' +import { + useFollowersMetricsDisplay, + useFollowingMetricsDisplay, +} from '#/state/preferences/metrics-display-preference' import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useProfileQuery} from '#/state/queries/profile' import {type SessionAccount, useSession} from '#/state/session' import {useSetDrawerOpen} from '#/state/shell' -import {formatCount} from '#/view/com/util/numeric/format' import {UserAvatar} from '#/view/com/util/UserAvatar' import {NavSignupCard} from '#/view/shell/NavSignupCard' import {atoms as a, tokens, useTheme, web} from '#/alf' @@ -77,9 +83,18 @@ let DrawerProfileCard = ({ const {data: profile} = useProfileQuery({did: account.did}) const {isActive: live} = useActorStatus(profile) - // disable metrics - const disableFollowersMetrics = useDisableFollowersMetrics() - const disableFollowingMetrics = useDisableFollowingMetrics() + const followersMetricsDisplay = useFollowersMetricsDisplay() + const followingMetricsDisplay = useFollowingMetricsDisplay() + const followersCount = profile?.followersCount ?? 0 + const followingCount = profile?.followsCount ?? 0 + const showFollowers = shouldShowProfileCountsMetric( + followersMetricsDisplay, + followersCount, + ) + const showFollowing = shouldShowProfileCountsMetric( + followingMetricsDisplay, + followingCount, + ) return ( - {disableFollowersMetrics && disableFollowingMetrics ? null : ( + {!showFollowers && !showFollowing ? null : ( - {!disableFollowersMetrics ? ( + {showFollowers ? ( - - {formatCount(i18n, profile?.followersCount ?? 0)} - {' '} - + {!shouldShowCountsMetricLabelOnly( + followersMetricsDisplay, + followersCount, + ) ? ( + + {formatCountsMetricNumber( + i18n, + followersMetricsDisplay, + followersCount, + )} + + ) : null}{' '} + ) : null} - {!disableFollowersMetrics && !disableFollowingMetrics ? ( + {showFollowers && showFollowing ? ( {' '} ·{' '} ) : null} - {!disableFollowingMetrics ? ( + {showFollowing ? ( - - {formatCount(i18n, profile?.followsCount ?? 0)} - {' '} + {!shouldShowCountsMetricLabelOnly( + followingMetricsDisplay, + followingCount, + ) ? ( + + {formatCountsMetricNumber( + i18n, + followingMetricsDisplay, + followingCount, + )} + + ) : null}{' '} -- 2.51.2