From 9fcaa8ff39a639bf426f647d9c33b2a24b37f67e Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 16 Jan 2026 16:42:59 +0200 Subject: [PATCH] refactor: pass full draft data instead of re-fetching The useLoadDraft and useDeleteDraftMutation hooks were fetching drafts via getDrafts() to look up a draft by ID. This was problematic because getDrafts is paginated, so drafts not on the first page wouldn't be found. Changes: - Add full Draft object to DraftSummary type - useLoadDraft now takes Draft directly (only loads local media) - useDeleteDraftMutation now takes {draftId, draft} to avoid re-fetch - Update DraftItem and DraftsListDialog to pass full draft data Co-Authored-By: Claude Opus 4.5 --- src/view/com/composer/Composer.tsx | 13 ++-- src/view/com/composer/drafts/DraftItem.tsx | 6 +- .../com/composer/drafts/DraftsListDialog.tsx | 4 +- src/view/com/composer/drafts/state/api.ts | 2 +- src/view/com/composer/drafts/state/queries.ts | 59 ++++++++----------- src/view/com/composer/drafts/state/schema.ts | 5 +- 6 files changed, 39 insertions(+), 50 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 2b1adfe425..ff056ceb9b 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -327,22 +327,19 @@ export const ComposePost = ({ const handleSelectDraft = React.useCallback( async (draftSummary: DraftSummary) => { - // Load full draft from server with media - const result = await loadDraft(draftSummary.id) - if (!result) return - - const {draft, loadedMedia} = result + // Load local media files for the draft + const {loadedMedia} = await loadDraft(draftSummary.draft) // Convert server draft to composer posts - const posts = draftToComposerPosts(draft, loadedMedia) + const posts = draftToComposerPosts(draftSummary.draft, loadedMedia) // Dispatch restore action (this also sets draftId in state) composerDispatch({ type: 'restore_from_draft', draftId: draftSummary.id, posts, - threadgateAllow: draft.threadgateAllow, - postgateEmbeddingRules: draft.postgateEmbeddingRules, + threadgateAllow: draftSummary.draft.threadgateAllow, + postgateEmbeddingRules: draftSummary.draft.postgateEmbeddingRules, loadedMedia, }) }, diff --git a/src/view/com/composer/drafts/DraftItem.tsx b/src/view/com/composer/drafts/DraftItem.tsx index 1d06dd1392..28876d9a15 100644 --- a/src/view/com/composer/drafts/DraftItem.tsx +++ b/src/view/com/composer/drafts/DraftItem.tsx @@ -30,15 +30,15 @@ export function DraftItem({ }: { draft: DraftSummary onSelect: (draft: DraftSummary) => void - onDelete: (draftId: string) => void + onDelete: (draft: DraftSummary) => void }) { const {_} = useLingui() const t = useTheme() const discardPromptControl = Prompt.usePromptControl() const handleDelete = useCallback(() => { - onDelete(draft.id) - }, [onDelete, draft.id]) + onDelete(draft) + }, [onDelete, draft]) return ( <> diff --git a/src/view/com/composer/drafts/DraftsListDialog.tsx b/src/view/com/composer/drafts/DraftsListDialog.tsx index 2ecb8b52a2..83fe05a6a6 100644 --- a/src/view/com/composer/drafts/DraftsListDialog.tsx +++ b/src/view/com/composer/drafts/DraftsListDialog.tsx @@ -41,8 +41,8 @@ export function DraftsListDialog({ ) const handleDeleteDraft = useCallback( - (draftId: string) => { - deleteDraft(draftId) + (draftSummary: DraftSummary) => { + deleteDraft({draftId: draftSummary.id, draft: draftSummary.draft}) }, [deleteDraft], ) diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index cd42afbb4a..bd686e6db7 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -289,12 +289,12 @@ export function draftViewToSummary( return { id: view.id, + draft: view.draft, previewText, hasMedia, hasMissingMedia, mediaCount, postCount: view.draft.posts.length, - isReply: false, // Reply drafts not supported updatedAt: view.updatedAt, posts, } diff --git a/src/view/com/composer/drafts/state/queries.ts b/src/view/com/composer/drafts/state/queries.ts index af21ae38fe..25e2c54730 100644 --- a/src/view/com/composer/drafts/state/queries.ts +++ b/src/view/com/composer/drafts/state/queries.ts @@ -40,29 +40,19 @@ export function useDraftsQuery() { } /** - * Hook to load a specific draft for editing + * Hook to load a draft's local media for editing. + * Takes the full Draft object (from DraftSummary) to avoid re-fetching. */ export function useLoadDraft() { - const agent = useAgent() - return useCallback( async ( - draftId: string, + draft: AppBskyDraftDefs.Draft, ): Promise<{ - draft: AppBskyDraftDefs.Draft loadedMedia: Map - } | null> => { - // Fetch the draft from server - const res = await agent.app.bsky.draft.getDrafts({}) - const draftView = res.data.drafts.find(d => d.id === draftId) - - if (!draftView) { - return null - } - + }> => { // Load local media files const loadedMedia = new Map() - for (const post of draftView.draft.posts) { + for (const post of draft.posts) { // Load images if (post.embedImages) { for (const img of post.embedImages) { @@ -93,9 +83,9 @@ export function useLoadDraft() { } } - return {draft: draftView.draft, loadedMedia} + return {loadedMedia} }, - [agent], + [], ) } @@ -158,30 +148,31 @@ export function useSaveDraftMutation() { } /** - * Hook to delete a draft + * Hook to delete a draft. + * Takes the full draft data to avoid re-fetching for media cleanup. */ export function useDeleteDraftMutation() { const agent = useAgent() const queryClient = useQueryClient() return useMutation({ - mutationFn: async (draftId: string) => { - // First fetch the draft to get media paths for cleanup - const res = await agent.app.bsky.draft.getDrafts({}) - const draftView = res.data.drafts.find(d => d.id === draftId) - - if (draftView) { - // Delete local media files - for (const post of draftView.draft.posts) { - if (post.embedImages) { - for (const img of post.embedImages) { - await storage.deleteMediaFromLocal(img.localRef.path) - } + mutationFn: async ({ + draftId, + draft, + }: { + draftId: string + draft: AppBskyDraftDefs.Draft + }) => { + // Delete local media files + for (const post of draft.posts) { + if (post.embedImages) { + for (const img of post.embedImages) { + await storage.deleteMediaFromLocal(img.localRef.path) } - if (post.embedVideos) { - for (const vid of post.embedVideos) { - await storage.deleteMediaFromLocal(vid.localRef.path) - } + } + if (post.embedVideos) { + for (const vid of post.embedVideos) { + await storage.deleteMediaFromLocal(vid.localRef.path) } } } diff --git a/src/view/com/composer/drafts/state/schema.ts b/src/view/com/composer/drafts/state/schema.ts index 62be4e55d5..59221380d7 100644 --- a/src/view/com/composer/drafts/state/schema.ts +++ b/src/view/com/composer/drafts/state/schema.ts @@ -2,6 +2,7 @@ * Types for draft display and local media tracking. * Server draft types come from @atproto/api. */ +import {type AppBskyDraftDefs} from '@atproto/api' /** * Reference to locally cached media file for display @@ -47,6 +48,8 @@ export type DraftPostDisplay = { */ export type DraftSummary = { id: string + /** The full draft data from the server */ + draft: AppBskyDraftDefs.Draft /** First ~100 chars of first post */ previewText: string /** Whether the draft has media */ @@ -57,8 +60,6 @@ export type DraftSummary = { mediaCount: number /** Number of posts in thread */ postCount: number - /** Whether this is a reply (always false - replies not supported) */ - isReply: boolean /** ISO timestamp of last update */ updatedAt: string /** All posts in the draft for full display */