diff --git a/desktop/src/shared/theme/CommunityThemeController.tsx b/desktop/src/shared/theme/CommunityThemeController.tsx index e939d3f853..4932df6fb9 100644 --- a/desktop/src/shared/theme/CommunityThemeController.tsx +++ b/desktop/src/shared/theme/CommunityThemeController.tsx @@ -228,6 +228,7 @@ export function CommunityThemeController() { clearCommunityThemeMigrationOutbox(pubkey, relayUrl); const migrationWasSubmitted = manager.cancelPendingPublish( remote.preference, + remote, ); cacheAndApplyCommunityTheme( pubkey, diff --git a/desktop/src/shared/theme/communityThemeSync.test.mjs b/desktop/src/shared/theme/communityThemeSync.test.mjs index 6e14bd32be..64ae8e6d66 100644 --- a/desktop/src/shared/theme/communityThemeSync.test.mjs +++ b/desktop/src/shared/theme/communityThemeSync.test.mjs @@ -745,6 +745,74 @@ test("remote accepted after submit is republished after the stale event settles" } }); +test("migration self-echo during submission acknowledges without replacement", async () => { + const timer = installFakeTimer(); + const publish = Promise.withResolvers(); + const published = []; + const acknowledgements = []; + let liveCallback; + let signed = 0; + globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "nip44_decrypt_from_self") { + return Promise.resolve(JSON.stringify(preference)); + } + if (command === "sign_event") { + signed += 1; + return Promise.resolve( + JSON.stringify( + relayEvent({ + id: `event-${signed}`, + content: args.content, + created_at: args.createdAt, + }), + ), + ); + } + throw new Error(`unexpected command: ${command}`); + }, + }; + mock.method(relayClient, "subscribeLive", (_filter, callback) => { + liveCallback = callback; + return Promise.resolve(async () => {}); + }); + mock.method(relayClient, "publishEvent", (event) => { + published.push(event); + return publish.promise; + }); + try { + const manager = new CommunityThemeSyncManager("alice", (event) => { + acknowledgements.push(event); + }); + await manager.subscribe((remote) => { + manager.acceptRemote(remote); + manager.cancelPendingPublish(remote.preference, remote); + }); + manager.publish(preference); + timer.fire(); + await waitUntil(() => published.length === 1); + + liveCallback(published[0]); + await new Promise((resolve) => setImmediate(resolve)); + publish.resolve(); + await waitUntil(() => acknowledgements.length === 1); + + assert.equal(published.length, 1); + assert.equal(timer.pending(), false); + assert.deepEqual(manager.getPending(), null); + assert.deepEqual(acknowledgements[0], { + preference, + createdAt: published[0].created_at, + eventId: published[0].id, + }); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + timer.restore(); + mock.reset(); + } +}); + test("live legacy updates inherit the latest complete appearance", async () => { const initial = { ...preference, glassOpacity: 40 }; const latest = { diff --git a/desktop/src/shared/theme/communityThemeSync.ts b/desktop/src/shared/theme/communityThemeSync.ts index 6372252283..fd9b262229 100644 --- a/desktop/src/shared/theme/communityThemeSync.ts +++ b/desktop/src/shared/theme/communityThemeSync.ts @@ -105,7 +105,7 @@ export class CommunityThemeSyncManager { private readonly onPublished: (published: PublishedCommunityTheme) => void; private readonly getLegacyFallback: () => CommunityThemePreference; private publishGeneration = 0; - private activePublishSubmitted = false; + private activeSubmittedPublish: PublishedCommunityTheme | null = null; private replacementAfterCancel: CommunityThemePreference | null = null; constructor( @@ -174,12 +174,12 @@ export class CommunityThemeSyncManager { private startPublish(): void { if (this.destroyed || this.publishInFlight || !this.pending) return; this.publishInFlight = true; - this.activePublishSubmitted = false; + this.activeSubmittedPublish = null; const preference = this.pending; const generation = this.publishGeneration; void this.doPublish(preference, generation).finally(() => { this.publishInFlight = false; - this.activePublishSubmitted = false; + this.activeSubmittedPublish = null; if (this.replacementAfterCancel) { this.pending = this.replacementAfterCancel; this.replacementAfterCancel = null; @@ -222,8 +222,17 @@ export class CommunityThemeSyncManager { cancelPendingPublish( replacementAfterSubmit?: CommunityThemePreference, + acceptedRemote?: RemoteCommunityTheme, ): boolean { - const submitted = this.activePublishSubmitted; + if ( + this.activeSubmittedPublish && + acceptedRemote && + acceptedRemote.createdAt === this.activeSubmittedPublish.createdAt && + acceptedRemote.eventId === this.activeSubmittedPublish.eventId + ) { + return false; + } + const submitted = this.activeSubmittedPublish !== null; this.replacementAfterCancel = submitted && replacementAfterSubmit ? replacementAfterSubmit : null; this.publishGeneration += 1; @@ -272,7 +281,11 @@ export class CommunityThemeSyncManager { ], }); if (wasCancelled()) return; - this.activePublishSubmitted = true; + this.activeSubmittedPublish = { + preference, + createdAt: event.created_at, + eventId: event.id, + }; await relayClient.publishEvent( event, "Timed out publishing community theme.",