clean up orphaned media

This commit is contained in:
Samuel Newman
2026-01-27 21:07:12 +02:00
parent 468fe2f2ed
commit 0cdc120ae6
5 changed files with 196 additions and 20 deletions
+2
View File
@@ -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
+39 -6
View File
@@ -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.
+43 -2
View File
@@ -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<string> {
const refs = new Set<string>()
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
}
+106 -12
View File
@@ -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<string> => {
}): Promise<{
draftId: string
localRefPaths: Map<string, string>
originalLocalRefs: Set<string> | 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<string>
}) => {
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),
})
},
})
}
+6
View File
@@ -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<string, string>
/** Set of original localRef paths from the draft being edited. Used to identify orphaned media on save. */
originalLocalRefs?: Set<string>
}
export type ComposerAction =
@@ -138,6 +140,8 @@ export type ComposerAction =
/** Map of localRefPath -> loaded media path/URL */
loadedMedia: Map<string, string>
/** Set of original localRef paths from the draft. Used to identify orphaned media on save. */
originalLocalRefs: Set<string>
}
| {
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({