flip the preferences and labeler reads to sdk types

`UsePreferencesQueryResponse` now derives from the sdk's `BskyPreferences`
rather than the legacy one, which removes the cast the previous slice put on the
assembled response - the query already returns the sdk action's result, so the
two now type structurally.

`labeler.ts`'s three service reads move to the appview client and return
`app.bsky.labeler.defs` views, because `interpretLabelValueDefinitions` takes
the lexicon-typed view. `moderation-opts.tsx` reads `Client.appLabelers`
instead of `AtpAgent.appLabelers`: the lex static is already branded, so the
fallback labeler list satisfies `ModerationPrefsLabeler` without a cast.

`MutedWords` follows the prefs types: `expiresAt` is a `DatetimeString` now,
built with `toDatetimeString` rather than `Date.toISOString`, and
`sanitizeMutedWordValue` comes from `@bsky.app/sdk/utils`.
This commit is contained in:
Samuel Newman
2026-08-04 02:35:51 +03:00
parent 9aa6b49daf
commit f1411df0a4
9 changed files with 69 additions and 75 deletions
+10 -8
View File
@@ -1,6 +1,7 @@
import {useCallback, useState} from 'react' import {useCallback, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {type AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api' import {type DatetimeString, toDatetimeString} from '@atproto/syntax'
import {sanitizeMutedWordValue} from '@bsky.app/sdk/utils'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
@@ -35,6 +36,7 @@ import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import {type app} from '#/lexicons'
const ONE_DAY = 24 * 60 * 60 * 1000 const ONE_DAY = 24 * 60 * 60 * 1000
@@ -68,20 +70,20 @@ function MutedWordsInner() {
const sanitizedValue = sanitizeMutedWordValue(field) const sanitizedValue = sanitizeMutedWordValue(field)
const surfaces = ['tag', targets.includes('content') && 'content'].filter( const surfaces = ['tag', targets.includes('content') && 'content'].filter(
Boolean, Boolean,
) as AppBskyActorDefs.MutedWord['targets'] ) as app.bsky.actor.defs.MutedWord['targets']
const actorTarget = excludeFollowing ? 'exclude-following' : 'all' const actorTarget = excludeFollowing ? 'exclude-following' : 'all'
const now = Date.now() const now = Date.now()
const rawDuration = durations.at(0) const rawDuration = durations.at(0)
// undefined evaluates to 'forever' // undefined evaluates to 'forever'
let duration: string | undefined let duration: DatetimeString | undefined
if (rawDuration === '24_hours') { if (rawDuration === '24_hours') {
duration = new Date(now + ONE_DAY).toISOString() duration = toDatetimeString(new Date(now + ONE_DAY))
} else if (rawDuration === '7_days') { } else if (rawDuration === '7_days') {
duration = new Date(now + 7 * ONE_DAY).toISOString() duration = toDatetimeString(new Date(now + 7 * ONE_DAY))
} else if (rawDuration === '30_days') { } else if (rawDuration === '30_days') {
duration = new Date(now + 30 * ONE_DAY).toISOString() duration = toDatetimeString(new Date(now + 30 * ONE_DAY))
} }
if (!sanitizedValue || !surfaces.length) { if (!sanitizedValue || !surfaces.length) {
@@ -421,7 +423,7 @@ function MutedWordsInner() {
function MutedWordRow({ function MutedWordRow({
style, style,
word, word,
}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) { }: ViewStyleProp & {word: app.bsky.actor.defs.MutedWord}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation() const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
@@ -440,7 +442,7 @@ function MutedWordRow({
updateMutedWord({ updateMutedWord({
...word, ...word,
expiresAt: days expiresAt: days
? new Date(Date.now() + days * ONE_DAY).toISOString() ? toDatetimeString(new Date(Date.now() + days * ONE_DAY))
: undefined, : undefined,
}) })
} }
@@ -23,7 +23,6 @@ import {PostInteractionSettingsForm} from '#/components/dialogs/PostInteractionS
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {type app} from '#/lexicons'
export function Screen() { export function Screen() {
const gutters = useGutters(['base']) const gutters = useGutters(['base'])
@@ -76,15 +75,14 @@ function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) {
* Preferences are still typed against the legacy client, so the stored * Preferences are still typed against the legacy client, so the stored
* rules arrive unbranded. Wave B migrates `getPreferences`. * rules arrive unbranded. Wave B migrates `getPreferences`.
*/ */
allow: preferences.postInteractionSettings allow: preferences.postInteractionSettings.threadgateAllowRules,
.threadgateAllowRules as app.bsky.feed.threadgate.Main['allow'],
}) })
}, [preferences.postInteractionSettings.threadgateAllowRules]) }, [preferences.postInteractionSettings.threadgateAllowRules])
const postgate = useMemo(() => { const postgate = useMemo(() => {
return createPostgateRecord({ return createPostgateRecord({
post: '', post: '',
embeddingRules: preferences.postInteractionSettings embeddingRules:
.postgateEmbeddingRules as app.bsky.feed.postgate.Main['embeddingRules'], preferences.postInteractionSettings.postgateEmbeddingRules,
}) })
}, [preferences.postInteractionSettings.postgateEmbeddingRules]) }, [preferences.postInteractionSettings.postgateEmbeddingRules])
+3 -5
View File
@@ -1,14 +1,12 @@
import {createContext, useContext} from 'react' import {createContext, useContext} from 'react'
import { import {type InterpretedLabelValueDefinition} from '@bsky.app/sdk/moderation'
type AppBskyLabelerDefs,
type InterpretedLabelValueDefinition,
} from '@atproto/api'
import {type app} from '#/lexicons'
import {useLabelDefinitionsQuery} from '../queries/preferences' import {useLabelDefinitionsQuery} from '../queries/preferences'
interface StateContext { interface StateContext {
labelDefs: Record<string, InterpretedLabelValueDefinition[]> labelDefs: Record<string, InterpretedLabelValueDefinition[]>
labelers: AppBskyLabelerDefs.LabelerViewDetailed[] labelers: app.bsky.labeler.defs.LabelerViewDetailed[]
} }
const stateContext = createContext<StateContext>({ const stateContext = createContext<StateContext>({
+10 -4
View File
@@ -1,5 +1,6 @@
import {createContext, useContext, useMemo} from 'react' import {createContext, useContext, useMemo} from 'react'
import {AtpAgent, type ModerationOpts} from '@atproto/api' import {Client} from '@atproto/lex'
import {type ModerationOpts} from '@bsky.app/sdk/moderation'
import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences' import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const' import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
@@ -38,16 +39,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return undefined return undefined
} }
return { return {
userDid, /*
* `did`/`hiddenPosts` come from persisted storage typed as plain
* `string`, so brand them to the SDK's `DidString`/`AtUriString` slots.
*/
userDid: userDid as ModerationOpts['userDid'],
prefs: { prefs: {
...moderationPrefs, ...moderationPrefs,
labelers: moderationPrefs.labelers.length labelers: moderationPrefs.labelers.length
? moderationPrefs.labelers ? moderationPrefs.labelers
: AtpAgent.appLabelers.map(did => ({ : Client.appLabelers.map(did => ({
did, did,
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
})), })),
hiddenPosts: hiddenPosts || [], hiddenPosts: (hiddenPosts ||
[]) as ModerationOpts['prefs']['hiddenPosts'],
}, },
labelDefs, labelDefs,
} }
+27 -23
View File
@@ -1,4 +1,3 @@
import {type AppBskyLabelerDefs} from '@atproto/api'
import {type DidString} from '@atproto/syntax' import {type DidString} from '@atproto/syntax'
import {addLabeler, removeLabeler} from '@bsky.app/sdk' import {addLabeler, removeLabeler} from '@bsky.app/sdk'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
@@ -11,7 +10,8 @@ import {
usePreferencesQuery, usePreferencesQuery,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {createQueryKey} from '#/state/queries/util' import {createQueryKey} from '#/state/queries/util'
import {useAgent, usePdsClient} from '#/state/session' import {useAppviewClient, usePdsClient} from '#/state/session'
import {app} from '#/lexicons'
const labelerInfoQueryKeyRoot = 'labeler-info' const labelerInfoQueryKeyRoot = 'labeler-info'
export const labelerInfoQueryKey = (did: string) => [ export const labelerInfoQueryKey = (did: string) => [
@@ -35,45 +35,47 @@ export function useLabelerInfoQuery({
did?: string did?: string
enabled?: boolean enabled?: boolean
}) { }) {
const agent = useAgent() const client = useAppviewClient()
return useQuery({ return useQuery({
enabled: !!did && enabled !== false, enabled: !!did && enabled !== false,
queryKey: labelerInfoQueryKey(did as string), queryKey: labelerInfoQueryKey(did as string),
queryFn: async () => { queryFn: async () => {
const res = await agent.app.bsky.labeler.getServices({ const res = await client.call(app.bsky.labeler.getServices, {
dids: [did!], dids: [did! as DidString],
detailed: true, detailed: true,
}) })
return res.data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed return res.views[0] as app.bsky.labeler.defs.LabelerViewDetailed
}, },
}) })
} }
export function useLabelersInfoQuery({dids}: {dids: string[]}) { export function useLabelersInfoQuery({dids}: {dids: string[]}) {
const agent = useAgent() const client = useAppviewClient()
return useQuery({ return useQuery({
enabled: !!dids.length, enabled: !!dids.length,
queryKey: labelersInfoQueryKey(dids), queryKey: labelersInfoQueryKey(dids),
queryFn: async () => { queryFn: async () => {
const res = await agent.app.bsky.labeler.getServices({dids}) const res = await client.call(app.bsky.labeler.getServices, {
return res.data.views as AppBskyLabelerDefs.LabelerView[] dids: dids as DidString[],
})
return res.views as app.bsky.labeler.defs.LabelerView[]
}, },
}) })
} }
export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) { export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) {
const agent = useAgent() const client = useAppviewClient()
return useQuery({ return useQuery({
enabled: !!dids.length, enabled: !!dids.length,
queryKey: createLabelersDetailedInfoQueryKey(dids), queryKey: createLabelersDetailedInfoQueryKey(dids),
gcTime: GCTIME.INFINITY, gcTime: GCTIME.INFINITY,
staleTime: STALE.MINUTES.ONE, staleTime: STALE.MINUTES.ONE,
queryFn: async () => { queryFn: async () => {
const res = await agent.app.bsky.labeler.getServices({ const res = await client.call(app.bsky.labeler.getServices, {
dids, dids: dids as DidString[],
detailed: true, detailed: true,
}) })
return res.data.views as AppBskyLabelerDefs.LabelerViewDetailed[] return res.views as app.bsky.labeler.defs.LabelerViewDetailed[]
}, },
}) })
} }
@@ -98,7 +100,7 @@ export function useRemoveLabelersMutation() {
export function useLabelerSubscriptionMutation() { export function useLabelerSubscriptionMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const agent = useAgent() const appviewClient = useAppviewClient()
const pdsClient = usePdsClient() const pdsClient = usePdsClient()
const preferences = usePreferencesQuery() const preferences = usePreferencesQuery()
@@ -122,28 +124,30 @@ export function useLabelerSubscriptionMutation() {
const labelerDids = ( const labelerDids = (
preferences.data?.moderationPrefs?.labelers ?? [] preferences.data?.moderationPrefs?.labelers ?? []
).map(l => l.did) ).map(l => l.did)
const invalidLabelers: string[] = [] const invalidLabelers: DidString[] = []
if (labelerDids.length) { if (labelerDids.length) {
const profiles = await agent.getProfiles({actors: labelerDids}) const profiles = await appviewClient.call(app.bsky.actor.getProfiles, {
if (profiles.data) { actors: labelerDids,
for (const did of labelerDids) { })
const exists = profiles.data.profiles.find(p => p.did === did) if (profiles) {
for (const labelerDid of labelerDids) {
const exists = profiles.profiles.find(p => p.did === labelerDid)
if (exists) { if (exists) {
// profile came back but it's not a valid labeler // profile came back but it's not a valid labeler
if (exists.associated && !exists.associated.labeler) { if (exists.associated && !exists.associated.labeler) {
invalidLabelers.push(did) invalidLabelers.push(labelerDid)
} }
} else { } else {
// no response came back, might be deactivated or takendown // no response came back, might be deactivated or takendown
invalidLabelers.push(did) invalidLabelers.push(labelerDid)
} }
} }
} }
} }
if (invalidLabelers.length) { if (invalidLabelers.length) {
await Promise.all( await Promise.all(
invalidLabelers.map(did => invalidLabelers.map(labelerDid =>
pdsClient.call(removeLabeler, did as DidString), pdsClient.call(removeLabeler, labelerDid),
), ),
) )
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import {DEFAULT_LABEL_SETTINGS} from '@atproto/api' import {DEFAULT_LABEL_SETTINGS} from '@bsky.app/sdk/moderation'
import { import {
type ThreadViewPreferences, type ThreadViewPreferences,
+12 -27
View File
@@ -89,13 +89,10 @@ export function usePreferencesQuery() {
agent.configureLabelers(labelerDids) agent.configureLabelers(labelerDids)
/* /*
* The sdk's `BskyPreferences` is field-for-field identical to the * `BskyPreferences` is now the sdk's own type, so the assembled
* legacy one that `UsePreferencesQueryResponse` is still derived from; * response types structurally with no cast at this seam.
* only its strings are branded (`AtUriString`, `DidString`). Cast at
* this one seam so every downstream consumer of the response - notably
* the `moderationPrefs` readers - keeps its current types.
*/ */
const preferences = { const preferences: UsePreferencesQueryResponse = {
...res, ...res,
savedFeeds: res.savedFeeds.filter(f => f.type !== 'unknown'), savedFeeds: res.savedFeeds.filter(f => f.type !== 'unknown'),
/** /**
@@ -111,7 +108,7 @@ export function usePreferencesQuery() {
...(res.threadViewPrefs ?? {}), ...(res.threadViewPrefs ?? {}),
}, },
userAge: res.birthDate ? getAge(res.birthDate) : undefined, userAge: res.birthDate ? getAge(res.birthDate) : undefined,
} as UsePreferencesQueryResponse }
return preferences return preferences
} }
}, },
@@ -381,11 +378,8 @@ export function useUpsertMutedWordsMutation() {
const client = usePdsClient() const client = usePdsClient()
return useMutation({ return useMutation({
mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => { mutationFn: async (mutedWords: app.bsky.actor.defs.MutedWord[]) => {
await client.call( await client.call(upsertMutedWords, mutedWords)
upsertMutedWords,
mutedWords as app.bsky.actor.defs.MutedWord[],
)
// triggers a refetch // triggers a refetch
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: preferencesQueryKey, queryKey: preferencesQueryKey,
@@ -399,11 +393,8 @@ export function useUpdateMutedWordMutation() {
const client = usePdsClient() const client = usePdsClient()
return useMutation({ return useMutation({
mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => { mutationFn: async (mutedWord: app.bsky.actor.defs.MutedWord) => {
await client.call( await client.call(updateMutedWord, mutedWord)
updateMutedWord,
mutedWord as app.bsky.actor.defs.MutedWord,
)
// triggers a refetch // triggers a refetch
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: preferencesQueryKey, queryKey: preferencesQueryKey,
@@ -417,11 +408,8 @@ export function useRemoveMutedWordMutation() {
const client = usePdsClient() const client = usePdsClient()
return useMutation({ return useMutation({
mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => { mutationFn: async (mutedWord: app.bsky.actor.defs.MutedWord) => {
await client.call( await client.call(removeMutedWord, mutedWord)
removeMutedWord,
mutedWord as app.bsky.actor.defs.MutedWord,
)
// triggers a refetch // triggers a refetch
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: preferencesQueryKey, queryKey: preferencesQueryKey,
@@ -435,11 +423,8 @@ export function useRemoveMutedWordsMutation() {
const client = usePdsClient() const client = usePdsClient()
return useMutation({ return useMutation({
mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => { mutationFn: async (mutedWords: app.bsky.actor.defs.MutedWord[]) => {
await client.call( await client.call(removeMutedWords, mutedWords)
removeMutedWords,
mutedWords as app.bsky.actor.defs.MutedWord[],
)
// triggers a refetch // triggers a refetch
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: preferencesQueryKey, queryKey: preferencesQueryKey,
+2 -1
View File
@@ -1,5 +1,6 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import {AtpAgent, interpretLabelValueDefinitions} from '@atproto/api' import {AtpAgent} from '@atproto/api'
import {interpretLabelValueDefinitions} from '@bsky.app/sdk/moderation'
import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities'
import {useLabelersDetailedInfoQuery} from '../labeler' import {useLabelersDetailedInfoQuery} from '../labeler'
+1 -1
View File
@@ -1,4 +1,4 @@
import {type BskyFeedViewPreference, type BskyPreferences} from '@atproto/api' import {type BskyFeedViewPreference, type BskyPreferences} from '@bsky.app/sdk'
export type UsePreferencesQueryResponse = Omit< export type UsePreferencesQueryResponse = Omit<
BskyPreferences, BskyPreferences,