clean up orphaned media
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user