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