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:
+100
-37
@@ -75,10 +75,12 @@ export function useSaveDraft() {
|
||||
composerState,
|
||||
replyTo,
|
||||
existingDraftId,
|
||||
loadedMediaMap,
|
||||
}: {
|
||||
composerState: ComposerState
|
||||
replyTo?: ComposerOpts['replyTo']
|
||||
existingDraftId?: string
|
||||
loadedMediaMap?: Map<string, string> // localId -> path/url
|
||||
}): Promise<StoredDraft> => {
|
||||
if (!did) {
|
||||
throw new Error('No account')
|
||||
@@ -87,23 +89,44 @@ export function useSaveDraft() {
|
||||
const now = new Date().toISOString()
|
||||
const draftId = existingDraftId || nanoid()
|
||||
|
||||
// If updating existing draft, delete old media first
|
||||
if (existingDraftId) {
|
||||
const existingDraft = await storage.loadDraftMeta(did, existingDraftId)
|
||||
if (existingDraft) {
|
||||
// Clean up old media that's no longer used
|
||||
await cleanupOldMedia(did, existingDraft)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize the composer state
|
||||
// Collect old media localIds for cleanup
|
||||
let oldMediaLocalIds: Set<string> = new Set()
|
||||
if (existingDraftId) {
|
||||
const existingDraft = await storage.loadDraftMeta(did, existingDraftId)
|
||||
if (existingDraft) {
|
||||
oldMediaLocalIds = collectMediaLocalIds(existingDraft)
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize the composer state, tracking which localIds are reused
|
||||
const reusedLocalIds = new Set<string>()
|
||||
const posts: StoredPostDraft[] = []
|
||||
|
||||
for (const post of composerState.thread.posts) {
|
||||
const storedPost = await serializePost(did, post)
|
||||
const storedPost = await serializePost(
|
||||
did,
|
||||
post,
|
||||
pathToLocalId,
|
||||
reusedLocalIds,
|
||||
)
|
||||
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 = {
|
||||
id: draftId,
|
||||
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
|
||||
*/
|
||||
async function serializePost(
|
||||
accountDid: string,
|
||||
post: PostDraft,
|
||||
pathToLocalId: Map<string, string>,
|
||||
reusedLocalIds: Set<string>,
|
||||
): Promise<StoredPostDraft> {
|
||||
const richtext: StoredRichText = {
|
||||
text: post.richtext.text,
|
||||
@@ -188,11 +231,15 @@ async function serializePost(
|
||||
storedPost.images = await serializeImages(
|
||||
accountDid,
|
||||
post.embed.media.images,
|
||||
pathToLocalId,
|
||||
reusedLocalIds,
|
||||
)
|
||||
} else if (post.embed.media.type === 'video') {
|
||||
storedPost.video = await serializeVideo(
|
||||
accountDid,
|
||||
post.embed.media.video,
|
||||
pathToLocalId,
|
||||
reusedLocalIds,
|
||||
)
|
||||
} else if (post.embed.media.type === 'gif') {
|
||||
storedPost.gif = serializeGif(post.embed.media)
|
||||
@@ -208,16 +255,36 @@ async function serializePost(
|
||||
async function serializeImages(
|
||||
accountDid: string,
|
||||
images: ComposerImage[],
|
||||
pathToLocalId: Map<string, string>,
|
||||
reusedLocalIds: Set<string>,
|
||||
): Promise<LocalMediaRef[]> {
|
||||
const refs: LocalMediaRef[] = []
|
||||
|
||||
for (const image of images) {
|
||||
const path = image.transformed?.path || image.source.path
|
||||
const localId = await storage.saveMediaToLocal(
|
||||
accountDid,
|
||||
path,
|
||||
image.source.mime,
|
||||
)
|
||||
|
||||
// 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,
|
||||
path,
|
||||
image.source.mime,
|
||||
)
|
||||
}
|
||||
|
||||
refs.push({
|
||||
localId,
|
||||
@@ -238,6 +305,8 @@ async function serializeImages(
|
||||
async function serializeVideo(
|
||||
accountDid: string,
|
||||
videoState: VideoState,
|
||||
pathToLocalId: Map<string, string>,
|
||||
reusedLocalIds: Set<string>,
|
||||
): Promise<LocalMediaRef | undefined> {
|
||||
// Only save videos that have been compressed (have a video file)
|
||||
if (!videoState.video) {
|
||||
@@ -245,11 +314,24 @@ async function serializeVideo(
|
||||
}
|
||||
|
||||
const video = videoState.video
|
||||
const localId = await storage.saveMediaToLocal(
|
||||
accountDid,
|
||||
video.uri,
|
||||
video.mimeType,
|
||||
)
|
||||
const path = video.uri
|
||||
|
||||
// Check if this video is already in drafts storage
|
||||
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 {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -324,3 +324,22 @@ export async function mediaExists(
|
||||
const info = await getInfoAsync(path)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -349,3 +349,17 @@ export function revokeMediaUrl(url: string): void {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -353,6 +353,7 @@ export const ComposePost = ({
|
||||
composerState,
|
||||
replyTo,
|
||||
existingDraftId: composerState.draftId,
|
||||
loadedMediaMap: composerState.loadedMediaMap,
|
||||
})
|
||||
composerDispatch({type: 'mark_saved', draftId: savedDraft.id})
|
||||
onClose()
|
||||
@@ -368,6 +369,7 @@ export const ComposePost = ({
|
||||
composerState,
|
||||
replyTo,
|
||||
existingDraftId: composerState.draftId,
|
||||
loadedMediaMap: composerState.loadedMediaMap,
|
||||
})
|
||||
composerDispatch({type: 'mark_saved', draftId: savedDraft.id})
|
||||
}, [saveDraft, composerState, replyTo, composerDispatch])
|
||||
|
||||
@@ -266,7 +266,7 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.pt_xs]}>
|
||||
<View style={[a.pt_xs, a.pointer_events_none]}>
|
||||
{/* Images - use real embed components */}
|
||||
{viewImages.length === 1 && (
|
||||
<AutoSizedImage image={viewImages[0]} hideBadge />
|
||||
|
||||
@@ -106,6 +106,8 @@ export type ComposerState = {
|
||||
draftId?: string
|
||||
/** Whether the composer has been modified since loading a draft. */
|
||||
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 =
|
||||
@@ -317,6 +319,7 @@ export function composerReducer(
|
||||
mutableNeedsFocusActive: true,
|
||||
draftId: draft.id,
|
||||
isDirty: false,
|
||||
loadedMediaMap: loadedMedia,
|
||||
thread: {
|
||||
posts,
|
||||
postgate: draft.postgate || state.thread.postgate,
|
||||
@@ -330,6 +333,7 @@ export function composerReducer(
|
||||
mutableNeedsFocusActive: true,
|
||||
draftId: undefined,
|
||||
isDirty: false,
|
||||
loadedMediaMap: undefined,
|
||||
thread: {
|
||||
posts: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user