diff --git a/desktop/src/features/messages/lib/draftSync.test.mjs b/desktop/src/features/messages/lib/draftSync.test.mjs index 76d2f4fb6..ff6ba17eb 100644 --- a/desktop/src/features/messages/lib/draftSync.test.mjs +++ b/desktop/src/features/messages/lib/draftSync.test.mjs @@ -203,6 +203,58 @@ test("test_tombstone_failure_sidecar_suppresses_remote_resurrection", async () = ); }); +test("test_remote_tombstone_blocks_stale_cleanup_publish", async () => { + setup(); + const remote = wrapped({ + id: "remote-draft", + createdAt: 1, + address: "address-a", + channelId: channelA, + content: "cipher", + }); + const tombstone = wrapped({ + id: "remote-tombstone", + createdAt: 2, + address: "address-a", + channelId: channelA, + content: "", + }); + let events = [remote]; + const published = []; + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + decrypt: async () => payload(channelA, "draft"), + deriveAddress: async () => "address-a", + encrypt: async (content) => content, + fetchEvents: async () => events, + sign: async (input) => ({ + id: "stale-cleanup-publish", + created_at: input.createdAt ?? 0, + kind: input.kind, + pubkey, + content: input.content, + sig: "", + tags: input.tags, + }), + publishEvent: async (event) => published.push(event), + }); + + await manager.fetchAllOwnDrafts(); + events = [tombstone]; + await manager.fetchAllOwnDrafts(); + + // Models the mounted composer's stale cleanup after the remote delete. The + // unconditional tombstone abort must remove this local write without + // publishing it back to the relay. + const stale = draft(channelA, "stale cleanup content"); + saveDraftEntry(channelA, stale); + manager.queuePublish(channelA, stale); + await manager.flushPublishes(); + await manager.destroy(); + + assert.deepEqual(published, []); + assert.equal(loadDraftEntry(channelA), undefined); +}); + test("test_remote_tombstone_removes_known_draft", async () => { setup(); const remote = wrapped({ @@ -545,56 +597,3 @@ test("test_stale_tombstone_completion_preserves_rebased_delete", async () => { assert.ok(retried.some((event) => event.content === "")); assert.ok(retried.every((event) => event.created_at > draftEvent.created_at)); }); - -test("test_remote_tombstone_during_publish_allows_later_draft", async () => { - setup(); - const published = []; - const draftPublish = deferred(); - const remoteTombstone = wrapped({ - id: "remote-tombstone", - createdAt: Math.floor(Date.now() / 1_000) + 100, - address: "address-a", - channelId: channelA, - content: "", - }); - 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 draftPublish.promise; - }, - }); - - manager.queuePublish(channelA, draft(channelA, "raced draft")); - const flush = manager.flushPublishes(); - while (published.length === 0) await Promise.resolve(); - await manager.mergeEvent(remoteTombstone); - draftPublish.resolve(); - await flush; - - assert.equal( - localStorage.getItem(`buzz-draft-sync.v1:wss://relay.example:${pubkey}`), - "{}", - ); - - manager.queuePublish(channelA, draft(channelA, "new draft")); - await manager.flushPublishes(); - await manager.destroy(); - - const newDraft = published.find((event) => - event.content.includes("new draft"), - ); - assert.ok(newDraft); - assert.ok(newDraft.created_at > remoteTombstone.created_at); -}); diff --git a/desktop/src/features/messages/lib/draftSync.ts b/desktop/src/features/messages/lib/draftSync.ts index 8041e6b6e..2c13df7a9 100644 --- a/desktop/src/features/messages/lib/draftSync.ts +++ b/desktop/src/features/messages/lib/draftSync.ts @@ -33,7 +33,6 @@ type PendingPublish = { draft: DraftState; channelId: string; address?: string; - base?: RemoteHead; }; type PendingDeletion = { draftKey: string; @@ -126,7 +125,6 @@ export class DraftSyncManager { draftKey, draft, channelId: draft.channelId, - base: entry.remoteHead?.content === "" ? entry.remoteHead : undefined, }; this.state.set(draftKey, entry); // Resolve the opaque address while the draft is alive so normal delete @@ -254,7 +252,7 @@ export class DraftSyncManager { this.reschedulePublishes(); return; } - if (state.remoteHead?.content === "" && !pending.base) { + if (state.remoteHead?.content === "") { state.pendingPublish = undefined; removeRemoteDraftEntry(pending.draftKey); this.reschedulePublishes(); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 589c6b568..f3821985e 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -12,7 +12,9 @@ import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota" // so any component consuming `useDraftsSnapshot()` re-renders immediately. type Subscriber = () => void; +type RemoteDraftRemovalSubscriber = (draftKey: string) => void; const _subscribers = new Set(); +const _remoteDraftRemovalSubscribers = new Set(); let _version = 0; /** Notify all active subscribers. Called by every write path. */ @@ -30,6 +32,16 @@ function subscribeToStore(callback: Subscriber): () => void { }; } +/** Subscribe to explicit remote NIP-37 tombstone removals. */ +export function subscribeToRemoteDraftRemovals( + callback: RemoteDraftRemovalSubscriber, +): () => void { + _remoteDraftRemovalSubscribers.add(callback); + return () => { + _remoteDraftRemovalSubscribers.delete(callback); + }; +} + function getStoreSnapshot(): number { return _version; } @@ -272,6 +284,9 @@ export function removeRemoteDraftEntry(draftKey: string): void { if (!map.delete(draftKey)) return; flushStore(map); notifySubscribers(); + for (const subscriber of _remoteDraftRemovalSubscribers) { + subscriber(draftKey); + } } export function clearDraftEntry(draftKey: string): void { diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index ba4c1a22e..a81069061 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -400,3 +400,81 @@ test("strictmode_draft_no_draft_cleanup_persists_empty_imeta", async () => { await handle.unmount(); }); + +/** + * A remote tombstone is a delete-wins signal, not an ordinary missing-store + * entry. The mounted composer must clear its snapshot before cleanup so stale + * editor text cannot recreate the deleted draft or queue a relay publish. + */ +test("remote_tombstone_clears_mounted_snapshot_before_cleanup", async () => { + const DRAFT_KEY = "chan-lifecycle-remote-delete"; + setupStore("pubkey-lifecycle-remote-delete"); + persistDraftEntry( + DRAFT_KEY, + "stale editor text", + DRAFT_KEY, + [IMG_A], + [IMG_A.url], + ); + + let editorContent = "stale editor text"; + let pendingImeta = [IMG_A]; + const spoileredRef = { current: new Set([IMG_A.url]) }; + const persisted = []; + + function HarnessComposer() { + useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: (key) => loadDraftEntry(key), + persistDraft: (key, content, channelId, imeta, spoileredUrls) => { + persisted.push({ content, imeta, spoileredUrls }); + persistDraftEntry(key, content, channelId, imeta, spoileredUrls); + }, + livePendingImeta: pendingImeta, + setPendingImeta: (imeta) => { + pendingImeta = imeta; + }, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: (urls) => { + spoileredRef.current = urls; + }, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + }); + + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + persisted.length = 0; + const { removeRemoteDraftEntry } = await import("../lib/useDrafts.ts"); + await act(async () => { + removeRemoteDraftEntry(DRAFT_KEY); + }); + await handle.unmount(); + + assert.equal(editorContent, "", "remote delete clears mounted editor text"); + assert.deepEqual(pendingImeta, [], "remote delete clears pending images"); + assert.deepEqual( + [...spoileredRef.current], + [], + "remote delete clears spoiler state", + ); + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "cleanup does not recreate draft", + ); + assert.ok( + persisted.every( + ({ content, imeta }) => content === "" && imeta.length === 0, + ), + "cleanup never persists the stale draft contents", + ); +}); diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 6fcfc79ef..adb31d16d 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -2,7 +2,10 @@ import * as React from "react"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { syncDraftChannel } from "@/features/messages/lib/draftSync"; -import type { DraftState } from "@/features/messages/lib/useDrafts"; +import { + subscribeToRemoteDraftRemovals, + type DraftState, +} from "@/features/messages/lib/useDrafts"; type UseDraftPersistLifecycleParams = { effectiveDraftKey: string | null | undefined; @@ -121,4 +124,24 @@ export function useDraftPersistLifecycle({ } }; }, [effectiveDraftKey]); + + // Remote tombstones are an explicit delete-wins signal. Do not infer this + // from store absence: an unsaved composer may legitimately have no entry. + React.useEffect(() => { + if (!effectiveDraftKey) return; + return subscribeToRemoteDraftRemovals((draftKey) => { + if (draftKey !== effectiveDraftKey) return; + clearContent(); + pendingImetaForPersistRef.current = []; + setPendingImeta([]); + spoileredAttachmentUrlsRef.current = new Set(); + setSpoileredAttachmentUrls(new Set()); + }); + }, [ + effectiveDraftKey, + clearContent, + setPendingImeta, + setSpoileredAttachmentUrls, + spoileredAttachmentUrlsRef, + ]); }