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 3809decb05
commit 94addf2bd1
8 changed files with 360 additions and 49 deletions
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
Archive,
@@ -438,14 +438,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),
@@ -467,7 +461,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
@@ -505,7 +498,7 @@ function ThemeSettingsCard() {
const handleSelectTheme = (name: SyntaxThemeName) => {
setTheme(name);
if (selectedMode === "system") {
if (activeMode === "system") {
setFollowSystem(true);
} else {
setFollowSystem(false);
@@ -518,7 +511,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)
@@ -579,7 +572,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;
@@ -594,7 +587,7 @@ function ThemeSettingsCard() {
/>
);
})}
{selectedMode === "light" &&
{activeMode === "light" &&
allLightThemes.map((name) => (
<SingleThemeTile
isActive={selectedThemeName === name}
@@ -604,7 +597,7 @@ function ThemeSettingsCard() {
vars={getVars(name)}
/>
))}
{selectedMode === "dark" &&
{activeMode === "dark" &&
allDarkThemes.map((name) => (
<SingleThemeTile
isActive={selectedThemeName === name}
@@ -656,16 +649,16 @@ function ThemeSettingsCard() {
className="absolute bottom-0.5 left-0.5 top-0.5 z-0 rounded-md bg-background shadow-sm transition-transform duration-[250ms] ease-out motion-reduce:transition-none"
data-testid="appearance-color-mode-indicator"
style={{
transform: `translateX(${APPEARANCE_MODE_OPTIONS.findIndex((option) => option.mode === selectedMode) * 100}%)`,
transform: `translateX(${APPEARANCE_MODE_OPTIONS.findIndex((option) => option.mode === activeMode) * 100}%)`,
width: "calc((100% - 4px) / 3)",
}}
/>
{APPEARANCE_MODE_OPTIONS.map(({ mode, label, Icon }) => (
<button
aria-pressed={selectedMode === mode}
aria-pressed={activeMode === mode}
className={cn(
"relative z-10 flex h-full items-center justify-center gap-1.5 rounded-md bg-transparent px-2.5 text-xs font-medium transition-colors duration-[250ms] ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
selectedMode === mode
activeMode === mode
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
@@ -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,
),
);
@@ -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, {
@@ -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,