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) {