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})
+ },
+ })
+}