From 6457c30e6849de3aa8ad76783f23a330bcbf050e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 20 Aug 2026 08:17:14 +0100 Subject: [PATCH] fix(theme): preserve in-flight preference updates Signed-off-by: kenny lopez --- .../features/settings/ui/SettingsPanels.tsx | 2 +- .../shared/theme/communityThemeSync.test.mjs | 95 +++++++++++++++++++ .../src/shared/theme/communityThemeSync.ts | 29 ++++-- .../shared/theme/community_theme_sync.dart | 9 ++ .../settings/theme_picker_page_test.dart | 3 +- .../theme/community_theme_sync_test.dart | 60 ++++++++++++ 6 files changed, 190 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 4c42fd1b2b..50ad699dcd 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -656,7 +656,7 @@ function ThemeSettingsCard() { Icon, }))} testId="appearance-color-mode-control" - value={selectedMode} + value={activeMode} /> diff --git a/desktop/src/shared/theme/communityThemeSync.test.mjs b/desktop/src/shared/theme/communityThemeSync.test.mjs index 64ae8e6d66..81c513ebb6 100644 --- a/desktop/src/shared/theme/communityThemeSync.test.mjs +++ b/desktop/src/shared/theme/communityThemeSync.test.mjs @@ -7,6 +7,11 @@ import { isNewerCommunityThemeCoordinate, shouldSeedCommunityTheme, } from "./communityThemeSync.ts"; +import { + clearCommunityThemeOutbox, + readCommunityThemeOutbox, + writeCommunityThemeOutbox, +} from "./communityThemePreference.ts"; const preference = { version: 1, @@ -18,6 +23,15 @@ const preference = { prominentActiveTab: false, }; +function localStorageStub() { + const data = new Map(); + return { + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => data.set(key, String(value)), + removeItem: (key) => data.delete(key), + }; +} + function installFakeTimer() { globalThis.window ??= {}; let callback = null; @@ -745,6 +759,87 @@ test("remote accepted after submit is republished after the stale event settles" } }); +test("a user edit after migration cancellation survives the stale submission", async () => { + const timer = installFakeTimer(); + const firstPublish = Promise.withResolvers(); + const published = []; + const acknowledgements = []; + const userPreference = { ...preference, accent: "#ef4444" }; + let signed = 0; + globalThis.window.localStorage = localStorageStub(); + 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", (event) => { + acknowledgements.push(event); + clearCommunityThemeOutbox( + "alice", + "wss://relay.example", + event.preference, + ); + }); + 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); + + writeCommunityThemeOutbox("alice", "wss://relay.example", userPreference); + manager.publish(userPreference); + firstPublish.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + await waitUntil(() => timer.pending()); + + assert.deepEqual(manager.getPending(), userPreference); + assert.deepEqual( + readCommunityThemeOutbox("alice", "wss://relay.example"), + userPreference, + ); + + timer.fire(); + await waitUntil(() => acknowledgements.length === 1); + assert.equal(published.length, 2); + assert.ok(published[1].created_at > published[0].created_at + 100); + assert.deepEqual(acknowledgements[0].preference, userPreference); + assert.equal( + readCommunityThemeOutbox("alice", "wss://relay.example"), + null, + ); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + delete globalThis.window.localStorage; + timer.restore(); + mock.reset(); + } +}); + test("migration self-echo during submission acknowledges without replacement", async () => { const timer = installFakeTimer(); const publish = Promise.withResolvers(); diff --git a/desktop/src/shared/theme/communityThemeSync.ts b/desktop/src/shared/theme/communityThemeSync.ts index fd9b262229..2874bcf12e 100644 --- a/desktop/src/shared/theme/communityThemeSync.ts +++ b/desktop/src/shared/theme/communityThemeSync.ts @@ -105,8 +105,12 @@ export class CommunityThemeSyncManager { private readonly onPublished: (published: PublishedCommunityTheme) => void; private readonly getLegacyFallback: () => CommunityThemePreference; private publishGeneration = 0; + private publishRevision = 0; private activeSubmittedPublish: PublishedCommunityTheme | null = null; - private replacementAfterCancel: CommunityThemePreference | null = null; + private replacementAfterCancel: { + preference: CommunityThemePreference; + publishRevision: number; + } | null = null; constructor( pubkey: string, @@ -155,6 +159,7 @@ export class CommunityThemeSyncManager { publish(preference: CommunityThemePreference): void { if (this.destroyed) return; + this.publishRevision += 1; this.pending = preference; this.publishRetryAttempt = 0; this.schedulePublish(DEBOUNCE_MS); @@ -177,17 +182,24 @@ export class CommunityThemeSyncManager { this.activeSubmittedPublish = null; const preference = this.pending; const generation = this.publishGeneration; + const publishRevision = this.publishRevision; void this.doPublish(preference, generation).finally(() => { this.publishInFlight = false; this.activeSubmittedPublish = null; - if (this.replacementAfterCancel) { - this.pending = this.replacementAfterCancel; - this.replacementAfterCancel = null; + const replacement = this.replacementAfterCancel; + this.replacementAfterCancel = null; + if ( + replacement && + this.pending === null && + replacement.publishRevision === this.publishRevision + ) { + this.pending = replacement.preference; } if ( !this.destroyed && this.pending && - !sameCommunityThemePreference(this.pending, preference) && + (this.publishRevision !== publishRevision || + !sameCommunityThemePreference(this.pending, preference)) && this.debounceTimer === null ) { this.schedulePublish(0); @@ -234,7 +246,12 @@ export class CommunityThemeSyncManager { } const submitted = this.activeSubmittedPublish !== null; this.replacementAfterCancel = - submitted && replacementAfterSubmit ? replacementAfterSubmit : null; + submitted && replacementAfterSubmit + ? { + preference: replacementAfterSubmit, + publishRevision: this.publishRevision, + } + : null; this.publishGeneration += 1; if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); diff --git a/mobile/lib/shared/theme/community_theme_sync.dart b/mobile/lib/shared/theme/community_theme_sync.dart index 944e62efb7..a8da2e1b77 100644 --- a/mobile/lib/shared/theme/community_theme_sync.dart +++ b/mobile/lib/shared/theme/community_theme_sync.dart @@ -62,6 +62,7 @@ class CommunityThemeSyncManager { int _publishRetryAttempt = 0; bool _publishInFlight = false; bool _publishRequestedWhileInFlight = false; + int _remoteRevision = 0; bool _hydrationObserved = false; bool _disposed = false; @@ -333,6 +334,7 @@ class CommunityThemeSyncManager { // a desktop client's glass and prominent-tab choices. Bookkeeping stays on // the local `preference` so the outbox ack contract is unaffected. final remote = _lastRemote?.preference; + final remoteRevision = _remoteRevision; final outgoing = remote == null ? preference : preference.mergeDesktopAppearanceFrom(remote); @@ -358,6 +360,12 @@ class CommunityThemeSyncManager { if (published == null) { throw StateError('Signed event coordinate unavailable'); } + if (_remoteRevision != remoteRevision) { + _lastPublished = null; + _publishRetryAttempt = 0; + if (_pending == preference) _schedulePublish(Duration.zero); + return; + } final publishedCoordinateIsStale = _lastCreatedAt > published.createdAt || (_lastCreatedAt == published.createdAt && @@ -427,6 +435,7 @@ class CommunityThemeSyncManager { _lastCreatedAt = remote.createdAt; _lastEventId = remote.eventId; _lastRemote = remote; + _remoteRevision++; _observeHydration(); if (_pending != null) { _lastPublished = null; diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart index 1ee04ab5e2..26f40ddce7 100644 --- a/mobile/test/features/settings/theme_picker_page_test.dart +++ b/mobile/test/features/settings/theme_picker_page_test.dart @@ -175,11 +175,12 @@ void main() { tester, SettingsPage( profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ); - expect(find.text('Theme · This community'), findsOneWidget); + expect(find.text('Style · This community'), findsOneWidget); expect(find.text('Color mode'), findsOneWidget); expect(find.text('Color style'), findsOneWidget); }); diff --git a/mobile/test/shared/theme/community_theme_sync_test.dart b/mobile/test/shared/theme/community_theme_sync_test.dart index 709857c108..01b6652b4f 100644 --- a/mobile/test/shared/theme/community_theme_sync_test.dart +++ b/mobile/test/shared/theme/community_theme_sync_test.dart @@ -304,6 +304,64 @@ void main() { }, ); + test( + 're-merges desktop fields when remote advances during submission', + () async { + final firstSubmission = Completer(); + final relay = _FakeSignedRelay( + eventId: 'a-mobile', + firstSubmissionGate: firstSubmission.future, + ); + final session = _FakeSession(); + final acknowledgements = []; + final manager = _manager( + session, + relay, + onPublished: acknowledgements.add, + ); + await manager.initialize(); + manager.publish(local); + final firstFlush = manager.flush(); + await _waitUntil(() => relay.attempts == 1); + + const desktop = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + glassBackground: false, + glassOpacity: 80, + prominentActiveTab: true, + ); + session.emit( + _event( + id: 'z-desktop', + createdAt: relay.startedCreatedAts.single, + content: jsonEncode(desktop.toJson()), + ), + ); + firstSubmission.complete(); + await firstFlush; + + expect(acknowledgements, isEmpty); + expect(manager.pending, local); + await _waitUntil(() => relay.attempts == 2); + + final recovered = + jsonDecode(relay.submissions[1].content) as Map; + expect(recovered['theme'], local.theme); + expect(recovered['accent'], local.accent); + expect(recovered['glassBackground'], false); + expect(recovered['glassOpacity'], 80); + expect(recovered['prominentActiveTab'], true); + expect( + relay.submittedEvents[1].createdAt, + greaterThan(relay.startedCreatedAts.first), + ); + expect(acknowledgements, [local]); + expect(manager.pending, isNull); + }, + ); + test('remote coordinate advances pending local publish timestamp', () async { final relay = _FakeSignedRelay(); final session = _FakeSession(); @@ -709,6 +767,7 @@ class _FakeSignedRelay implements SignedEventRelay { final String eventId; final Future? firstSubmissionGate; int attempts = 0; + final startedCreatedAts = []; final submissions = <_Submission>[]; final submittedEvents = []; @override @@ -722,6 +781,7 @@ class _FakeSignedRelay implements SignedEventRelay { void Function(NostrEvent)? onSigned, }) async { attempts++; + startedCreatedAts.add(createdAt ?? 0); if (attempts == 1 && firstSubmissionGate != null) { await firstSubmissionGate; }