From 0ba971b1f6649f545cb74306d87a6447c3c17a08 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Sun, 16 Aug 2026 17:13:17 -0400 Subject: [PATCH] APP-2774: Add algorithmic visibility setting (#11417) --- .../actor/contentVisibilityDeclaration.json | 21 +++++ src/analytics/features/types.ts | 1 + src/analytics/metrics/types.ts | 2 + .../Settings/PrivacyAndSecuritySettings.tsx | 29 ++++-- .../components/AlgoVisibilityOptOut.tsx | 51 ++++++++++ src/screens/Settings/components/PwiOptOut.tsx | 10 +- src/state/queries/content-visibility.ts | 92 +++++++++++++++++++ 7 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 lexicons/app/bsky/actor/contentVisibilityDeclaration.json create mode 100644 src/screens/Settings/components/AlgoVisibilityOptOut.tsx create mode 100644 src/state/queries/content-visibility.ts diff --git a/lexicons/app/bsky/actor/contentVisibilityDeclaration.json b/lexicons/app/bsky/actor/contentVisibilityDeclaration.json new file mode 100644 index 0000000000..ec7d627e43 --- /dev/null +++ b/lexicons/app/bsky/actor/contentVisibilityDeclaration.json @@ -0,0 +1,21 @@ +{ + "lexicon": 1, + "id": "app.bsky.actor.contentVisibilityDeclaration", + "defs": { + "main": { + "type": "record", + "description": "A declaration of an account's preferences for appearing in content discovery surfaces.", + "key": "literal:self", + "record": { + "type": "object", + "required": ["hideFromAlgorithmicRecommendations"], + "properties": { + "hideFromAlgorithmicRecommendations": { + "type": "boolean", + "description": "Whether the account requests that its posts be hidden from algorithmic recommendations. Consumers must treat a missing record as false." + } + } + } + } + } +} diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 4e8d183c8d..d86ad83ff7 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -25,6 +25,7 @@ export enum Features { FollowSortEnable = 'follow_sort:enable', OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable', CanonicalPostNumberingEnable = 'canonical_post_numbering:enable', + ContentVisibilitySettingsEnable = 'content_visibility_settings:enable', // values TrendingDiscoverValues = 'trending_discover:values', diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 6e30c9d912..a63438eda4 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -1075,6 +1075,8 @@ export type Events = { 'bot:label:toggle': {state: 'add' | 'remove'} 'bot:badge:click': {} + 'contentVisibility:algorithmicRecommendations:change': {hide: boolean} + 'live:create': {duration: number} 'live:edit': {} 'live:remove': {} diff --git a/src/screens/Settings/PrivacyAndSecuritySettings.tsx b/src/screens/Settings/PrivacyAndSecuritySettings.tsx index 9d2a7c8a11..b0aab2661f 100644 --- a/src/screens/Settings/PrivacyAndSecuritySettings.tsx +++ b/src/screens/Settings/PrivacyAndSecuritySettings.tsx @@ -13,10 +13,13 @@ import * as Admonition from '#/components/Admonition' import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash' import {Key_Stroke2_Corner2_Rounded as KeyIcon} from '#/components/icons/Key' +import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass' import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' import * as Layout from '#/components/Layout' import {InlineLinkText} from '#/components/Link' +import {useAnalytics} from '#/analytics' import {type app} from '#/lexicons' +import {AlgoVisibilityOptOut} from './components/AlgoVisibilityOptOut' import {Email2FAToggle} from './components/Email2FAToggle' import {PwiOptOut} from './components/PwiOptOut' import {ItemTextWithSubtitle} from './NotificationSettings/components/ItemTextWithSubtitle' @@ -28,6 +31,10 @@ type Props = NativeStackScreenProps< export function PrivacyAndSecuritySettingsScreen({}: Props) { const {_} = useLingui() const t = useTheme() + const ax = useAnalytics() + const isContentVisibilityEnabled = ax.features.enabled( + ax.features.ContentVisibilitySettingsEnable, + ) const {data: appPasswords} = useAppPasswordsQuery() const {currentAccount} = useSession() const { @@ -101,13 +108,16 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) { /> - + {isContentVisibilityEnabled && ( + + + + + )} + - - Logged-out visibility - - + @@ -115,11 +125,10 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) { - Note: Bluesky is an open and public network. This setting - only limits the visibility of your content on the Bluesky - app and website, and other apps may not respect this - setting. Your content may still be shown to logged-out - users by other apps and websites. + Note: Bluesky is part of the Atmosphere, an open public + network. These settings ask other apps and websites to + limit your visibility, but they can choose not to. Your + public content may still appear elsewhere. diff --git a/src/screens/Settings/components/AlgoVisibilityOptOut.tsx b/src/screens/Settings/components/AlgoVisibilityOptOut.tsx new file mode 100644 index 0000000000..c512905749 --- /dev/null +++ b/src/screens/Settings/components/AlgoVisibilityOptOut.tsx @@ -0,0 +1,51 @@ +import {View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' + +import { + useContentVisibilityMutation, + useContentVisibilityQuery, +} from '#/state/queries/content-visibility' +import {atoms as a, useTheme} from '#/alf' +import * as Toggle from '#/components/forms/Toggle' +import {Text} from '#/components/Typography' + +export function AlgoVisibilityOptOut() { + const t = useTheme() + const {t: l} = useLingui() + const {data, isPending: isQueryPending} = useContentVisibilityQuery() + const updateContentVisibility = useContentVisibilityMutation() + + const isOptedOut = data?.hideFromAlgorithmicRecommendations ?? false + const canToggle = !isQueryPending && !updateContentVisibility.isPending + + const onToggleOptOut = (hide: boolean) => { + updateContentVisibility.mutate(hide) + } + + return ( + + + + + Ask apps to hide my posts from algorithmic recommendations + + + + + + + + Bluesky will not show your posts in the Discover feed (except to your + followers) and will ask other apps not to show your posts in their own + algorithmic recommendations. + + + + ) +} diff --git a/src/screens/Settings/components/PwiOptOut.tsx b/src/screens/Settings/components/PwiOptOut.tsx index 826b427e31..4b2edfb64b 100644 --- a/src/screens/Settings/components/PwiOptOut.tsx +++ b/src/screens/Settings/components/PwiOptOut.tsx @@ -86,12 +86,12 @@ export function PwiOptOut() { value={isOptedOut} onChange={onToggleOptOut} label={_( - msg`Discourage apps from showing my account to logged-out users`, + msg`Ask apps and sites not to show my account to logged-out users`, )} style={[a.w_full]}> - Discourage apps from showing my account to logged-out users + Ask apps and sites not to show my account to logged-out users @@ -99,9 +99,9 @@ export function PwiOptOut() { - Bluesky will not show your profile and posts to logged-out users. - Other apps may not honor this request. This does not make your account - private. + Bluesky will not show your account to logged-out users and will ask + other apps to do the same. Other apps may not honor this request. It + doesn't make your account private. diff --git a/src/state/queries/content-visibility.ts b/src/state/queries/content-visibility.ts new file mode 100644 index 0000000000..6d53fdae7d --- /dev/null +++ b/src/state/queries/content-visibility.ts @@ -0,0 +1,92 @@ +import {t} from '@lingui/core/macro' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' + +import {isRecordNotFoundError} from '#/lib/xrpc-error' +import {createQueryKey} from '#/state/queries/util' +import {usePdsClient, useSession} from '#/state/session' +import * as Toast from '#/components/Toast' +import {useAnalytics} from '#/analytics' +import {app} from '#/lexicons' + +export const contentVisibilityQueryKey = (did: string) => + createQueryKey('content-visibility', {did}) + +export function useContentVisibilityQuery() { + const client = usePdsClient() + const {currentAccount} = useSession() + const did = currentAccount?.did + + return useQuery({ + queryKey: contentVisibilityQueryKey(did ?? ''), + queryFn: async () => { + try { + const response = await client.get( + app.bsky.actor.contentVisibilityDeclaration, + { + repo: did!, + rkey: 'self', + }, + ) + return app.bsky.actor.contentVisibilityDeclaration.$parse( + response.value, + ) + } catch (error) { + if (isRecordNotFoundError(error)) { + return app.bsky.actor.contentVisibilityDeclaration.$build({ + hideFromAlgorithmicRecommendations: false, + }) + } + throw error + } + }, + enabled: !!did, + }) +} + +export function useContentVisibilityMutation() { + const ax = useAnalytics() + const client = usePdsClient() + const {currentAccount} = useSession() + const queryClient = useQueryClient() + const did = currentAccount?.did + const queryKey = contentVisibilityQueryKey(did ?? '') + + return useMutation({ + mutationFn: async (hideFromAlgorithmicRecommendations: boolean) => { + if (!did) throw new Error('Not signed in') + + const record = app.bsky.actor.contentVisibilityDeclaration.$build({ + hideFromAlgorithmicRecommendations, + }) + await client.put(app.bsky.actor.contentVisibilityDeclaration, record, { + repo: did, + rkey: 'self', + }) + return record + }, + onMutate: async hideFromAlgorithmicRecommendations => { + await queryClient.cancelQueries({queryKey}) + const previous = + queryClient.getQueryData( + queryKey, + ) + queryClient.setQueryData( + queryKey, + app.bsky.actor.contentVisibilityDeclaration.$build({ + hideFromAlgorithmicRecommendations, + }), + ) + return {previous} + }, + onError: (_error, _variables, context) => { + queryClient.setQueryData(queryKey, context?.previous) + Toast.show(t`Failed to update content visibility`) + }, + onSuccess: (_record, hide) => { + ax.metric('contentVisibility:algorithmicRecommendations:change', {hide}) + }, + onSettled: () => { + void queryClient.invalidateQueries({queryKey}) + }, + }) +}