fix(theme): preserve legacy appearance migration

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-13 18:35:29 +01:00
parent 693256b561
commit c016e83172
8 changed files with 357 additions and 46 deletions

View File

@@ -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<AppearanceMode>(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}
<div className="max-h-[430px] overflow-y-auto rounded-lg pt-2">
<div className="flex flex-wrap gap-4 p-1">
{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) => (
<SingleThemeTile
isActive={selectedThemeName === name}
@@ -610,7 +603,7 @@ function ThemeSettingsCard() {
vars={getVars(name)}
/>
))}
{selectedMode === "dark" &&
{activeMode === "dark" &&
allDarkThemes.map((name) => (
<SingleThemeTile
isActive={selectedThemeName === name}

View File

@@ -3,13 +3,15 @@ import { useCommunities } from "@/features/communities/useCommunities";
import { relayClient } from "@/shared/api/relayClient";
import { useIdentityQuery } from "@/shared/api/hooks";
import {
DEFAULT_COMMUNITY_THEME,
cacheAndApplyCommunityTheme,
clearCommunityThemeOutbox,
communityThemeAppearanceFallback,
communityThemeApplyExpectation,
communityThemePersistenceAction,
communityThemeScopeFallback,
hasMigratedCommunityTheme,
hasMigratedCommunityThemeAppearance,
markCommunityThemeAppearanceMigrated,
markCommunityThemeMigrated,
readCommunityThemeOutbox,
readCommunityThemePreference,
@@ -80,21 +82,28 @@ export function CommunityThemeController() {
useLayoutEffect(() => {
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,
),
);

View File

@@ -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, {

View File

@@ -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,

View File

@@ -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<String, dynamic> 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,
);
}

View File

@@ -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<String, dynamic>;
final properties = contract['properties'] as Map<String, dynamic>;
final glassBackground =
properties['glassBackground'] as Map<String, dynamic>;
final glassOpacity = properties['glassOpacity'] as Map<String, dynamic>;
final prominentActiveTab =
properties['prominentActiveTab'] as Map<String, dynamic>;
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', () {

View File

@@ -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<String, dynamic>)['accent'] == '#ef4444';
});
final persisted =
jsonDecode(
prefs.getString(
storage.key(keys.public, 'https://relay.example'),
)!,
)
as Map<String, dynamic>;
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();

View File

@@ -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
}
}
}