From 034345a9b679f4795be8e6fad1f6a0492ce05d81 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Tue, 11 Aug 2026 09:29:51 -0400 Subject: [PATCH] Store content visibility in actor record --- src/analytics/metrics/types.ts | 2 + .../components/AlgoVisibilityOptOut.tsx | 74 +++------------- .../queries/content-visibility-record.ts | 34 +++++++ src/state/queries/content-visibility.test.ts | 41 +++++++++ src/state/queries/content-visibility.ts | 88 +++++++++++++++++++ 5 files changed, 178 insertions(+), 61 deletions(-) create mode 100644 src/state/queries/content-visibility-record.ts create mode 100644 src/state/queries/content-visibility.test.ts create mode 100644 src/state/queries/content-visibility.ts diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index b5e01d11e3..fe302592c0 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -1080,6 +1080,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/components/AlgoVisibilityOptOut.tsx b/src/screens/Settings/components/AlgoVisibilityOptOut.tsx index 3a2f41482e..fb64935b10 100644 --- a/src/screens/Settings/components/AlgoVisibilityOptOut.tsx +++ b/src/screens/Settings/components/AlgoVisibilityOptOut.tsx @@ -1,79 +1,31 @@ -import {useCallback} from 'react' import {View} from 'react-native' -import {type $Typed, ComAtprotoLabelDefs} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import { - useProfileQuery, - useProfileUpdateMutation, -} from '#/state/queries/profile' -import {useSession} from '#/state/session' + 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' -import * as bsky from '#/types/bsky' - -const NO_PROMOTE_LABEL = '!no-promote' +import {useAnalytics} from '#/analytics' export function AlgoVisibilityOptOut() { const t = useTheme() const {_} = useLingui() - const {currentAccount} = useSession() - const {data: profile} = useProfileQuery({did: currentAccount?.did}) - const updateProfile = useProfileUpdateMutation() + const ax = useAnalytics() + const {data, isPending: isQueryPending} = useContentVisibilityQuery() + const updateContentVisibility = useContentVisibilityMutation() - const isOptedOut = - profile?.labels?.some(label => label.val === NO_PROMOTE_LABEL) || false - const canToggle = profile && !updateProfile.isPending + const isOptedOut = data?.hideFromAlgorithmicRecommendations ?? false + const canToggle = !isQueryPending && !updateContentVisibility.isPending - const onToggleOptOut = useCallback(() => { - if (!profile) { - return - } - let wasAdded = false - updateProfile.mutate({ - profile, - updates: existing => { - const labels: $Typed = bsky.validate( - existing.labels, - ComAtprotoLabelDefs.validateSelfLabels, - ) - ? existing.labels - : { - $type: 'com.atproto.label.defs#selfLabels', - values: [], - } - - const hasLabel = labels.values.some( - label => label.val === NO_PROMOTE_LABEL, - ) - if (hasLabel) { - labels.values = labels.values.filter( - label => label.val !== NO_PROMOTE_LABEL, - ) - } else { - wasAdded = true - labels.values.push({val: NO_PROMOTE_LABEL}) - } - - if (labels.values.length === 0) { - delete existing.labels - } else { - existing.labels = labels - } - - return existing - }, - checkCommitted: response => { - const exists = !!response.data.labels?.some( - label => label.val === NO_PROMOTE_LABEL, - ) - return exists === wasAdded - }, - }) - }, [updateProfile, profile]) + const onToggleOptOut = (hide: boolean) => { + ax.metric('contentVisibility:algorithmicRecommendations:change', {hide}) + updateContentVisibility.mutate(hide) + } return ( diff --git a/src/state/queries/content-visibility-record.ts b/src/state/queries/content-visibility-record.ts new file mode 100644 index 0000000000..2bfc07148a --- /dev/null +++ b/src/state/queries/content-visibility-record.ts @@ -0,0 +1,34 @@ +export const CONTENT_VISIBILITY_COLLECTION = + 'app.bsky.actor.contentVisibility' as const +export const CONTENT_VISIBILITY_RKEY = 'self' + +export type ContentVisibilityRecord = { + $type: typeof CONTENT_VISIBILITY_COLLECTION + hideFromAlgorithmicRecommendations: boolean +} + +export function createContentVisibilityRecord( + hideFromAlgorithmicRecommendations: boolean, +): ContentVisibilityRecord { + return { + $type: CONTENT_VISIBILITY_COLLECTION, + hideFromAlgorithmicRecommendations, + } +} + +export function parseContentVisibilityRecord( + value: unknown, +): ContentVisibilityRecord { + if ( + typeof value !== 'object' || + value === null || + !('$type' in value) || + value.$type !== CONTENT_VISIBILITY_COLLECTION || + !('hideFromAlgorithmicRecommendations' in value) || + typeof value.hideFromAlgorithmicRecommendations !== 'boolean' + ) { + throw new Error('Invalid content visibility record') + } + + return value as ContentVisibilityRecord +} diff --git a/src/state/queries/content-visibility.test.ts b/src/state/queries/content-visibility.test.ts new file mode 100644 index 0000000000..08f3f31967 --- /dev/null +++ b/src/state/queries/content-visibility.test.ts @@ -0,0 +1,41 @@ +import { + CONTENT_VISIBILITY_COLLECTION, + createContentVisibilityRecord, + parseContentVisibilityRecord, +} from './content-visibility-record' + +describe('content visibility records', () => { + it('creates a record with explicit hide semantics', () => { + expect(createContentVisibilityRecord(true)).toEqual({ + $type: CONTENT_VISIBILITY_COLLECTION, + hideFromAlgorithmicRecommendations: true, + }) + expect(createContentVisibilityRecord(false)).toEqual({ + $type: CONTENT_VISIBILITY_COLLECTION, + hideFromAlgorithmicRecommendations: false, + }) + }) + + it('parses valid records', () => { + const record = createContentVisibilityRecord(true) + expect(parseContentVisibilityRecord(record)).toBe(record) + }) + + it.each([ + null, + {}, + {$type: CONTENT_VISIBILITY_COLLECTION}, + { + $type: CONTENT_VISIBILITY_COLLECTION, + hideFromAlgorithmicRecommendations: 'true', + }, + { + $type: 'app.bsky.actor.profile', + hideFromAlgorithmicRecommendations: true, + }, + ])('rejects invalid records: %p', value => { + expect(() => parseContentVisibilityRecord(value)).toThrow( + 'Invalid content visibility record', + ) + }) +}) diff --git a/src/state/queries/content-visibility.ts b/src/state/queries/content-visibility.ts new file mode 100644 index 0000000000..bce40dfc8c --- /dev/null +++ b/src/state/queries/content-visibility.ts @@ -0,0 +1,88 @@ +import {t} from '@lingui/core/macro' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' + +import {useAgent, useSession} from '#/state/session' +import * as Toast from '#/components/Toast' +import { + CONTENT_VISIBILITY_COLLECTION, + CONTENT_VISIBILITY_RKEY, + type ContentVisibilityRecord, + createContentVisibilityRecord, + parseContentVisibilityRecord, +} from './content-visibility-record' + +export const contentVisibilityQueryKey = (did: string) => [ + 'content-visibility', + did, +] + +export function useContentVisibilityQuery() { + const agent = useAgent() + const {currentAccount} = useSession() + const did = currentAccount?.did + + return useQuery({ + queryKey: contentVisibilityQueryKey(did ?? ''), + queryFn: async () => { + try { + const response = await agent.com.atproto.repo.getRecord({ + repo: did!, + collection: CONTENT_VISIBILITY_COLLECTION, + rkey: CONTENT_VISIBILITY_RKEY, + }) + return parseContentVisibilityRecord(response.data.value) + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith('Could not locate record') + ) { + return createContentVisibilityRecord(false) + } + throw error + } + }, + enabled: !!did, + }) +} + +export function useContentVisibilityMutation() { + const agent = useAgent() + 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 = createContentVisibilityRecord( + hideFromAlgorithmicRecommendations, + ) + await agent.com.atproto.repo.putRecord({ + repo: did, + collection: CONTENT_VISIBILITY_COLLECTION, + rkey: CONTENT_VISIBILITY_RKEY, + record, + }) + return record + }, + onMutate: async hideFromAlgorithmicRecommendations => { + await queryClient.cancelQueries({queryKey}) + const previous = + queryClient.getQueryData(queryKey) + queryClient.setQueryData( + queryKey, + createContentVisibilityRecord(hideFromAlgorithmicRecommendations), + ) + return {previous} + }, + onError: (_error, _variables, context) => { + queryClient.setQueryData(queryKey, context?.previous) + Toast.show(t`Failed to update content visibility`) + }, + onSettled: () => { + void queryClient.invalidateQueries({queryKey}) + }, + }) +}