mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Will Pfleger
parent
325ff603ed
commit
2732fc6481
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user