fix(desktop): clear composers on remote draft deletion

Remote tombstones now clear mounted composer snapshots before lifecycle cleanup can persist stale content.

Remove the tombstone publish bypass so cleanup cannot recreate a deleted draft on the relay.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-13 15:14:21 -04:00
co-authored by Will Pfleger
parent 325ff603ed
commit 2732fc6481
5 changed files with 170 additions and 57 deletions
@@ -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);
});
@@ -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();
@@ -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<Subscriber>();
const _remoteDraftRemovalSubscribers = new Set<RemoteDraftRemovalSubscriber>();
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 {
@@ -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",
);
});
@@ -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,
]);
}