clean up orphaned media
This commit is contained in:
@@ -39,6 +39,8 @@ export type ImageSource = ImageMeta & {
|
|||||||
type ComposerImageBase = {
|
type ComposerImageBase = {
|
||||||
alt: string
|
alt: string
|
||||||
source: ImageSource
|
source: ImageSource
|
||||||
|
/** Original localRef path from draft, if editing an existing draft. Used to reuse the same storage key. */
|
||||||
|
localRefPath?: string
|
||||||
}
|
}
|
||||||
type ComposerImageWithoutTransformation = ComposerImageBase & {
|
type ComposerImageWithoutTransformation = ComposerImageBase & {
|
||||||
transformed?: undefined
|
transformed?: undefined
|
||||||
|
|||||||
@@ -130,8 +130,12 @@ import {Text} from '#/components/Typography'
|
|||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
|
||||||
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
||||||
import {draftToComposerPosts} from './drafts/state/api'
|
import {draftToComposerPosts, extractLocalRefs} from './drafts/state/api'
|
||||||
import {loadDraft, useSaveDraftMutation} from './drafts/state/queries'
|
import {
|
||||||
|
loadDraft,
|
||||||
|
useCleanupPublishedDraftMutation,
|
||||||
|
useSaveDraftMutation,
|
||||||
|
} from './drafts/state/queries'
|
||||||
import {type DraftSummary} from './drafts/state/schema'
|
import {type DraftSummary} from './drafts/state/schema'
|
||||||
import {PostLanguageSelect} from './select-language/PostLanguageSelect'
|
import {PostLanguageSelect} from './select-language/PostLanguageSelect'
|
||||||
import {
|
import {
|
||||||
@@ -193,6 +197,7 @@ export const ComposePost = ({
|
|||||||
const discardPromptControl = Prompt.usePromptControl()
|
const discardPromptControl = Prompt.usePromptControl()
|
||||||
const {mutateAsync: saveDraft, isPending: _isSavingDraft} =
|
const {mutateAsync: saveDraft, isPending: _isSavingDraft} =
|
||||||
useSaveDraftMutation()
|
useSaveDraftMutation()
|
||||||
|
const {mutate: cleanupPublishedDraft} = useCleanupPublishedDraftMutation()
|
||||||
const {closeAllDialogs} = useDialogStateControlContext()
|
const {closeAllDialogs} = useDialogStateControlContext()
|
||||||
const {closeAllModals} = useModalControls()
|
const {closeAllModals} = useModalControls()
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
@@ -326,9 +331,22 @@ export const ComposePost = ({
|
|||||||
|
|
||||||
const handleSelectDraft = React.useCallback(
|
const handleSelectDraft = React.useCallback(
|
||||||
async (draftSummary: DraftSummary) => {
|
async (draftSummary: DraftSummary) => {
|
||||||
|
logger.debug('loading draft for editing', {
|
||||||
|
draftId: draftSummary.id,
|
||||||
|
})
|
||||||
|
|
||||||
// Load local media files for the draft
|
// Load local media files for the draft
|
||||||
const {loadedMedia} = await loadDraft(draftSummary.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
|
// Convert server draft to composer posts
|
||||||
const posts = draftToComposerPosts(draftSummary.draft, loadedMedia)
|
const posts = draftToComposerPosts(draftSummary.draft, loadedMedia)
|
||||||
|
|
||||||
@@ -340,6 +358,7 @@ export const ComposePost = ({
|
|||||||
threadgateAllow: draftSummary.draft.threadgateAllow,
|
threadgateAllow: draftSummary.draft.threadgateAllow,
|
||||||
postgateEmbeddingRules: draftSummary.draft.postgateEmbeddingRules,
|
postgateEmbeddingRules: draftSummary.draft.postgateEmbeddingRules,
|
||||||
loadedMedia,
|
loadedMedia,
|
||||||
|
originalLocalRefs,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[composerDispatch],
|
[composerDispatch],
|
||||||
@@ -354,11 +373,11 @@ export const ComposePost = ({
|
|||||||
|
|
||||||
const handleSaveDraft = React.useCallback(async () => {
|
const handleSaveDraft = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const draftId = await saveDraft({
|
const result = await saveDraft({
|
||||||
composerState,
|
composerState,
|
||||||
existingDraftId: composerState.draftId,
|
existingDraftId: composerState.draftId,
|
||||||
})
|
})
|
||||||
composerDispatch({type: 'mark_saved', draftId})
|
composerDispatch({type: 'mark_saved', draftId: result.draftId})
|
||||||
onClose()
|
onClose()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to save draft', {error: e})
|
logger.error('Failed to save draft', {error: e})
|
||||||
@@ -368,11 +387,11 @@ export const ComposePost = ({
|
|||||||
|
|
||||||
// Save without closing - for use by DraftsButton
|
// Save without closing - for use by DraftsButton
|
||||||
const saveCurrentDraft = React.useCallback(async () => {
|
const saveCurrentDraft = React.useCallback(async () => {
|
||||||
const draftId = await saveDraft({
|
const result = await saveDraft({
|
||||||
composerState,
|
composerState,
|
||||||
existingDraftId: composerState.draftId,
|
existingDraftId: composerState.draftId,
|
||||||
})
|
})
|
||||||
composerDispatch({type: 'mark_saved', draftId})
|
composerDispatch({type: 'mark_saved', draftId: result.draftId})
|
||||||
}, [saveDraft, composerState, composerDispatch])
|
}, [saveDraft, composerState, composerDispatch])
|
||||||
|
|
||||||
// Check if composer is empty (no content to save)
|
// Check if composer is empty (no content to save)
|
||||||
@@ -630,6 +649,17 @@ export const ComposePost = ({
|
|||||||
if (postUri && !replyTo) {
|
if (postUri && !replyTo) {
|
||||||
emitPostCreated()
|
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()
|
setLangPrefs.savePostLanguageToHistory()
|
||||||
if (initQuote) {
|
if (initQuote) {
|
||||||
// We want to wait for the quote count to update before we call `onPost`, which will refetch data
|
// 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,
|
setLangPrefs,
|
||||||
queryClient,
|
queryClient,
|
||||||
navigation,
|
navigation,
|
||||||
|
composerState.draftId,
|
||||||
|
composerState.originalLocalRefs,
|
||||||
|
cleanupPublishedDraft,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Preserves the referential identity passed to each post item.
|
// Preserves the referential identity passed to each post item.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type PostDraft,
|
type PostDraft,
|
||||||
} from '#/view/com/composer/state/composer'
|
} from '#/view/com/composer/state/composer'
|
||||||
import {type VideoState} from '#/view/com/composer/state/video'
|
import {type VideoState} from '#/view/com/composer/state/video'
|
||||||
|
import {logger} from './logger'
|
||||||
import {type DraftPostDisplay, type DraftSummary} from './schema'
|
import {type DraftPostDisplay, type DraftSummary} from './schema'
|
||||||
|
|
||||||
const TENOR_HOSTNAME = 'media.tenor.com'
|
const TENOR_HOSTNAME = 'media.tenor.com'
|
||||||
@@ -131,6 +132,8 @@ function postDraftToServerPost(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize images to server format with localRef paths.
|
* Serialize images to server format with localRef paths.
|
||||||
|
* Reuses existing localRefPath if present (when editing a draft),
|
||||||
|
* otherwise generates a new one.
|
||||||
*/
|
*/
|
||||||
function serializeImages(
|
function serializeImages(
|
||||||
images: ComposerImage[],
|
images: ComposerImage[],
|
||||||
@@ -138,10 +141,17 @@ function serializeImages(
|
|||||||
): AppBskyDraftDefs.DraftEmbedImage[] {
|
): AppBskyDraftDefs.DraftEmbedImage[] {
|
||||||
return images.map(image => {
|
return images.map(image => {
|
||||||
const sourcePath = image.transformed?.path || image.source.path
|
const sourcePath = image.transformed?.path || image.source.path
|
||||||
// Use a unique key for the localRef path
|
// Reuse existing localRefPath if present (editing draft), otherwise generate new
|
||||||
const localRefPath = `image:${nanoid()}`
|
const isReusing = !!image.localRefPath
|
||||||
|
const localRefPath = image.localRefPath || `image:${nanoid()}`
|
||||||
localRefPaths.set(localRefPath, sourcePath)
|
localRefPaths.set(localRefPath, sourcePath)
|
||||||
|
|
||||||
|
logger.debug('serializing image', {
|
||||||
|
localRefPath,
|
||||||
|
isReusing,
|
||||||
|
sourcePath,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
$type: 'app.bsky.draft.defs#draftEmbedImage',
|
$type: 'app.bsky.draft.defs#draftEmbedImage',
|
||||||
localRef: {
|
localRef: {
|
||||||
@@ -357,8 +367,14 @@ export function draftToComposerPosts(
|
|||||||
for (const img of post.embedImages) {
|
for (const img of post.embedImages) {
|
||||||
const path = loadedMedia.get(img.localRef.path)
|
const path = loadedMedia.get(img.localRef.path)
|
||||||
if (path) {
|
if (path) {
|
||||||
|
logger.debug('restoring image with localRefPath', {
|
||||||
|
localRefPath: img.localRef.path,
|
||||||
|
loadedPath: path,
|
||||||
|
})
|
||||||
images.push({
|
images.push({
|
||||||
alt: img.alt || '',
|
alt: img.alt || '',
|
||||||
|
// Preserve the original localRefPath for reuse when saving
|
||||||
|
localRefPath: img.localRef.path,
|
||||||
source: {
|
source: {
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
path,
|
path,
|
||||||
@@ -480,3 +496,28 @@ export function threadgateToUISettings(
|
|||||||
})
|
})
|
||||||
.filter((s): s is {type: string; list?: string} => s !== null)
|
.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() {
|
export function useSaveDraftMutation() {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
@@ -95,34 +99,79 @@ export function useSaveDraftMutation() {
|
|||||||
}: {
|
}: {
|
||||||
composerState: ComposerState
|
composerState: ComposerState
|
||||||
existingDraftId?: string
|
existingDraftId?: string
|
||||||
}): Promise<string> => {
|
}): Promise<{
|
||||||
|
draftId: string
|
||||||
|
localRefPaths: Map<string, string>
|
||||||
|
originalLocalRefs: Set<string> | undefined
|
||||||
|
}> => {
|
||||||
// Convert composer state to server draft format
|
// Convert composer state to server draft format
|
||||||
const {draft, localRefPaths} = composerStateToDraft(composerState)
|
const {draft, localRefPaths} = composerStateToDraft(composerState)
|
||||||
|
|
||||||
// Save media files locally
|
logger.debug('saving draft', {
|
||||||
for (const [localRefPath, sourcePath] of localRefPaths) {
|
existingDraftId,
|
||||||
// Check if this media is already saved (re-saving existing draft)
|
localRefPathCount: localRefPaths.size,
|
||||||
if (!storage.mediaExists(localRefPath)) {
|
originalLocalRefCount: composerState.originalLocalRefs?.size ?? 0,
|
||||||
await storage.saveMediaToLocal(localRefPath, sourcePath)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 1. NETWORK FIRST - Update/create server draft
|
||||||
|
let draftId: string
|
||||||
if (existingDraftId) {
|
if (existingDraftId) {
|
||||||
// Update existing draft
|
// Update existing draft
|
||||||
|
logger.debug('updating existing draft on server', {
|
||||||
|
draftId: existingDraftId,
|
||||||
|
})
|
||||||
await agent.app.bsky.draft.updateDraft({
|
await agent.app.bsky.draft.updateDraft({
|
||||||
draft: {
|
draft: {
|
||||||
id: existingDraftId,
|
id: existingDraftId,
|
||||||
draft,
|
draft,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return existingDraftId
|
draftId = existingDraftId
|
||||||
} else {
|
} else {
|
||||||
// Create new draft
|
// Create new draft
|
||||||
|
logger.debug('creating new draft on server')
|
||||||
const res = await agent.app.bsky.draft.createDraft({draft})
|
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})
|
queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY})
|
||||||
},
|
},
|
||||||
onError: error => {
|
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
|
isDirty: boolean
|
||||||
/** Map of localId -> loaded media path/URL for the current draft. Used for re-saving without re-copying media. */
|
/** Map of localId -> loaded media path/URL for the current draft. Used for re-saving without re-copying media. */
|
||||||
loadedMediaMap?: Map<string, string>
|
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 =
|
export type ComposerAction =
|
||||||
@@ -138,6 +140,8 @@ export type ComposerAction =
|
|||||||
|
|
||||||
/** Map of localRefPath -> loaded media path/URL */
|
/** Map of localRefPath -> loaded media path/URL */
|
||||||
loadedMedia: Map<string, string>
|
loadedMedia: Map<string, string>
|
||||||
|
/** Set of original localRef paths from the draft. Used to identify orphaned media on save. */
|
||||||
|
originalLocalRefs: Set<string>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'clear'
|
type: 'clear'
|
||||||
@@ -268,6 +272,7 @@ export function composerReducer(
|
|||||||
threadgateAllow,
|
threadgateAllow,
|
||||||
postgateEmbeddingRules,
|
postgateEmbeddingRules,
|
||||||
loadedMedia,
|
loadedMedia,
|
||||||
|
originalLocalRefs,
|
||||||
} = action
|
} = action
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -276,6 +281,7 @@ export function composerReducer(
|
|||||||
draftId,
|
draftId,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
loadedMediaMap: loadedMedia,
|
loadedMediaMap: loadedMedia,
|
||||||
|
originalLocalRefs,
|
||||||
thread: {
|
thread: {
|
||||||
posts,
|
posts,
|
||||||
postgate: createPostgateRecord({
|
postgate: createPostgateRecord({
|
||||||
|
|||||||
Reference in New Issue
Block a user