APP-2774: Add algorithmic visibility setting (#11417)
This commit is contained in:
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ export enum Features {
|
|||||||
FollowSortEnable = 'follow_sort:enable',
|
FollowSortEnable = 'follow_sort:enable',
|
||||||
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
|
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
|
||||||
CanonicalPostNumberingEnable = 'canonical_post_numbering:enable',
|
CanonicalPostNumberingEnable = 'canonical_post_numbering:enable',
|
||||||
|
ContentVisibilitySettingsEnable = 'content_visibility_settings:enable',
|
||||||
|
|
||||||
// values
|
// values
|
||||||
TrendingDiscoverValues = 'trending_discover:values',
|
TrendingDiscoverValues = 'trending_discover:values',
|
||||||
|
|||||||
@@ -1075,6 +1075,8 @@ export type Events = {
|
|||||||
'bot:label:toggle': {state: 'add' | 'remove'}
|
'bot:label:toggle': {state: 'add' | 'remove'}
|
||||||
'bot:badge:click': {}
|
'bot:badge:click': {}
|
||||||
|
|
||||||
|
'contentVisibility:algorithmicRecommendations:change': {hide: boolean}
|
||||||
|
|
||||||
'live:create': {duration: number}
|
'live:create': {duration: number}
|
||||||
'live:edit': {}
|
'live:edit': {}
|
||||||
'live:remove': {}
|
'live:remove': {}
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import * as Admonition from '#/components/Admonition'
|
|||||||
import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
|
import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
|
||||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash'
|
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash'
|
||||||
import {Key_Stroke2_Corner2_Rounded as KeyIcon} from '#/components/icons/Key'
|
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 {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText} from '#/components/Link'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
import {type app} from '#/lexicons'
|
import {type app} from '#/lexicons'
|
||||||
|
import {AlgoVisibilityOptOut} from './components/AlgoVisibilityOptOut'
|
||||||
import {Email2FAToggle} from './components/Email2FAToggle'
|
import {Email2FAToggle} from './components/Email2FAToggle'
|
||||||
import {PwiOptOut} from './components/PwiOptOut'
|
import {PwiOptOut} from './components/PwiOptOut'
|
||||||
import {ItemTextWithSubtitle} from './NotificationSettings/components/ItemTextWithSubtitle'
|
import {ItemTextWithSubtitle} from './NotificationSettings/components/ItemTextWithSubtitle'
|
||||||
@@ -28,6 +31,10 @@ type Props = NativeStackScreenProps<
|
|||||||
export function PrivacyAndSecuritySettingsScreen({}: Props) {
|
export function PrivacyAndSecuritySettingsScreen({}: Props) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const ax = useAnalytics()
|
||||||
|
const isContentVisibilityEnabled = ax.features.enabled(
|
||||||
|
ax.features.ContentVisibilitySettingsEnable,
|
||||||
|
)
|
||||||
const {data: appPasswords} = useAppPasswordsQuery()
|
const {data: appPasswords} = useAppPasswordsQuery()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const {
|
const {
|
||||||
@@ -101,13 +108,16 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) {
|
|||||||
/>
|
/>
|
||||||
</SettingsList.LinkItem>
|
</SettingsList.LinkItem>
|
||||||
<SettingsList.Divider />
|
<SettingsList.Divider />
|
||||||
<SettingsList.Group>
|
{isContentVisibilityEnabled && (
|
||||||
|
<SettingsList.Item style={[a.align_start]}>
|
||||||
|
<SettingsList.ItemIcon icon={MagnifyingGlassIcon} />
|
||||||
|
<AlgoVisibilityOptOut />
|
||||||
|
</SettingsList.Item>
|
||||||
|
)}
|
||||||
|
<SettingsList.Item style={[a.align_start]}>
|
||||||
<SettingsList.ItemIcon icon={EyeSlashIcon} />
|
<SettingsList.ItemIcon icon={EyeSlashIcon} />
|
||||||
<SettingsList.ItemText>
|
|
||||||
<Trans>Logged-out visibility</Trans>
|
|
||||||
</SettingsList.ItemText>
|
|
||||||
<PwiOptOut />
|
<PwiOptOut />
|
||||||
</SettingsList.Group>
|
</SettingsList.Item>
|
||||||
<SettingsList.Item>
|
<SettingsList.Item>
|
||||||
<Admonition.Outer type="tip" style={[a.flex_1]}>
|
<Admonition.Outer type="tip" style={[a.flex_1]}>
|
||||||
<Admonition.Row>
|
<Admonition.Row>
|
||||||
@@ -115,11 +125,10 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) {
|
|||||||
<Admonition.Content>
|
<Admonition.Content>
|
||||||
<Admonition.Text>
|
<Admonition.Text>
|
||||||
<Trans>
|
<Trans>
|
||||||
Note: Bluesky is an open and public network. This setting
|
Note: Bluesky is part of the Atmosphere, an open public
|
||||||
only limits the visibility of your content on the Bluesky
|
network. These settings ask other apps and websites to
|
||||||
app and website, and other apps may not respect this
|
limit your visibility, but they can choose not to. Your
|
||||||
setting. Your content may still be shown to logged-out
|
public content may still appear elsewhere.
|
||||||
users by other apps and websites.
|
|
||||||
</Trans>
|
</Trans>
|
||||||
</Admonition.Text>
|
</Admonition.Text>
|
||||||
<Admonition.Text>
|
<Admonition.Text>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<View style={[a.flex_1, a.gap_sm]}>
|
||||||
|
<Toggle.Item
|
||||||
|
name="algorithmic_visibility"
|
||||||
|
disabled={!canToggle}
|
||||||
|
value={isOptedOut}
|
||||||
|
onChange={onToggleOptOut}
|
||||||
|
label={l`Ask apps to hide my posts from algorithmic recommendations`}
|
||||||
|
style={[a.w_full]}>
|
||||||
|
<Toggle.LabelText style={[a.flex_1]}>
|
||||||
|
<Trans>
|
||||||
|
Ask apps to hide my posts from algorithmic recommendations
|
||||||
|
</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
<Toggle.Platform />
|
||||||
|
</Toggle.Item>
|
||||||
|
|
||||||
|
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
|
||||||
|
<Trans>
|
||||||
|
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.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -86,12 +86,12 @@ export function PwiOptOut() {
|
|||||||
value={isOptedOut}
|
value={isOptedOut}
|
||||||
onChange={onToggleOptOut}
|
onChange={onToggleOptOut}
|
||||||
label={_(
|
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]}>
|
style={[a.w_full]}>
|
||||||
<Toggle.LabelText style={[a.flex_1]}>
|
<Toggle.LabelText style={[a.flex_1]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
Discourage apps from showing my account to logged-out users
|
Ask apps and sites not to show my account to logged-out users
|
||||||
</Trans>
|
</Trans>
|
||||||
</Toggle.LabelText>
|
</Toggle.LabelText>
|
||||||
<Toggle.Platform />
|
<Toggle.Platform />
|
||||||
@@ -99,9 +99,9 @@ export function PwiOptOut() {
|
|||||||
|
|
||||||
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
|
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
Bluesky will not show your profile and posts to logged-out users.
|
Bluesky will not show your account to logged-out users and will ask
|
||||||
Other apps may not honor this request. This does not make your account
|
other apps to do the same. Other apps may not honor this request. It
|
||||||
private.
|
doesn't make your account private.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -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<app.bsky.actor.contentVisibilityDeclaration.Main>(
|
||||||
|
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})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user