diff --git a/src/state/gallery.ts b/src/state/gallery.ts index ea3445dba4..09c2e84328 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -39,6 +39,8 @@ export type ImageSource = ImageMeta & { type ComposerImageBase = { alt: string source: ImageSource + /** Original localRef path from draft, if editing an existing draft. Used to reuse the same storage key. */ + localRefPath?: string } type ComposerImageWithoutTransformation = ComposerImageBase & { transformed?: undefined diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 6583ceee34..45372a1e91 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -130,8 +130,12 @@ import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' -import {draftToComposerPosts} from './drafts/state/api' -import {loadDraft, useSaveDraftMutation} from './drafts/state/queries' +import {draftToComposerPosts, extractLocalRefs} from './drafts/state/api' +import { + loadDraft, + useCleanupPublishedDraftMutation, + useSaveDraftMutation, +} from './drafts/state/queries' import {type DraftSummary} from './drafts/state/schema' import {PostLanguageSelect} from './select-language/PostLanguageSelect' import { @@ -193,6 +197,7 @@ export const ComposePost = ({ const discardPromptControl = Prompt.usePromptControl() const {mutateAsync: saveDraft, isPending: _isSavingDraft} = useSaveDraftMutation() + const {mutate: cleanupPublishedDraft} = useCleanupPublishedDraftMutation() const {closeAllDialogs} = useDialogStateControlContext() const {closeAllModals} = useModalControls() const {data: preferences} = usePreferencesQuery() @@ -326,9 +331,22 @@ export const ComposePost = ({ const handleSelectDraft = React.useCallback( async (draftSummary: DraftSummary) => { + logger.debug('loading draft for editing', { + draftId: draftSummary.id, + }) + // Load local media files for the draft const {loadedMedia} = await loadDraft(draftSummary.draft) + // Extract original localRefs for orphan detection on save + const originalLocalRefs = extractLocalRefs(draftSummary.draft) + + logger.debug('draft loaded', { + draftId: draftSummary.id, + loadedMediaCount: loadedMedia.size, + originalLocalRefCount: originalLocalRefs.size, + }) + // Convert server draft to composer posts const posts = draftToComposerPosts(draftSummary.draft, loadedMedia) @@ -340,6 +358,7 @@ export const ComposePost = ({ threadgateAllow: draftSummary.draft.threadgateAllow, postgateEmbeddingRules: draftSummary.draft.postgateEmbeddingRules, loadedMedia, + originalLocalRefs, }) }, [composerDispatch], @@ -354,11 +373,11 @@ export const ComposePost = ({ const handleSaveDraft = React.useCallback(async () => { try { - const draftId = await saveDraft({ + const result = await saveDraft({ composerState, existingDraftId: composerState.draftId, }) - composerDispatch({type: 'mark_saved', draftId}) + composerDispatch({type: 'mark_saved', draftId: result.draftId}) onClose() } catch (e) { logger.error('Failed to save draft', {error: e}) @@ -368,11 +387,11 @@ export const ComposePost = ({ // Save without closing - for use by DraftsButton const saveCurrentDraft = React.useCallback(async () => { - const draftId = await saveDraft({ + const result = await saveDraft({ composerState, existingDraftId: composerState.draftId, }) - composerDispatch({type: 'mark_saved', draftId}) + composerDispatch({type: 'mark_saved', draftId: result.draftId}) }, [saveDraft, composerState, composerDispatch]) // Check if composer is empty (no content to save) @@ -630,6 +649,17 @@ export const ComposePost = ({ if (postUri && !replyTo) { emitPostCreated() } + // Clean up draft and its media after successful publish + if (composerState.draftId && composerState.originalLocalRefs) { + logger.debug('post published, cleaning up draft', { + draftId: composerState.draftId, + mediaFileCount: composerState.originalLocalRefs.size, + }) + cleanupPublishedDraft({ + draftId: composerState.draftId, + originalLocalRefs: composerState.originalLocalRefs, + }) + } setLangPrefs.savePostLanguageToHistory() if (initQuote) { // We want to wait for the quote count to update before we call `onPost`, which will refetch data @@ -693,6 +723,9 @@ export const ComposePost = ({ setLangPrefs, queryClient, navigation, + composerState.draftId, + composerState.originalLocalRefs, + cleanupPublishedDraft, ]) // Preserves the referential identity passed to each post item. diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index bd686e6db7..d96f96971e 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -12,6 +12,7 @@ import { type PostDraft, } from '#/view/com/composer/state/composer' import {type VideoState} from '#/view/com/composer/state/video' +import {logger} from './logger' import {type DraftPostDisplay, type DraftSummary} from './schema' const TENOR_HOSTNAME = 'media.tenor.com' @@ -131,6 +132,8 @@ function postDraftToServerPost( /** * Serialize images to server format with localRef paths. + * Reuses existing localRefPath if present (when editing a draft), + * otherwise generates a new one. */ function serializeImages( images: ComposerImage[], @@ -138,10 +141,17 @@ function serializeImages( ): AppBskyDraftDefs.DraftEmbedImage[] { return images.map(image => { const sourcePath = image.transformed?.path || image.source.path - // Use a unique key for the localRef path - const localRefPath = `image:${nanoid()}` + // Reuse existing localRefPath if present (editing draft), otherwise generate new + const isReusing = !!image.localRefPath + const localRefPath = image.localRefPath || `image:${nanoid()}` localRefPaths.set(localRefPath, sourcePath) + logger.debug('serializing image', { + localRefPath, + isReusing, + sourcePath, + }) + return { $type: 'app.bsky.draft.defs#draftEmbedImage', localRef: { @@ -357,8 +367,14 @@ export function draftToComposerPosts( for (const img of post.embedImages) { const path = loadedMedia.get(img.localRef.path) if (path) { + logger.debug('restoring image with localRefPath', { + localRefPath: img.localRef.path, + loadedPath: path, + }) images.push({ alt: img.alt || '', + // Preserve the original localRefPath for reuse when saving + localRefPath: img.localRef.path, source: { id: nanoid(), path, @@ -480,3 +496,28 @@ export function threadgateToUISettings( }) .filter((s): s is {type: string; list?: string} => s !== null) } + +/** + * Extract all localRef paths from a draft. + * Used to identify which media files belong to a draft for cleanup. + */ +export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set { + const refs = new Set() + for (const post of draft.posts) { + if (post.embedImages) { + for (const img of post.embedImages) { + refs.add(img.localRef.path) + } + } + if (post.embedVideos) { + for (const vid of post.embedVideos) { + refs.add(vid.localRef.path) + } + } + } + logger.debug('extracted localRefs from draft', { + count: refs.size, + refs: Array.from(refs), + }) + return refs +} diff --git a/src/view/com/composer/drafts/state/queries.ts b/src/view/com/composer/drafts/state/queries.ts index a1efe2177c..ce8119f87a 100644 --- a/src/view/com/composer/drafts/state/queries.ts +++ b/src/view/com/composer/drafts/state/queries.ts @@ -82,7 +82,11 @@ export async function loadDraft(draft: AppBskyDraftDefs.Draft): Promise<{ } /** - * Hook to save a draft + * Hook to save a draft. + * + * IMPORTANT: Network operations happen first in mutationFn. + * Local storage operations (save new media, delete orphaned media) happen in onSuccess. + * This ensures we don't lose data if the network request fails. */ export function useSaveDraftMutation() { const agent = useAgent() @@ -95,34 +99,79 @@ export function useSaveDraftMutation() { }: { composerState: ComposerState existingDraftId?: string - }): Promise => { + }): Promise<{ + draftId: string + localRefPaths: Map + originalLocalRefs: Set | undefined + }> => { // Convert composer state to server draft format const {draft, localRefPaths} = composerStateToDraft(composerState) - // Save media files locally - for (const [localRefPath, sourcePath] of localRefPaths) { - // Check if this media is already saved (re-saving existing draft) - if (!storage.mediaExists(localRefPath)) { - await storage.saveMediaToLocal(localRefPath, sourcePath) - } - } + logger.debug('saving draft', { + existingDraftId, + localRefPathCount: localRefPaths.size, + originalLocalRefCount: composerState.originalLocalRefs?.size ?? 0, + }) + // 1. NETWORK FIRST - Update/create server draft + let draftId: string if (existingDraftId) { // Update existing draft + logger.debug('updating existing draft on server', { + draftId: existingDraftId, + }) await agent.app.bsky.draft.updateDraft({ draft: { id: existingDraftId, draft, }, }) - return existingDraftId + draftId = existingDraftId } else { // Create new draft + logger.debug('creating new draft on server') const res = await agent.app.bsky.draft.createDraft({draft}) - return res.data.id + draftId = res.data.id + logger.debug('created new draft', {draftId}) + } + + // Return data needed for onSuccess + return { + draftId, + localRefPaths, + originalLocalRefs: composerState.originalLocalRefs, } }, - onSuccess: () => { + onSuccess: async ({draftId, localRefPaths, originalLocalRefs}) => { + // 2. LOCAL STORAGE ONLY AFTER NETWORK SUCCEEDS + logger.debug('network save succeeded, processing local storage', { + draftId, + }) + + // Save new/changed media files + for (const [localRefPath, sourcePath] of localRefPaths) { + // Only save if this media doesn't already exist (reusing localRefPath) + if (!storage.mediaExists(localRefPath)) { + logger.debug('saving new media file', {localRefPath}) + await storage.saveMediaToLocal(localRefPath, sourcePath) + } else { + logger.debug('skipping existing media file', {localRefPath}) + } + } + + // Delete orphaned media (old refs not in new) + if (originalLocalRefs) { + const newLocalRefs = new Set(localRefPaths.keys()) + for (const oldRef of originalLocalRefs) { + if (!newLocalRefs.has(oldRef)) { + logger.debug('deleting orphaned media file', { + localRefPath: oldRef, + }) + await storage.deleteMediaFromLocal(oldRef) + } + } + } + queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY}) }, onError: error => { @@ -175,3 +224,48 @@ export function useDeleteDraftMutation() { }, }) } + +/** + * Hook to clean up a draft after it has been published. + * Deletes the draft from server and all associated local media. + * Takes draftId and originalLocalRefs from composer state. + */ +export function useCleanupPublishedDraftMutation() { + const agent = useAgent() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + draftId, + originalLocalRefs, + }: { + draftId: string + originalLocalRefs: Set + }) => { + logger.debug('cleaning up published draft', { + draftId, + mediaFileCount: originalLocalRefs.size, + }) + // Delete from server first + await agent.app.bsky.draft.deleteDraft({id: draftId}) + logger.debug('deleted draft from server', {draftId}) + }, + onSuccess: async (_, {originalLocalRefs}) => { + // Delete all local media files for this draft + for (const localRef of originalLocalRefs) { + logger.debug('deleting media file after publish', { + localRefPath: localRef, + }) + await storage.deleteMediaFromLocal(localRef) + } + queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY}) + logger.debug('cleanup after publish complete') + }, + onError: error => { + // Log but don't throw - the post was already published successfully + logger.warn('Failed to clean up published draft', { + safeMessage: error instanceof Error ? error.message : String(error), + }) + }, + }) +} diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 7932378dab..ea4399568d 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -108,6 +108,8 @@ export type ComposerState = { isDirty: boolean /** Map of localId -> loaded media path/URL for the current draft. Used for re-saving without re-copying media. */ loadedMediaMap?: Map + /** Set of original localRef paths from the draft being edited. Used to identify orphaned media on save. */ + originalLocalRefs?: Set } export type ComposerAction = @@ -138,6 +140,8 @@ export type ComposerAction = /** Map of localRefPath -> loaded media path/URL */ loadedMedia: Map + /** Set of original localRef paths from the draft. Used to identify orphaned media on save. */ + originalLocalRefs: Set } | { type: 'clear' @@ -268,6 +272,7 @@ export function composerReducer( threadgateAllow, postgateEmbeddingRules, loadedMedia, + originalLocalRefs, } = action return { @@ -276,6 +281,7 @@ export function composerReducer( draftId, isDirty: false, loadedMediaMap: loadedMedia, + originalLocalRefs, thread: { posts, postgate: createPostgateRecord({