diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx index 8148a1f3b..050f0e6bf 100644 --- a/src/components/KnownFollowers.tsx +++ b/src/components/KnownFollowers.tsx @@ -9,7 +9,7 @@ import {Plural, Trans, useLingui} from '@lingui/react/macro' import { type FollowedByMetricsDisplay, - shouldShowFollowedByOverflowCount, + shouldShowFollowedByExactText, shouldShowFollowedByOverflowPlus, shouldShowFollowedByText, } from '#/lib/metrics-display' @@ -52,7 +52,7 @@ export function KnownFollowers({ onLinkPress, minimal, showIfEmpty, - followedByDisplay = 'names', + followedByDisplay = 'visible', }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts @@ -123,6 +123,7 @@ function KnownFollowersInner({ const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium] const showText = shouldShowFollowedByText(followedByDisplay) + const showExactText = shouldShowFollowedByExactText(followedByDisplay) const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => { const moderation = moderateProfile(f, moderationOpts) @@ -150,18 +151,11 @@ function KnownFollowersInner({ const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE const dim = SIZE + AVI_BORDER * 2 const radius = avatarBorderRadius(SIZE, enableSquareAvatars ?? true) - 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', }, @@ -212,7 +206,7 @@ function KnownFollowersInner({ /> ))} - {showEndCap ? ( + {showOverflowPlus ? ( - {showOverflowCount && overflowCount > 0 ? ( - - - +{overflowCount} - - - ) : ( - - )} + ) : null} @@ -265,60 +245,18 @@ function KnownFollowersInner({ }, ]} numberOfLines={2}> - {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} - {' '} - and{' '} - - + {showExactText ? ( + ) : ( - // only 1 - - Followed by{' '} - - {slice[0].profile.displayName} - - + )} ) : null} @@ -328,6 +266,130 @@ function KnownFollowersInner({ ) } +function FollowedByNamesText({ + serverCount, + slice, + textStyle, +}: { + serverCount: number + slice: Array<{profile: {did: string; displayName: string}}> + textStyle: object[] +}) { + if (slice.length >= 2) { + // 2-n followers, including blocks + // only 2 + if (serverCount > 2) { + return ( + + Followed by{' '} + + {slice[0].profile.displayName} + + ,{' '} + + {slice[1].profile.displayName} + + , and{' '} + + + ) + } + return ( + + Followed by{' '} + + {slice[0].profile.displayName} + {' '} + and{' '} + + {slice[1].profile.displayName} + + + ) + } + + if (serverCount > 1) { + // 1-n followers, including blocks + return ( + + Followed by{' '} + + {slice[0].profile.displayName} + {' '} + and + + ) + } + + // only 1 + return ( + + Followed by{' '} + + {slice[0].profile.displayName} + + + ) +} + +function FollowedByExactText({ + serverCount, + slice, + textStyle, +}: { + serverCount: number + slice: Array<{profile: {did: string; displayName: string}}> + textStyle: object[] +}) { + if (slice.length >= 3) { + return ( + + Followed by{' '} + , + including{' '} + + {slice[0].profile.displayName} + + ,{' '} + + {slice[1].profile.displayName} + + , and{' '} + + {slice[2].profile.displayName} + + + ) + } + + if (slice.length === 2) { + return ( + + Followed by{' '} + , + including{' '} + + {slice[0].profile.displayName} + {' '} + and{' '} + + {slice[1].profile.displayName} + + + ) + } + + return ( + + Followed by{' '} + , including{' '} + + {slice[0].profile.displayName} + + + ) +} + function EmptyFallback({show}: {show?: boolean}) { const t = useTheme() diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index e49e7da17..378f6e877 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -35,6 +35,7 @@ import { useFollowersMetricsDisplay, useFollowingMetricsDisplay, } from '#/state/preferences/metrics-display-preference' +import {useShowFollowedByOnOwnProfile} from '#/state/preferences/show-followed-by-on-own-profile' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' @@ -515,6 +516,7 @@ function Inner({ const followersMetricsDisplay = useFollowersMetricsDisplay() const followingMetricsDisplay = useFollowingMetricsDisplay() const followedByMetricsDisplay = useFollowedByMetricsDisplay() + const showFollowedByOnOwnProfile = useShowFollowedByOnOwnProfile() const followersCount = profile.followersCount || 0 const followingCount = profile.followsCount || 0 const pluralizedFollowers = plural(profile.followersCount || 0, { @@ -752,7 +754,7 @@ function Inner({ ) : undefined} - {!isMe && + {(!isMe || showFollowedByOnOwnProfile) && !isFollowedByMetricHidden(followedByMetricsDisplay) && shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( diff --git a/src/lib/metrics-display.ts b/src/lib/metrics-display.ts index ca9789c20..0e62eab9c 100644 --- a/src/lib/metrics-display.ts +++ b/src/lib/metrics-display.ts @@ -18,13 +18,15 @@ const followedByMetricsDisplayValues = [ 'hidden', 'lite', 'visible', - 'names', + 'exact', ] as const -export const followedByMetricsDisplaySchema = z.preprocess( - val => (val === 'accessible' ? 'lite' : val), - z.enum(followedByMetricsDisplayValues), -) +export const followedByMetricsDisplaySchema = z.preprocess(val => { + if (val === 'accessible') return 'lite' + // Legacy "names" is the current "visible" (named followers) behavior. + if (val === 'names') return 'visible' + return val +}, z.enum(followedByMetricsDisplayValues)) export type FollowedByMetricsDisplay = z.infer< typeof followedByMetricsDisplaySchema > @@ -55,7 +57,7 @@ export function migrateFollowedByMetricsDisplay( if (legacyDisabled === true) { return 'hidden' } - return 'names' + return 'visible' } export function isCountsMetricHidden(mode: CountsMetricsDisplay): boolean { @@ -136,15 +138,13 @@ export function isFollowedByMetricHidden( export function shouldShowFollowedByText( mode: FollowedByMetricsDisplay, ): boolean { - return mode === 'names' + return mode === 'visible' || mode === 'exact' } -export function shouldShowFollowedByOverflowCount( +export function shouldShowFollowedByExactText( mode: FollowedByMetricsDisplay, - serverCount: number, - shownCount: number, ): boolean { - return mode === 'visible' && serverCount > shownCount + return mode === 'exact' } export function shouldShowFollowedByOverflowPlus( diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index cdfd2dc75..2d8b9d24b 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -25,6 +25,7 @@ import {useShowGermDmButton} from '#/state/preferences' import {useConfirmFollowUnfollow} from '#/state/preferences/confirm-follow-unfollow' import {useHideScaryFollowButtons} from '#/state/preferences/hide-scary-follow-buttons' import {useFollowedByMetricsDisplay} from '#/state/preferences/metrics-display-preference' +import {useShowFollowedByOnOwnProfile} from '#/state/preferences/show-followed-by-on-own-profile' import { useProfileBlockMutationQueue, useProfileFollowMutationQueue, @@ -156,6 +157,7 @@ let ProfileHeaderStandard = ({ // disable metrics const followedByMetricsDisplay = useFollowedByMetricsDisplay() + const showFollowedByOnOwnProfile = useShowFollowedByOnOwnProfile() return ( <> @@ -221,7 +223,7 @@ let ProfileHeaderStandard = ({ )} - {!isMe && + {(!isMe || showFollowedByOnOwnProfile) && !isFollowedByMetricHidden(followedByMetricsDisplay) && !isBlockedUser && shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( diff --git a/src/screens/Settings/RunesSettings/ImpressionsSettings.tsx b/src/screens/Settings/RunesSettings/ImpressionsSettings.tsx index e467c32a5..91cb0f17a 100644 --- a/src/screens/Settings/RunesSettings/ImpressionsSettings.tsx +++ b/src/screens/Settings/RunesSettings/ImpressionsSettings.tsx @@ -1,11 +1,8 @@ -import {type ComponentProps, type ReactNode} from 'react' +import {type ComponentProps} from 'react' import {View} from 'react-native' import {Trans, useLingui} from '@lingui/react/macro' -import { - type CountsMetricsDisplay, - type FollowedByMetricsDisplay, -} from '#/lib/metrics-display' +import {type CountsMetricsDisplay} from '#/lib/metrics-display' import { useFollowedByMetricsDisplay, useFollowersMetricsDisplay, @@ -26,12 +23,16 @@ import { useSetRepostsMetricsDisplay, useSetSavesMetricsDisplay, } from '#/state/preferences/metrics-display-preference' +import { + useSetShowFollowedByOnOwnProfile, + useShowFollowedByOnOwnProfile, +} from '#/state/preferences/show-followed-by-on-own-profile' import { useSetShowFollowsYouBadge, useShowFollowsYouBadge, } from '#/state/preferences/show-follows-you-badge' import * as SettingsList from '#/screens/Settings/components/SettingsList' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, useBreakpoints} 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' @@ -39,6 +40,8 @@ import {Reply as ReplyIcon} from '#/components/icons/Reply' import {Text} from '#/components/Typography' import {RunesScreenLayout} from './components/RunesScreenLayout' +type MetricsDisplayMode = CountsMetricsDisplay + export function RunesImpressionsSettingsScreen() { const {t: l} = useLingui() @@ -62,75 +65,94 @@ export function RunesImpressionsSettingsScreen() { const setFollowedByMetricsDisplay = useSetFollowedByMetricsDisplay() const showFollowsYouBadge = useShowFollowsYouBadge() const setShowFollowsYouBadge = useSetShowFollowsYouBadge() + const showFollowedByOnOwnProfile = useShowFollowedByOnOwnProfile() + const setShowFollowedByOnOwnProfile = useSetShowFollowedByOnOwnProfile() - const countsLabels = useCountsDisplayLabels() - const followedByLabels = useFollowedByDisplayLabels() + const labels = useMetricDisplayLabels() return ( - - - - - - - - - - + + + Show “Followed by” on own profile + + + + style={[a.w_full, a.px_lg, a.py_md]}> + + + Enable extra "Follows you" label + + ) } @@ -150,71 +172,22 @@ function ImpressionsSectionHeader({ ) } -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({ +function MetricRow({ name, value, labels, onChange, }: { name: string - value: CountsMetricsDisplay - labels: Record - onChange: (value: CountsMetricsDisplay) => void + value: MetricsDisplayMode + labels: Record + onChange: (value: MetricsDisplayMode) => void }) { const {t: l} = useLingui() const {gtPhone} = useBreakpoints() const handleChange = (values: string[]) => { - const next = values[0] as CountsMetricsDisplay | undefined + const next = values[0] as MetricsDisplayMode | undefined if ( next === 'hidden' || next === 'lite' || @@ -226,7 +199,16 @@ function CountsMetricRow({ } return ( - + {name} @@ -251,84 +233,11 @@ function CountsMetricRow({ - - ) -} - -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 { +function useMetricDisplayLabels(): Record { const {t: l} = useLingui() return { hidden: l`Hidden`, @@ -337,16 +246,3 @@ function useCountsDisplayLabels(): Record { 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 be8aa8ebf..68d8b0e9b 100644 --- a/src/state/persisted/__tests__/schema.test.ts +++ b/src/state/persisted/__tests__/schema.test.ts @@ -88,4 +88,16 @@ describe('persisted schema helpers', () => { expect(parsed!.likesMetricsDisplay).toBe('lite') expect(parsed!.followedByMetricsDisplay).toBe('lite') }) + + it('migrates legacy followed-by names display mode to visible', () => { + const parsed = tryParse( + JSON.stringify({ + ...partialState, + followedByMetricsDisplay: 'names', + }), + ) + + expect(parsed).toBeDefined() + expect(parsed!.followedByMetricsDisplay).toBe('visible') + }) }) diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 56bc2b3ee..d0d9c963d 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -216,6 +216,7 @@ const schema = z.object({ followedByMetricsDisplay: followedByMetricsDisplaySchema.optional(), postsMetricsDisplay: countsMetricsDisplaySchema.optional(), showFollowsYouBadge: z.boolean().optional(), + showFollowedByOnOwnProfile: z.boolean().optional(), hideSimilarAccountsRecomm: z.boolean().optional(), hideScaryFollowButtons: z.boolean().optional(), showGermDmButton: z.boolean().optional(), @@ -370,9 +371,10 @@ export const defaults: Schema = { replyMetricsDisplay: 'visible', followersMetricsDisplay: 'visible', followingMetricsDisplay: 'visible', - followedByMetricsDisplay: 'names', + followedByMetricsDisplay: 'visible', postsMetricsDisplay: 'visible', showFollowsYouBadge: false, + showFollowedByOnOwnProfile: true, hideSimilarAccountsRecomm: true, hideScaryFollowButtons: false, showGermDmButton: false, diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index e32e2dd10..c6b42fb4b 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -50,6 +50,7 @@ import {Provider as PostNameReplacementProvider} from './post-name-replacement.t import {Provider as RepostCarouselProvider} from './repost-carousel-enabled' import {Provider as SettingsSyncProvider} from './settings-sync' import {Provider as ShowAvatarFollowButtonProvider} from './show-avatar-follow-button' +import {Provider as ShowFollowedByOnOwnProfileProvider} from './show-followed-by-on-own-profile' import {Provider as ShowFollowsYouBadgeProvider} from './show-follows-you-badge' import {Provider as ShowGermDmButtonProvider} from './show-germ-dm-button' import {Provider as ShowLinkInHandleProvider} from './show-link-in-handle' @@ -186,6 +187,7 @@ export function Provider({children}: PropsWithChildren<{}>) { + @@ -233,6 +235,7 @@ export function Provider({children}: PropsWithChildren<{}>) { + diff --git a/src/state/preferences/settings-sync.tsx b/src/state/preferences/settings-sync.tsx index 20c9ce0ff..27890116d 100644 --- a/src/state/preferences/settings-sync.tsx +++ b/src/state/preferences/settings-sync.tsx @@ -63,6 +63,7 @@ export const SYNCED_PREFS_KEYS = [ 'followedByMetricsDisplay', 'postsMetricsDisplay', 'showFollowsYouBadge', + 'showFollowedByOnOwnProfile', 'hideSimilarAccountsRecomm', 'hideScaryFollowButtons', 'showGermDmButton', diff --git a/src/state/preferences/show-followed-by-on-own-profile.tsx b/src/state/preferences/show-followed-by-on-own-profile.tsx new file mode 100644 index 000000000..854217d95 --- /dev/null +++ b/src/state/preferences/show-followed-by-on-own-profile.tsx @@ -0,0 +1,55 @@ +import { + createContext, + type PropsWithChildren, + useCallback, + useContext, + useEffect, + useState, +} from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = boolean +type SetContext = (v: boolean) => void + +const stateContext = createContext( + persisted.defaults.showFollowedByOnOwnProfile ?? true, +) +const setContext = createContext((_: boolean) => {}) + +export function Provider({children}: PropsWithChildren<{}>) { + const [state, setState] = useState( + persisted.get('showFollowedByOnOwnProfile') ?? + persisted.defaults.showFollowedByOnOwnProfile ?? + true, + ) + + const setStateWrapped = useCallback((showFollowedByOnOwnProfile: boolean) => { + setState(showFollowedByOnOwnProfile) + persisted.write('showFollowedByOnOwnProfile', showFollowedByOnOwnProfile) + }, []) + + useEffect(() => { + return persisted.onUpdate('showFollowedByOnOwnProfile', next => { + setState( + next ?? persisted.defaults.showFollowedByOnOwnProfile ?? true, + ) + }) + }, []) + + return ( + + + {children} + + + ) +} + +export function useShowFollowedByOnOwnProfile() { + return useContext(stateContext) +} + +export function useSetShowFollowedByOnOwnProfile() { + return useContext(setContext) +}