From 4244c6342509d42576f29dc1dfdfbc437a2f08a9 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 13 Jul 2026 12:42:37 -0400 Subject: [PATCH] fix(desktop): preserve in-flight draft sync intents Avoid letting an older asynchronous publish erase a newer edit or leave a concurrent deletion behind it. Rebase any raced tombstone after the accepted draft event. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../features/messages/lib/draftSync.test.mjs | 90 +++++++++++++++++++ .../src/features/messages/lib/draftSync.ts | 31 ++++++- 2 files changed, 118 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/messages/lib/draftSync.test.mjs b/desktop/src/features/messages/lib/draftSync.test.mjs index ef7508c5d..31e7bd8bf 100644 --- a/desktop/src/features/messages/lib/draftSync.test.mjs +++ b/desktop/src/features/messages/lib/draftSync.test.mjs @@ -24,6 +24,7 @@ import { clearAllDrafts, initDraftStore, loadDraftEntry, + removeRemoteDraftEntry, saveDraftEntry, } from "./useDrafts.ts"; @@ -379,3 +380,92 @@ test("test_unuploaded_attachment_cancels_stale_text_publish", async () => { assert.equal(published.length, 0); }); + +function deferred() { + let resolve; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +test("test_newer_edit_during_inflight_publish_survives", async () => { + setup(); + const published = []; + const firstPublish = deferred(); + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + deriveAddress: async () => "address-a", + encrypt: async (content) => content, + fetchEvents: async () => [], + sign: async (input) => ({ + id: `signed-${published.length}`, + created_at: input.createdAt ?? 0, + kind: input.kind, + pubkey, + content: input.content, + sig: "", + tags: input.tags, + }), + publishEvent: async (event) => { + published.push(event); + if (published.length === 1) await firstPublish.promise; + }, + }); + + manager.queuePublish(channelA, draft(channelA, "older edit")); + const flush = manager.flushPublishes(); + while (published.length === 0) await Promise.resolve(); + manager.queuePublish(channelA, draft(channelA, "newer edit")); + firstPublish.resolve(); + await flush; + await manager.destroy(); + + assert.equal(published.length, 2); + assert.match(published[1].content, /newer edit/); +}); + +test("test_deletion_during_inflight_publish_wins", async () => { + setup(); + const published = []; + const firstPublish = deferred(); + const tombstonePublished = deferred(); + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + deriveAddress: async () => "address-a", + encrypt: async (content) => content, + fetchEvents: async () => [], + sign: async (input) => ({ + id: `signed-${published.length}`, + created_at: input.createdAt ?? 0, + kind: input.kind, + pubkey, + content: input.content, + sig: "", + tags: input.tags, + }), + publishEvent: async (event) => { + published.push(event); + if (published.length === 1) await firstPublish.promise; + if (event.content === "") tombstonePublished.resolve(); + }, + }); + + const local = draft(channelA, "draft to delete"); + saveDraftEntry(channelA, local); + manager.queuePublish(channelA, local); + const flush = manager.flushPublishes(); + while (published.length === 0) await Promise.resolve(); + const deletion = manager.queueDeletion(channelA, channelA); + await tombstonePublished.promise; + removeRemoteDraftEntry(channelA); + firstPublish.resolve(); + await flush; + await deletion; + await manager.destroy(); + + const draftEvent = published.find((event) => event.content !== ""); + const rebasedTombstone = published.find( + (event) => event.content === "" && event.created_at > draftEvent.created_at, + ); + assert.ok(rebasedTombstone); + assert.equal(loadDraftEntry(channelA), undefined); +}); diff --git a/desktop/src/features/messages/lib/draftSync.ts b/desktop/src/features/messages/lib/draftSync.ts index 3cdf3a3d2..d9863aab9 100644 --- a/desktop/src/features/messages/lib/draftSync.ts +++ b/desktop/src/features/messages/lib/draftSync.ts @@ -244,6 +244,10 @@ export class DraftSyncManager { limit: 1, }); state = this.state.get(address) ?? state; + if (state.pendingDeletion) { + this.reschedulePublishes(); + return; + } if (state.remoteHead?.content === "") { state.pendingPublish = undefined; removeRemoteDraftEntry(pending.draftKey); @@ -271,9 +275,30 @@ export class DraftSyncManager { "Timed out publishing draft.", "Failed to publish draft.", ); - state.base = event; - state.remoteHead = event; - state.pendingPublish = undefined; + const current = this.state.get(address) ?? state; + const deletionWon = + current.pendingDeletion !== undefined || + current.remoteHead?.content === ""; + if (!current.remoteHead || compareHeads(event, current.remoteHead) >= 0) { + current.base = event; + current.remoteHead = event; + } + if (current.pendingPublish === pending) + current.pendingPublish = undefined; + if (deletionWon) { + const deletion = + current.pendingDeletion ?? + ({ + draftKey: pending.draftKey, + channelId: pending.channelId, + address, + } satisfies PendingDeletion); + deletion.base = event; + current.pendingDeletion = deletion; + this.writeSidecar(); + await this.publishTombstone(deletion); + } + this.reschedulePublishes(); } catch (error) { console.warn("[draftSync] draft publish failed:", error); }