diff --git a/desktop/src/shared/theme/CommunityThemeController.tsx b/desktop/src/shared/theme/CommunityThemeController.tsx index bc76f8bdb..2720b5f35 100644 --- a/desktop/src/shared/theme/CommunityThemeController.tsx +++ b/desktop/src/shared/theme/CommunityThemeController.tsx @@ -5,6 +5,7 @@ import { useIdentityQuery } from "@/shared/api/hooks"; import { cacheAndApplyCommunityTheme, captureCommunityThemeAppearanceSnapshot, + clearCommunityThemeMigrationOutbox, clearCommunityThemeOutbox, communityThemeAppearanceFallback, communityThemeApplyExpectation, @@ -12,10 +13,13 @@ import { communityThemeScopeFallback, hasMigratedCommunityTheme, markCommunityThemeMigrated, + readCommunityThemeCurrentAppearance, + readCommunityThemeMigrationOutbox, readCommunityThemeOutbox, readCommunityThemePreference, - refreshCommunityThemeAppearanceSnapshot, + refreshCommunityThemeCurrentAppearance, sameCommunityThemePreference, + writeCommunityThemeMigrationOutbox, writeCommunityThemeOutbox, writeCommunityThemePreference, type CommunityThemeAppearance, @@ -92,13 +96,17 @@ export function CommunityThemeController() { initialPreferenceRef.current, ); appearanceSnapshotRef.current = snapshot; + const currentAppearance = readCommunityThemeCurrentAppearance( + pubkey, + snapshot, + ); const appearanceFallback = communityThemeAppearanceFallback(snapshot); const scopeFallback: CommunityThemePreference = { ...communityThemeScopeFallback( hasMigratedCommunityTheme(pubkey), initialPreferenceRef.current, ), - ...snapshot, + ...currentAppearance, }; const local = readCommunityThemePreference( pubkey, @@ -135,6 +143,10 @@ export function CommunityThemeController() { // the correct in-session value. const snapshot = appearanceSnapshotRef.current; const appearanceFallback = communityThemeAppearanceFallback(snapshot); + const currentAppearance = readCommunityThemeCurrentAppearance( + pubkey, + snapshot ?? appearanceFallback, + ); const local = readCommunityThemePreference( pubkey, relayUrl, @@ -146,15 +158,15 @@ export function CommunityThemeController() { appearanceFallback, ); // A value already cached for this relay is the safest fallback for a later - // incomplete event. Without one, use the durable appearance snapshot, which - // holds the profile's pre-migration glass and tab choices for every scope. + // incomplete event. Without one, use the immutable pre-migration snapshot, + // never the mutable current-choice fallback used only for absent scopes. const legacyFallback = durablePending ?? local ?? appearanceFallback; const scopeFallback: CommunityThemePreference = { ...communityThemeScopeFallback( hasMigratedCommunityTheme(pubkey), initialPreferenceRef.current, ), - ...snapshot, + ...currentAppearance, }; scopeRef.current = scope; lastRemoteRef.current = { createdAt: 0, eventId: "" }; @@ -174,11 +186,21 @@ export function CommunityThemeController() { published.preference, legacyFallback, ); + clearCommunityThemeMigrationOutbox(pubkey, relayUrl); }, legacyFallback, ); managerRef.current = manager; - if (durablePending) manager.publish(durablePending); + if (durablePending) { + manager.publish(durablePending); + } else { + const migrationPending = readCommunityThemeMigrationOutbox( + pubkey, + relayUrl, + legacyFallback, + ); + if (migrationPending) manager.publish(migrationPending); + } const applyRemote = (remote: RemoteCommunityTheme) => { if (scopeRef.current !== scope) return; @@ -196,6 +218,10 @@ export function CommunityThemeController() { manager.publish(dirty); return; } + // Migration-only upgrades never outrank relay state. A newer complete + // 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(); cacheAndApplyCommunityTheme( @@ -206,7 +232,7 @@ export function CommunityThemeController() { ); if ( remote.needsUpgrade && - writeCommunityThemeOutbox(pubkey, relayUrl, remote.preference) + writeCommunityThemeMigrationOutbox(pubkey, relayUrl, remote.preference) ) { manager.publish(remote.preference); } @@ -290,13 +316,11 @@ export function CommunityThemeController() { communityThemeAppearanceFallback(appearanceSnapshotRef.current), ); if (stored && sameCommunityThemePreference(stored, preference)) return; - // A genuine user edit that changes glass/opacity/prominent-tab is the - // user's current profile-wide appearance intent, so refresh the snapshot - // that seeds no-record communities. Without this the snapshot stays frozen - // at the pre-migration value and a later empty community resurrects it over - // the user's current choice. Only a changed appearance refreshes it, so an - // accent- or theme-only edit never leaks this community's glass into the - // profile-wide seed. + // A genuine user edit that changes glass/opacity/prominent-tab updates the + // profile-wide fallback used only for a genuinely absent community. Keep + // that value separate from the immutable pre-migration snapshot used to + // hydrate older three-field records, or one community's explicit choice + // contaminates every still-legacy community. const priorScoped = scopedPreferenceRef.current; if ( !priorScoped || @@ -304,10 +328,7 @@ export function CommunityThemeController() { priorScoped.glassOpacity !== preference.glassOpacity || priorScoped.prominentActiveTab !== preference.prominentActiveTab ) { - appearanceSnapshotRef.current = refreshCommunityThemeAppearanceSnapshot( - pubkey, - preference, - ); + refreshCommunityThemeCurrentAppearance(pubkey, preference); } scopedPreferenceRef.current = preference; if (!writeCommunityThemePreference(pubkey, relayUrl, preference)) return; diff --git a/desktop/src/shared/theme/communityThemePreference.test.mjs b/desktop/src/shared/theme/communityThemePreference.test.mjs index e565f3888..761fca602 100644 --- a/desktop/src/shared/theme/communityThemePreference.test.mjs +++ b/desktop/src/shared/theme/communityThemePreference.test.mjs @@ -8,16 +8,20 @@ import { clearCommunityThemeOutbox, communityThemeAppearanceFallback, communityThemeApplyExpectation, + communityThemeMigrationOutboxKey, communityThemeOutboxKey, communityThemePersistenceAction, communityThemeScopeFallback, communityThemeStorageKey, parseCommunityThemePreference, readCommunityThemeAppearanceSnapshot, + readCommunityThemeCurrentAppearance, + readCommunityThemeMigrationOutbox, readCommunityThemeOutbox, readCommunityThemePreference, - refreshCommunityThemeAppearanceSnapshot, + refreshCommunityThemeCurrentAppearance, sameCommunityThemePreference, + writeCommunityThemeMigrationOutbox, writeCommunityThemeOutbox, writeCommunityThemePreference, } from "./communityThemePreference.ts"; @@ -283,11 +287,9 @@ test("a full store pins the snapshot across a controller remount", () => { ); }); -test("a user glass edit refreshes the snapshot for no-record communities", () => { - // The initial snapshot is frozen at the pre-migration appearance. Once the - // user changes glass, that former value is stale: a no-record community must - // inherit the new choice, not resurrect the old one. refresh overwrites the - // durable snapshot so the empty-scope fallback tracks the current choice. +test("a user glass edit updates absent scopes without changing the legacy snapshot", () => { + // Empty communities track the latest explicit choice, but a legacy record + // must always inherit the immutable pre-migration appearance. globalThis.window = { localStorage: localStorageStub() }; const preMigration = { ...DEFAULT_COMMUNITY_THEME, @@ -299,11 +301,6 @@ test("a user glass edit refreshes the snapshot for no-record communities", () => "edit-pk", preMigration, ); - assert.deepEqual(pinned, { - glassBackground: true, - glassOpacity: 80, - prominentActiveTab: true, - }); const edited = { ...DEFAULT_COMMUNITY_THEME, @@ -311,17 +308,34 @@ test("a user glass edit refreshes the snapshot for no-record communities", () => glassOpacity: 40, prominentActiveTab: false, }; - const refreshed = refreshCommunityThemeAppearanceSnapshot("edit-pk", edited); - assert.deepEqual(refreshed, { + const current = refreshCommunityThemeCurrentAppearance("edit-pk", edited); + assert.deepEqual(current, { glassBackground: false, glassOpacity: 40, prominentActiveTab: false, }); - // A subsequent capture (a later community mount) sees the refreshed value, - // so the no-record fallback carries the user's current choice. assert.deepEqual( - captureCommunityThemeAppearanceSnapshot("edit-pk", preMigration), - refreshed, + readCommunityThemeCurrentAppearance("edit-pk", pinned), + current, + ); + + // The next mount still sees the original snapshot for an older payload. + assert.deepEqual( + captureCommunityThemeAppearanceSnapshot("edit-pk", edited), + pinned, + ); + const legacy = { + version: 1, + theme: "houston", + accent: "#a855f7", + followSystem: false, + }; + assert.deepEqual( + parseCommunityThemePreference( + legacy, + communityThemeAppearanceFallback(pinned), + ), + { ...legacy, ...pinned }, ); }); @@ -436,6 +450,40 @@ test("dirty outbox survives restart and clears only its exact revision", () => { ); }); +test("migration upgrades are isolated from user edits", () => { + globalThis.window = { localStorage: localStorageStub() }; + const migration = { ...DEFAULT_COMMUNITY_THEME, theme: "houston" }; + const userEdit = { ...DEFAULT_COMMUNITY_THEME, accent: "#ef4444" }; + + assert.equal( + writeCommunityThemeMigrationOutbox("alice", "wss://a.example", migration), + true, + ); + assert.deepEqual( + readCommunityThemeMigrationOutbox("alice", "wss://a.example"), + migration, + ); + assert.equal(readCommunityThemeOutbox("alice", "wss://a.example"), null); + + // A genuine edit supersedes and clears the migration-only upgrade. + assert.equal( + writeCommunityThemeOutbox("alice", "wss://a.example", userEdit), + true, + ); + assert.equal( + readCommunityThemeMigrationOutbox("alice", "wss://a.example"), + null, + ); + assert.deepEqual( + readCommunityThemeOutbox("alice", "wss://a.example"), + userEdit, + ); + assert.notEqual( + communityThemeMigrationOutboxKey("alice", "wss://a.example"), + communityThemeOutboxKey("alice", "wss://a.example"), + ); +}); + test("malformed local data returns null so switching can apply the safe default", () => { globalThis.window = { localStorage: localStorageStub() }; const key = communityThemeStorageKey("alice", "wss://broken.example"); diff --git a/desktop/src/shared/theme/communityThemePreference.ts b/desktop/src/shared/theme/communityThemePreference.ts index ae3cb52bc..68e6234f8 100644 --- a/desktop/src/shared/theme/communityThemePreference.ts +++ b/desktop/src/shared/theme/communityThemePreference.ts @@ -11,9 +11,12 @@ import { SYNTAX_THEMES, type SyntaxThemeName } from "./theme-loader"; const STORAGE_KEY_PREFIX = "buzz-community-theme.v1"; const OUTBOX_KEY_PREFIX = "buzz-community-theme-outbox.v1"; +const MIGRATION_OUTBOX_KEY_PREFIX = "buzz-community-theme-migration-outbox.v1"; const MIGRATION_KEY_PREFIX = "buzz-community-theme-migrated.v1"; const APPEARANCE_SNAPSHOT_KEY_PREFIX = "buzz-community-theme-appearance-snapshot.v1"; +const CURRENT_APPEARANCE_KEY_PREFIX = + "buzz-community-theme-current-appearance.v1"; export type CommunityThemePreference = { version: 1; @@ -59,6 +62,13 @@ export function communityThemeOutboxKey( return `${OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; } +export function communityThemeMigrationOutboxKey( + pubkey: string, + relayUrl: string, +): string { + return `${MIGRATION_OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + export function parseCommunityThemePreference( value: unknown, legacyFallback: CommunityThemePreference = DEFAULT_COMMUNITY_THEME, @@ -150,12 +160,71 @@ export function writeCommunityThemeOutbox( communityThemeOutboxKey(pubkey, relayUrl), JSON.stringify(preference), ); + clearCommunityThemeMigrationOutbox(pubkey, relayUrl); return true; } catch { return false; } } +export function readCommunityThemeMigrationOutbox( + pubkey: string, + relayUrl: string, + legacyFallback: CommunityThemePreference = DEFAULT_COMMUNITY_THEME, +): CommunityThemePreference | null { + try { + const raw = window.localStorage.getItem( + communityThemeMigrationOutboxKey(pubkey, relayUrl), + ); + return raw + ? parseCommunityThemePreference(JSON.parse(raw), legacyFallback) + : null; + } catch { + return null; + } +} + +export function writeCommunityThemeMigrationOutbox( + pubkey: string, + relayUrl: string, + preference: CommunityThemePreference, +): boolean { + try { + window.localStorage.setItem( + communityThemeMigrationOutboxKey(pubkey, relayUrl), + JSON.stringify(preference), + ); + return true; + } catch { + return false; + } +} + +export function clearCommunityThemeMigrationOutbox( + pubkey: string, + relayUrl: string, + acknowledged?: CommunityThemePreference, + legacyFallback: CommunityThemePreference = DEFAULT_COMMUNITY_THEME, +): void { + if (acknowledged) { + const pending = readCommunityThemeMigrationOutbox( + pubkey, + relayUrl, + legacyFallback, + ); + if (!pending || !sameCommunityThemePreference(pending, acknowledged)) { + return; + } + } + try { + window.localStorage.removeItem( + communityThemeMigrationOutboxKey(pubkey, relayUrl), + ); + } catch { + // A later remote can safely cancel the migration-only upgrade again. + } +} + export function clearCommunityThemeOutbox( pubkey: string, relayUrl: string, @@ -194,6 +263,10 @@ export function communityThemeAppearanceSnapshotKey(pubkey: string): string { return `${APPEARANCE_SNAPSHOT_KEY_PREFIX}:${pubkey}`; } +export function communityThemeCurrentAppearanceKey(pubkey: string): string { + return `${CURRENT_APPEARANCE_KEY_PREFIX}:${pubkey}`; +} + function parseCommunityThemeAppearance( value: unknown, ): CommunityThemeAppearance | null { @@ -229,6 +302,7 @@ export function readCommunityThemeAppearanceSnapshot( } const inMemoryAppearanceSnapshots = new Map(); +const inMemoryCurrentAppearances = new Map(); /** * Persist the profile's pre-migration appearance the first time it is seen and @@ -270,35 +344,47 @@ export function captureCommunityThemeAppearanceSnapshot( } /** - * Overwrite the profile's appearance snapshot with a later explicit user glass - * choice. The initial snapshot is frozen at the pre-migration appearance, but - * once the user changes glass/opacity/prominent-tab that former value is stale: - * a no-record community would otherwise resurrect it and republish it over the - * user's current choice. Callers refresh only on a genuine user edit, never on - * a programmatic per-community apply, so a community's own record can never - * leak into the profile-wide seed. + * Persist the latest explicit user glass choice separately from the immutable + * migration snapshot. Empty communities may inherit this current choice, while + * legacy records must continue to inherit the original pre-migration value. */ -export function refreshCommunityThemeAppearanceSnapshot( +export function refreshCommunityThemeCurrentAppearance( pubkey: string, appearance: CommunityThemeAppearance, ): CommunityThemeAppearance { - const snapshot: CommunityThemeAppearance = { + const current: CommunityThemeAppearance = { glassBackground: appearance.glassBackground, glassOpacity: appearance.glassOpacity, prominentActiveTab: appearance.prominentActiveTab, }; try { window.localStorage.setItem( - communityThemeAppearanceSnapshotKey(pubkey), - JSON.stringify(snapshot), + communityThemeCurrentAppearanceKey(pubkey), + JSON.stringify(current), ); - inMemoryAppearanceSnapshots.delete(pubkey); + inMemoryCurrentAppearances.delete(pubkey); } catch { - // Retain the refreshed value in memory when the store is full, mirroring - // the capture path so the current choice still survives a remount. - inMemoryAppearanceSnapshots.set(pubkey, snapshot); + inMemoryCurrentAppearances.set(pubkey, current); } - return snapshot; + return current; +} + +export function readCommunityThemeCurrentAppearance( + pubkey: string, + fallback: CommunityThemeAppearance, +): CommunityThemeAppearance { + try { + const raw = window.localStorage.getItem( + communityThemeCurrentAppearanceKey(pubkey), + ); + if (raw) { + const current = parseCommunityThemeAppearance(JSON.parse(raw)); + if (current) return current; + } + } catch { + // Fall through to the in-memory current choice or immutable snapshot. + } + return inMemoryCurrentAppearances.get(pubkey) ?? fallback; } export function writeCommunityThemePreference(