Fix re-saving drafts with existing media

When re-saving a draft, the code was trying to copy media files that
were already in drafts storage to new locations, causing copy errors.

Changes:
- Add extractLocalIdFromPath() to detect if a path is already in drafts
- Track loadedMediaMap in ComposerState for identifying reusable media
- Only delete old media that wasn't reused during re-save
- Pass loadedMediaMap when saving to enable media reuse detection
- Disable pointer events on draft media preview

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-01-13 18:26:02 +02:00
parent ec554210dc
commit 813d972fdf
6 changed files with 140 additions and 38 deletions
+93 -30
View File
@@ -75,10 +75,12 @@ export function useSaveDraft() {
composerState, composerState,
replyTo, replyTo,
existingDraftId, existingDraftId,
loadedMediaMap,
}: { }: {
composerState: ComposerState composerState: ComposerState
replyTo?: ComposerOpts['replyTo'] replyTo?: ComposerOpts['replyTo']
existingDraftId?: string existingDraftId?: string
loadedMediaMap?: Map<string, string> // localId -> path/url
}): Promise<StoredDraft> => { }): Promise<StoredDraft> => {
if (!did) { if (!did) {
throw new Error('No account') throw new Error('No account')
@@ -87,23 +89,44 @@ export function useSaveDraft() {
const now = new Date().toISOString() const now = new Date().toISOString()
const draftId = existingDraftId || nanoid() const draftId = existingDraftId || nanoid()
// If updating existing draft, delete old media first // Build a reverse map (path -> localId) for identifying reusable media
const pathToLocalId = new Map<string, string>()
if (loadedMediaMap) {
for (const [localId, path] of loadedMediaMap) {
pathToLocalId.set(path, localId)
}
}
// Collect old media localIds for cleanup
let oldMediaLocalIds: Set<string> = new Set()
if (existingDraftId) { if (existingDraftId) {
const existingDraft = await storage.loadDraftMeta(did, existingDraftId) const existingDraft = await storage.loadDraftMeta(did, existingDraftId)
if (existingDraft) { if (existingDraft) {
// Clean up old media that's no longer used oldMediaLocalIds = collectMediaLocalIds(existingDraft)
await cleanupOldMedia(did, existingDraft)
} }
} }
// Serialize the composer state // Serialize the composer state, tracking which localIds are reused
const reusedLocalIds = new Set<string>()
const posts: StoredPostDraft[] = [] const posts: StoredPostDraft[] = []
for (const post of composerState.thread.posts) { for (const post of composerState.thread.posts) {
const storedPost = await serializePost(did, post) const storedPost = await serializePost(
did,
post,
pathToLocalId,
reusedLocalIds,
)
posts.push(storedPost) posts.push(storedPost)
} }
// Clean up old media that wasn't reused
for (const oldLocalId of oldMediaLocalIds) {
if (!reusedLocalIds.has(oldLocalId)) {
await storage.deleteMediaFromLocal(did, oldLocalId)
}
}
const draft: StoredDraft = { const draft: StoredDraft = {
id: draftId, id: draftId,
accountDid: did, accountDid: did,
@@ -162,12 +185,32 @@ export function useDeleteDraft() {
}) })
} }
/**
* Collect all media localIds from a draft
*/
function collectMediaLocalIds(draft: StoredDraft): Set<string> {
const localIds = new Set<string>()
for (const post of draft.posts) {
if (post.images) {
for (const image of post.images) {
localIds.add(image.localId)
}
}
if (post.video) {
localIds.add(post.video.localId)
}
}
return localIds
}
/** /**
* Serialize a post for storage * Serialize a post for storage
*/ */
async function serializePost( async function serializePost(
accountDid: string, accountDid: string,
post: PostDraft, post: PostDraft,
pathToLocalId: Map<string, string>,
reusedLocalIds: Set<string>,
): Promise<StoredPostDraft> { ): Promise<StoredPostDraft> {
const richtext: StoredRichText = { const richtext: StoredRichText = {
text: post.richtext.text, text: post.richtext.text,
@@ -188,11 +231,15 @@ async function serializePost(
storedPost.images = await serializeImages( storedPost.images = await serializeImages(
accountDid, accountDid,
post.embed.media.images, post.embed.media.images,
pathToLocalId,
reusedLocalIds,
) )
} else if (post.embed.media.type === 'video') { } else if (post.embed.media.type === 'video') {
storedPost.video = await serializeVideo( storedPost.video = await serializeVideo(
accountDid, accountDid,
post.embed.media.video, post.embed.media.video,
pathToLocalId,
reusedLocalIds,
) )
} else if (post.embed.media.type === 'gif') { } else if (post.embed.media.type === 'gif') {
storedPost.gif = serializeGif(post.embed.media) storedPost.gif = serializeGif(post.embed.media)
@@ -208,16 +255,36 @@ async function serializePost(
async function serializeImages( async function serializeImages(
accountDid: string, accountDid: string,
images: ComposerImage[], images: ComposerImage[],
pathToLocalId: Map<string, string>,
reusedLocalIds: Set<string>,
): Promise<LocalMediaRef[]> { ): Promise<LocalMediaRef[]> {
const refs: LocalMediaRef[] = [] const refs: LocalMediaRef[] = []
for (const image of images) { for (const image of images) {
const path = image.transformed?.path || image.source.path const path = image.transformed?.path || image.source.path
const localId = await storage.saveMediaToLocal(
// Check if this image is already in drafts storage
// First try the pathToLocalId map (works for both native and web)
let existingLocalId: string | null | undefined = pathToLocalId.get(path)
// On native, also check if the path is in the media directory
if (!existingLocalId) {
existingLocalId = storage.extractLocalIdFromPath(accountDid, path)
}
let localId: string
if (existingLocalId) {
// Reuse existing media
localId = existingLocalId
reusedLocalIds.add(localId)
} else {
// Save new media
localId = await storage.saveMediaToLocal(
accountDid, accountDid,
path, path,
image.source.mime, image.source.mime,
) )
}
refs.push({ refs.push({
localId, localId,
@@ -238,6 +305,8 @@ async function serializeImages(
async function serializeVideo( async function serializeVideo(
accountDid: string, accountDid: string,
videoState: VideoState, videoState: VideoState,
pathToLocalId: Map<string, string>,
reusedLocalIds: Set<string>,
): Promise<LocalMediaRef | undefined> { ): Promise<LocalMediaRef | undefined> {
// Only save videos that have been compressed (have a video file) // Only save videos that have been compressed (have a video file)
if (!videoState.video) { if (!videoState.video) {
@@ -245,11 +314,24 @@ async function serializeVideo(
} }
const video = videoState.video const video = videoState.video
const localId = await storage.saveMediaToLocal( const path = video.uri
accountDid,
video.uri, // Check if this video is already in drafts storage
video.mimeType, let existingLocalId: string | null | undefined = pathToLocalId.get(path)
)
if (!existingLocalId) {
existingLocalId = storage.extractLocalIdFromPath(accountDid, path)
}
let localId: string
if (existingLocalId) {
// Reuse existing media
localId = existingLocalId
reusedLocalIds.add(localId)
} else {
// Save new media
localId = await storage.saveMediaToLocal(accountDid, path, video.mimeType)
}
return { return {
localId, localId,
@@ -284,25 +366,6 @@ function serializeGif(gifMedia: {
} }
} }
/**
* Clean up old media when updating a draft
*/
async function cleanupOldMedia(
accountDid: string,
draft: StoredDraft,
): Promise<void> {
for (const post of draft.posts) {
if (post.images) {
for (const image of post.images) {
await storage.deleteMediaFromLocal(accountDid, image.localId)
}
}
if (post.video) {
await storage.deleteMediaFromLocal(accountDid, post.video.localId)
}
}
}
/** /**
* Load media from storage and return paths/URLs for use in composer * Load media from storage and return paths/URLs for use in composer
*/ */
+19
View File
@@ -324,3 +324,22 @@ export async function mediaExists(
const info = await getInfoAsync(path) const info = await getInfoAsync(path)
return info.exists return info.exists
} }
/**
* Extract the localId from a path if it's already in drafts media storage
* Returns null if the path is not in drafts storage
*/
export function extractLocalIdFromPath(
accountDid: string,
path: string,
): string | null {
const mediaDir = getMediaDirectory(accountDid)
if (path.startsWith(mediaDir)) {
// Extract the localId from the path (it's the filename)
const localId = path.slice(mediaDir.length).replace(/^\//, '')
if (localId && !localId.includes('/')) {
return localId
}
}
return null
}
+14
View File
@@ -349,3 +349,17 @@ export function revokeMediaUrl(url: string): void {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
} }
/**
* Extract the localId from a path if it's already in drafts media storage
* For web, this always returns null since blob URLs don't contain localId
* The hooks layer handles tracking of web localIds separately
*/
export function extractLocalIdFromPath(
_accountDid: string,
_path: string,
): string | null {
// Web uses blob URLs which don't contain the localId
// Tracking is done via loadedMediaMap in hooks.ts
return null
}
+2
View File
@@ -353,6 +353,7 @@ export const ComposePost = ({
composerState, composerState,
replyTo, replyTo,
existingDraftId: composerState.draftId, existingDraftId: composerState.draftId,
loadedMediaMap: composerState.loadedMediaMap,
}) })
composerDispatch({type: 'mark_saved', draftId: savedDraft.id}) composerDispatch({type: 'mark_saved', draftId: savedDraft.id})
onClose() onClose()
@@ -368,6 +369,7 @@ export const ComposePost = ({
composerState, composerState,
replyTo, replyTo,
existingDraftId: composerState.draftId, existingDraftId: composerState.draftId,
loadedMediaMap: composerState.loadedMediaMap,
}) })
composerDispatch({type: 'mark_saved', draftId: savedDraft.id}) composerDispatch({type: 'mark_saved', draftId: savedDraft.id})
}, [saveDraft, composerState, replyTo, composerDispatch]) }, [saveDraft, composerState, replyTo, composerDispatch])
+1 -1
View File
@@ -266,7 +266,7 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
} }
return ( return (
<View style={[a.pt_xs]}> <View style={[a.pt_xs, a.pointer_events_none]}>
{/* Images - use real embed components */} {/* Images - use real embed components */}
{viewImages.length === 1 && ( {viewImages.length === 1 && (
<AutoSizedImage image={viewImages[0]} hideBadge /> <AutoSizedImage image={viewImages[0]} hideBadge />
+4
View File
@@ -106,6 +106,8 @@ export type ComposerState = {
draftId?: string draftId?: string
/** Whether the composer has been modified since loading a draft. */ /** Whether the composer has been modified since loading a draft. */
isDirty: boolean 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>
} }
export type ComposerAction = export type ComposerAction =
@@ -317,6 +319,7 @@ export function composerReducer(
mutableNeedsFocusActive: true, mutableNeedsFocusActive: true,
draftId: draft.id, draftId: draft.id,
isDirty: false, isDirty: false,
loadedMediaMap: loadedMedia,
thread: { thread: {
posts, posts,
postgate: draft.postgate || state.thread.postgate, postgate: draft.postgate || state.thread.postgate,
@@ -330,6 +333,7 @@ export function composerReducer(
mutableNeedsFocusActive: true, mutableNeedsFocusActive: true,
draftId: undefined, draftId: undefined,
isDirty: false, isDirty: false,
loadedMediaMap: undefined,
thread: { thread: {
posts: [ posts: [
{ {