From 2f5e99420ff49e5e85c612a8d902e1d63e940b37 Mon Sep 17 00:00:00 2001 From: Kenny Lopez Date: Sat, 15 Aug 2026 09:30:46 +0100 Subject: [PATCH] fix(theme): preserve newer remote migration state Cancel migration-only publishes before signing when newer complete relay state arrives, and reassert accepted state if submission already began. Parse later legacy updates against the latest scoped appearance instead of the startup fallback. Co-authored-by: Kenny Lopez Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez Signed-off-by: kenny lopez --- .../shared/theme/CommunityThemeController.tsx | 18 +- .../shared/theme/communityThemeSync.test.mjs | 166 ++++++++++++++++++ .../src/shared/theme/communityThemeSync.ts | 58 ++++-- 3 files changed, 224 insertions(+), 18 deletions(-) diff --git a/desktop/src/shared/theme/CommunityThemeController.tsx b/desktop/src/shared/theme/CommunityThemeController.tsx index 2720b5f355..e939d3f853 100644 --- a/desktop/src/shared/theme/CommunityThemeController.tsx +++ b/desktop/src/shared/theme/CommunityThemeController.tsx @@ -188,7 +188,11 @@ export function CommunityThemeController() { ); clearCommunityThemeMigrationOutbox(pubkey, relayUrl); }, - legacyFallback, + () => + scopedPreferenceRef.current ?? + readCommunityThemeOutbox(pubkey, relayUrl, legacyFallback) ?? + readCommunityThemePreference(pubkey, relayUrl, legacyFallback) ?? + legacyFallback, ); managerRef.current = manager; if (durablePending) { @@ -222,14 +226,22 @@ export function CommunityThemeController() { // coordinate cancels the pending upgrade rather than being overwritten by // a legacy-derived republish two seconds later. clearCommunityThemeMigrationOutbox(pubkey, relayUrl); - scopedPreferenceRef.current = remote.preference; - manager.cancelPendingPublish(); + const migrationWasSubmitted = manager.cancelPendingPublish( + remote.preference, + ); cacheAndApplyCommunityTheme( pubkey, relayUrl, remote.preference, applyPreference, ); + scopedPreferenceRef.current = remote.preference; + if (migrationWasSubmitted) { + // Submission cannot be withdrawn. Reassert the accepted remote after + // the stale migration settles so it remains the winning coordinate. + writeCommunityThemeOutbox(pubkey, relayUrl, remote.preference); + return; + } if ( remote.needsUpgrade && writeCommunityThemeMigrationOutbox(pubkey, relayUrl, remote.preference) diff --git a/desktop/src/shared/theme/communityThemeSync.test.mjs b/desktop/src/shared/theme/communityThemeSync.test.mjs index 8e39230757..6e14bd32be 100644 --- a/desktop/src/shared/theme/communityThemeSync.test.mjs +++ b/desktop/src/shared/theme/communityThemeSync.test.mjs @@ -637,6 +637,172 @@ test("transient publish failure retries and acknowledges exact event", async () } }); +test("newer complete remote cancels an in-flight migration before signing", async () => { + const timer = installFakeTimer(); + const encryption = Promise.withResolvers(); + const published = []; + let signed = 0; + globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + if (command === "nip44_encrypt_to_self") return encryption.promise; + 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, "publishEvent", (event) => { + published.push(event); + return Promise.resolve(); + }); + try { + const manager = new CommunityThemeSyncManager("alice"); + manager.publish(preference); + timer.fire(); + await new Promise((resolve) => setImmediate(resolve)); + + manager.acceptRemote({ + preference: { ...preference, theme: "dracula" }, + createdAt: 456, + eventId: "remote-winner", + needsUpgrade: false, + }); + manager.cancelPendingPublish(); + encryption.resolve("cipher"); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(signed, 0); + assert.equal(published.length, 0); + assert.equal(manager.getPending(), null); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + timer.restore(); + mock.reset(); + } +}); + +test("remote accepted after submit is republished after the stale event settles", async () => { + const timer = installFakeTimer(); + const firstPublish = Promise.withResolvers(); + const published = []; + let signed = 0; + globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + 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, "publishEvent", (event) => { + published.push(event); + return published.length === 1 ? firstPublish.promise : Promise.resolve(); + }); + try { + const manager = new CommunityThemeSyncManager("alice"); + manager.publish(preference); + timer.fire(); + await waitUntil(() => published.length === 1); + + const remotePreference = { ...preference, theme: "dracula" }; + manager.acceptRemote({ + preference: remotePreference, + createdAt: published[0].created_at + 100, + eventId: "remote-winner", + needsUpgrade: false, + }); + assert.equal(manager.cancelPendingPublish(remotePreference), true); + firstPublish.resolve(); + await waitUntil(() => timer.pending()); + timer.fire(); + await waitUntil(() => published.length === 2); + + assert.ok(published[1].created_at > published[0].created_at + 100); + assert.deepEqual(manager.getPending(), null); + } 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 = { + ...preference, + glassBackground: false, + glassOpacity: 80, + prominentActiveTab: true, + }; + let fallback = initial; + let liveCallback; + const updates = []; + globalThis.window.__TAURI_INTERNALS__ = { + invoke(command) { + if (command === "nip44_decrypt_from_self") { + return Promise.resolve( + JSON.stringify({ + version: 1, + theme: "dracula", + accent: preference.accent, + followSystem: preference.followSystem, + }), + ); + } + throw new Error(`unexpected command: ${command}`); + }, + }; + mock.method(relayClient, "subscribeLive", (_filter, callback) => { + liveCallback = callback; + return Promise.resolve(async () => {}); + }); + try { + const manager = new CommunityThemeSyncManager( + "alice", + undefined, + () => fallback, + ); + await manager.subscribe((remote) => updates.push(remote)); + fallback = latest; + liveCallback( + relayEvent({ + id: "legacy-update", + content: "ciphertext", + created_at: 456, + }), + ); + await waitUntil(() => updates.length === 1); + + assert.deepEqual(updates[0].preference, { + ...latest, + theme: "dracula", + }); + assert.equal(updates[0].needsUpgrade, true); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + mock.reset(); + } +}); + async function waitUntil(condition) { for (let attempt = 0; attempt < 100; attempt += 1) { if (condition()) return; diff --git a/desktop/src/shared/theme/communityThemeSync.ts b/desktop/src/shared/theme/communityThemeSync.ts index d8395ad669..6372252283 100644 --- a/desktop/src/shared/theme/communityThemeSync.ts +++ b/desktop/src/shared/theme/communityThemeSync.ts @@ -65,12 +65,12 @@ export function shouldSeedCommunityTheme( async function decryptAndParse( event: RelayEvent, - legacyFallback: CommunityThemePreference, + legacyFallback: () => CommunityThemePreference, ): Promise { try { const plaintext = await nip44DecryptFromSelf(event.content); const parsed = JSON.parse(plaintext); - const preference = parseCommunityThemePreference(parsed, legacyFallback); + const preference = parseCommunityThemePreference(parsed, legacyFallback()); return preference ? { preference, @@ -103,16 +103,24 @@ export class CommunityThemeSyncManager { private publishRetryAttempt = 0; private readonly remoteProcessing = new Set>(); private readonly onPublished: (published: PublishedCommunityTheme) => void; - private readonly legacyFallback: CommunityThemePreference; + private readonly getLegacyFallback: () => CommunityThemePreference; + private publishGeneration = 0; + private activePublishSubmitted = false; + private replacementAfterCancel: CommunityThemePreference | null = null; constructor( pubkey: string, onPublished: (published: PublishedCommunityTheme) => void = () => {}, - legacyFallback: CommunityThemePreference = DEFAULT_COMMUNITY_THEME, + legacyFallback: + | CommunityThemePreference + | (() => CommunityThemePreference) = DEFAULT_COMMUNITY_THEME, ) { this.pubkey = pubkey; this.onPublished = onPublished; - this.legacyFallback = legacyFallback; + this.getLegacyFallback = + typeof legacyFallback === "function" + ? legacyFallback + : () => legacyFallback; } async fetchRemote(): Promise { @@ -125,7 +133,7 @@ export class CommunityThemeSyncManager { }); if (events.length === 0) return { status: "absent" }; if (events[0].pubkey !== this.pubkey) return { status: "invalid" }; - const processing = decryptAndParse(events[0], this.legacyFallback); + const processing = decryptAndParse(events[0], this.getLegacyFallback); this.trackRemoteProcessing(processing); const remote = await processing; if (!remote) return { status: "invalid" }; @@ -166,9 +174,16 @@ export class CommunityThemeSyncManager { private startPublish(): void { if (this.destroyed || this.publishInFlight || !this.pending) return; this.publishInFlight = true; + this.activePublishSubmitted = false; const preference = this.pending; - void this.doPublish(preference).finally(() => { + const generation = this.publishGeneration; + void this.doPublish(preference, generation).finally(() => { this.publishInFlight = false; + this.activePublishSubmitted = false; + if (this.replacementAfterCancel) { + this.pending = this.replacementAfterCancel; + this.replacementAfterCancel = null; + } if ( !this.destroyed && this.pending && @@ -205,19 +220,31 @@ export class CommunityThemeSyncManager { } } - cancelPendingPublish(): void { + cancelPendingPublish( + replacementAfterSubmit?: CommunityThemePreference, + ): boolean { + const submitted = this.activePublishSubmitted; + this.replacementAfterCancel = + submitted && replacementAfterSubmit ? replacementAfterSubmit : null; + this.publishGeneration += 1; if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); this.debounceTimer = null; } this.pending = null; + return submitted; } - private async doPublish(preference: CommunityThemePreference): Promise { + private async doPublish( + preference: CommunityThemePreference, + generation: number, + ): Promise { + const wasCancelled = () => + this.destroyed || generation !== this.publishGeneration; try { const lastPublished = this.lastPublished; if ( - this.destroyed || + wasCancelled() || (lastPublished && sameCommunityThemePreference(lastPublished.preference, preference)) ) { @@ -231,7 +258,7 @@ export class CommunityThemeSyncManager { return; } const ciphertext = await nip44EncryptToSelf(JSON.stringify(preference)); - if (this.destroyed) return; + if (wasCancelled()) return; const event = await signRelayEvent({ kind: KIND_COMMUNITY_THEME, content: ciphertext, @@ -244,14 +271,15 @@ export class CommunityThemeSyncManager { ["t", D_TAG], ], }); - if (this.destroyed) return; + if (wasCancelled()) return; + this.activePublishSubmitted = true; await relayClient.publishEvent( event, "Timed out publishing community theme.", "Failed to publish community theme.", ); await this.waitForDeliveredRemotes(); - if (this.destroyed) return; + if (wasCancelled()) return; const published = { preference, createdAt: event.created_at, @@ -289,7 +317,7 @@ export class CommunityThemeSyncManager { } catch (error) { console.warn("[communityThemeSync] publish failed:", error); if ( - this.destroyed || + wasCancelled() || !this.pending || !sameCommunityThemePreference(this.pending, preference) ) { @@ -374,7 +402,7 @@ export class CommunityThemeSyncManager { onUpdate: (remote: RemoteCommunityTheme) => void, ): void { if (event.pubkey !== this.pubkey || this.destroyed) return; - const processing = decryptAndParse(event, this.legacyFallback).then( + const processing = decryptAndParse(event, this.getLegacyFallback).then( (remote) => { if (this.destroyed) return; if (!remote) {