mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00: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,
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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();
|
||||
|
||||
44
schema/community-theme-v1.json
Normal file
44
schema/community-theme-v1.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user