From 4aae6b2c22a6fdd7459cfbbbee0de9a58e10ef6e Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 13 Aug 2026 22:26:16 +0300 Subject: [PATCH] [SDK] Migrate search, bookmarks, contacts and feed queries to the lex clients (#11360) Co-authored-by: Claude Fable 5 --- .../Autocomplete/useAutocomplete/index.ts | 9 +- .../contacts/screens/PhoneInput.tsx | 40 ++--- .../contacts/screens/VerifyNumber.tsx | 139 +++++++++--------- .../contacts/screens/ViewMatches.tsx | 9 +- src/screens/Settings/FindContactsSettings.tsx | 29 ++-- src/state/feed-feedback.tsx | 35 +++-- src/state/queries/actor-autocomplete.ts | 19 +-- src/state/queries/actor-search.ts | 17 +-- .../queries/bookmarks/useBookmarkMutation.ts | 14 +- .../queries/bookmarks/useBookmarksQuery.ts | 52 ++++--- src/state/queries/feed.ts | 124 +++++++++------- src/state/queries/find-contacts.ts | 41 +++--- src/state/queries/pds-detection.ts | 23 ++- src/state/queries/resolve-uri.ts | 28 ++-- src/view/com/auth/LoggedOut.tsx | 13 +- src/view/com/composer/drafts/state/queries.ts | 45 ++++-- 16 files changed, 350 insertions(+), 287 deletions(-) diff --git a/src/components/Autocomplete/useAutocomplete/index.ts b/src/components/Autocomplete/useAutocomplete/index.ts index 498cc766cd..1dc3100f37 100644 --- a/src/components/Autocomplete/useAutocomplete/index.ts +++ b/src/components/Autocomplete/useAutocomplete/index.ts @@ -6,13 +6,14 @@ import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {STALE} from '#/state/queries' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences' -import {useAgent} from '#/state/session' +import {useAppviewClient} from '#/state/session' import { type AutocompleteApi, type AutocompleteItem, type AutocompleteItemType, type AutocompleteProfile, } from '#/components/Autocomplete/types' +import {app} from '#/lexicons' import {useEmojiSearch} from './useEmojiSearch' const DEFAULT_MOD_OPTS = { @@ -31,7 +32,7 @@ export function useAutocomplete({ limit?: number showSearchFallback?: boolean }): AutocompleteApi { - const agent = useAgent() + const client = useAppviewClient() const moderationOpts = useModerationOpts() const emojiSearch = useEmojiSearch() @@ -52,12 +53,12 @@ export function useAutocomplete({ // Going from "foo" to "foo." should not clear matches. q = q.toLowerCase().trim().replace(/\.$/, '') - const res = await agent.searchActorsTypeahead({ + const data = await client.call(app.bsky.actor.searchActorsTypeahead, { q, limit: limit || 8, }) - return (res?.data.actors || []).map(profile => ({ + return (data?.actors || []).map(profile => ({ key: profile.did, type: 'profile' as const, value: '@' + profile.handle, diff --git a/src/components/contacts/screens/PhoneInput.tsx b/src/components/contacts/screens/PhoneInput.tsx index 6904789a6e..95d63055c0 100644 --- a/src/components/contacts/screens/PhoneInput.tsx +++ b/src/components/contacts/screens/PhoneInput.tsx @@ -2,7 +2,6 @@ import {useState} from 'react' import {Keyboard, View} from 'react-native' import {KeyboardAvoidingView} from 'react-native-keyboard-controller' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {AppBskyContactStartPhoneVerification} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' @@ -14,8 +13,9 @@ import { getDefaultCountry, } from '#/lib/international-telephone-codes' import {cleanError, isNetworkError} from '#/lib/strings/errors' +import {matchXrpcError} from '#/lib/xrpc-error' import {logger} from '#/logger' -import {useAgent} from '#/state/session' +import {useAppviewClient} from '#/state/session' import {OnboardingPosition} from '#/screens/Onboarding/Layout' import { android, @@ -34,6 +34,7 @@ import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {useGeolocation} from '#/geolocation' +import {app} from '#/lexicons' import {isFindContactsFeatureEnabled} from '../country-allowlist' import { constructFullPhoneNumber, @@ -56,7 +57,7 @@ export function PhoneInput({ const {_} = useLingui() const ax = useAnalytics() const t = useTheme() - const agent = useAgent() + const client = useAppviewClient() const location = useGeolocation() const [countryCode, setCountryCode] = useState( () => state.phoneCountryCode ?? getDefaultCountry(location), @@ -78,7 +79,7 @@ export function PhoneInput({ phoneNumber: string }) => { // sends a onetime code to the user's phone number - await agent.app.bsky.contact.startPhoneVerification({ + await client.call(app.bsky.contact.startPhoneVerification, { phone: constructFullPhoneNumber(phoneCountryCode, phoneNumber), }) }, @@ -102,23 +103,22 @@ export function PhoneInput({ msg`A network error occurred. Please check your internet connection`, ), ) - } else if ( - err instanceof - AppBskyContactStartPhoneVerification.RateLimitExceededError - ) { - setError(_(msg`Rate limit exceeded. Please try again later.`)) - } else if ( - err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError - ) { - setError( - _( - msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`, - ), - ) - } else { - logger.error('Verify phone number failed', {safeMessage: err}) - setError(_(msg`An error occurred. ${cleanError(err)}`)) + return } + switch (matchXrpcError(err, app.bsky.contact.startPhoneVerification)) { + case 'RateLimitExceeded': + setError(_(msg`Rate limit exceeded. Please try again later.`)) + return + case 'InvalidPhone': + setError( + _( + msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`, + ), + ) + return + } + logger.error('Verify phone number failed', {safeMessage: err}) + setError(_(msg`An error occurred. ${cleanError(err)}`)) }, }) diff --git a/src/components/contacts/screens/VerifyNumber.tsx b/src/components/contacts/screens/VerifyNumber.tsx index f9d062f691..0bd5369397 100644 --- a/src/components/contacts/screens/VerifyNumber.tsx +++ b/src/components/contacts/screens/VerifyNumber.tsx @@ -1,9 +1,5 @@ import {useEffect, useMemo, useState} from 'react' import {Text as NestedText, View} from 'react-native' -import { - AppBskyContactStartPhoneVerification, - AppBskyContactVerifyPhone, -} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' @@ -11,8 +7,9 @@ import {useMutation} from '@tanstack/react-query' import {clamp} from '#/lib/numbers' import {cleanError, isNetworkError} from '#/lib/strings/errors' +import {matchXrpcError} from '#/lib/xrpc-error' import {logger} from '#/logger' -import {useAgent} from '#/state/session' +import {useAppviewClient} from '#/state/session' import {OnboardingPosition} from '#/screens/Onboarding/Layout' import {atoms as a, useGutters, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -25,6 +22,7 @@ import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' +import {app} from '#/lexicons' import {OTPInput} from '../components/OTPInput' import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number' import {type Action, type State, useOnPressBackButton} from '../state' @@ -43,7 +41,7 @@ export function VerifyNumber({ const t = useTheme() const {_} = useLingui() const ax = useAnalytics() - const agent = useAgent() + const client = useAppviewClient() const gutters = useGutters([0, 'wide']) const [otpCode, setOtpCode] = useState('') @@ -72,8 +70,11 @@ export function VerifyNumber({ isSuccess, } = useMutation({ mutationFn: async (code: string) => { - const res = await agent.app.bsky.contact.verifyPhone({code, phone}) - return res.data.token + const data = await client.call(app.bsky.contact.verifyPhone, { + code, + phone, + }) + return data.token }, onSuccess: async token => { // let the success state show for a moment @@ -99,44 +100,47 @@ export function VerifyNumber({ msg`A network error occurred. Please check your internet connection.`, ), }) - } else if (err instanceof AppBskyContactVerifyPhone.InvalidCodeError) { - setError({ - retryable: true, - isResendError: true, - message: _(msg`This code is invalid. Resend to get a new code.`), - }) - } else if (err instanceof AppBskyContactVerifyPhone.InvalidPhoneError) { - setError({ - retryable: false, - isResendError: false, - message: _( - msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`, - ), - }) - } else if ( - err instanceof AppBskyContactVerifyPhone.RateLimitExceededError - ) { - setError({ - retryable: true, - isResendError: false, - message: _( - msg`Too many attempts. Please wait a few minutes and try again.`, - ), - }) - } else { - logger.error('Verify phone number failed', {safeMessage: err}) - setError({ - retryable: true, - isResendError: false, - message: _(msg`An error occurred. ${cleanError(err)}`), - }) + return } + switch (matchXrpcError(err, app.bsky.contact.verifyPhone)) { + case 'InvalidCode': + setError({ + retryable: true, + isResendError: true, + message: _(msg`This code is invalid. Resend to get a new code.`), + }) + return + case 'InvalidPhone': + setError({ + retryable: false, + isResendError: false, + message: _( + msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`, + ), + }) + return + case 'RateLimitExceeded': + setError({ + retryable: true, + isResendError: false, + message: _( + msg`Too many attempts. Please wait a few minutes and try again.`, + ), + }) + return + } + logger.error('Verify phone number failed', {safeMessage: err}) + setError({ + retryable: true, + isResendError: false, + message: _(msg`An error occurred. ${cleanError(err)}`), + }) }, }) const {mutate: resendCode, isPending: isResendingCode} = useMutation({ mutationFn: async () => { - await agent.app.bsky.contact.startPhoneVerification({phone: phone}) + await client.call(app.bsky.contact.startPhoneVerification, {phone: phone}) }, onSuccess: () => { dispatch({type: 'RESEND_VERIFICATION_CODE'}) @@ -155,35 +159,34 @@ export function VerifyNumber({ msg`A network error occurred. Please check your internet connection.`, ), }) - } else if ( - err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError - ) { - setError({ - retryable: false, - isResendError: true, - message: _( - msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`, - ), - }) - } else if ( - err instanceof - AppBskyContactStartPhoneVerification.RateLimitExceededError - ) { - setError({ - retryable: true, - isResendError: true, - message: _( - msg`Too many codes sent. Please wait a few minutes and try again.`, - ), - }) - } else { - logger.error('Resend failed', {safeMessage: err}) - setError({ - retryable: true, - isResendError: true, - message: _(msg`An error occurred. ${cleanError(err)}`), - }) + return } + switch (matchXrpcError(err, app.bsky.contact.startPhoneVerification)) { + case 'InvalidPhone': + setError({ + retryable: false, + isResendError: true, + message: _( + msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`, + ), + }) + return + case 'RateLimitExceeded': + setError({ + retryable: true, + isResendError: true, + message: _( + msg`Too many codes sent. Please wait a few minutes and try again.`, + ), + }) + return + } + logger.error('Resend failed', {safeMessage: err}) + setError({ + retryable: true, + isResendError: true, + message: _(msg`An error occurred. ${cleanError(err)}`), + }) }, }) diff --git a/src/components/contacts/screens/ViewMatches.tsx b/src/components/contacts/screens/ViewMatches.tsx index 3543c604f5..dab3a79c6b 100644 --- a/src/components/contacts/screens/ViewMatches.tsx +++ b/src/components/contacts/screens/ViewMatches.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' import * as SMS from 'expo-sms' import {type ModerationOpts} from '@atproto/api' +import {type DidString} from '@atproto/syntax' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Plural, Trans} from '@lingui/react/macro' @@ -20,7 +21,7 @@ import { optimisticRemoveMatch, useMatchesPassthroughQuery, } from '#/state/queries/find-contacts' -import {useAgent, useSession} from '#/state/session' +import {useAgent, useAppviewClient, useSession} from '#/state/session' import {List, type ListMethods} from '#/view/com/util/List' import {UserAvatar} from '#/view/com/util/UserAvatar' import {OnboardingPosition} from '#/screens/Onboarding/Layout' @@ -41,6 +42,7 @@ import * as ProfileCard from '#/components/ProfileCard' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' +import {app} from '#/lexicons' import type * as bsky from '#/types/bsky' import {InviteInfo} from '../components/InviteInfo' import {type Action, type Contact, type Match, type State} from '../state' @@ -90,6 +92,7 @@ export function ViewMatches({ const moderationOpts = useModerationOpts() const queryClient = useQueryClient() const agent = useAgent() + const client = useAppviewClient() const insets = useSafeAreaInsets() const listRef = useRef(null) @@ -218,7 +221,9 @@ export function ViewMatches({ const {mutate: dismissMatch} = useMutation({ mutationFn: async (did: string) => { - await agent.app.bsky.contact.dismissMatch({subject: did}) + await client.call(app.bsky.contact.dismissMatch, { + subject: did as DidString, + }) }, onMutate: did => { ax.metric('contacts:matches:dismiss', {entryPoint: context}) diff --git a/src/screens/Settings/FindContactsSettings.tsx b/src/screens/Settings/FindContactsSettings.tsx index 57f1edde90..6104fd12b7 100644 --- a/src/screens/Settings/FindContactsSettings.tsx +++ b/src/screens/Settings/FindContactsSettings.tsx @@ -1,11 +1,8 @@ import {useCallback, useEffect, useState} from 'react' import {type ListRenderItemInfo, View} from 'react-native' import * as Contacts from 'expo-contacts' -import { - type AppBskyContactDefs, - type AppBskyContactGetSyncStatus, - type ModerationOpts, -} from '@atproto/api' +import {type AppBskyContactDefs, type ModerationOpts} from '@atproto/api' +import {type DidString} from '@atproto/syntax' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Plural, Trans} from '@lingui/react/macro' @@ -32,7 +29,7 @@ import { useContactsMatchesQuery, useContactsSyncStatusQuery, } from '#/state/queries/find-contacts' -import {useAgent, useSession} from '#/state/session' +import {useAgent, useAppviewClient, useSession} from '#/state/session' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {List} from '#/view/com/util/List' import {atoms as a, tokens, useGutters, useTheme} from '#/alf' @@ -52,6 +49,7 @@ import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' import {InviteFriendsDialog} from '#/features/inviteFriends' +import {app} from '#/lexicons' import type * as bsky from '#/types/bsky' import {bulkWriteFollows} from '../Onboarding/util' @@ -194,7 +192,7 @@ function SyncStatus({ refetchStatus: () => Promise }) { const ax = useAnalytics() - const agent = useAgent() + const client = useAppviewClient() const queryClient = useQueryClient() const {_} = useLingui() const moderationOpts = useModerationOpts() @@ -219,7 +217,9 @@ function SyncStatus({ const {mutate: dismissMatch} = useMutation({ mutationFn: async (did: string) => { - await agent.app.bsky.contact.dismissMatch({subject: did}) + await client.call(app.bsky.contact.dismissMatch, { + subject: did as DidString, + }) }, onMutate: async (did: string) => { ax.metric('contacts:settings:dismiss', {}) @@ -371,6 +371,7 @@ function StatusHeader({ const {_} = useLingui() const ax = useAnalytics() const agent = useAgent() + const client = useAppviewClient() const queryClient = useQueryClient() const {currentAccount} = useSession() @@ -384,12 +385,12 @@ function StatusHeader({ let cursor: string | undefined do { - const page = await agent.app.bsky.contact.getMatches({ + const page = await client.call(app.bsky.contact.getMatches, { limit: 100, cursor, }) - cursor = page.data.cursor - for (const profile of page.data.matches) { + cursor = page.cursor + for (const profile of page.matches) { if ( profile.did !== currentAccount?.did && !isBlockedOrBlocking(profile) && @@ -487,17 +488,17 @@ function StatusFooter({syncedAt}: {syncedAt: string}) { const {_, i18n} = useLingui() const t = useTheme() const ax = useAnalytics() - const agent = useAgent() + const client = useAppviewClient() const queryClient = useQueryClient() const {mutate: removeData, isPending} = useMutation({ mutationFn: async () => { - await agent.app.bsky.contact.removeData({}) + await client.call(app.bsky.contact.removeData, {}) }, onMutate: () => ax.metric('contacts:settings:removeData', {}), onSuccess: () => { Toast.show(_(msg`Contacts removed`)) - queryClient.setQueryData( + queryClient.setQueryData( findContactsStatusQueryKey, {syncStatus: undefined}, ) diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx index 15c12e732d..dbd10b3c9d 100644 --- a/src/state/feed-feedback.tsx +++ b/src/state/feed-feedback.tsx @@ -8,6 +8,7 @@ import { } from 'react' import {AppState, type AppStateStatus} from 'react-native' import {type AppBskyFeedDefs} from '@atproto/api' +import {type AtUriString, type DidString} from '@atproto/syntax' import throttle from 'lodash.throttle' import {PROD_FEEDS, STAGING_FEEDS} from '#/lib/constants' @@ -22,7 +23,8 @@ import { } from '#/state/queries/post-feed' import {getItemsForFeedback} from '#/view/com/posts/PostFeed' import {useAnalytics} from '#/analytics' -import {useAgent} from './session' +import {app} from '#/lexicons' +import {useAppviewClient} from './session' export const FEEDBACK_FEEDS = [...PROD_FEEDS, ...STAGING_FEEDS] @@ -65,7 +67,7 @@ export function useFeedFeedback( ) { const ax = useAnalytics() const logger = ax.logger.useChild(ax.logger.Context.FeedFeedback) - const agent = useAgent() + const client = useAppviewClient() const feed = !!feedSourceInfo && isFeedSourceFeedInfo(feedSourceInfo) @@ -150,16 +152,19 @@ export function useFeedFeedback( return } - // Send to the feed - agent.app.bsky.feed - .sendInteractions( - {interactions: interactionsToSend, feed: feed?.uri}, + /* + * Send to the feed. Interactions go to the feed generator rather than the + * appview, which the agent did by setting `atproto-proxy` by hand; the + * client's per-call `service` option writes that same header. + */ + client + .call( + app.bsky.feed.sendInteractions, { - encoding: 'application/json', - headers: { - 'atproto-proxy': `${proxyDid}#bsky_fg`, - }, + interactions: interactionsToSend, + feed: feed?.uri as AtUriString | undefined, }, + {service: `${proxyDid as DidString}#bsky_fg`}, ) .catch(() => {}) // ignore upstream errors @@ -172,7 +177,7 @@ export function useFeedFeedback( ) throttledFlushAggregatedStats() logger.debug('flushed') - }, [agent, throttledFlushAggregatedStats, proxyDid, enabled, feed]) + }, [client, throttledFlushAggregatedStats, proxyDid, enabled, feed]) const sendToFeed = useMemo( () => @@ -283,9 +288,13 @@ function toString(interaction: AppBskyFeedDefs.Interaction): string { }|${interaction.reqId || ''}` } -function toInteraction(str: string): AppBskyFeedDefs.Interaction { +function toInteraction(str: string): app.bsky.feed.defs.Interaction { const [item, event, feedContext, reqId] = str.split('|') - return {item, event, feedContext, reqId} + /* + * The fields come from splitting an internally-built key, so neither the + * at-uri nor the event token is narrowed by the compiler here. + */ + return {item, event, feedContext, reqId} as app.bsky.feed.defs.Interaction } type AggregatedStats = { diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index c8acb39f96..14377c5347 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -9,7 +9,8 @@ import {keepPreviousData, useQuery, useQueryClient} from '@tanstack/react-query' import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation' import {logger} from '#/logger' import {STALE} from '#/state/queries' -import {useAgent} from '#/state/session' +import {useAppviewClient} from '#/state/session' +import {app} from '#/lexicons' import {useModerationOpts} from '../preferences/moderation-opts' import {DEFAULT_LOGGED_OUT_PREFERENCES} from './preferences' @@ -27,7 +28,7 @@ export function useActorAutocompleteQuery( limit?: number, ) { const moderationOpts = useModerationOpts() - const agent = useAgent() + const client = useAppviewClient() prefix = prefix.toLowerCase().trim() if (prefix.endsWith('.')) { @@ -39,13 +40,13 @@ export function useActorAutocompleteQuery( staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(prefix || ''), async queryFn() { - const res = prefix - ? await agent.searchActorsTypeahead({ + const data = prefix + ? await client.call(app.bsky.actor.searchActorsTypeahead, { q: prefix, limit: limit || 8, }) : undefined - return res?.data.actors || [] + return data?.actors || [] }, select: useCallback( (data: AppBskyActorDefs.ProfileViewBasic[]) => { @@ -65,7 +66,7 @@ export type ActorAutocompleteFn = ReturnType export function useActorAutocompleteFn() { const queryClient = useQueryClient() const moderationOpts = useModerationOpts() - const agent = useAgent() + const client = useAppviewClient() return useCallback( async ({query, limit = 8}: {query: string; limit?: number}) => { @@ -77,7 +78,7 @@ export function useActorAutocompleteFn() { staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(query || ''), queryFn: () => - agent.searchActorsTypeahead({ + client.call(app.bsky.actor.searchActorsTypeahead, { q: query, limit, }), @@ -91,11 +92,11 @@ export function useActorAutocompleteFn() { return computeSuggestions({ q: query, - searched: res?.data.actors, + searched: res?.actors, moderationOpts: moderationOpts || DEFAULT_MOD_OPTS, }) }, - [queryClient, moderationOpts, agent], + [queryClient, moderationOpts, client], ) } diff --git a/src/state/queries/actor-search.ts b/src/state/queries/actor-search.ts index 84e6d88f29..547bf39b0f 100644 --- a/src/state/queries/actor-search.ts +++ b/src/state/queries/actor-search.ts @@ -1,4 +1,3 @@ -import {type AppBskyActorSearchActors} from '@atproto/api' import { type InfiniteData, keepPreviousData, @@ -8,7 +7,8 @@ import { } from '@tanstack/react-query' import {STALE} from '#/state/queries' -import {useAgent} from '#/state/session' +import {useAppviewClient} from '#/state/session' +import {app} from '#/lexicons' export const RQKEY_ROOT = 'actor-search' export const RQKEY = (query: string, limit?: number) => [ @@ -28,23 +28,22 @@ export function useActorSearch({ maintainData?: boolean limit?: number }) { - const agent = useAgent() + const client = useAppviewClient() return useInfiniteQuery< - AppBskyActorSearchActors.OutputSchema, + app.bsky.actor.searchActors.$OutputBody, Error, - InfiniteData, + InfiniteData, QueryKey, string | undefined >({ staleTime: STALE.MINUTES.FIVE, queryKey: RQKEY(query, limit), queryFn: async ({pageParam}) => { - const res = await agent.searchActors({ + return await client.call(app.bsky.actor.searchActors, { q: query, limit, cursor: pageParam, }) - return res.data }, enabled: enabled && !!query, initialPageParam: undefined, @@ -54,7 +53,7 @@ export function useActorSearch({ }) } -function select(data: InfiniteData) { +function select(data: InfiniteData) { // enforce uniqueness const dids = new Set() @@ -77,7 +76,7 @@ export function* findAllProfilesInQueryData( did: string, ) { const queryDatas = queryClient.getQueriesData< - InfiniteData + InfiniteData >({ queryKey: [RQKEY_ROOT], }) diff --git a/src/state/queries/bookmarks/useBookmarkMutation.ts b/src/state/queries/bookmarks/useBookmarkMutation.ts index c6e745aa04..af5a4a2d8d 100644 --- a/src/state/queries/bookmarks/useBookmarkMutation.ts +++ b/src/state/queries/bookmarks/useBookmarkMutation.ts @@ -1,4 +1,5 @@ import {type AppBskyFeedDefs} from '@atproto/api' +import {type AtUriString} from '@atproto/syntax' import {useMutation, useQueryClient} from '@tanstack/react-query' import {isNetworkError} from '#/lib/strings/errors' @@ -8,7 +9,8 @@ import { optimisticallyDeleteBookmark, optimisticallySaveBookmark, } from '#/state/queries/bookmarks/useBookmarksQuery' -import {useAgent} from '#/state/session' +import {useAppviewClient} from '#/state/session' +import {app} from '#/lexicons' type MutationArgs = | {action: 'create'; post: AppBskyFeedDefs.PostView} @@ -23,20 +25,20 @@ type MutationArgs = export function useBookmarkMutation() { const qc = useQueryClient() - const agent = useAgent() + const client = useAppviewClient() return useMutation({ async mutationFn(args: MutationArgs) { if (args.action === 'create') { updatePostShadow(qc, args.post.uri, {bookmarked: true}) - await agent.app.bsky.bookmark.createBookmark({ - uri: args.post.uri, + await client.call(app.bsky.bookmark.createBookmark, { + uri: args.post.uri as AtUriString, cid: args.post.cid, }) } else if (args.action === 'delete') { updatePostShadow(qc, args.uri, {bookmarked: false}) - await agent.app.bsky.bookmark.deleteBookmark({ - uri: args.uri, + await client.call(app.bsky.bookmark.deleteBookmark, { + uri: args.uri as AtUriString, }) } }, diff --git a/src/state/queries/bookmarks/useBookmarksQuery.ts b/src/state/queries/bookmarks/useBookmarksQuery.ts index 3e8e87a132..14323625f9 100644 --- a/src/state/queries/bookmarks/useBookmarksQuery.ts +++ b/src/state/queries/bookmarks/useBookmarksQuery.ts @@ -1,9 +1,4 @@ -import { - type $Typed, - type AppBskyBookmarkGetBookmarks, - AppBskyFeedDefs, - AtUri, -} from '@atproto/api' +import {type $Typed, AppBskyFeedDefs, AtUri} from '@atproto/api' import { type InfiniteData, type QueryClient, @@ -16,28 +11,28 @@ import { embedViewRecordToPostView, getEmbeddedPost, } from '#/state/queries/util' -import {useAgent} from '#/state/session' +import {useAppviewClient} from '#/state/session' +import {app} from '#/lexicons' import * as bsky from '#/types/bsky' export const bookmarksQueryKeyRoot = 'bookmarks' export const createBookmarksQueryKey = () => [bookmarksQueryKeyRoot] export function useBookmarksQuery() { - const agent = useAgent() + const client = useAppviewClient() return useInfiniteQuery< - AppBskyBookmarkGetBookmarks.OutputSchema, + app.bsky.bookmark.getBookmarks.$OutputBody, Error, - InfiniteData, + InfiniteData, QueryKey, string | undefined >({ queryKey: createBookmarksQueryKey(), async queryFn({pageParam}) { - const res = await agent.app.bsky.bookmark.getBookmarks({ + return await client.call(app.bsky.bookmark.getBookmarks, { cursor: pageParam, }) - return res.data }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, @@ -45,7 +40,7 @@ export function useBookmarksQuery() { } export async function truncateAndInvalidate(qc: QueryClient) { - qc.setQueriesData>( + qc.setQueriesData>( {queryKey: [bookmarksQueryKeyRoot]}, data => { if (data) { @@ -64,7 +59,7 @@ export async function optimisticallySaveBookmark( qc: QueryClient, post: AppBskyFeedDefs.PostView, ) { - qc.setQueriesData>( + qc.setQueriesData>( { queryKey: [bookmarksQueryKeyRoot], }, @@ -75,19 +70,22 @@ export async function optimisticallySaveBookmark( pages: data.pages.map((page, index) => { if (index === 0) { post.$type = 'app.bsky.feed.defs#postView' + /* + * The optimistic entry is synthesized from an `@atproto/api` + * `PostView`, whose string fields are unbranded, so it is asserted + * to the vendored view type the query data is now keyed on. + */ + const bookmark = { + createdAt: new Date().toISOString(), + subject: { + uri: post.uri, + cid: post.cid, + }, + item: post as $Typed, + } as unknown as app.bsky.bookmark.defs.BookmarkView return { ...page, - bookmarks: [ - { - createdAt: new Date().toISOString(), - subject: { - uri: post.uri, - cid: post.cid, - }, - item: post as $Typed, - }, - ...page.bookmarks, - ], + bookmarks: [bookmark, ...page.bookmarks], } } return page @@ -101,7 +99,7 @@ export async function optimisticallyDeleteBookmark( qc: QueryClient, {uri}: {uri: string}, ) { - qc.setQueriesData>( + qc.setQueriesData>( { queryKey: [bookmarksQueryKeyRoot], }, @@ -125,7 +123,7 @@ export function* findAllPostsInQueryData( uri: string, ): Generator { const queryDatas = queryClient.getQueriesData< - InfiniteData + InfiniteData >({ queryKey: [bookmarksQueryKeyRoot], }) diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index c3ac1acd53..9a9a8c09b9 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -3,11 +3,11 @@ import { type AppBskyActorDefs, type AppBskyFeedDefs, type AppBskyGraphDefs, - type AppBskyUnspeccedGetPopularFeedGenerators, AtUri, moderateFeedGenerator, RichText, } from '@atproto/api' +import {type AtUriString} from '@atproto/syntax' import {t} from '@lingui/core/macro' import { type InfiniteData, @@ -26,7 +26,8 @@ import {GCTIME, STALE} from '#/state/queries' import {RQKEY as listQueryKey} from '#/state/queries/list' import {usePreferencesQuery} from '#/state/queries/preferences' import {createQueryKey} from '#/state/queries/util' -import {useAgent, useSession} from '#/state/session' +import {useAppviewClient, useSession} from '#/state/session' +import {app} from '#/lexicons' import {router} from '#/routes' import {useModerationOpts} from '../preferences/moderation-opts' import {type FeedDescriptor} from './post-feed' @@ -184,7 +185,7 @@ export function getAvatarTypeFromUri(uri: string) { export function useFeedSourceInfoQuery({uri}: {uri: string}) { const type = getFeedTypeFromUri(uri) - const agent = useAgent() + const client = useAppviewClient() return useQuery({ staleTime: STALE.INFINITY, @@ -193,14 +194,16 @@ export function useFeedSourceInfoQuery({uri}: {uri: string}) { let view: FeedSourceInfo if (type === 'feed') { - const res = await agent.app.bsky.feed.getFeedGenerator({feed: uri}) - view = hydrateFeedGenerator(res.data.view) + const data = await client.call(app.bsky.feed.getFeedGenerator, { + feed: uri as AtUriString, + }) + view = hydrateFeedGenerator(data.view) } else { - const res = await agent.app.bsky.graph.getList({ - list: uri, + const data = await client.call(app.bsky.graph.getList, { + list: uri as AtUriString, limit: 1, }) - view = hydrateList(res.data.list) + view = hydrateList(data.list) } return view @@ -234,7 +237,7 @@ export function createGetPopularFeedsQueryKey( export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { const {hasSession} = useSession() - const agent = useAgent() + const client = useAppviewClient() const limit = options?.limit || 10 const {data: preferences} = usePreferencesQuery() const queryClient = useQueryClient() @@ -255,24 +258,27 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { enabled: Boolean(moderationOpts) && options?.enabled !== false, queryKey: createGetPopularFeedsQueryKey(options), queryFn: async ({pageParam}) => { - const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ - limit, - cursor: pageParam, - }) + const data = await client.call( + app.bsky.unspecced.getPopularFeedGenerators, + { + limit, + cursor: pageParam, + }, + ) // precache feeds - for (const feed of res.data.feeds) { + for (const feed of data.feeds) { const hydratedFeed = hydrateFeedGenerator(feed) precacheFeed(queryClient, hydratedFeed) } - return res.data + return data }, initialPageParam: undefined as string | undefined, getNextPageParam: lastPage => lastPage.cursor, select: useCallback( ( - data: InfiniteData, + data: InfiniteData, ) => { const { savedFeeds, @@ -336,24 +342,27 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { } export function useSearchPopularFeedsMutation() { - const agent = useAgent() + const client = useAppviewClient() const moderationOpts = useModerationOpts() return useMutation({ mutationFn: async (query: string) => { - const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ - limit: 10, - query: query, - }) + const data = await client.call( + app.bsky.unspecced.getPopularFeedGenerators, + { + limit: 10, + query: query, + }, + ) if (moderationOpts) { - return res.data.feeds.filter(feed => { + return data.feeds.filter(feed => { const decision = moderateFeedGenerator(feed, moderationOpts) return !decision.ui('contentMedia').blur }) } - return res.data.feeds + return data.feeds }, }) } @@ -371,7 +380,7 @@ export function usePopularFeedsSearch({ query: string enabled?: boolean }) { - const agent = useAgent() + const client = useAppviewClient() const moderationOpts = useModerationOpts() const enabledInner = enabled ?? Boolean(moderationOpts) @@ -379,12 +388,15 @@ export function usePopularFeedsSearch({ enabled: enabledInner, queryKey: createPopularFeedsSearchQueryKey(query), queryFn: async () => { - const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ - limit: 15, - query: query, - }) + const data = await client.call( + app.bsky.unspecced.getPopularFeedGenerators, + { + limit: 15, + query: query, + }, + ) - return res.data.feeds + return data.feeds }, placeholderData: keepPreviousData, select(data) { @@ -444,7 +456,7 @@ const createPinnedFeedInfosQueryKey = ( export function usePinnedFeedsInfos() { const {hasSession} = useSession() - const agent = useAgent() + const client = useAppviewClient() const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery() const pinnedItems = preferences?.savedFeeds.filter(feed => feed.pinned) ?? [] @@ -467,13 +479,13 @@ export function usePinnedFeedsInfos() { const pinnedFeeds = pinnedItems.filter(feed => feed.type === 'feed') let feedsPromise = Promise.resolve() if (pinnedFeeds.length > 0) { - feedsPromise = agent.app.bsky.feed - .getFeedGenerators({ - feeds: pinnedFeeds.map(f => f.value), + feedsPromise = client + .call(app.bsky.feed.getFeedGenerators, { + feeds: pinnedFeeds.map(f => f.value as AtUriString), }) - .then(res => { - for (let i = 0; i < res.data.feeds.length; i++) { - const feedView = res.data.feeds[i] + .then(data => { + for (let i = 0; i < data.feeds.length; i++) { + const feedView = data.feeds[i] resolved.set(feedView.uri, hydrateFeedGenerator(feedView)) } }) @@ -482,13 +494,13 @@ export function usePinnedFeedsInfos() { // Get all lists. This currently has to be done individually. const pinnedLists = pinnedItems.filter(feed => feed.type === 'list') const listsPromises = pinnedLists.map(list => - agent.app.bsky.graph - .getList({ - list: list.value, + client + .call(app.bsky.graph.getList, { + list: list.value as AtUriString, limit: 1, }) - .then(res => { - const listView = res.data.list + .then(data => { + const listView = data.list resolved.set(listView.uri, hydrateList(listView)) }), ) @@ -551,7 +563,7 @@ export type SavedFeedItem = } export function useSavedFeeds() { - const agent = useAgent() + const client = useAppviewClient() const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery() const savedItems = preferences?.savedFeeds ?? [] const queryClient = useQueryClient() @@ -582,25 +594,25 @@ export function useSavedFeeds() { let feedsPromise = Promise.resolve() if (savedFeeds.length > 0) { - feedsPromise = agent.app.bsky.feed - .getFeedGenerators({ - feeds: savedFeeds.map(f => f.value), + feedsPromise = client + .call(app.bsky.feed.getFeedGenerators, { + feeds: savedFeeds.map(f => f.value as AtUriString), }) - .then(res => { - res.data.feeds.forEach(f => { + .then(data => { + data.feeds.forEach(f => { resolvedFeeds.set(f.uri, f) }) }) } const listsPromises = savedLists.map(list => - agent.app.bsky.graph - .getList({ - list: list.value, + client + .call(app.bsky.graph.getList, { + list: list.value as AtUriString, limit: 1, }) - .then(res => { - const listView = res.data.list + .then(data => { + const listView = data.list resolvedLists.set(listView.uri, listView) }), ) @@ -656,7 +668,7 @@ export function useSavedFeeds() { const feedInfoQueryKeyRoot = 'feedInfo' export function useFeedInfo(feedUri: string | undefined) { - const agent = useAgent() + const client = useAppviewClient() return useQuery({ staleTime: STALE.INFINITY, @@ -666,11 +678,11 @@ export function useFeedInfo(feedUri: string | undefined) { return null } - const res = await agent.app.bsky.feed.getFeedGenerator({ - feed: feedUri, + const data = await client.call(app.bsky.feed.getFeedGenerator, { + feed: feedUri as AtUriString, }) - const feedSourceInfo = hydrateFeedGenerator(res.data.view) + const feedSourceInfo = hydrateFeedGenerator(data.view) return feedSourceInfo }, }) diff --git a/src/state/queries/find-contacts.ts b/src/state/queries/find-contacts.ts index b1eb6c9c5e..e462e6a6c1 100644 --- a/src/state/queries/find-contacts.ts +++ b/src/state/queries/find-contacts.ts @@ -1,4 +1,3 @@ -import {type AppBskyContactGetMatches} from '@atproto/api' import { type InfiniteData, type QueryClient, @@ -6,8 +5,9 @@ import { useQuery, } from '@tanstack/react-query' -import {useAgent} from '#/state/session' +import {useAppviewClient} from '#/state/session' import {type Match} from '#/components/contacts/state' +import {app} from '#/lexicons' import type * as bsky from '#/types/bsky' import {STALE} from '.' @@ -15,13 +15,12 @@ const RQ_KEY_ROOT = 'find-contacts' export const findContactsStatusQueryKey = [RQ_KEY_ROOT, 'sync-status'] export function useContactsSyncStatusQuery() { - const agent = useAgent() + const client = useAppviewClient() return useQuery({ queryKey: findContactsStatusQueryKey, queryFn: async () => { - const status = await agent.app.bsky.contact.getSyncStatus() - return status.data + return await client.call(app.bsky.contact.getSyncStatus, {}) }, staleTime: STALE.SECONDS.THIRTY, }) @@ -30,15 +29,14 @@ export function useContactsSyncStatusQuery() { export const findContactsGetMatchesQueryKey = [RQ_KEY_ROOT, 'matches'] export function useContactsMatchesQuery() { - const agent = useAgent() + const client = useAppviewClient() return useInfiniteQuery({ queryKey: findContactsGetMatchesQueryKey, queryFn: async ({pageParam}) => { - const matches = await agent.app.bsky.contact.getMatches({ + return await client.call(app.bsky.contact.getMatches, { cursor: pageParam, }) - return matches.data }, initialPageParam: undefined as string | undefined, getNextPageParam: lastPage => lastPage.cursor, @@ -47,20 +45,19 @@ export function useContactsMatchesQuery() { } export function optimisticRemoveMatch(queryClient: QueryClient, did: string) { - queryClient.setQueryData>( - findContactsGetMatchesQueryKey, - old => { - if (!old) return old + queryClient.setQueryData< + InfiniteData + >(findContactsGetMatchesQueryKey, old => { + if (!old) return old - return { - ...old, - pages: old.pages.map(page => ({ - ...page, - matches: page.matches.filter(match => match.did !== did), - })), - } - }, - ) + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + matches: page.matches.filter(match => match.did !== did), + })), + } + }) } export const findContactsMatchesPassthroughQueryKey = (dids: string[]) => [ @@ -95,7 +92,7 @@ export function* findAllProfilesInQueryData( did: string, ): Generator { const queryDatas = queryClient.getQueriesData< - InfiniteData + InfiniteData >({ queryKey: findContactsGetMatchesQueryKey, }) diff --git a/src/state/queries/pds-detection.ts b/src/state/queries/pds-detection.ts index 697963aa93..1f2b413d6d 100644 --- a/src/state/queries/pds-detection.ts +++ b/src/state/queries/pds-detection.ts @@ -1,13 +1,15 @@ import {useState} from 'react' import {type DidDocument, getPdsEndpoint} from '@atproto/common-web' +import {type HandleString} from '@atproto/syntax' import {useQuery, useQueryClient} from '@tanstack/react-query' -import {DEFAULT_SERVICE, PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {DEFAULT_SERVICE} from '#/lib/constants' import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {STALE} from '#/state/queries' -import {Agent} from '#/state/session/agent' +import {getPublicAppviewClient} from '#/state/session/clients' +import {com} from '#/lexicons' const RQKEY_ROOT = 'pds-detection' export const RQKEY = (identifier: string) => [RQKEY_ROOT, identifier] @@ -147,16 +149,25 @@ export async function resolvePdsForIdentifier( identifier: string, ): Promise<{did: string; pdsUrl: string | null} | null> { const norm = normalizeIdentifier(identifier) - const agent = new Agent(null, {service: PUBLIC_BSKY_SERVICE}) + /* + * Resolution runs without a session, so this uses the public appview client + * rather than a session-scoped one. It matches the unauthenticated, + * unproxied public agent this previously constructed by hand. + */ + const client = getPublicAppviewClient() try { let did: string if (norm.startsWith('did:')) { did = norm } else { - const res = await withResolveTimeout(signal => - agent.resolveHandle({handle: norm}, {signal}), + const data = await withResolveTimeout(signal => + client.call( + com.atproto.identity.resolveHandle, + {handle: norm as HandleString}, + {signal}, + ), ) - did = res.data.did + did = data.did } logger.debug('pds-detection: resolved identifier to DID', { identifier: norm, diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts index 45ef4e2288..9ca0aab36d 100644 --- a/src/state/queries/resolve-uri.ts +++ b/src/state/queries/resolve-uri.ts @@ -1,15 +1,18 @@ -import {type AtpAgent, AtUri} from '@atproto/api' +import {AtUri} from '@atproto/api' +import {type Client} from '@atproto/lex' +import {type HandleString} from '@atproto/syntax' import {type QueryClient, queryOptions, useQuery} from '@tanstack/react-query' import {STALE} from '#/state/queries' -import {useAgent} from '#/state/session' +import {useAppviewClient} from '#/state/session' +import {com} from '#/lexicons' import {useUnstableProfileViewCache} from './profile' const RQKEY_ROOT = 'resolved-did' export const RQKEY = (didOrHandle: string) => [RQKEY_ROOT, didOrHandle] const resolvedDidQueryOptions = ( - agent: AtpAgent, + client: Client, getUnstableProfile: (did: string) => {did: string} | undefined, didOrHandle: string | undefined, ) => @@ -21,8 +24,15 @@ const resolvedDidQueryOptions = ( // Just return the did if it's already one if (didOrHandle.startsWith('did:')) return didOrHandle - const res = await agent.resolveHandle({handle: didOrHandle}) - return res.data.did + /* + * Resolution stays on the appview client: the old agent call was proxied + * to the appview, and the PDS implementation is not equivalent for + * handles hosted elsewhere. + */ + const data = await client.call(com.atproto.identity.resolveHandle, { + handle: didOrHandle as HandleString, + }) + return data.did }, initialData: () => { // Return undefined if no did or handle @@ -37,11 +47,11 @@ export function useResolveUriQuery(uri: string | undefined) { const urip = new AtUri(uri || '') const host = urip.host - const agent = useAgent() + const client = useAppviewClient() const {getUnstableProfile} = useUnstableProfileViewCache() return useQuery({ - ...resolvedDidQueryOptions(agent, getUnstableProfile, host), + ...resolvedDidQueryOptions(client, getUnstableProfile, host), select: did => ({ did, uri: AtUri.make(did, urip.collection, urip.rkey).toString(), @@ -50,11 +60,11 @@ export function useResolveUriQuery(uri: string | undefined) { } export function useResolveDidQuery(didOrHandle: string | undefined) { - const agent = useAgent() + const client = useAppviewClient() const {getUnstableProfile} = useUnstableProfileViewCache() return useQuery( - resolvedDidQueryOptions(agent, getUnstableProfile, didOrHandle), + resolvedDidQueryOptions(client, getUnstableProfile, didOrHandle), ) } diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index da0038a9cc..5becdd45d3 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -1,13 +1,14 @@ import {useCallback, useEffect, useState} from 'react' import {View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {type DidString} from '@atproto/syntax' import {useLingui} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {PressableScale} from '#/lib/custom-animations/PressableScale' import {STALE} from '#/state/queries' import {profilesQueryKey} from '#/state/queries/profile' -import {useAgent, useSession} from '#/state/session' +import {useAppviewClient, useSession} from '#/state/session' import {useSetActiveLanding} from '#/state/shell/landing' import { useLoggedOutView, @@ -23,6 +24,7 @@ import {atoms as a, native, tokens, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {useAnalytics} from '#/analytics' +import {app} from '#/lexicons' import {SplashScreen} from './SplashScreen' enum ScreenState { @@ -65,19 +67,18 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { const queryClient = useQueryClient() const {accounts} = useSession() - const agent = useAgent() + const client = useAppviewClient() useEffect(() => { - const actors = accounts.map(acc => acc.did) + const actors = accounts.map(acc => acc.did as DidString) if (actors.length === 0) return void queryClient.prefetchQuery({ queryKey: profilesQueryKey(actors), staleTime: STALE.MINUTES.FIVE, queryFn: async () => { - const res = await agent.getProfiles({actors}) - return res.data + return await client.call(app.bsky.actor.getProfiles, {actors}) }, }) - }, [accounts, agent, queryClient]) + }, [accounts, client, queryClient]) const onPressDismiss = useCallback(() => { if (onDismiss) { diff --git a/src/view/com/composer/drafts/state/queries.ts b/src/view/com/composer/drafts/state/queries.ts index 07dd67dd88..0108dbbbff 100644 --- a/src/view/com/composer/drafts/state/queries.ts +++ b/src/view/com/composer/drafts/state/queries.ts @@ -1,4 +1,4 @@ -import {AppBskyDraftCreateDraft, AppBskyDraftDefs} from '@atproto/api' +import {AppBskyDraftDefs} from '@atproto/api' import { useInfiniteQuery, useMutation, @@ -6,10 +6,12 @@ import { } from '@tanstack/react-query' import {isNetworkError} from '#/lib/strings/errors' -import {useAgent} from '#/state/session' +import {matchXrpcError} from '#/lib/xrpc-error' +import {useAppviewClient} from '#/state/session' import {type ComposerState} from '#/view/com/composer/state/composer' import {useAnalytics} from '#/analytics' import {getDeviceId} from '#/analytics/identifiers' +import {app} from '#/lexicons' import {composerStateToDraft, draftViewToSummary} from './api' import {logger} from './logger' import * as storage from './storage' @@ -20,7 +22,7 @@ const DRAFTS_QUERY_KEY = ['drafts'] * Hook to list all drafts for the current account */ export function useDraftsQuery() { - const agent = useAgent() + const client = useAppviewClient() const ax = useAnalytics() return useInfiniteQuery({ @@ -28,10 +30,12 @@ export function useDraftsQuery() { queryFn: async ({pageParam}) => { // Ensure media cache is populated before checking which media exists await storage.ensureMediaCachePopulated() - const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam}) + const data = await client.call(app.bsky.draft.getDrafts, { + cursor: pageParam, + }) return { - cursor: res.data.cursor, - drafts: res.data.drafts.map(view => + cursor: data.cursor, + drafts: data.drafts.map(view => draftViewToSummary({ view, analytics: ax, @@ -116,7 +120,7 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{ * This ensures we don't lose data if the network request fails. */ export function useSaveDraftMutation() { - const agent = useAgent() + const client = useAppviewClient() const queryClient = useQueryClient() return useMutation({ @@ -132,7 +136,14 @@ export function useSaveDraftMutation() { originalLocalRefs: Set | undefined }> => { // Convert composer state to server draft format - const {draft, localRefPaths} = await composerStateToDraft(composerState) + const {draft: apiDraft, localRefPaths} = + await composerStateToDraft(composerState) + /* + * `composerStateToDraft` builds the draft against the `@atproto/api` + * types, whose string fields are unbranded, so it is asserted once here + * to the vendored input type. + */ + const draft = apiDraft as unknown as app.bsky.draft.defs.Draft logger.debug('saving draft', { existingDraftId, @@ -147,7 +158,7 @@ export function useSaveDraftMutation() { logger.debug('updating existing draft on server', { draftId: existingDraftId, }) - await agent.app.bsky.draft.updateDraft({ + await client.call(app.bsky.draft.updateDraft, { draft: { id: existingDraftId, draft, @@ -157,8 +168,8 @@ export function useSaveDraftMutation() { } else { // Create new draft logger.debug('creating new draft on server') - const res = await agent.app.bsky.draft.createDraft({draft}) - draftId = res.data.id + const data = await client.call(app.bsky.draft.createDraft, {draft}) + draftId = data.id logger.debug('created new draft', {draftId}) } @@ -203,7 +214,7 @@ export function useSaveDraftMutation() { }, onError: error => { // Check for draft limit error - if (error instanceof AppBskyDraftCreateDraft.DraftLimitReachedError) { + if (matchXrpcError(error, app.bsky.draft.createDraft)) { logger.error('Draft limit reached', {safeMessage: error.message}) // Error will be handled by caller } else if (!isNetworkError(error)) { @@ -220,7 +231,7 @@ export function useSaveDraftMutation() { * Takes the full draft data to avoid re-fetching for media cleanup. */ export function useDeleteDraftMutation() { - const agent = useAgent() + const client = useAppviewClient() const queryClient = useQueryClient() return useMutation({ @@ -231,7 +242,7 @@ export function useDeleteDraftMutation() { draft: AppBskyDraftDefs.Draft }) => { // Delete from server first - if this fails, we keep local media for retry - await agent.app.bsky.draft.deleteDraft({id: draftId}) + await client.call(app.bsky.draft.deleteDraft, {id: draftId}) }, onSuccess: async (_, {draft}) => { // Only delete local media after server deletion succeeds @@ -264,7 +275,7 @@ export function useDeleteDraftMutation() { * Takes draftId and originalLocalRefs from composer state. */ export function useCleanupPublishedDraftMutation() { - const agent = useAgent() + const client = useAppviewClient() const queryClient = useQueryClient() return useMutation({ @@ -280,7 +291,9 @@ export function useCleanupPublishedDraftMutation() { mediaFileCount: originalLocalRefs.size, }) // Delete from server first - await agent.app.bsky.draft.deleteDraft({id: draftId}) + await client.call(app.bsky.draft.deleteDraft, { + id: draftId, + }) logger.debug('deleted draft from server', {draftId}) }, onSuccess: async (_, {originalLocalRefs}) => {