From c016e83172694fc4fc3242ac83544bcbb1e44c68 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 13 Aug 2026 18:35:29 +0100 Subject: [PATCH] fix(theme): preserve legacy appearance migration Signed-off-by: kenny lopez --- .../features/settings/ui/SettingsPanels.tsx | 19 ++--- .../shared/theme/CommunityThemeController.tsx | 68 ++++++++++----- .../theme/communityThemePreference.test.mjs | 85 ++++++++++++++++++- .../shared/theme/communityThemePreference.ts | 38 +++++++++ .../theme/community_theme_preference.dart | 36 ++++++-- .../community_theme_preference_test.dart | 54 +++++++++++- .../theme/community_theme_provider_test.dart | 59 +++++++++++++ schema/community-theme-v1.json | 44 ++++++++++ 8 files changed, 357 insertions(+), 46 deletions(-) create mode 100644 schema/community-theme-v1.json diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 73a22c6e8d..4c42fd1b2b 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Archive, @@ -444,14 +444,8 @@ function ThemeSettingsCard() { ? "light" : "dark"; - const [selectedMode, setSelectedMode] = useState(activeMode); const [themeStyleExpanded, setThemeStyleExpanded] = useState(false); - // Community sync can replace the appearance while this panel remains open. - // Mirror that external value so the segmented control never describes the - // previous community's color mode. - useEffect(() => setSelectedMode(activeMode), [activeMode]); - const getVars = (name: SyntaxThemeName) => withAccentPreviewVars( previewVarsByTheme[name] ?? getThemeFallbackPreviewVars(name), @@ -473,7 +467,6 @@ function ThemeSettingsCard() { }, [pairedLight, darkOnly]); const handleModeSelect = (mode: AppearanceMode) => { - setSelectedMode(mode); if (mode === "system") { setFollowSystem(true); // If the current theme is unpaired, resolveSystemTheme can't switch it @@ -511,7 +504,7 @@ function ThemeSettingsCard() { const handleSelectTheme = (name: SyntaxThemeName) => { setTheme(name); - if (selectedMode === "system") { + if (activeMode === "system") { setFollowSystem(true); } else { setFollowSystem(false); @@ -524,7 +517,7 @@ function ThemeSettingsCard() { return selectedThemeName === lightName || selectedThemeName === darkName; }; const selectedPairedTheme = - selectedMode === "system" ? pairedLight.find(isPairActive) : undefined; + activeMode === "system" ? pairedLight.find(isPairActive) : undefined; const selectedTheme = selectedThemeName as SyntaxThemeName; const selectedPairedDarkTheme = selectedPairedTheme ? getThemePair(selectedPairedTheme) @@ -585,7 +578,7 @@ function ThemeSettingsCard() { ) : null}
- {selectedMode === "system" && + {activeMode === "system" && pairedLight.map((lightName) => { const darkName = getThemePair(lightName); if (!darkName) return null; @@ -600,7 +593,7 @@ function ThemeSettingsCard() { /> ); })} - {selectedMode === "light" && + {activeMode === "light" && allLightThemes.map((name) => ( ))} - {selectedMode === "dark" && + {activeMode === "dark" && allDarkThemes.map((name) => ( { if (!pubkey || !relayUrl) return; - const legacyFallback = communityThemeScopeFallback( + const scopeFallback = communityThemeScopeFallback( hasMigratedCommunityTheme(pubkey), initialPreferenceRef.current, ); + const appearanceFallback = communityThemeAppearanceFallback( + hasMigratedCommunityThemeAppearance(pubkey), + initialPreferenceRef.current, + ); const local = readCommunityThemePreference( pubkey, relayUrl, - legacyFallback, + appearanceFallback, ); - const dirty = readCommunityThemeOutbox(pubkey, relayUrl, legacyFallback); - // Preserve the user's existing global appearance the first time this - // feature sees their current community. Later missing/malformed target - // records use the stable default so the previous community never leaks. - const fallback = legacyFallback; - const scopedPreference = dirty ?? local ?? fallback; + const dirty = readCommunityThemeOutbox( + pubkey, + relayUrl, + appearanceFallback, + ); + // Scope migration decides whether an entirely empty community inherits the + // outer appearance. Appearance migration separately fills fields missing + // from an existing three-field record. + const scopedPreference = dirty ?? local ?? scopeFallback; scopedPreferenceRef.current = scopedPreference; applyPreference(scopedPreference); // Initialization is programmatic even when the provider already exposes @@ -110,7 +119,25 @@ export function CommunityThemeController() { useEffect(() => { if (!pubkey || !relayUrl) return; const scope = `${pubkey}:${relayUrl}`; - const legacyFallback = communityThemeScopeFallback( + const appearanceFallback = communityThemeAppearanceFallback( + hasMigratedCommunityThemeAppearance(pubkey), + initialPreferenceRef.current, + ); + const local = readCommunityThemePreference( + pubkey, + relayUrl, + appearanceFallback, + ); + const durablePending = readCommunityThemeOutbox( + pubkey, + relayUrl, + appearanceFallback, + ); + // A value already cached for this relay is the safest fallback for a later + // incomplete event. Without one, use the one-time appearance migration + // seed, which is never taken from a previous community after migration. + const legacyFallback = durablePending ?? local ?? appearanceFallback; + const scopeFallback = communityThemeScopeFallback( hasMigratedCommunityTheme(pubkey), initialPreferenceRef.current, ); @@ -136,11 +163,6 @@ export function CommunityThemeController() { legacyFallback, ); managerRef.current = manager; - const durablePending = readCommunityThemeOutbox( - pubkey, - relayUrl, - legacyFallback, - ); if (durablePending) manager.publish(durablePending); const applyRemote = (remote: RemoteCommunityTheme) => { @@ -188,16 +210,18 @@ export function CommunityThemeController() { if (remote) { applyRemote(remote); markCommunityThemeMigrated(pubkey); + markCommunityThemeAppearanceMigrated(pubkey); } else if (shouldSeedCommunityTheme(result)) { - const local = + const seed = readCommunityThemeOutbox(pubkey, relayUrl, legacyFallback) ?? readCommunityThemePreference(pubkey, relayUrl, legacyFallback) ?? scopedPreferenceRef.current ?? - DEFAULT_COMMUNITY_THEME; - writeCommunityThemePreference(pubkey, relayUrl, local); - writeCommunityThemeOutbox(pubkey, relayUrl, local); + scopeFallback; + writeCommunityThemePreference(pubkey, relayUrl, seed); + writeCommunityThemeOutbox(pubkey, relayUrl, seed); markCommunityThemeMigrated(pubkey); - manager.publish(local); + markCommunityThemeAppearanceMigrated(pubkey); + manager.publish(seed); } // Invalid or unavailable hydration keeps the already-applied fallback // without publishing over relay state we could not establish safely. @@ -250,8 +274,8 @@ export function CommunityThemeController() { const stored = readCommunityThemePreference( pubkey, relayUrl, - communityThemeScopeFallback( - hasMigratedCommunityTheme(pubkey), + communityThemeAppearanceFallback( + hasMigratedCommunityThemeAppearance(pubkey), initialPreferenceRef.current, ), ); diff --git a/desktop/src/shared/theme/communityThemePreference.test.mjs b/desktop/src/shared/theme/communityThemePreference.test.mjs index 70a5803a48..f3804a6196 100644 --- a/desktop/src/shared/theme/communityThemePreference.test.mjs +++ b/desktop/src/shared/theme/communityThemePreference.test.mjs @@ -1,14 +1,20 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { DEFAULT_COMMUNITY_THEME, cacheAndApplyCommunityTheme, clearCommunityThemeOutbox, + communityThemeAppearanceFallback, communityThemeApplyExpectation, communityThemeOutboxKey, communityThemePersistenceAction, communityThemeScopeFallback, communityThemeStorageKey, + hasMigratedCommunityTheme, + hasMigratedCommunityThemeAppearance, + markCommunityThemeAppearanceMigrated, + markCommunityThemeMigrated, parseCommunityThemePreference, readCommunityThemeOutbox, readCommunityThemePreference, @@ -16,6 +22,7 @@ import { writeCommunityThemeOutbox, writeCommunityThemePreference, } from "./communityThemePreference.ts"; +import { GLASS_OPACITY_MAX, GLASS_OPACITY_MIN } from "./ThemeProvider.tsx"; function localStorageStub() { const data = new Map(); @@ -98,7 +105,7 @@ test("older theme records inherit the pre-migration appearance controls", () => }); }); -test("later legacy records use stable defaults instead of a prior community", () => { +test("completed appearance migration uses stable defaults", () => { const priorCommunity = { ...DEFAULT_COMMUNITY_THEME, glassBackground: true, @@ -115,12 +122,86 @@ test("later legacy records use stable defaults instead of a prior community", () assert.deepEqual( parseCommunityThemePreference( olderRecord, - communityThemeScopeFallback(true, priorCommunity), + communityThemeAppearanceFallback(true, priorCommunity), ), { ...DEFAULT_COMMUNITY_THEME, ...olderRecord }, ); }); +test("appearance widening is independent from community scoping", () => { + globalThis.window = { localStorage: localStorageStub() }; + const inherited = { + ...DEFAULT_COMMUNITY_THEME, + glassBackground: true, + glassOpacity: 80, + prominentActiveTab: true, + }; + const legacy = { + version: 1, + theme: "houston", + accent: "#a855f7", + followSystem: false, + }; + window.localStorage.setItem( + communityThemeStorageKey("alice", "wss://relay.example"), + JSON.stringify(legacy), + ); + + markCommunityThemeMigrated("alice"); + assert.equal(hasMigratedCommunityTheme("alice"), true); + assert.equal(hasMigratedCommunityThemeAppearance("alice"), false); + assert.deepEqual( + readCommunityThemePreference( + "alice", + "wss://relay.example", + communityThemeAppearanceFallback( + hasMigratedCommunityThemeAppearance("alice"), + inherited, + ), + ), + { + ...legacy, + glassBackground: true, + glassOpacity: 80, + prominentActiveTab: true, + }, + ); + + markCommunityThemeAppearanceMigrated("alice"); + assert.equal(hasMigratedCommunityThemeAppearance("alice"), true); + assert.deepEqual( + communityThemeAppearanceFallback( + hasMigratedCommunityThemeAppearance("alice"), + inherited, + ), + DEFAULT_COMMUNITY_THEME, + ); +}); + +test("desktop appearance limits match the shared wire contract", () => { + const contract = JSON.parse( + readFileSync( + new URL("../../../../schema/community-theme-v1.json", import.meta.url), + "utf8", + ), + ); + + assert.equal( + DEFAULT_COMMUNITY_THEME.glassBackground, + contract.properties.glassBackground.default, + ); + assert.equal( + DEFAULT_COMMUNITY_THEME.glassOpacity, + contract.properties.glassOpacity.default, + ); + assert.equal(GLASS_OPACITY_MIN, contract.properties.glassOpacity.minimum); + assert.equal(GLASS_OPACITY_MAX, contract.properties.glassOpacity.maximum); + assert.equal( + DEFAULT_COMMUNITY_THEME.prominentActiveTab, + contract.properties.prominentActiveTab.default, + ); +}); + test("appearance equality includes glass and prominent-tab choices", () => { assert.equal( sameCommunityThemePreference(DEFAULT_COMMUNITY_THEME, { diff --git a/desktop/src/shared/theme/communityThemePreference.ts b/desktop/src/shared/theme/communityThemePreference.ts index eed642e045..f3457b5723 100644 --- a/desktop/src/shared/theme/communityThemePreference.ts +++ b/desktop/src/shared/theme/communityThemePreference.ts @@ -12,6 +12,8 @@ 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_KEY_PREFIX = "buzz-community-theme-migrated.v1"; +const APPEARANCE_MIGRATION_KEY_PREFIX = + "buzz-community-theme-appearance-migrated.v1"; export type CommunityThemePreference = { version: 1; @@ -181,6 +183,29 @@ export function markCommunityThemeMigrated(pubkey: string): void { } } +export function hasMigratedCommunityThemeAppearance(pubkey: string): boolean { + try { + return ( + window.localStorage.getItem( + `${APPEARANCE_MIGRATION_KEY_PREFIX}:${pubkey}`, + ) === "true" + ); + } catch { + return false; + } +} + +export function markCommunityThemeAppearanceMigrated(pubkey: string): void { + try { + window.localStorage.setItem( + `${APPEARANCE_MIGRATION_KEY_PREFIX}:${pubkey}`, + "true", + ); + } catch { + // The expanded preference remains usable in memory when storage is full. + } +} + export function writeCommunityThemePreference( pubkey: string, relayUrl: string, @@ -214,6 +239,19 @@ export function communityThemeScopeFallback( return migrated ? DEFAULT_COMMUNITY_THEME : inherited; } +/** + * Choose values for appearance fields missing from the original v1 payload. + * This migration is independent from the older per-community scope migration: + * profiles that already completed scoping still need one chance to retain + * their former global glass and prominent-tab preferences. + */ +export function communityThemeAppearanceFallback( + migrated: boolean, + inherited: CommunityThemePreference, +): CommunityThemePreference { + return migrated ? DEFAULT_COMMUNITY_THEME : inherited; +} + export function sameCommunityThemePreference( left: CommunityThemePreference, right: CommunityThemePreference, diff --git a/mobile/lib/shared/theme/community_theme_preference.dart b/mobile/lib/shared/theme/community_theme_preference.dart index ae9e9d75ba..3b065b5cc8 100644 --- a/mobile/lib/shared/theme/community_theme_preference.dart +++ b/mobile/lib/shared/theme/community_theme_preference.dart @@ -29,6 +29,9 @@ class CommunityThemePreference { final bool glassBackground; final int glassOpacity; final bool prominentActiveTab; + final bool includesGlassBackground; + final bool includesGlassOpacity; + final bool includesProminentActiveTab; const CommunityThemePreference({ this.version = 1, @@ -38,6 +41,9 @@ class CommunityThemePreference { this.glassBackground = defaultCommunityGlassBackground, this.glassOpacity = defaultCommunityGlassOpacity, this.prominentActiveTab = defaultCommunityProminentActiveTab, + this.includesGlassBackground = true, + this.includesGlassOpacity = true, + this.includesProminentActiveTab = true, }); factory CommunityThemePreference.fromJson(Map json) { @@ -49,13 +55,16 @@ class CommunityThemePreference { json['followSystem'] is! bool) { throw const FormatException('Invalid community theme preference'); } - final glassBackground = json.containsKey('glassBackground') + final includesGlassBackground = json.containsKey('glassBackground'); + final includesGlassOpacity = json.containsKey('glassOpacity'); + final includesProminentActiveTab = json.containsKey('prominentActiveTab'); + final glassBackground = includesGlassBackground ? json['glassBackground'] : defaultCommunityGlassBackground; - final glassOpacity = json.containsKey('glassOpacity') + final glassOpacity = includesGlassOpacity ? json['glassOpacity'] : defaultCommunityGlassOpacity; - final prominentActiveTab = json.containsKey('prominentActiveTab') + final prominentActiveTab = includesProminentActiveTab ? json['prominentActiveTab'] : defaultCommunityProminentActiveTab; if (glassBackground is! bool || @@ -74,6 +83,9 @@ class CommunityThemePreference { glassBackground: glassBackground, glassOpacity: glassOpacity.toInt(), prominentActiveTab: prominentActiveTab, + includesGlassBackground: includesGlassBackground, + includesGlassOpacity: includesGlassOpacity, + includesProminentActiveTab: includesProminentActiveTab, ); } @@ -82,9 +94,9 @@ class CommunityThemePreference { 'theme': theme, 'accent': accent, 'followSystem': followSystem, - 'glassBackground': glassBackground, - 'glassOpacity': glassOpacity, - 'prominentActiveTab': prominentActiveTab, + if (includesGlassBackground) 'glassBackground': glassBackground, + if (includesGlassOpacity) 'glassOpacity': glassOpacity, + if (includesProminentActiveTab) 'prominentActiveTab': prominentActiveTab, }; CommunityThemePreference copyWith({ @@ -102,6 +114,10 @@ class CommunityThemePreference { glassBackground: glassBackground ?? this.glassBackground, glassOpacity: glassOpacity ?? this.glassOpacity, prominentActiveTab: prominentActiveTab ?? this.prominentActiveTab, + includesGlassBackground: glassBackground != null || includesGlassBackground, + includesGlassOpacity: glassOpacity != null || includesGlassOpacity, + includesProminentActiveTab: + prominentActiveTab != null || includesProminentActiveTab, ); ThemeMode get mode { @@ -117,7 +133,10 @@ class CommunityThemePreference { followSystem == other.followSystem && glassBackground == other.glassBackground && glassOpacity == other.glassOpacity && - prominentActiveTab == other.prominentActiveTab; + prominentActiveTab == other.prominentActiveTab && + includesGlassBackground == other.includesGlassBackground && + includesGlassOpacity == other.includesGlassOpacity && + includesProminentActiveTab == other.includesProminentActiveTab; @override int get hashCode => Object.hash( @@ -127,6 +146,9 @@ class CommunityThemePreference { glassBackground, glassOpacity, prominentActiveTab, + includesGlassBackground, + includesGlassOpacity, + includesProminentActiveTab, ); } diff --git a/mobile/test/shared/theme/community_theme_preference_test.dart b/mobile/test/shared/theme/community_theme_preference_test.dart index 1507e3064f..b1982b6050 100644 --- a/mobile/test/shared/theme/community_theme_preference_test.dart +++ b/mobile/test/shared/theme/community_theme_preference_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; @@ -24,7 +25,7 @@ void main() { ); }); - test('older v1 payloads gain capability-independent defaults', () { + test('older v1 payloads retain missing desktop-only fields', () { final preference = CommunityThemePreference.fromJson({ 'version': 1, 'theme': 'buzz', @@ -35,7 +36,56 @@ void main() { expect(preference.glassBackground, isFalse); expect(preference.glassOpacity, 65); expect(preference.prominentActiveTab, isFalse); - expect(preference.toJson(), containsPair('glassOpacity', 65)); + expect(preference.toJson(), isNot(contains('glassBackground'))); + expect(preference.toJson(), isNot(contains('glassOpacity'))); + expect(preference.toJson(), isNot(contains('prominentActiveTab'))); + }); + + test('mobile edits do not widen legacy appearance fields with defaults', () { + final legacy = CommunityThemePreference.fromJson({ + 'version': 1, + 'theme': 'buzz', + 'accent': '#3b82f6', + 'followSystem': true, + }); + + final edited = legacy.copyWith(accent: '#ef4444'); + + expect(edited.toJson(), { + 'version': 1, + 'theme': 'buzz', + 'accent': '#ef4444', + 'followSystem': true, + }); + + expect(legacy.copyWith(glassOpacity: 70).toJson(), { + 'version': 1, + 'theme': 'buzz', + 'accent': '#3b82f6', + 'followSystem': true, + 'glassOpacity': 70, + }); + }); + + test('mobile appearance limits match the shared wire contract', () { + final contract = + jsonDecode(File('../schema/community-theme-v1.json').readAsStringSync()) + as Map; + final properties = contract['properties'] as Map; + final glassBackground = + properties['glassBackground'] as Map; + final glassOpacity = properties['glassOpacity'] as Map; + final prominentActiveTab = + properties['prominentActiveTab'] as Map; + + expect(defaultCommunityGlassBackground, glassBackground['default'] as bool); + expect(defaultCommunityGlassOpacity, glassOpacity['default'] as int); + expect(minCommunityGlassOpacity, glassOpacity['minimum'] as int); + expect(maxCommunityGlassOpacity, glassOpacity['maximum'] as int); + expect( + defaultCommunityProminentActiveTab, + prominentActiveTab['default'] as bool, + ); }); test('rejects invalid theme and appearance values', () { diff --git a/mobile/test/shared/theme/community_theme_provider_test.dart b/mobile/test/shared/theme/community_theme_provider_test.dart index 540e0755c5..7f0474ec02 100644 --- a/mobile/test/shared/theme/community_theme_provider_test.dart +++ b/mobile/test/shared/theme/community_theme_provider_test.dart @@ -11,6 +11,65 @@ import 'package:nostr/nostr.dart' as nostr; import 'package:shared_preferences/shared_preferences.dart'; void main() { + test( + 'mobile edit preserves omitted desktop-only fields from a legacy cache', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keys = nostr.Keys.generate(); + final storage = CommunityThemeStorage(prefs); + await prefs.setString( + storage.key(keys.public, 'https://relay.example'), + jsonEncode({ + 'version': 1, + 'theme': 'buzz', + 'accent': '#3b82f6', + 'followSystem': true, + }), + ); + final session = _ThemeRelaySession( + keys.nsec, + keys.public, + initialStatus: SessionStatus.reconnecting, + ); + final container = ProviderContainer( + overrides: [ + communityThemeStorageProvider.overrideWithValue(storage), + relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)), + relaySessionProvider.overrideWith(() => session), + ], + ); + addTearDown(container.dispose); + container.listen( + communityThemeProvider, + (_, _) {}, + fireImmediately: true, + ); + + container + .read(communityThemeProvider.notifier) + .setAccent(accentIndexForWireValue('#ef4444')!); + + await _waitUntil(() { + final raw = prefs.getString( + storage.key(keys.public, 'https://relay.example'), + ); + if (raw == null) return false; + return (jsonDecode(raw) as Map)['accent'] == '#ef4444'; + }); + final persisted = + jsonDecode( + prefs.getString( + storage.key(keys.public, 'https://relay.example'), + )!, + ) + as Map; + expect(persisted, isNot(contains('glassBackground'))); + expect(persisted, isNot(contains('glassOpacity'))); + expect(persisted, isNot(contains('prominentActiveTab'))); + }, + ); + test('mobile edits retain desktop-only appearance preferences', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); diff --git a/schema/community-theme-v1.json b/schema/community-theme-v1.json new file mode 100644 index 0000000000..3ccc5e29e4 --- /dev/null +++ b/schema/community-theme-v1.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/block/buzz/schema/community-theme-v1.json", + "title": "Buzz community theme preference v1", + "description": "Canonical completed payload. Clients also accept the original three-field v1 form during migration.", + "type": "object", + "required": [ + "version", + "theme", + "accent", + "followSystem", + "glassBackground", + "glassOpacity", + "prominentActiveTab" + ], + "properties": { + "version": { + "const": 1 + }, + "theme": { + "type": "string" + }, + "accent": { + "type": "string" + }, + "followSystem": { + "type": "boolean" + }, + "glassBackground": { + "type": "boolean", + "default": false + }, + "glassOpacity": { + "type": "integer", + "minimum": 30, + "maximum": 90, + "default": 65 + }, + "prominentActiveTab": { + "type": "boolean", + "default": false + } + } +}