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:
+93
-30
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user