mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
**Category:** fix **User Impact:** Previously selected community themes are preserved when opening a relay through onboarding, while first-time theme migration still completes for communities with no saved theme. **Problem:** During community initialization, desktop queried theme history before establishing live delivery. If a replacement event arrived while an empty history query was in flight, the client could incorrectly treat the theme as absent and publish the default over the user's saved selection. **Solution:** Subscribe before fetching history, expose whether live readiness reached EOSE, flush buffered live events before resolving EOSE, and retain the newest delivered replacement through hydration. Seed the inherited/default theme only when both live and history snapshots reach EOSE with no valid or unreadable event; subscription failures, CLOSED, readiness timeout, relay failure, and unreadable events fail closed without publishing. <details> <summary>File changes</summary> **desktop/src/shared/api/relayClientSession.ts / relayClientShared.ts / relayClosedRecovery.ts** Distinguish EOSE from CLOSED/timeout readiness and flush buffered events before resolving an EOSE fence. **desktop/src/shared/theme/CommunityThemeController.tsx** Seed and complete first-community migration only for confirmed absence; uncertain hydration remains non-publishing. **desktop/src/shared/theme/communityThemePreference.ts** Keep the inherited appearance for the first migrated community and the stable default for later empty communities. **desktop/src/shared/theme/communityThemeSync.ts** Arbitrate live and history results into valid, confirmed-absent, invalid, or unavailable hydration outcomes. **Tests** Cover EOSE/CLOSED readiness, subscription failure, timeout, unreadable and live-racing events, no-op initialization, and first-to-later community fallback isolation. </details> ## Reproduction steps 1. Save a non-default appearance for a community relay. 2. Remove the community locally, then open the same relay again through onboarding. 3. Arrange for the saved replacement event to arrive live while the initial history query returns empty. 4. Confirm the saved appearance remains selected and the client does not publish the default theme over it. 5. On an account with no theme records, open a first empty community and confirm its inherited appearance is migrated; open a later empty community and confirm it starts from the stable default. ## Validation - Pre-push desktop checks, typecheck, and full desktop tests: passed at `f79556b0e` - Focused theme/relay readiness tests: 38 passed - Desktop file-size ratchet and diff check: passed --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
212 lines
6.0 KiB
JavaScript
212 lines
6.0 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
DEFAULT_COMMUNITY_THEME,
|
|
cacheAndApplyCommunityTheme,
|
|
clearCommunityThemeOutbox,
|
|
communityThemeApplyExpectation,
|
|
communityThemeOutboxKey,
|
|
communityThemePersistenceAction,
|
|
communityThemeScopeFallback,
|
|
communityThemeStorageKey,
|
|
parseCommunityThemePreference,
|
|
readCommunityThemeOutbox,
|
|
readCommunityThemePreference,
|
|
writeCommunityThemeOutbox,
|
|
writeCommunityThemePreference,
|
|
} from "./communityThemePreference.ts";
|
|
|
|
function localStorageStub() {
|
|
const data = new Map();
|
|
return {
|
|
getItem: (key) => data.get(key) ?? null,
|
|
setItem: (key, value) => data.set(key, String(value)),
|
|
removeItem: (key) => data.delete(key),
|
|
};
|
|
}
|
|
|
|
test("parses only the versioned stable appearance contract", () => {
|
|
const valid = {
|
|
version: 1,
|
|
theme: "houston",
|
|
accent: "#a855f7",
|
|
followSystem: false,
|
|
};
|
|
assert.deepEqual(parseCommunityThemePreference(valid), valid);
|
|
assert.equal(parseCommunityThemePreference({ ...valid, version: 2 }), null);
|
|
assert.equal(
|
|
parseCommunityThemePreference({ ...valid, theme: "future-theme" }),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
parseCommunityThemePreference({ ...valid, accent: "url(image)" }),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
parseCommunityThemePreference({ ...valid, followSystem: "false" }),
|
|
null,
|
|
);
|
|
});
|
|
|
|
test("local preferences are isolated by pubkey and normalized relay", () => {
|
|
globalThis.window = { localStorage: localStorageStub() };
|
|
const aliceA = {
|
|
...DEFAULT_COMMUNITY_THEME,
|
|
theme: "houston",
|
|
followSystem: false,
|
|
};
|
|
const aliceB = { ...DEFAULT_COMMUNITY_THEME, theme: "catppuccin-latte" };
|
|
const bobA = { ...DEFAULT_COMMUNITY_THEME, accent: "#ef4444" };
|
|
assert.equal(
|
|
writeCommunityThemePreference("alice", "WSS://A.EXAMPLE/", aliceA),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
writeCommunityThemePreference("alice", "wss://b.example", aliceB),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
writeCommunityThemePreference("bob", "wss://a.example", bobA),
|
|
true,
|
|
);
|
|
assert.deepEqual(
|
|
readCommunityThemePreference("alice", "wss://a.example"),
|
|
aliceA,
|
|
);
|
|
assert.deepEqual(
|
|
readCommunityThemePreference("alice", "wss://b.example/"),
|
|
aliceB,
|
|
);
|
|
assert.deepEqual(
|
|
readCommunityThemePreference("bob", "wss://a.example"),
|
|
bobA,
|
|
);
|
|
assert.notEqual(
|
|
communityThemeStorageKey("alice", "wss://a.example"),
|
|
communityThemeStorageKey("alice", "wss://b.example"),
|
|
);
|
|
});
|
|
|
|
test("dirty outbox survives restart and clears only its exact revision", () => {
|
|
globalThis.window = { localStorage: localStorageStub() };
|
|
const first = { ...DEFAULT_COMMUNITY_THEME, theme: "houston" };
|
|
const second = { ...DEFAULT_COMMUNITY_THEME, accent: "#ef4444" };
|
|
|
|
assert.equal(
|
|
writeCommunityThemeOutbox("alice", "WSS://A.EXAMPLE/", first),
|
|
true,
|
|
);
|
|
assert.deepEqual(readCommunityThemeOutbox("alice", "wss://a.example"), first);
|
|
writeCommunityThemeOutbox("alice", "wss://a.example", second);
|
|
clearCommunityThemeOutbox("alice", "wss://a.example", first);
|
|
assert.deepEqual(
|
|
readCommunityThemeOutbox("alice", "wss://a.example"),
|
|
second,
|
|
);
|
|
clearCommunityThemeOutbox("alice", "wss://a.example", second);
|
|
assert.equal(readCommunityThemeOutbox("alice", "wss://a.example"), null);
|
|
assert.notEqual(
|
|
communityThemeOutboxKey("alice", "wss://a.example"),
|
|
communityThemeStorageKey("alice", "wss://a.example"),
|
|
);
|
|
});
|
|
|
|
test("malformed local data returns null so switching can apply the safe default", () => {
|
|
globalThis.window = { localStorage: localStorageStub() };
|
|
const key = communityThemeStorageKey("alice", "wss://broken.example");
|
|
window.localStorage.setItem(
|
|
key,
|
|
JSON.stringify({ version: 1, theme: "missing" }),
|
|
);
|
|
assert.equal(
|
|
readCommunityThemePreference("alice", "wss://broken.example"),
|
|
null,
|
|
);
|
|
window.localStorage.setItem(key, "{");
|
|
assert.equal(
|
|
readCommunityThemePreference("alice", "wss://broken.example"),
|
|
null,
|
|
);
|
|
});
|
|
|
|
test("remote preference still applies when its local cache write fails", () => {
|
|
globalThis.window = {
|
|
localStorage: {
|
|
getItem: () => null,
|
|
setItem: () => {
|
|
throw new Error("quota exceeded");
|
|
},
|
|
},
|
|
};
|
|
let applied = null;
|
|
cacheAndApplyCommunityTheme(
|
|
"alice",
|
|
"wss://a.example",
|
|
DEFAULT_COMMUNITY_THEME,
|
|
(preference) => {
|
|
applied = preference;
|
|
},
|
|
);
|
|
assert.deepEqual(applied, DEFAULT_COMMUNITY_THEME);
|
|
});
|
|
|
|
test("already-applied relay state leaves the next user edit publishable", () => {
|
|
const applied = {
|
|
...DEFAULT_COMMUNITY_THEME,
|
|
theme: "catppuccin-latte",
|
|
followSystem: false,
|
|
};
|
|
|
|
assert.equal(communityThemeApplyExpectation(applied, applied), null);
|
|
assert.deepEqual(
|
|
communityThemeApplyExpectation(applied, DEFAULT_COMMUNITY_THEME),
|
|
applied,
|
|
);
|
|
});
|
|
|
|
test("no-op initialization remains programmatic", () => {
|
|
const expectation = communityThemeApplyExpectation(
|
|
DEFAULT_COMMUNITY_THEME,
|
|
DEFAULT_COMMUNITY_THEME,
|
|
true,
|
|
);
|
|
|
|
assert.equal(
|
|
communityThemePersistenceAction(expectation, DEFAULT_COMMUNITY_THEME),
|
|
"acknowledge",
|
|
);
|
|
});
|
|
|
|
test("confirmed first-community migration isolates later empty scopes", () => {
|
|
const inherited = {
|
|
...DEFAULT_COMMUNITY_THEME,
|
|
theme: "dracula",
|
|
followSystem: false,
|
|
};
|
|
|
|
assert.deepEqual(communityThemeScopeFallback(false, inherited), inherited);
|
|
assert.deepEqual(
|
|
communityThemeScopeFallback(true, inherited),
|
|
DEFAULT_COMMUNITY_THEME,
|
|
);
|
|
});
|
|
|
|
test("community switch defers stale outgoing appearance persistence", () => {
|
|
const outgoing = {
|
|
...DEFAULT_COMMUNITY_THEME,
|
|
theme: "houston",
|
|
followSystem: false,
|
|
};
|
|
const incoming = {
|
|
...DEFAULT_COMMUNITY_THEME,
|
|
theme: "catppuccin-latte",
|
|
};
|
|
|
|
assert.equal(communityThemePersistenceAction(incoming, outgoing), "defer");
|
|
assert.equal(
|
|
communityThemePersistenceAction(incoming, incoming),
|
|
"acknowledge",
|
|
);
|
|
assert.equal(communityThemePersistenceAction(null, incoming), "persist");
|
|
});
|