mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(theme): preserve legacy appearance migration
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user