[SDK] Swap moderation to @bsky.app/sdk and move labeler config to Client statics (#11383)
This commit is contained in:
@@ -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>({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import {useCallback} from 'react'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
moderateProfile,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
import {type ModerationOpts} from '@bsky.app/sdk/moderation'
|
||||
import {keepPreviousData, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation'
|
||||
import {moderateProfile} from '#/lib/moderation/subjects'
|
||||
import {logger} from '#/logger'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {useAppviewClient} from '#/state/session'
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {useMemo, useRef} from 'react'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
AppBskyFeedDefs,
|
||||
AtUri,
|
||||
moderatePost,
|
||||
} from '@atproto/api'
|
||||
import {type AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {type AtUriString} from '@atproto/syntax'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -17,6 +12,7 @@ import {
|
||||
import {CustomFeedAPI} from '#/lib/api/feed/custom'
|
||||
import {aggregateUserInterests} from '#/lib/api/feed/utils'
|
||||
import {FeedTuner} from '#/lib/api/feed-manip'
|
||||
import {moderatePost} from '#/lib/moderation/subjects'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyGraphDefs,
|
||||
AtUri,
|
||||
moderateFeedGenerator,
|
||||
} from '@atproto/api'
|
||||
import {type AtUriString} from '@atproto/syntax'
|
||||
import {RichText} from '@bsky.app/sdk/richtext'
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants'
|
||||
import {moderateFeedGenerator} from '#/lib/moderation/subjects'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {asSdkFacets} from '#/lib/strings/rich-text-helpers'
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,12 +17,7 @@
|
||||
*/
|
||||
|
||||
import {useCallback, useEffect, useMemo, useRef} from 'react'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AtUri,
|
||||
moderatePost,
|
||||
} from '@atproto/api'
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
|
||||
import {
|
||||
type InfiniteData,
|
||||
type QueryClient,
|
||||
@@ -31,6 +26,7 @@ import {
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {moderatePost} from '#/lib/moderation/subjects'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {useAppviewClient} from '#/state/session'
|
||||
|
||||
@@ -6,16 +6,15 @@ import {
|
||||
type AppBskyGraphDefs,
|
||||
AppBskyGraphStarterpack,
|
||||
type AppBskyNotificationListNotifications,
|
||||
hasMutedWord,
|
||||
moderateNotification,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {type Client} from '@atproto/lex'
|
||||
import {type AtUriString} from '@atproto/syntax'
|
||||
import {type ModerationOpts} from '@bsky.app/sdk/moderation'
|
||||
import {type QueryClient} from '@tanstack/react-query'
|
||||
import chunk from 'lodash.chunk'
|
||||
|
||||
import {labelIsHideableOffense} from '#/lib/moderation'
|
||||
import {hasMutedWord, moderateNotification} from '#/lib/moderation/subjects'
|
||||
import {app} from '#/lexicons'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {precacheProfile} from '../profile'
|
||||
|
||||
@@ -5,12 +5,13 @@ import {
|
||||
AppBskyFeedDefs,
|
||||
type AppBskyFeedPost,
|
||||
AtUri,
|
||||
moderatePost,
|
||||
type ModerationDecision,
|
||||
type ModerationPrefs,
|
||||
} from '@atproto/api'
|
||||
import {type Client} from '@atproto/lex'
|
||||
import {type AtIdentifierString, type AtUriString} from '@atproto/syntax'
|
||||
import {
|
||||
type ModerationDecision,
|
||||
type ModerationPrefs,
|
||||
} from '@bsky.app/sdk/moderation'
|
||||
import {
|
||||
type InfiniteData,
|
||||
type QueryClient,
|
||||
@@ -31,6 +32,7 @@ import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types'
|
||||
import {aggregateUserInterests} from '#/lib/api/feed/utils'
|
||||
import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip'
|
||||
import {DISCOVER_FEED_URI} from '#/lib/constants'
|
||||
import {moderatePost} from '#/lib/moderation/subjects'
|
||||
import {logger} from '#/logger'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {DEFAULT_LABEL_SETTINGS} from '@atproto/api'
|
||||
import {DEFAULT_LABEL_SETTINGS} from '@bsky.app/sdk/moderation'
|
||||
|
||||
import {
|
||||
type ThreadViewPreferences,
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from '#/state/queries/preferences/types'
|
||||
import {createQueryKey} from '#/state/queries/util'
|
||||
import {useAgent, usePdsClient} from '#/state/session'
|
||||
import {saveLabelers} from '#/state/session/moderation'
|
||||
import {applyLabelersToClient, saveLabelers} from '#/state/session/moderation'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
@@ -81,21 +81,23 @@ export function usePreferencesQuery() {
|
||||
/*
|
||||
* `BskyAgent.getPreferences` used to call `configureLabelers` itself,
|
||||
* so subscribing to a labeler took effect on the very next appview
|
||||
* read. The sdk action has no such side effect, and appview reads still
|
||||
* inherit `atproto-accept-labelers` from the agent's fetch handler, so
|
||||
* apply the subscriptions there rather than on the wrapping client -
|
||||
* setting them on both would emit the header twice.
|
||||
* read. The sdk action has no such side effect, so apply the
|
||||
* subscriptions here - without this, subscribing to or unsubscribing
|
||||
* from a labeler would not affect server-attached labels until the
|
||||
* session bundle was rebuilt.
|
||||
*
|
||||
* `applyLabelersToClient` writes to the agent, which is what stamps the
|
||||
* header on the requests the wrapping appview client issues, and it
|
||||
* drops the Bluesky moderation DID so the globally redacted authority is
|
||||
* not also listed unredacted.
|
||||
*/
|
||||
agent.configureLabelers(labelerDids)
|
||||
applyLabelersToClient(agent, 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 +113,7 @@ export function usePreferencesQuery() {
|
||||
...(res.threadViewPrefs ?? {}),
|
||||
},
|
||||
userAge: res.birthDate ? getAge(res.birthDate) : undefined,
|
||||
} as UsePreferencesQueryResponse
|
||||
}
|
||||
return preferences
|
||||
}
|
||||
},
|
||||
@@ -381,11 +383,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 +398,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 +413,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 +428,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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import {moderateFeedGenerator} from '@atproto/api'
|
||||
import {type DidString} from '@atproto/syntax'
|
||||
import {
|
||||
type InfiniteData,
|
||||
@@ -6,6 +5,7 @@ import {
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {moderateFeedGenerator} from '#/lib/moderation/subjects'
|
||||
import {useAppviewClient} from '#/state/session'
|
||||
import {app} from '#/lexicons'
|
||||
import {useModerationOpts} from '../preferences/moderation-opts'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import {moderateUserList} from '@atproto/api'
|
||||
import {type DidString} from '@atproto/syntax'
|
||||
import {
|
||||
type InfiniteData,
|
||||
@@ -6,6 +5,7 @@ import {
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {moderateUserList} from '#/lib/moderation/subjects'
|
||||
import {useAppviewClient} from '#/state/session'
|
||||
import {app} from '#/lexicons'
|
||||
import {useModerationOpts} from '../preferences/moderation-opts'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useCallback, useMemo, useRef} from 'react'
|
||||
import {type AppBskyFeedDefs, AtUri, moderatePost} from '@atproto/api'
|
||||
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {
|
||||
type InfiniteData,
|
||||
type QueryClient,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {moderatePost} from '#/lib/moderation/subjects'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useAppviewClient} from '#/state/session'
|
||||
import {type SearchFilters} from '#/screens/Search/searchParams'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {hasMutedWord} from '@atproto/api'
|
||||
import {hasMutedWord} from '@bsky.app/sdk/moderation'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {AppBskyUnspeccedDefs, type ModerationOpts} from '@atproto/api'
|
||||
import {AppBskyUnspeccedDefs} from '@atproto/api'
|
||||
import {type ModerationOpts} from '@bsky.app/sdk/moderation'
|
||||
|
||||
import {
|
||||
type ApiThreadItem,
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
type AppBskyUnspeccedDefs,
|
||||
type AppBskyUnspeccedGetPostThreadOtherV2,
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
type ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {type ModerationDecision} from '@bsky.app/sdk/moderation'
|
||||
|
||||
export type ApiThreadItem =
|
||||
| AppBskyUnspeccedGetPostThreadV2.ThreadItem
|
||||
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
type AppBskyUnspeccedDefs,
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
AtUri,
|
||||
moderatePost,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {type ModerationOpts} from '@bsky.app/sdk/moderation'
|
||||
|
||||
import {moderatePost} from '#/lib/moderation/subjects'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {
|
||||
type ApiThreadItem,
|
||||
|
||||
@@ -15,6 +15,7 @@ jest.mock('jwt-decode', () => ({
|
||||
|
||||
import {CHAT_PROXY_SERVICE} from '#/lib/constants'
|
||||
import {app, chat, com} from '#/lexicons'
|
||||
import {configureGlobalAppLabelers} from '../additional-moderation-authorities'
|
||||
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
|
||||
import {
|
||||
agentToAppviewClient,
|
||||
@@ -163,6 +164,32 @@ describe('agentToAppviewClient', () => {
|
||||
expect(entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not duplicate a global app labeler set on both statics', async () => {
|
||||
/*
|
||||
* `configureGlobalAppLabelers` populates the agent AND the lex `Client`
|
||||
* static, because clients built without a wrapped agent read only the
|
||||
* latter. On this path both producers are in play for the same request, and
|
||||
* neither dedupes against the other - the agent joins its list with the
|
||||
* existing header string while lex collects into a `Set` keyed on the
|
||||
* `;redact`-suffixed value. The appview client suppresses its `appLabelers`
|
||||
* so exactly one producer contributes.
|
||||
*/
|
||||
configureGlobalAppLabelers(['did:plc:global-labeler'])
|
||||
const {agent} = setup(fetchMock)
|
||||
|
||||
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, {
|
||||
actor: HANDLE,
|
||||
})
|
||||
|
||||
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
|
||||
const labelers = new Headers(init?.headers).get('atproto-accept-labelers')
|
||||
const entries = labelers!
|
||||
.split(',')
|
||||
.map(l => l.trim())
|
||||
.filter(l => l.includes('did:plc:global-labeler'))
|
||||
expect(entries).toEqual(['did:plc:global-labeler;redact'])
|
||||
})
|
||||
|
||||
it('sends the session access token', async () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
|
||||
@@ -238,6 +265,8 @@ describe('agentToPdsClient', () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
agent.configureProxy('did:web:api.bsky.app#bsky_appview')
|
||||
agent.configureLabelersHeader(['did:plc:labeler'])
|
||||
/* nor the global authorities: a PDS call is not an appview read */
|
||||
configureGlobalAppLabelers(['did:plc:global-labeler'])
|
||||
|
||||
await agentToPdsClient(agent).call(com.atproto.server.getSession, {})
|
||||
|
||||
@@ -302,6 +331,8 @@ describe('agentToChatClient', () => {
|
||||
it('does not emit the agent labeler header', async () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
agent.configureLabelersHeader(['did:plc:labeler'])
|
||||
/* nor the global authorities: a chat call is not an appview read */
|
||||
configureGlobalAppLabelers(['did:plc:global-labeler'])
|
||||
|
||||
await agentToChatClient(agent)
|
||||
.call(chat.bsky.convo.listConvos, {})
|
||||
|
||||
@@ -838,8 +838,8 @@ describe('factory account snapshot after preparation', () => {
|
||||
*/
|
||||
let capturedAgent: BskyAppAgent | undefined
|
||||
mockConfigureModerationForAccount.mockImplementationOnce(
|
||||
(agent: unknown) => {
|
||||
capturedAgent = agent as BskyAppAgent
|
||||
(bundle: unknown) => {
|
||||
capturedAgent = (bundle as {agent: BskyAppAgent}).agent
|
||||
},
|
||||
)
|
||||
mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => {
|
||||
@@ -904,8 +904,8 @@ describe('a session destroyed or rejected during preparation', () => {
|
||||
*/
|
||||
let capturedAgent: BskyAppAgent | undefined
|
||||
mockConfigureModerationForAccount.mockImplementationOnce(
|
||||
(agent: unknown) => {
|
||||
capturedAgent = agent as BskyAppAgent
|
||||
(bundle: unknown) => {
|
||||
capturedAgent = (bundle as {agent: BskyAppAgent}).agent
|
||||
},
|
||||
)
|
||||
mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => {
|
||||
@@ -934,8 +934,8 @@ describe('a session destroyed or rejected during preparation', () => {
|
||||
it('resume: a prep rejection propagates and disposes the bundle', async () => {
|
||||
let capturedAgent: BskyAppAgent | undefined
|
||||
mockConfigureModerationForAccount.mockImplementationOnce(
|
||||
(agent: unknown) => {
|
||||
capturedAgent = agent as BskyAppAgent
|
||||
(bundle: unknown) => {
|
||||
capturedAgent = (bundle as {agent: BskyAppAgent}).agent
|
||||
},
|
||||
)
|
||||
mockPrefetchAgeAssuranceServerData.mockImplementationOnce(() =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
import {Client} from '@atproto/lex'
|
||||
|
||||
import {device} from '#/storage'
|
||||
|
||||
@@ -86,5 +87,23 @@ export function configureAdditionalModerationAuthorities() {
|
||||
new Set([...AtpAgent.appLabelers, ...additionalLabelers]),
|
||||
)
|
||||
|
||||
AtpAgent.configure({appLabelers})
|
||||
configureGlobalAppLabelers(appLabelers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the global app labelers on BOTH statics, so the agent-backed request path
|
||||
* and any client built without a wrapped agent emit the same `;redact`
|
||||
* authorities.
|
||||
*
|
||||
* Keeping the two in lockstep is what makes the duplicate-header hazard
|
||||
* avoidable: the agent joins its own list with whatever the caller already put
|
||||
* on the request, and lex appends the `Client` static on top of that, so a DID
|
||||
* present in both would appear twice. The agent-wrapping clients therefore
|
||||
* suppress their `appLabelers` (see `clients.ts`), which leaves exactly one
|
||||
* producer per request while both statics stay populated for the paths that
|
||||
* read only one of them.
|
||||
*/
|
||||
export function configureGlobalAppLabelers(dids: string[]) {
|
||||
AtpAgent.configure({appLabelers: dids})
|
||||
Client.configure({appLabelers: dids as `did:${string}:${string}`[]})
|
||||
}
|
||||
|
||||
@@ -31,18 +31,30 @@ const chatClients = new WeakMap<BskyAppAgent, Client>()
|
||||
* routing. Because the agent already emits both headers, the client is
|
||||
* deliberately built with neither a `service` option nor labelers - setting
|
||||
* either here would emit them a second time.
|
||||
*
|
||||
* `appLabelers: null` suppresses the class-wide `Client.appLabelers` for this
|
||||
* instance specifically. The static is populated (see
|
||||
* `configureGlobalAppLabelers`) so that clients built without a wrapped agent
|
||||
* carry the global authorities, but the agent already stamped those same DIDs
|
||||
* onto the request, and lex would append its own copy on top: the agent joins
|
||||
* its list with the existing header value while lex collects into a `Set` keyed
|
||||
* on the suffixed string, so neither dedupes against the other and every global
|
||||
* authority would appear twice.
|
||||
*/
|
||||
export function agentToAppviewClient(agent: BskyAppAgent): Client {
|
||||
const existing = appviewClients.get(agent)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const client = createLexClient({
|
||||
get did() {
|
||||
return agent.did
|
||||
const client = createLexClient(
|
||||
{
|
||||
get did() {
|
||||
return agent.did
|
||||
},
|
||||
fetchHandler: (path, init) => agent.fetchHandler(path, init),
|
||||
},
|
||||
fetchHandler: (path, init) => agent.fetchHandler(path, init),
|
||||
})
|
||||
{appLabelers: null},
|
||||
)
|
||||
appviewClients.set(agent, client)
|
||||
return client
|
||||
}
|
||||
@@ -58,7 +70,10 @@ export function agentToAppviewClient(agent: BskyAppAgent): Client {
|
||||
* for `com.atproto.*` repo/server/identity calls.
|
||||
*
|
||||
* No `service` option for the same reason: adding one would reintroduce the
|
||||
* proxy header this client exists to avoid.
|
||||
* proxy header this client exists to avoid. `appLabelers: null` is the same
|
||||
* kind of suppression: a PDS request is not an appview read, so it must carry no
|
||||
* moderation authorities at all - without this it would start emitting the
|
||||
* global `Client.appLabelers`.
|
||||
*
|
||||
* The handler is wrapped in a closure rather than passed by reference because
|
||||
* `PasswordSessionManager.fetchHandler` reads `this`. Relative paths are
|
||||
@@ -71,12 +86,16 @@ export function agentToPdsClient(agent: BskyAppAgent): Client {
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const client = createLexClient({
|
||||
get did() {
|
||||
return agent.did
|
||||
const client = createLexClient(
|
||||
{
|
||||
get did() {
|
||||
return agent.did
|
||||
},
|
||||
fetchHandler: (path, init) =>
|
||||
agent.sessionManager.fetchHandler(path, init),
|
||||
},
|
||||
fetchHandler: (path, init) => agent.sessionManager.fetchHandler(path, init),
|
||||
})
|
||||
{appLabelers: null},
|
||||
)
|
||||
pdsClients.set(agent, client)
|
||||
return client
|
||||
}
|
||||
@@ -88,7 +107,8 @@ export function agentToPdsClient(agent: BskyAppAgent): Client {
|
||||
* and PDS routing, no agent-level proxy or labeler headers - but constructed
|
||||
* with {@link CHAT_PROXY_SERVICE} as its `service`, so lex-client emits
|
||||
* `atproto-proxy: <CHAT_PROXY_SERVICE>` on every request and `chat.bsky.*`
|
||||
* calls are proxied to the chat service.
|
||||
* calls are proxied to the chat service. `appLabelers: null` for the same
|
||||
* reason as the PDS client: the chat service takes no moderation authorities.
|
||||
*/
|
||||
export function agentToChatClient(agent: BskyAppAgent): Client {
|
||||
const existing = chatClients.get(agent)
|
||||
@@ -103,7 +123,7 @@ export function agentToChatClient(agent: BskyAppAgent): Client {
|
||||
fetchHandler: (path, init) =>
|
||||
agent.sessionManager.fetchHandler(path, init),
|
||||
},
|
||||
{service: CHAT_PROXY_SERVICE},
|
||||
{appLabelers: null, service: CHAT_PROXY_SERVICE},
|
||||
)
|
||||
chatClients.set(agent, client)
|
||||
return client
|
||||
@@ -150,12 +170,14 @@ let publicLexClient: Client | undefined
|
||||
* {@link networkAwareFetch} so public reads feed the app's reachability signal
|
||||
* like authenticated ones do.
|
||||
*
|
||||
* Unlike the public agent it parallels, this client sends neither
|
||||
* `atproto-proxy` nor `atproto-accept-labelers`. `createPublicAgent` configures
|
||||
* the app labeler and the proxy header, so a logged-out appview *agent* read
|
||||
* does carry labelers while the same read through this client does not. A
|
||||
* consumer that needs moderation labels on public reads must configure labelers
|
||||
* itself before issuing the request.
|
||||
* Unlike the agent-wrapping clients, this one does NOT suppress
|
||||
* `Client.appLabelers`: there is no agent underneath to stamp the header, so the
|
||||
* class-wide static is the only producer and a logged-out read carries the same
|
||||
* `;redact` moderation authorities an authenticated one does.
|
||||
*
|
||||
* That makes `configureModerationForGuest()` load-bearing rather than
|
||||
* test-only - it is what populates the static before this client's first
|
||||
* request. `createPublicSessionBundle` runs it while building the bundle.
|
||||
*/
|
||||
export function getPublicAppviewClient(): Client {
|
||||
return (publicLexClient ??= createLexClient({
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function createSessionBundleAndCreateAccount(
|
||||
accountDid = earlyAccount.did
|
||||
|
||||
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
|
||||
configureModerationForAccount(bundle.agent, earlyAccount)
|
||||
configureModerationForAccount(bundle, earlyAccount)
|
||||
|
||||
const createdAt = toDatetimeString(new Date())
|
||||
const birthdate = birthDate.toISOString()
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import {AtpAgent, BSKY_LABELER_DID} from '@atproto/api'
|
||||
import {type AtpAgent, BSKY_LABELER_DID} from '@atproto/api'
|
||||
import {type Client} from '@atproto/lex'
|
||||
|
||||
import {IS_TEST_USER} from '#/lib/constants'
|
||||
import {account as accountStorage} from '#/storage'
|
||||
import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities'
|
||||
import {
|
||||
configureAdditionalModerationAuthorities,
|
||||
configureGlobalAppLabelers,
|
||||
} from './additional-moderation-authorities'
|
||||
import {type SessionAccount} from './types'
|
||||
|
||||
/**
|
||||
@@ -26,6 +30,29 @@ export function readLabelers(did: string): string[] | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an account's labeler subscriptions without duplicating the globally
|
||||
* redacted Bluesky moderation authority.
|
||||
*
|
||||
* The Bluesky DID is filtered out because it already flows through the global
|
||||
* `appLabelers`, which lex and the agent both emit with a `;redact` suffix.
|
||||
* Listing it per-subscription would add a second, non-redacting entry for the
|
||||
* same authority.
|
||||
*
|
||||
* Writes to the agent rather than the client: the agent-level fetch handler is
|
||||
* what stamps `atproto-accept-labelers` on the requests the wrapping clients
|
||||
* issue, so setting them here reaches every appview read. The bundle rework
|
||||
* moves this to `appviewClient.setLabelers` once the agent is gone.
|
||||
*/
|
||||
export function applyLabelersToClient(
|
||||
agent: AtpAgent,
|
||||
subscribedDids: string[],
|
||||
) {
|
||||
agent.configureLabelersHeader(
|
||||
subscribedDids.filter(did => did !== BSKY_LABELER_DID),
|
||||
)
|
||||
}
|
||||
|
||||
export function configureModerationForGuest() {
|
||||
// This global mutation is *only* OK because this code is only relevant for testing.
|
||||
// Don't add any other global behavior here!
|
||||
@@ -39,7 +66,7 @@ export function configureModerationForGuest() {
|
||||
* in the same tick, before any request goes out.
|
||||
*/
|
||||
export function configureModerationForAccount(
|
||||
agent: AtpAgent,
|
||||
bundle: {agent: AtpAgent; appviewClient?: Client},
|
||||
account: SessionAccount,
|
||||
) {
|
||||
// This global mutation is *only* OK because this code is only relevant for testing.
|
||||
@@ -47,15 +74,13 @@ export function configureModerationForAccount(
|
||||
switchToBskyAppLabeler()
|
||||
if (IS_TEST_USER(account.handle)) {
|
||||
// Test accounts may briefly use the production authority while this resolves.
|
||||
void trySwitchToTestAppLabeler(agent)
|
||||
void trySwitchToTestAppLabeler(bundle.agent)
|
||||
}
|
||||
|
||||
// The code below is actually relevant to production (and isn't global).
|
||||
const labelerDids = readLabelers(account.did)
|
||||
if (labelerDids) {
|
||||
agent.configureLabelersHeader(
|
||||
labelerDids.filter(did => did !== BSKY_LABELER_DID),
|
||||
)
|
||||
applyLabelersToClient(bundle.agent, labelerDids)
|
||||
} else {
|
||||
// If there are no headers in the storage, we'll not send them on the initial requests.
|
||||
// If we wanted to fix this, we could block on the preferences query here.
|
||||
@@ -65,7 +90,7 @@ export function configureModerationForAccount(
|
||||
}
|
||||
|
||||
function switchToBskyAppLabeler() {
|
||||
AtpAgent.configure({appLabelers: [BSKY_LABELER_DID]})
|
||||
configureGlobalAppLabelers([BSKY_LABELER_DID])
|
||||
}
|
||||
|
||||
/** Resolve and install the test environment's moderation authority. */
|
||||
@@ -77,6 +102,6 @@ async function trySwitchToTestAppLabeler(agent: AtpAgent) {
|
||||
)?.data.did
|
||||
if (did) {
|
||||
console.warn('USING TEST ENV MODERATION')
|
||||
AtpAgent.configure({appLabelers: [did]})
|
||||
configureGlobalAppLabelers([did])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,9 @@ export type PublicSessionBundle = {
|
||||
|
||||
/**
|
||||
* Build the logged-out bundle. `createPublicAgent` installs the guest
|
||||
* moderation authorities as part of building the agent.
|
||||
* moderation authorities as part of building the agent, which is what populates
|
||||
* the global `Client.appLabelers` that {@link getPublicAppviewClient} relies on
|
||||
* for its labeler header.
|
||||
*/
|
||||
export function createPublicSessionBundle(): PublicSessionBundle {
|
||||
return {
|
||||
@@ -290,7 +292,7 @@ export async function createSessionBundleAndResume(
|
||||
storedAccount.pdsUrl,
|
||||
) ?? storedAccount
|
||||
|
||||
configureModerationForAccount(bundle.agent, earlyAccount)
|
||||
configureModerationForAccount(bundle, earlyAccount)
|
||||
const aa = prefetchAgeAssuranceServerData({
|
||||
appviewClient: agentToAppviewClient(bundle.agent),
|
||||
accountClient: agentToPdsClient(bundle.agent),
|
||||
@@ -358,7 +360,7 @@ export async function createSessionBundleAndLogin(
|
||||
accountDid = earlyAccount.did
|
||||
|
||||
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
|
||||
configureModerationForAccount(bundle.agent, earlyAccount)
|
||||
configureModerationForAccount(bundle, earlyAccount)
|
||||
const aa = prefetchAgeAssuranceServerData({
|
||||
appviewClient: agentToAppviewClient(bundle.agent),
|
||||
accountClient: agentToPdsClient(bundle.agent),
|
||||
@@ -400,7 +402,7 @@ export function createSessionBundleFromStoredAccount(
|
||||
)
|
||||
bundle = buildBundle(session, storedAccount.pdsUrl)
|
||||
registerBundleKillSwitch(bundle, hooks.kill)
|
||||
configureModerationForAccount(bundle.agent, storedAccount)
|
||||
configureModerationForAccount(bundle, storedAccount)
|
||||
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
const account = session.destroyed
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
type ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {type ModerationDecision} from '@bsky.app/sdk/moderation'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
Reference in New Issue
Block a user