migrate the bookmark and draft queries to the appview client

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-03 17:49:19 +03:00
parent c0aafe4ffd
commit 8a87a5fdea
3 changed files with 62 additions and 49 deletions
@@ -1,4 +1,5 @@
import {type AppBskyFeedDefs} from '@atproto/api' import {type AppBskyFeedDefs} from '@atproto/api'
import {type AtUriString} from '@atproto/syntax'
import {useMutation, useQueryClient} from '@tanstack/react-query' import {useMutation, useQueryClient} from '@tanstack/react-query'
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
@@ -8,7 +9,8 @@ import {
optimisticallyDeleteBookmark, optimisticallyDeleteBookmark,
optimisticallySaveBookmark, optimisticallySaveBookmark,
} from '#/state/queries/bookmarks/useBookmarksQuery' } from '#/state/queries/bookmarks/useBookmarksQuery'
import {useAgent} from '#/state/session' import {useAppviewClient} from '#/state/session'
import {app} from '#/lexicons'
type MutationArgs = type MutationArgs =
| {action: 'create'; post: AppBskyFeedDefs.PostView} | {action: 'create'; post: AppBskyFeedDefs.PostView}
@@ -23,20 +25,20 @@ type MutationArgs =
export function useBookmarkMutation() { export function useBookmarkMutation() {
const qc = useQueryClient() const qc = useQueryClient()
const agent = useAgent() const client = useAppviewClient()
return useMutation({ return useMutation({
async mutationFn(args: MutationArgs) { async mutationFn(args: MutationArgs) {
if (args.action === 'create') { if (args.action === 'create') {
updatePostShadow(qc, args.post.uri, {bookmarked: true}) updatePostShadow(qc, args.post.uri, {bookmarked: true})
await agent.app.bsky.bookmark.createBookmark({ await client.call(app.bsky.bookmark.createBookmark, {
uri: args.post.uri, uri: args.post.uri as AtUriString,
cid: args.post.cid, cid: args.post.cid,
}) })
} else if (args.action === 'delete') { } else if (args.action === 'delete') {
updatePostShadow(qc, args.uri, {bookmarked: false}) updatePostShadow(qc, args.uri, {bookmarked: false})
await agent.app.bsky.bookmark.deleteBookmark({ await client.call(app.bsky.bookmark.deleteBookmark, {
uri: args.uri, uri: args.uri as AtUriString,
}) })
} }
}, },
@@ -1,9 +1,4 @@
import { import {type $Typed, AppBskyFeedDefs, AtUri} from '@atproto/api'
type $Typed,
type AppBskyBookmarkGetBookmarks,
AppBskyFeedDefs,
AtUri,
} from '@atproto/api'
import { import {
type InfiniteData, type InfiniteData,
type QueryClient, type QueryClient,
@@ -16,28 +11,28 @@ import {
embedViewRecordToPostView, embedViewRecordToPostView,
getEmbeddedPost, getEmbeddedPost,
} from '#/state/queries/util' } from '#/state/queries/util'
import {useAgent} from '#/state/session' import {useAppviewClient} from '#/state/session'
import {app} from '#/lexicons'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
export const bookmarksQueryKeyRoot = 'bookmarks' export const bookmarksQueryKeyRoot = 'bookmarks'
export const createBookmarksQueryKey = () => [bookmarksQueryKeyRoot] export const createBookmarksQueryKey = () => [bookmarksQueryKeyRoot]
export function useBookmarksQuery() { export function useBookmarksQuery() {
const agent = useAgent() const client = useAppviewClient()
return useInfiniteQuery< return useInfiniteQuery<
AppBskyBookmarkGetBookmarks.OutputSchema, app.bsky.bookmark.getBookmarks.$OutputBody,
Error, Error,
InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>, InfiniteData<app.bsky.bookmark.getBookmarks.$OutputBody>,
QueryKey, QueryKey,
string | undefined string | undefined
>({ >({
queryKey: createBookmarksQueryKey(), queryKey: createBookmarksQueryKey(),
async queryFn({pageParam}) { async queryFn({pageParam}) {
const res = await agent.app.bsky.bookmark.getBookmarks({ return await client.call(app.bsky.bookmark.getBookmarks, {
cursor: pageParam, cursor: pageParam,
}) })
return res.data
}, },
initialPageParam: undefined, initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor, getNextPageParam: lastPage => lastPage.cursor,
@@ -45,7 +40,7 @@ export function useBookmarksQuery() {
} }
export async function truncateAndInvalidate(qc: QueryClient) { export async function truncateAndInvalidate(qc: QueryClient) {
qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>( qc.setQueriesData<InfiniteData<app.bsky.bookmark.getBookmarks.$OutputBody>>(
{queryKey: [bookmarksQueryKeyRoot]}, {queryKey: [bookmarksQueryKeyRoot]},
data => { data => {
if (data) { if (data) {
@@ -64,7 +59,7 @@ export async function optimisticallySaveBookmark(
qc: QueryClient, qc: QueryClient,
post: AppBskyFeedDefs.PostView, post: AppBskyFeedDefs.PostView,
) { ) {
qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>( qc.setQueriesData<InfiniteData<app.bsky.bookmark.getBookmarks.$OutputBody>>(
{ {
queryKey: [bookmarksQueryKeyRoot], queryKey: [bookmarksQueryKeyRoot],
}, },
@@ -75,19 +70,22 @@ export async function optimisticallySaveBookmark(
pages: data.pages.map((page, index) => { pages: data.pages.map((page, index) => {
if (index === 0) { if (index === 0) {
post.$type = 'app.bsky.feed.defs#postView' 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<AppBskyFeedDefs.PostView>,
} as unknown as app.bsky.bookmark.defs.BookmarkView
return { return {
...page, ...page,
bookmarks: [ bookmarks: [bookmark, ...page.bookmarks],
{
createdAt: new Date().toISOString(),
subject: {
uri: post.uri,
cid: post.cid,
},
item: post as $Typed<AppBskyFeedDefs.PostView>,
},
...page.bookmarks,
],
} }
} }
return page return page
@@ -101,7 +99,7 @@ export async function optimisticallyDeleteBookmark(
qc: QueryClient, qc: QueryClient,
{uri}: {uri: string}, {uri}: {uri: string},
) { ) {
qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>( qc.setQueriesData<InfiniteData<app.bsky.bookmark.getBookmarks.$OutputBody>>(
{ {
queryKey: [bookmarksQueryKeyRoot], queryKey: [bookmarksQueryKeyRoot],
}, },
@@ -125,7 +123,7 @@ export function* findAllPostsInQueryData(
uri: string, uri: string,
): Generator<AppBskyFeedDefs.PostView, undefined> { ): Generator<AppBskyFeedDefs.PostView, undefined> {
const queryDatas = queryClient.getQueriesData< const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema> InfiniteData<app.bsky.bookmark.getBookmarks.$OutputBody>
>({ >({
queryKey: [bookmarksQueryKeyRoot], queryKey: [bookmarksQueryKeyRoot],
}) })
+29 -16
View File
@@ -1,4 +1,4 @@
import {AppBskyDraftCreateDraft, AppBskyDraftDefs} from '@atproto/api' import {AppBskyDraftDefs} from '@atproto/api'
import { import {
useInfiniteQuery, useInfiniteQuery,
useMutation, useMutation,
@@ -6,10 +6,12 @@ import {
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {isNetworkError} from '#/lib/strings/errors' 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 {type ComposerState} from '#/view/com/composer/state/composer'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {getDeviceId} from '#/analytics/identifiers' import {getDeviceId} from '#/analytics/identifiers'
import {app} from '#/lexicons'
import {composerStateToDraft, draftViewToSummary} from './api' import {composerStateToDraft, draftViewToSummary} from './api'
import {logger} from './logger' import {logger} from './logger'
import * as storage from './storage' import * as storage from './storage'
@@ -20,7 +22,7 @@ const DRAFTS_QUERY_KEY = ['drafts']
* Hook to list all drafts for the current account * Hook to list all drafts for the current account
*/ */
export function useDraftsQuery() { export function useDraftsQuery() {
const agent = useAgent() const client = useAppviewClient()
const ax = useAnalytics() const ax = useAnalytics()
return useInfiniteQuery({ return useInfiniteQuery({
@@ -28,10 +30,12 @@ export function useDraftsQuery() {
queryFn: async ({pageParam}) => { queryFn: async ({pageParam}) => {
// Ensure media cache is populated before checking which media exists // Ensure media cache is populated before checking which media exists
await storage.ensureMediaCachePopulated() 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 { return {
cursor: res.data.cursor, cursor: data.cursor,
drafts: res.data.drafts.map(view => drafts: data.drafts.map(view =>
draftViewToSummary({ draftViewToSummary({
view, view,
analytics: ax, 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. * This ensures we don't lose data if the network request fails.
*/ */
export function useSaveDraftMutation() { export function useSaveDraftMutation() {
const agent = useAgent() const client = useAppviewClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
@@ -132,7 +136,14 @@ export function useSaveDraftMutation() {
originalLocalRefs: Set<string> | undefined originalLocalRefs: Set<string> | undefined
}> => { }> => {
// Convert composer state to server draft format // 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', { logger.debug('saving draft', {
existingDraftId, existingDraftId,
@@ -147,7 +158,7 @@ export function useSaveDraftMutation() {
logger.debug('updating existing draft on server', { logger.debug('updating existing draft on server', {
draftId: existingDraftId, draftId: existingDraftId,
}) })
await agent.app.bsky.draft.updateDraft({ await client.call(app.bsky.draft.updateDraft, {
draft: { draft: {
id: existingDraftId, id: existingDraftId,
draft, draft,
@@ -157,8 +168,8 @@ export function useSaveDraftMutation() {
} else { } else {
// Create new draft // Create new draft
logger.debug('creating new draft on server') logger.debug('creating new draft on server')
const res = await agent.app.bsky.draft.createDraft({draft}) const data = await client.call(app.bsky.draft.createDraft, {draft})
draftId = res.data.id draftId = data.id
logger.debug('created new draft', {draftId}) logger.debug('created new draft', {draftId})
} }
@@ -203,7 +214,7 @@ export function useSaveDraftMutation() {
}, },
onError: error => { onError: error => {
// Check for draft limit 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}) logger.error('Draft limit reached', {safeMessage: error.message})
// Error will be handled by caller // Error will be handled by caller
} else if (!isNetworkError(error)) { } else if (!isNetworkError(error)) {
@@ -220,7 +231,7 @@ export function useSaveDraftMutation() {
* Takes the full draft data to avoid re-fetching for media cleanup. * Takes the full draft data to avoid re-fetching for media cleanup.
*/ */
export function useDeleteDraftMutation() { export function useDeleteDraftMutation() {
const agent = useAgent() const client = useAppviewClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
@@ -231,7 +242,7 @@ export function useDeleteDraftMutation() {
draft: AppBskyDraftDefs.Draft draft: AppBskyDraftDefs.Draft
}) => { }) => {
// Delete from server first - if this fails, we keep local media for retry // 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}) => { onSuccess: async (_, {draft}) => {
// Only delete local media after server deletion succeeds // Only delete local media after server deletion succeeds
@@ -264,7 +275,7 @@ export function useDeleteDraftMutation() {
* Takes draftId and originalLocalRefs from composer state. * Takes draftId and originalLocalRefs from composer state.
*/ */
export function useCleanupPublishedDraftMutation() { export function useCleanupPublishedDraftMutation() {
const agent = useAgent() const client = useAppviewClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
@@ -280,7 +291,9 @@ export function useCleanupPublishedDraftMutation() {
mediaFileCount: originalLocalRefs.size, mediaFileCount: originalLocalRefs.size,
}) })
// Delete from server first // 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}) logger.debug('deleted draft from server', {draftId})
}, },
onSuccess: async (_, {originalLocalRefs}) => { onSuccess: async (_, {originalLocalRefs}) => {