route appview state and facet resolution off the pds client

Audit of usePdsClient call sites: the PDS client is for com.atproto.*
account/repo operations. Appview-private state (actor/list/thread mutes,
notification seen-state) and mention/facet resolution now use the
appview-routed client. useRichText also regains logged-out mention
resolution via useLexClient's public fallback (usePdsClient throws when
logged out, silently leaving mentions unresolved on StarterPackLanding
and web ProfileHoverCard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-17 15:01:08 +03:00
parent 8ee62d67bd
commit 4c0d99158f
10 changed files with 36 additions and 47 deletions
@@ -15,7 +15,7 @@ import {
useListCreateMutation, useListCreateMutation,
useListMetadataMutation, useListMetadataMutation,
} from '#/state/queries/list' } from '#/state/queries/list'
import {usePdsClient} from '#/state/session' import {useAppviewClient} from '#/state/session'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {EditableUserAvatar} from '#/view/com/util/UserAvatar' import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
@@ -134,7 +134,7 @@ function DialogInner({
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const pdsClient = usePdsClient() const appviewClient = useAppviewClient()
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
const { const {
mutateAsync: createListMutation, mutateAsync: createListMutation,
@@ -228,7 +228,7 @@ function DialogInner({
{cleanNewlines: true}, {cleanNewlines: true},
) )
await richText.detectFacets(pdsClient) await richText.detectFacets(appviewClient)
richText = shortenLinks(richText) richText = shortenLinks(richText)
richText = stripInvalidMentions(richText) richText = stripInvalidMentions(richText)
@@ -276,7 +276,7 @@ function DialogInner({
setImageError, setImageError,
activePurpose, activePurpose,
isCurateList, isCurateList,
pdsClient, appviewClient,
_, _,
]) ])
+6 -7
View File
@@ -1,20 +1,19 @@
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import {RichText as RichTextAPI} from '@bsky.app/sdk/richtext' import {RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
import {usePdsClient} from '#/state/session' import {useLexClient} from '#/state/session'
export function useRichText(text: string): [RichTextAPI, boolean] { export function useRichText(text: string): [RichTextAPI, boolean] {
const [prevText, setPrevText] = useState(text) const [prevText, setPrevText] = useState(text)
const [rawRT, setRawRT] = useState(() => new RichTextAPI({text})) const [rawRT, setRawRT] = useState(() => new RichTextAPI({text}))
const [resolvedRT, setResolvedRT] = useState<RichTextAPI | null>(null) const [resolvedRT, setResolvedRT] = useState<RichTextAPI | null>(null)
/* /*
* Facet detection resolves handles via `com.atproto.identity.resolveHandle`, * Facet/mention resolution is an appview job - it resolves handles via
* which the account (PDS) client serves. We standardize on the account client * `com.atproto.identity.resolveHandle` through the appview. `useLexClient`
* where a session is in scope (design section B). Logged out, this is the * falls back to the public client when logged out, so mentions still resolve
* throwing client; `detectFacets` will reject, and the raw (unresolved) * on logged-out surfaces (StarterPackLandingScreen, web ProfileHoverCard).
* RichText is returned in the meantime.
*/ */
const client = usePdsClient() const client = useLexClient()
if (text !== prevText) { if (text !== prevText) {
setPrevText(text) setPrevText(text)
setRawRT(new RichTextAPI({text})) setRawRT(new RichTextAPI({text}))
@@ -45,7 +45,7 @@ import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
import {useGetJoinLinkPreview} from '#/state/queries/join-links' import {useGetJoinLinkPreview} from '#/state/queries/join-links'
import {useGetPost} from '#/state/queries/post' import {useGetPost} from '#/state/queries/post'
import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util' import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
import {usePdsClient, useSession} from '#/state/session' import {useAppviewClient, useSession} from '#/state/session'
import {List, type ListMethods} from '#/view/com/util/List' import {List, type ListMethods} from '#/view/com/util/List'
import {MessageComposer} from '#/screens/Messages/components/MessageComposer' import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
import {MessageListError} from '#/screens/Messages/components/MessageListError' import {MessageListError} from '#/screens/Messages/components/MessageListError'
@@ -141,10 +141,9 @@ export function MessagesList({
const convoState = useConvoActive() const convoState = useConvoActive()
/* /*
* Facet detection resolves handles via `com.atproto.identity.resolveHandle`, * Facet detection resolves handles via `com.atproto.identity.resolveHandle`,
* which the account (PDS) client serves - chat requires a session, so the * which the appview client serves (design section B).
* client is always live here.
*/ */
const pdsClient = usePdsClient() const appviewClient = useAppviewClient()
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const getPost = useGetPost() const getPost = useGetPost()
const getJoinLinkPreview = useGetJoinLinkPreview() const getJoinLinkPreview = useGetJoinLinkPreview()
@@ -618,7 +617,7 @@ export function MessagesList({
replyTo = {messageId: reply.id} replyTo = {messageId: reply.id}
} }
await rt.detectFacets(pdsClient) await rt.detectFacets(appviewClient)
rt = shortenLinks(rt) rt = shortenLinks(rt)
rt = stripInvalidMentions(rt) rt = stripInvalidMentions(rt)
@@ -671,7 +670,7 @@ export function MessagesList({
} }
}, },
[ [
pdsClient, appviewClient,
convoState, convoState,
getPost, getPost,
getJoinLinkPreview, getJoinLinkPreview,
+4 -4
View File
@@ -9,7 +9,7 @@ import {type AtUriString} from '@atproto/syntax'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {app} from '#/lexicons' import {app} from '#/lexicons'
import {usePdsClient, useSession} from '../session' import {useAppviewClient, useSession} from '../session'
type StateContext = Map<string, boolean> type StateContext = Map<string, boolean>
type SetStateContext = (uri: string, value: boolean) => void type SetStateContext = (uri: string, value: boolean) => void
@@ -58,7 +58,7 @@ export function useSetThreadMute() {
} }
function useMigrateMutes(setThreadMute: SetStateContext) { function useMigrateMutes(setThreadMute: SetStateContext) {
const pdsClient = usePdsClient() const appviewClient = useAppviewClient()
const {currentAccount} = useSession() const {currentAccount} = useSession()
useEffect(() => { useEffect(() => {
@@ -89,7 +89,7 @@ function useMigrateMutes(setThreadMute: SetStateContext) {
setThreadMute(root, true) setThreadMute(root, true)
await pdsClient await appviewClient
.call(app.bsky.graph.muteThread, {root: root as AtUriString}) .call(app.bsky.graph.muteThread, {root: root as AtUriString})
// not a big deal if this fails, since the post might have been deleted // not a big deal if this fails, since the post might have been deleted
.catch(console.error) .catch(console.error)
@@ -102,5 +102,5 @@ function useMigrateMutes(setThreadMute: SetStateContext) {
cancelled = true cancelled = true
} }
} }
}, [pdsClient, currentAccount, setThreadMute]) }, [appviewClient, currentAccount, setThreadMute])
} }
+2 -3
View File
@@ -253,13 +253,12 @@ export function useListDeleteMutation() {
export function useListMuteMutation() { export function useListMuteMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const appviewClient = useAppviewClient() const appviewClient = useAppviewClient()
const pdsClient = usePdsClient()
return useMutation<void, Error, {uri: string; mute: boolean}>({ return useMutation<void, Error, {uri: string; mute: boolean}>({
mutationFn: async ({uri, mute}) => { mutationFn: async ({uri, mute}) => {
if (mute) { if (mute) {
await pdsClient.call(muteActorList, {list: uri as AtUriString}) await appviewClient.call(muteActorList, {list: uri as AtUriString})
} else { } else {
await pdsClient.call(unmuteActorList, {list: uri as AtUriString}) await appviewClient.call(unmuteActorList, {list: uri as AtUriString})
} }
await whenAppViewReady(appviewClient, uri, v => { await whenAppViewReady(appviewClient, uri, v => {
+3 -11
View File
@@ -20,7 +20,7 @@ import BroadcastChannel from '#/lib/broadcast'
import {resetBadgeCount} from '#/lib/notifications/notifications' import {resetBadgeCount} from '#/lib/notifications/notifications'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {truncateAndInvalidate} from '#/state/queries/util' import {truncateAndInvalidate} from '#/state/queries/util'
import {useAppviewClient, usePdsClient, useSession} from '#/state/session' import {useAppviewClient, useSession} from '#/state/session'
import {RQKEY as RQKEY_NOTIFS} from './feed' import {RQKEY as RQKEY_NOTIFS} from './feed'
import {type CachedFeedPage, type FeedPage} from './types' import {type CachedFeedPage, type FeedPage} from './types'
import {fetchPage} from './util' import {fetchPage} from './util'
@@ -55,7 +55,6 @@ apiContext.displayName = 'NotificationsUnreadApiContext'
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
const {hasSession} = useSession() const {hasSession} = useSession()
const appviewClient = useAppviewClient() const appviewClient = useAppviewClient()
const pdsClient = usePdsClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
@@ -123,7 +122,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return { return {
async markAllRead() { async markAllRead() {
// update server // update server
await pdsClient.call( await appviewClient.call(
updateSeenNotifications, updateSeenNotifications,
// toISOString() always yields a valid datetime string // toISOString() always yields a valid datetime string
cacheRef.current.syncedAt.toISOString() as DatetimeString, cacheRef.current.syncedAt.toISOString() as DatetimeString,
@@ -212,14 +211,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} }
}, },
} }
}, [ }, [setNumUnread, queryClient, moderationOpts, appviewClient, hasSession])
setNumUnread,
queryClient,
moderationOpts,
appviewClient,
pdsClient,
hasSession,
])
checkUnreadRef.current = api.checkUnread checkUnreadRef.current = api.checkUnread
return ( return (
+4 -4
View File
@@ -414,14 +414,14 @@ export function useThreadMuteMutationQueue(
} }
function useThreadMuteMutation() { function useThreadMuteMutation() {
const pdsClient = usePdsClient() const appviewClient = useAppviewClient()
return useMutation< return useMutation<
{}, {},
Error, Error,
{uri: string} // the root post's uri {uri: string} // the root post's uri
>({ >({
mutationFn: async ({uri}) => { mutationFn: async ({uri}) => {
await pdsClient.call(app.bsky.graph.muteThread, { await appviewClient.call(app.bsky.graph.muteThread, {
root: uri as AtUriString, root: uri as AtUriString,
}) })
return {} return {}
@@ -430,10 +430,10 @@ function useThreadMuteMutation() {
} }
function useThreadUnmuteMutation() { function useThreadUnmuteMutation() {
const pdsClient = usePdsClient() const appviewClient = useAppviewClient()
return useMutation<{}, Error, {uri: string}>({ return useMutation<{}, Error, {uri: string}>({
mutationFn: async ({uri}) => { mutationFn: async ({uri}) => {
await pdsClient.call(app.bsky.graph.unmuteThread, { await appviewClient.call(app.bsky.graph.unmuteThread, {
root: uri as AtUriString, root: uri as AtUriString,
}) })
return {} return {}
+4 -4
View File
@@ -474,10 +474,10 @@ export function useProfileMuteMutationQueue(
function useProfileMuteMutation() { function useProfileMuteMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const pdsClient = usePdsClient() const appviewClient = useAppviewClient()
return useMutation<void, Error, {did: string}>({ return useMutation<void, Error, {did: string}>({
mutationFn: async ({did}) => { mutationFn: async ({did}) => {
await pdsClient.call(muteActor, {actor: did as AtIdentifierString}) await appviewClient.call(muteActor, {actor: did as AtIdentifierString})
}, },
onSuccess() { onSuccess() {
void queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) void queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
@@ -487,10 +487,10 @@ function useProfileMuteMutation() {
function useProfileUnmuteMutation() { function useProfileUnmuteMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const pdsClient = usePdsClient() const appviewClient = useAppviewClient()
return useMutation<void, Error, {did: string}>({ return useMutation<void, Error, {did: string}>({
mutationFn: async ({did}) => { mutationFn: async ({did}) => {
await pdsClient.call(unmuteActor, {actor: did as AtIdentifierString}) await appviewClient.call(unmuteActor, {actor: did as AtIdentifierString})
}, },
onSuccess() { onSuccess() {
void queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) void queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
+2 -2
View File
@@ -110,7 +110,7 @@ export function useCreateStarterPackMutation({
let descriptionFacets: app.bsky.richtext.facet.Main[] | undefined let descriptionFacets: app.bsky.richtext.facet.Main[] | undefined
if (description) { if (description) {
const rt = new RichText({text: description}) const rt = new RichText({text: description})
await rt.detectFacets(pdsClient) await rt.detectFacets(appviewClient)
descriptionFacets = rt.facets descriptionFacets = rt.facets
} }
@@ -178,7 +178,7 @@ export function useEditStarterPackMutation({
let descriptionFacets: app.bsky.richtext.facet.Main[] | undefined let descriptionFacets: app.bsky.richtext.facet.Main[] | undefined
if (description) { if (description) {
const rt = new RichText({text: description}) const rt = new RichText({text: description})
await rt.detectFacets(pdsClient) await rt.detectFacets(appviewClient)
descriptionFacets = rt.facets descriptionFacets = rt.facets
} }
+2 -2
View File
@@ -29,7 +29,7 @@ import {useLabelerInfoQuery} from '#/state/queries/labeler'
import {resetProfilePostsQueries} from '#/state/queries/post-feed' import {resetProfilePostsQueries} from '#/state/queries/post-feed'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {usePdsClient, useSession} from '#/state/session' import {useLexClient, useSession} from '#/state/session'
import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens' import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens'
import {ProfileLists} from '#/view/com/lists/ProfileLists' import {ProfileLists} from '#/view/com/lists/ProfileLists'
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
@@ -610,7 +610,7 @@ function ProfileScreenLoaded({
} }
function useRichText(text: string): [RichTextAPI, boolean] { function useRichText(text: string): [RichTextAPI, boolean] {
const client = usePdsClient() const client = useLexClient()
const [prevText, setPrevText] = useState(text) const [prevText, setPrevText] = useState(text)
const [rawRT, setRawRT] = useState(() => new RichTextAPI({text})) const [rawRT, setRawRT] = useState(() => new RichTextAPI({text}))
const [resolvedRT, setResolvedRT] = useState<RichTextAPI | null>(null) const [resolvedRT, setResolvedRT] = useState<RichTextAPI | null>(null)