From 90428f2dcced2caadc0a8bc5822aa7e889fa6e18 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Tue, 28 Jul 2026 07:19:37 -0700 Subject: [PATCH 1/9] feat(desktop): sync themes per community Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/app/App.tsx | 2 + desktop/src/shared/constants/kinds.ts | 1 + .../shared/theme/CommunityThemeController.tsx | 158 ++++++++++++++++ desktop/src/shared/theme/ThemeProvider.tsx | 31 ++++ .../theme/communityThemePreference.test.mjs | 119 ++++++++++++ .../shared/theme/communityThemePreference.ts | 125 +++++++++++++ .../shared/theme/communityThemeSync.test.mjs | 117 ++++++++++++ .../src/shared/theme/communityThemeSync.ts | 172 ++++++++++++++++++ 8 files changed, 725 insertions(+) create mode 100644 desktop/src/shared/theme/CommunityThemeController.tsx create mode 100644 desktop/src/shared/theme/communityThemePreference.test.mjs create mode 100644 desktop/src/shared/theme/communityThemePreference.ts create mode 100644 desktop/src/shared/theme/communityThemeSync.test.mjs create mode 100644 desktop/src/shared/theme/communityThemeSync.ts diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 44618f2c72..106430fcbf 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -18,6 +18,7 @@ import { } from "@/app/communityViewTransition"; import { deriveShellRoute } from "@/app/AppShell.helpers"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; +import { CommunityThemeController } from "@/shared/theme/CommunityThemeController"; import { useReloadShortcut } from "@/app/useReloadShortcut"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; @@ -546,6 +547,7 @@ function CommunityApp({ if (appContent === null && (!transaction || isEnteringCurtain)) { appContent = communityApplied ? ( + (null); + const scopeRef = useRef(""); + const expectedAppliedRef = useRef(null); + const lastRemoteRef = useRef({ createdAt: 0, eventId: "" }); + const initialPreferenceRef = useRef({ + version: 1, + theme: theme.selectedThemeName as CommunityThemePreference["theme"], + accent: theme.accentColor, + followSystem: theme.followSystem, + }); + + const applyPreference = useCallback( + (preference: CommunityThemePreference) => { + expectedAppliedRef.current = preference; + theme.applyAppearance(preference); + }, + [theme.applyAppearance], + ); + + useLayoutEffect(() => { + if (!pubkey || !relayUrl) return; + const local = readCommunityThemePreference(pubkey, relayUrl); + // 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 = hasMigratedCommunityTheme(pubkey) + ? DEFAULT_COMMUNITY_THEME + : initialPreferenceRef.current; + applyPreference(local ?? fallback); + }, [pubkey, relayUrl, applyPreference]); + + useEffect(() => { + if (!pubkey || !relayUrl) return; + const scope = `${pubkey}:${relayUrl}`; + scopeRef.current = scope; + lastRemoteRef.current = { createdAt: 0, eventId: "" }; + const manager = new CommunityThemeSyncManager(pubkey); + managerRef.current = manager; + + const applyRemote = (remote: RemoteCommunityTheme) => { + if (scopeRef.current !== scope) return; + const last = lastRemoteRef.current; + if ( + remote.createdAt < last.createdAt || + (remote.createdAt === last.createdAt && remote.eventId <= last.eventId) + ) { + return; + } + lastRemoteRef.current = { + createdAt: remote.createdAt, + eventId: remote.eventId, + }; + manager.cancelPendingPublish(); + cacheAndApplyCommunityTheme( + pubkey, + relayUrl, + remote.preference, + applyPreference, + ); + }; + + void manager.fetchRemote().then((result) => { + if (scopeRef.current !== scope) return; + if (result.status === "valid") { + applyRemote(result.remote); + markCommunityThemeMigrated(pubkey); + } else if (shouldSeedCommunityTheme(result)) { + const local = + readCommunityThemePreference(pubkey, relayUrl) ?? + initialPreferenceRef.current; + writeCommunityThemePreference(pubkey, relayUrl, local); + markCommunityThemeMigrated(pubkey); + manager.publish(local); + } + // Invalid/future or unavailable records use the already-applied local + // fallback without publishing over relay state we cannot safely read. + }); + + let unsubscribe: (() => Promise) | null = null; + void manager.subscribe(applyRemote).then((dispose) => { + if (scopeRef.current !== scope) void dispose(); + else unsubscribe = dispose; + }); + const unsubscribeReconnect = relayClient.subscribeToReconnects(() => { + void manager.fetchRemote().then((result) => { + if (result.status === "valid") { + applyRemote(result.remote); + return; + } + if (result.status !== "absent") return; + const pending = manager.getPending(); + if (pending) manager.publish(pending); + }); + }); + + return () => { + if (scopeRef.current === scope) scopeRef.current = ""; + manager.destroy(); + if (managerRef.current === manager) managerRef.current = null; + unsubscribeReconnect(); + if (unsubscribe) void unsubscribe(); + }; + }, [pubkey, relayUrl, applyPreference]); + + useEffect(() => { + if (!pubkey || !relayUrl) return; + const preference: CommunityThemePreference = { + version: 1, + theme: theme.selectedThemeName as CommunityThemePreference["theme"], + accent: theme.accentColor, + followSystem: theme.followSystem, + }; + const expected = expectedAppliedRef.current; + if (expected && sameCommunityThemePreference(expected, preference)) { + expectedAppliedRef.current = null; + return; + } + const stored = readCommunityThemePreference(pubkey, relayUrl); + if (stored && sameCommunityThemePreference(stored, preference)) return; + if (!writeCommunityThemePreference(pubkey, relayUrl, preference)) return; + managerRef.current?.publish(preference); + }, [ + pubkey, + relayUrl, + theme.selectedThemeName, + theme.accentColor, + theme.followSystem, + ]); + + return null; +} diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx index d656815091..4e8cee2ed4 100644 --- a/desktop/src/shared/theme/ThemeProvider.tsx +++ b/desktop/src/shared/theme/ThemeProvider.tsx @@ -58,6 +58,11 @@ type ThemeContextValue = { setTheme: (name: string) => void; setAccentColor: (color: string) => void; setFollowSystem: (enabled: boolean) => void; + applyAppearance: (appearance: { + theme: SyntaxThemeName; + accent: string; + followSystem: boolean; + }) => void; }; type ThemeProviderProps = { @@ -611,6 +616,31 @@ export function ThemeProvider({ setFollowSystemState(enabled); }, []); + const applyAppearance = useCallback( + (appearance: { + theme: SyntaxThemeName; + accent: string; + followSystem: boolean; + }) => { + // Write the complete preference before updating state so applyTheme reads + // the target community's accent in the same batch, never the previous one. + try { + window.localStorage.setItem(THEME_STORAGE_KEY, appearance.theme); + window.localStorage.setItem(ACCENT_STORAGE_KEY, appearance.accent); + window.localStorage.setItem( + FOLLOW_SYSTEM_KEY, + appearance.followSystem ? "true" : "false", + ); + } catch { + // Keep the active appearance responsive even if the local cache is full. + } + setSelectedTheme(appearance.theme); + setAccentColorState(appearance.accent); + setFollowSystemState(appearance.followSystem); + }, + [], + ); + const value: ThemeContextValue = { themeName: effectiveTheme, selectedThemeName: selectedTheme, @@ -622,6 +652,7 @@ export function ThemeProvider({ setTheme, setAccentColor, setFollowSystem, + applyAppearance, }; return ( diff --git a/desktop/src/shared/theme/communityThemePreference.test.mjs b/desktop/src/shared/theme/communityThemePreference.test.mjs new file mode 100644 index 0000000000..338cba48c1 --- /dev/null +++ b/desktop/src/shared/theme/communityThemePreference.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + DEFAULT_COMMUNITY_THEME, + cacheAndApplyCommunityTheme, + communityThemeStorageKey, + parseCommunityThemePreference, + readCommunityThemePreference, + 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)), + }; +} + +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("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); +}); diff --git a/desktop/src/shared/theme/communityThemePreference.ts b/desktop/src/shared/theme/communityThemePreference.ts new file mode 100644 index 0000000000..ffc47118f6 --- /dev/null +++ b/desktop/src/shared/theme/communityThemePreference.ts @@ -0,0 +1,125 @@ +import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { ACCENT_COLORS } from "./ThemeProvider"; +import { SYNTAX_THEMES, type SyntaxThemeName } from "./theme-loader"; + +const STORAGE_KEY_PREFIX = "buzz-community-theme.v1"; +const MIGRATION_KEY_PREFIX = "buzz-community-theme-migrated.v1"; + +export type CommunityThemePreference = { + version: 1; + theme: SyntaxThemeName; + accent: string; + followSystem: boolean; +}; + +export const DEFAULT_COMMUNITY_THEME: CommunityThemePreference = Object.freeze({ + version: 1, + theme: "buzz", + accent: "#3b82f6", + followSystem: true, +}); + +const THEME_NAMES = new Set(SYNTAX_THEMES); +const ACCENTS = new Set(ACCENT_COLORS.map(({ value }) => value)); + +export function communityThemeStorageKey( + pubkey: string, + relayUrl: string, +): string { + return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + +export function parseCommunityThemePreference( + value: unknown, +): CommunityThemePreference | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + const candidate = value as Record; + if ( + candidate.version !== 1 || + typeof candidate.theme !== "string" || + !THEME_NAMES.has(candidate.theme) || + typeof candidate.accent !== "string" || + !ACCENTS.has(candidate.accent) || + typeof candidate.followSystem !== "boolean" + ) { + return null; + } + return { + version: 1, + theme: candidate.theme as SyntaxThemeName, + accent: candidate.accent, + followSystem: candidate.followSystem, + }; +} + +export function readCommunityThemePreference( + pubkey: string, + relayUrl: string, +): CommunityThemePreference | null { + try { + const raw = window.localStorage.getItem( + communityThemeStorageKey(pubkey, relayUrl), + ); + return raw ? parseCommunityThemePreference(JSON.parse(raw)) : null; + } catch { + return null; + } +} + +export function hasMigratedCommunityTheme(pubkey: string): boolean { + try { + return ( + window.localStorage.getItem(`${MIGRATION_KEY_PREFIX}:${pubkey}`) === + "true" + ); + } catch { + return false; + } +} + +export function markCommunityThemeMigrated(pubkey: string): void { + try { + window.localStorage.setItem(`${MIGRATION_KEY_PREFIX}:${pubkey}`, "true"); + } catch { + // The preference itself remains usable in memory when storage is full. + } +} + +export function writeCommunityThemePreference( + pubkey: string, + relayUrl: string, + preference: CommunityThemePreference, +): boolean { + try { + window.localStorage.setItem( + communityThemeStorageKey(pubkey, relayUrl), + JSON.stringify(preference), + ); + return true; + } catch { + return false; + } +} + +export function cacheAndApplyCommunityTheme( + pubkey: string, + relayUrl: string, + preference: CommunityThemePreference, + apply: (preference: CommunityThemePreference) => void, +): void { + writeCommunityThemePreference(pubkey, relayUrl, preference); + apply(preference); +} + +export function sameCommunityThemePreference( + left: CommunityThemePreference, + right: CommunityThemePreference, +): boolean { + return ( + left.theme === right.theme && + left.accent === right.accent && + left.followSystem === right.followSystem + ); +} diff --git a/desktop/src/shared/theme/communityThemeSync.test.mjs b/desktop/src/shared/theme/communityThemeSync.test.mjs new file mode 100644 index 0000000000..d85a8cf39d --- /dev/null +++ b/desktop/src/shared/theme/communityThemeSync.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; +import { relayClient } from "@/shared/api/relayClient"; +import { + CommunityThemeSyncManager, + shouldSeedCommunityTheme, +} from "./communityThemeSync.ts"; + +const preference = { + version: 1, + theme: "houston", + accent: "#3b82f6", + followSystem: false, +}; + +function installFakeTimer() { + globalThis.window ??= {}; + let callback = null; + const originalSet = window.setTimeout; + const originalClear = window.clearTimeout; + window.setTimeout = (fn) => { + callback = fn; + return 1; + }; + window.clearTimeout = () => { + callback = null; + }; + return { + fire: () => { + const fn = callback; + callback = null; + fn?.(); + }, + pending: () => callback !== null, + restore: () => { + window.setTimeout = originalSet; + window.clearTimeout = originalClear; + }, + }; +} + +test("destroy cancels a debounced community write before relay teardown", () => { + const timer = installFakeTimer(); + const publishes = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishes.push(args); + return Promise.resolve(); + }); + try { + const manager = new CommunityThemeSyncManager("alice"); + manager.publish(preference); + assert.equal(timer.pending(), true); + manager.destroy(); + assert.equal(timer.pending(), false); + timer.fire(); + assert.equal(publishes.length, 0); + } finally { + timer.restore(); + mock.reset(); + } +}); + +test("destroy is safe without a pending community write", () => { + const manager = new CommunityThemeSyncManager("alice"); + assert.doesNotThrow(() => manager.destroy()); + assert.equal(manager.getPending(), null); +}); + +function relayEvent(overrides = {}) { + return { + id: "event-id", + pubkey: "alice", + kind: 30078, + content: "not-decryptable", + created_at: 123, + tags: [["d", "community-theme"]], + ...overrides, + }; +} + +test("fetch distinguishes absent remote state from unreadable existing state", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + try { + const manager = new CommunityThemeSyncManager("alice"); + assert.deepEqual(await manager.fetchRemote(), { status: "absent" }); + } finally { + mock.reset(); + } + + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([relayEvent()]), + ); + try { + const manager = new CommunityThemeSyncManager("alice"); + assert.deepEqual(await manager.fetchRemote(), { status: "invalid" }); + } finally { + mock.reset(); + } +}); + +test("fetch reports relay failures as unavailable rather than absent", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("offline")), + ); + try { + const manager = new CommunityThemeSyncManager("alice"); + assert.deepEqual(await manager.fetchRemote(), { status: "unavailable" }); + } finally { + mock.reset(); + } +}); + +test("only confirmed absence permits seeding relay state", () => { + assert.equal(shouldSeedCommunityTheme({ status: "absent" }), true); + assert.equal(shouldSeedCommunityTheme({ status: "invalid" }), false); + assert.equal(shouldSeedCommunityTheme({ status: "unavailable" }), false); +}); diff --git a/desktop/src/shared/theme/communityThemeSync.ts b/desktop/src/shared/theme/communityThemeSync.ts new file mode 100644 index 0000000000..82d6b21f10 --- /dev/null +++ b/desktop/src/shared/theme/communityThemeSync.ts @@ -0,0 +1,172 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { + nip44DecryptFromSelf, + nip44EncryptToSelf, + signRelayEvent, +} from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_COMMUNITY_THEME } from "@/shared/constants/kinds"; +import { + parseCommunityThemePreference, + sameCommunityThemePreference, + type CommunityThemePreference, +} from "./communityThemePreference"; + +const D_TAG = "community-theme"; +const DEBOUNCE_MS = 2_000; + +export type RemoteCommunityTheme = { + preference: CommunityThemePreference; + createdAt: number; + eventId: string; +}; + +export type RemoteCommunityThemeResult = + | { status: "valid"; remote: RemoteCommunityTheme } + | { status: "absent" | "invalid" | "unavailable" }; + +export function shouldSeedCommunityTheme( + result: RemoteCommunityThemeResult, +): boolean { + return result.status === "absent"; +} + +async function decryptAndParse( + event: RelayEvent, +): Promise { + try { + const plaintext = await nip44DecryptFromSelf(event.content); + const preference = parseCommunityThemePreference(JSON.parse(plaintext)); + return preference + ? { preference, createdAt: event.created_at, eventId: event.id } + : null; + } catch { + return null; + } +} + +export class CommunityThemeSyncManager { + private readonly pubkey: string; + private debounceTimer: number | null = null; + private destroyed = false; + private lastRemoteCreatedAt = 0; + private lastPublished: CommunityThemePreference | null = null; + private pending: CommunityThemePreference | null = null; + + constructor(pubkey: string) { + this.pubkey = pubkey; + } + + async fetchRemote(): Promise { + try { + const events = await relayClient.fetchEvents({ + kinds: [KIND_COMMUNITY_THEME], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 1, + }); + if (events.length === 0) return { status: "absent" }; + if (events[0].pubkey !== this.pubkey) return { status: "invalid" }; + const remote = await decryptAndParse(events[0]); + if (!remote) return { status: "invalid" }; + this.lastRemoteCreatedAt = Math.max( + this.lastRemoteCreatedAt, + remote.createdAt, + ); + return { status: "valid", remote }; + } catch { + return { status: "unavailable" }; + } + } + + publish(preference: CommunityThemePreference): void { + if (this.destroyed) return; + this.pending = preference; + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + } + this.debounceTimer = window.setTimeout(() => { + this.debounceTimer = null; + void this.doPublish(preference); + }, DEBOUNCE_MS); + } + + getPending(): CommunityThemePreference | null { + return this.pending; + } + + cancelPendingPublish(): void { + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + this.pending = null; + } + + private async doPublish(preference: CommunityThemePreference): Promise { + try { + if ( + this.destroyed || + (this.lastPublished && + sameCommunityThemePreference(this.lastPublished, preference)) + ) { + this.pending = null; + return; + } + const ciphertext = await nip44EncryptToSelf(JSON.stringify(preference)); + if (this.destroyed) return; + const event = await signRelayEvent({ + kind: KIND_COMMUNITY_THEME, + content: ciphertext, + createdAt: Math.max( + Math.floor(Date.now() / 1_000), + this.lastRemoteCreatedAt + 1, + ), + tags: [ + ["d", D_TAG], + ["t", D_TAG], + ], + }); + if (this.destroyed) return; + await relayClient.publishEvent( + event, + "Timed out publishing community theme.", + "Failed to publish community theme.", + ); + this.lastRemoteCreatedAt = event.created_at; + this.lastPublished = preference; + this.pending = null; + } catch (error) { + console.warn("[communityThemeSync] publish failed:", error); + } + } + + async subscribe( + onUpdate: (remote: RemoteCommunityTheme) => void, + ): Promise<() => Promise> { + return relayClient.subscribeLive( + { + kinds: [KIND_COMMUNITY_THEME], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 0, + }, + (event: RelayEvent) => { + if (event.pubkey !== this.pubkey || this.destroyed) return; + void decryptAndParse(event).then((remote) => { + if (!remote || this.destroyed) return; + this.lastRemoteCreatedAt = Math.max( + this.lastRemoteCreatedAt, + remote.createdAt, + ); + onUpdate(remote); + }); + }, + ); + } + + destroy(): void { + this.destroyed = true; + this.cancelPendingPublish(); + } +} From 69bc4faed6c29b1b94170028f9751e1ca8aa408a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 29 Jul 2026 15:50:21 -0700 Subject: [PATCH 2/9] feat(desktop): clarify per-community theming scope in Appearance settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theme, mode, and accent are saved per community (CommunityThemeController), but the Appearance panel gave no hint of that scoping. Users switching communities would see their theme "change on its own" with no explanation. - Add a "Theme (per community)" SectionHeader above the mode selector, theme grid, and accent picker in SettingsPanels.tsx, with an inline outline Badge naming the active community (truncated at max-w-56, normal-case override of the uppercase badge base style) - Qualify the global Thread layout row with a muted "(all communities)" suffix so its scope contrasts with the per-community controls above - Gate both scoping labels on communities.length > 1 — with a single community there is nothing to disambiguate, and the panel renders exactly as it did before the per-community sync feature - Extract appearanceCommunityLabel into features/settings/lib/appearanceScopeCopy.ts (trims whitespace, falls back to "this community" when no community is active or the name is blank) with node:test coverage in appearanceScopeCopy.test.mjs - No e2e changes needed: specs only reference the unchanged "Appearance" heading and appearance-mode-* / settings-theme test IDs, and the mock boot path seeds one community so the gated labels do not render there Verified: pnpm test (3,733 passed), pnpm typecheck, pnpm check all clean; visual states captured via just desktop-screenshot. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../settings/lib/appearanceScopeCopy.test.mjs | 19 +++++++ .../settings/lib/appearanceScopeCopy.ts | 20 +++++++ .../features/settings/ui/SettingsPanels.tsx | 54 ++++++++++++++++++- 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/settings/lib/appearanceScopeCopy.test.mjs create mode 100644 desktop/src/features/settings/lib/appearanceScopeCopy.ts diff --git a/desktop/src/features/settings/lib/appearanceScopeCopy.test.mjs b/desktop/src/features/settings/lib/appearanceScopeCopy.test.mjs new file mode 100644 index 0000000000..94a554b8c2 --- /dev/null +++ b/desktop/src/features/settings/lib/appearanceScopeCopy.test.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { appearanceCommunityLabel } from "./appearanceScopeCopy.ts"; + +test("labels the active community by name", () => { + assert.equal(appearanceCommunityLabel("Block Builders"), "Block Builders"); +}); + +test("trims surrounding whitespace from the community name", () => { + assert.equal(appearanceCommunityLabel(" Buzz HQ "), "Buzz HQ"); +}); + +test("falls back to a generic label when no community is active", () => { + assert.equal(appearanceCommunityLabel(null), "this community"); + assert.equal(appearanceCommunityLabel(undefined), "this community"); + assert.equal(appearanceCommunityLabel(""), "this community"); + assert.equal(appearanceCommunityLabel(" "), "this community"); +}); diff --git a/desktop/src/features/settings/lib/appearanceScopeCopy.ts b/desktop/src/features/settings/lib/appearanceScopeCopy.ts new file mode 100644 index 0000000000..42de7f7fd4 --- /dev/null +++ b/desktop/src/features/settings/lib/appearanceScopeCopy.ts @@ -0,0 +1,20 @@ +/** + * Copy helper for the Appearance settings panel's per-community scoping. + * + * Theme, mode, and accent are saved per community (see + * `shared/theme/CommunityThemeController`), so the panel badges the community + * being customized. Kept as a pure function so the copy is unit-testable + * without rendering the settings tree. + */ + +/** + * Display label for the community whose appearance is being edited. + * Falls back to a generic phrase when no community is active or the + * stored name is blank. + */ +export function appearanceCommunityLabel( + communityName: string | null | undefined, +): string { + const trimmed = communityName?.trim(); + return trimmed ? trimmed : "this community"; +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index e74d1f3837..11c3a6609a 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -37,7 +37,10 @@ import { type ThreadViewMode, } from "@/features/channels/lib/threadViewModePreference"; import { cn } from "@/shared/lib/cn"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; +import { SectionHeader } from "@/shared/ui/PageHeader"; import { DropdownMenu, DropdownMenuContent, @@ -68,6 +71,7 @@ import { useThemePreviewVars, withAccentPreviewVars, } from "@/shared/theme/useThemePreviewVars"; +import { appearanceCommunityLabel } from "../lib/appearanceScopeCopy"; import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard"; import { HarnessesSettingsPanel } from "./HarnessesSettingsPanel"; import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard"; @@ -420,6 +424,13 @@ function ThemeSettingsCard() { setFollowSystem, } = useTheme(); + // Per-community scoping labels only earn their place when the user is + // actually in more than one community; with a single community there is + // nothing to disambiguate. + const { activeCommunity, communities } = useCommunities(); + const showCommunityScope = communities.length > 1; + const communityLabel = appearanceCommunityLabel(activeCommunity?.name); + // Buzz themes pin a neutral accent (GitHub black in light, white in dark), // so the accent picker is hidden while a Buzz theme is active. `themeName` is // the effective theme, so this also covers System mode resolving to Buzz. @@ -520,6 +531,34 @@ function ThemeSettingsCard() { description="Choose a theme for Buzz." /> + {/* Mode, theme, and accent are saved per community + (CommunityThemeController restores them on switch). When the user is + in multiple communities, a subheader with an inline badge names the + community being edited; with one community there is nothing to + disambiguate, so no scoping labels are shown. */} + {showCommunityScope ? ( + + Theme{" "} + + (per community) + + {activeCommunity ? ( + + {communityLabel} + + ) : null} + + } + /> + ) : null} + {/* Mode selector: System / Light / Dark */}
{( @@ -675,6 +714,11 @@ const THREAD_VIEW_MODE_OPTIONS: { */ function ThreadLayoutSetting() { const threadViewMode = useThreadViewMode(); + // The "(all communities)" qualifier contrasts with the per-community theme + // controls above; it's only meaningful when the user has multiple + // communities. + const { communities } = useCommunities(); + const showCommunityScope = communities.length > 1; const activeOption = THREAD_VIEW_MODE_OPTIONS.find( (option) => option.value === threadViewMode, @@ -684,7 +728,15 @@ function ThreadLayoutSetting() {
-

Thread layout

+

+ Thread layout + {showCommunityScope ? ( + + {" "} + (all communities) + + ) : null} +

{activeOption.description}

From cd1461241937cdb3bdd80eae06dbab640119d910 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 29 Jul 2026 16:22:15 -0700 Subject: [PATCH 3/9] fix(desktop): preserve themes across community switches - Defer community theme persistence while the incoming scoped appearance is still propagating through the global theme provider. - Distinguish stale, acknowledged, and persistable appearance states with a shared preference helper. - Add regression coverage for switching between communities with different themes without modifying E2E tests. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../shared/theme/CommunityThemeController.tsx | 28 +++++++++++++-- .../theme/communityThemePreference.test.mjs | 35 +++++++++++++++++++ .../shared/theme/communityThemePreference.ts | 22 ++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/desktop/src/shared/theme/CommunityThemeController.tsx b/desktop/src/shared/theme/CommunityThemeController.tsx index 57686a4af1..9942389d21 100644 --- a/desktop/src/shared/theme/CommunityThemeController.tsx +++ b/desktop/src/shared/theme/CommunityThemeController.tsx @@ -5,6 +5,8 @@ import { useIdentityQuery } from "@/shared/api/hooks"; import { DEFAULT_COMMUNITY_THEME, cacheAndApplyCommunityTheme, + communityThemeApplyExpectation, + communityThemePersistenceAction, hasMigratedCommunityTheme, markCommunityThemeMigrated, readCommunityThemePreference, @@ -36,9 +38,25 @@ export function CommunityThemeController() { followSystem: theme.followSystem, }); + const currentPreferenceRef = useRef({ + version: 1, + theme: theme.selectedThemeName as CommunityThemePreference["theme"], + accent: theme.accentColor, + followSystem: theme.followSystem, + }); + currentPreferenceRef.current = { + version: 1, + theme: theme.selectedThemeName as CommunityThemePreference["theme"], + accent: theme.accentColor, + followSystem: theme.followSystem, + }; + const applyPreference = useCallback( (preference: CommunityThemePreference) => { - expectedAppliedRef.current = preference; + expectedAppliedRef.current = communityThemeApplyExpectation( + preference, + currentPreferenceRef.current, + ); theme.applyAppearance(preference); }, [theme.applyAppearance], @@ -137,8 +155,12 @@ export function CommunityThemeController() { accent: theme.accentColor, followSystem: theme.followSystem, }; - const expected = expectedAppliedRef.current; - if (expected && sameCommunityThemePreference(expected, preference)) { + const persistenceAction = communityThemePersistenceAction( + expectedAppliedRef.current, + preference, + ); + if (persistenceAction === "defer") return; + if (persistenceAction === "acknowledge") { expectedAppliedRef.current = null; return; } diff --git a/desktop/src/shared/theme/communityThemePreference.test.mjs b/desktop/src/shared/theme/communityThemePreference.test.mjs index 338cba48c1..04199e7b36 100644 --- a/desktop/src/shared/theme/communityThemePreference.test.mjs +++ b/desktop/src/shared/theme/communityThemePreference.test.mjs @@ -3,6 +3,8 @@ import test from "node:test"; import { DEFAULT_COMMUNITY_THEME, cacheAndApplyCommunityTheme, + communityThemeApplyExpectation, + communityThemePersistenceAction, communityThemeStorageKey, parseCommunityThemePreference, readCommunityThemePreference, @@ -117,3 +119,36 @@ test("remote preference still applies when its local cache write fails", () => { ); 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("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"); +}); diff --git a/desktop/src/shared/theme/communityThemePreference.ts b/desktop/src/shared/theme/communityThemePreference.ts index ffc47118f6..9a2d130ca2 100644 --- a/desktop/src/shared/theme/communityThemePreference.ts +++ b/desktop/src/shared/theme/communityThemePreference.ts @@ -123,3 +123,25 @@ export function sameCommunityThemePreference( left.followSystem === right.followSystem ); } + +export function communityThemeApplyExpectation( + preference: CommunityThemePreference, + current: CommunityThemePreference, +): CommunityThemePreference | null { + return sameCommunityThemePreference(preference, current) ? null : preference; +} + +/** + * Decide whether the current context value is safe to persist for this scope. + * Applying a scoped preference updates the outer ThemeProvider asynchronously, + * so renders that still expose the previous scope must be deferred. + */ +export function communityThemePersistenceAction( + expectedApplied: CommunityThemePreference | null, + current: CommunityThemePreference, +): "persist" | "defer" | "acknowledge" { + if (!expectedApplied) return "persist"; + return sameCommunityThemePreference(expectedApplied, current) + ? "acknowledge" + : "defer"; +} From fc1fc5539f1f63b4f87863e2ab7976301f9c8ec1 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Thu, 30 Jul 2026 13:29:10 -0700 Subject: [PATCH 4/9] fix(desktop): preserve unsynced theme edits Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../shared/theme/CommunityThemeController.tsx | 30 +++++++++-- .../theme/communityThemePreference.test.mjs | 29 +++++++++++ .../shared/theme/communityThemePreference.ts | 52 +++++++++++++++++++ .../src/shared/theme/communityThemeSync.ts | 23 ++++++-- 4 files changed, 127 insertions(+), 7 deletions(-) diff --git a/desktop/src/shared/theme/CommunityThemeController.tsx b/desktop/src/shared/theme/CommunityThemeController.tsx index 9942389d21..8ab652fe74 100644 --- a/desktop/src/shared/theme/CommunityThemeController.tsx +++ b/desktop/src/shared/theme/CommunityThemeController.tsx @@ -5,12 +5,15 @@ import { useIdentityQuery } from "@/shared/api/hooks"; import { DEFAULT_COMMUNITY_THEME, cacheAndApplyCommunityTheme, + clearCommunityThemeOutbox, communityThemeApplyExpectation, communityThemePersistenceAction, hasMigratedCommunityTheme, markCommunityThemeMigrated, + readCommunityThemeOutbox, readCommunityThemePreference, sameCommunityThemePreference, + writeCommunityThemeOutbox, writeCommunityThemePreference, type CommunityThemePreference, } from "./communityThemePreference"; @@ -30,6 +33,7 @@ export function CommunityThemeController() { const managerRef = useRef(null); const scopeRef = useRef(""); const expectedAppliedRef = useRef(null); + const scopedPreferenceRef = useRef(null); const lastRemoteRef = useRef({ createdAt: 0, eventId: "" }); const initialPreferenceRef = useRef({ version: 1, @@ -65,13 +69,16 @@ export function CommunityThemeController() { useLayoutEffect(() => { if (!pubkey || !relayUrl) return; const local = readCommunityThemePreference(pubkey, relayUrl); + const dirty = readCommunityThemeOutbox(pubkey, relayUrl); // 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 = hasMigratedCommunityTheme(pubkey) ? DEFAULT_COMMUNITY_THEME : initialPreferenceRef.current; - applyPreference(local ?? fallback); + const scopedPreference = dirty ?? local ?? fallback; + scopedPreferenceRef.current = scopedPreference; + applyPreference(scopedPreference); }, [pubkey, relayUrl, applyPreference]); useEffect(() => { @@ -79,11 +86,20 @@ export function CommunityThemeController() { const scope = `${pubkey}:${relayUrl}`; scopeRef.current = scope; lastRemoteRef.current = { createdAt: 0, eventId: "" }; - const manager = new CommunityThemeSyncManager(pubkey); + const manager = new CommunityThemeSyncManager(pubkey, (acknowledged) => { + clearCommunityThemeOutbox(pubkey, relayUrl, acknowledged); + }); managerRef.current = manager; + const durablePending = readCommunityThemeOutbox(pubkey, relayUrl); + if (durablePending) manager.publish(durablePending); const applyRemote = (remote: RemoteCommunityTheme) => { if (scopeRef.current !== scope) return; + const dirty = readCommunityThemeOutbox(pubkey, relayUrl); + if (dirty) { + manager.publish(dirty); + return; + } const last = lastRemoteRef.current; if ( remote.createdAt < last.createdAt || @@ -95,6 +111,7 @@ export function CommunityThemeController() { createdAt: remote.createdAt, eventId: remote.eventId, }; + scopedPreferenceRef.current = remote.preference; manager.cancelPendingPublish(); cacheAndApplyCommunityTheme( pubkey, @@ -111,9 +128,12 @@ export function CommunityThemeController() { markCommunityThemeMigrated(pubkey); } else if (shouldSeedCommunityTheme(result)) { const local = + readCommunityThemeOutbox(pubkey, relayUrl) ?? readCommunityThemePreference(pubkey, relayUrl) ?? - initialPreferenceRef.current; + scopedPreferenceRef.current ?? + DEFAULT_COMMUNITY_THEME; writeCommunityThemePreference(pubkey, relayUrl, local); + writeCommunityThemeOutbox(pubkey, relayUrl, local); markCommunityThemeMigrated(pubkey); manager.publish(local); } @@ -133,7 +153,7 @@ export function CommunityThemeController() { return; } if (result.status !== "absent") return; - const pending = manager.getPending(); + const pending = readCommunityThemeOutbox(pubkey, relayUrl); if (pending) manager.publish(pending); }); }); @@ -166,7 +186,9 @@ export function CommunityThemeController() { } const stored = readCommunityThemePreference(pubkey, relayUrl); if (stored && sameCommunityThemePreference(stored, preference)) return; + scopedPreferenceRef.current = preference; if (!writeCommunityThemePreference(pubkey, relayUrl, preference)) return; + if (!writeCommunityThemeOutbox(pubkey, relayUrl, preference)) return; managerRef.current?.publish(preference); }, [ pubkey, diff --git a/desktop/src/shared/theme/communityThemePreference.test.mjs b/desktop/src/shared/theme/communityThemePreference.test.mjs index 04199e7b36..4764de06f7 100644 --- a/desktop/src/shared/theme/communityThemePreference.test.mjs +++ b/desktop/src/shared/theme/communityThemePreference.test.mjs @@ -3,11 +3,15 @@ import test from "node:test"; import { DEFAULT_COMMUNITY_THEME, cacheAndApplyCommunityTheme, + clearCommunityThemeOutbox, communityThemeApplyExpectation, + communityThemeOutboxKey, communityThemePersistenceAction, communityThemeStorageKey, parseCommunityThemePreference, + readCommunityThemeOutbox, readCommunityThemePreference, + writeCommunityThemeOutbox, writeCommunityThemePreference, } from "./communityThemePreference.ts"; @@ -16,6 +20,7 @@ function localStorageStub() { return { getItem: (key) => data.get(key) ?? null, setItem: (key, value) => data.set(key, String(value)), + removeItem: (key) => data.delete(key), }; } @@ -81,6 +86,30 @@ test("local preferences are isolated by pubkey and normalized relay", () => { ); }); +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"); diff --git a/desktop/src/shared/theme/communityThemePreference.ts b/desktop/src/shared/theme/communityThemePreference.ts index 9a2d130ca2..e525c95d49 100644 --- a/desktop/src/shared/theme/communityThemePreference.ts +++ b/desktop/src/shared/theme/communityThemePreference.ts @@ -3,6 +3,7 @@ import { ACCENT_COLORS } from "./ThemeProvider"; 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"; export type CommunityThemePreference = { @@ -29,6 +30,13 @@ export function communityThemeStorageKey( return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; } +export function communityThemeOutboxKey( + pubkey: string, + relayUrl: string, +): string { + return `${OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + export function parseCommunityThemePreference( value: unknown, ): CommunityThemePreference | null { @@ -68,6 +76,50 @@ export function readCommunityThemePreference( } } +export function readCommunityThemeOutbox( + pubkey: string, + relayUrl: string, +): CommunityThemePreference | null { + try { + const raw = window.localStorage.getItem( + communityThemeOutboxKey(pubkey, relayUrl), + ); + return raw ? parseCommunityThemePreference(JSON.parse(raw)) : null; + } catch { + return null; + } +} + +export function writeCommunityThemeOutbox( + pubkey: string, + relayUrl: string, + preference: CommunityThemePreference, +): boolean { + try { + window.localStorage.setItem( + communityThemeOutboxKey(pubkey, relayUrl), + JSON.stringify(preference), + ); + return true; + } catch { + return false; + } +} + +export function clearCommunityThemeOutbox( + pubkey: string, + relayUrl: string, + acknowledged: CommunityThemePreference, +): void { + const pending = readCommunityThemeOutbox(pubkey, relayUrl); + if (!pending || !sameCommunityThemePreference(pending, acknowledged)) return; + try { + window.localStorage.removeItem(communityThemeOutboxKey(pubkey, relayUrl)); + } catch { + // A later retry can safely publish the same replaceable event again. + } +} + export function hasMigratedCommunityTheme(pubkey: string): boolean { try { return ( diff --git a/desktop/src/shared/theme/communityThemeSync.ts b/desktop/src/shared/theme/communityThemeSync.ts index 82d6b21f10..26627df358 100644 --- a/desktop/src/shared/theme/communityThemeSync.ts +++ b/desktop/src/shared/theme/communityThemeSync.ts @@ -52,9 +52,14 @@ export class CommunityThemeSyncManager { private lastRemoteCreatedAt = 0; private lastPublished: CommunityThemePreference | null = null; private pending: CommunityThemePreference | null = null; + private readonly onPublished: (preference: CommunityThemePreference) => void; - constructor(pubkey: string) { + constructor( + pubkey: string, + onPublished: (preference: CommunityThemePreference) => void = () => {}, + ) { this.pubkey = pubkey; + this.onPublished = onPublished; } async fetchRemote(): Promise { @@ -110,7 +115,13 @@ export class CommunityThemeSyncManager { (this.lastPublished && sameCommunityThemePreference(this.lastPublished, preference)) ) { - this.pending = null; + if ( + this.pending && + sameCommunityThemePreference(this.pending, preference) + ) { + this.pending = null; + this.onPublished(preference); + } return; } const ciphertext = await nip44EncryptToSelf(JSON.stringify(preference)); @@ -135,7 +146,13 @@ export class CommunityThemeSyncManager { ); this.lastRemoteCreatedAt = event.created_at; this.lastPublished = preference; - this.pending = null; + if ( + this.pending && + sameCommunityThemePreference(this.pending, preference) + ) { + this.pending = null; + } + this.onPublished(preference); } catch (error) { console.warn("[communityThemeSync] publish failed:", error); } From 39aed13494f959f61901fd1ec797621552a12d9a Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Thu, 30 Jul 2026 15:15:50 -0700 Subject: [PATCH 5/9] fix(desktop): retry theme preference publishes Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../shared/theme/CommunityThemeController.tsx | 17 ++-- .../shared/theme/communityThemeSync.test.mjs | 89 ++++++++++++++++++- .../src/shared/theme/communityThemeSync.ts | 67 ++++++++++++-- 3 files changed, 157 insertions(+), 16 deletions(-) diff --git a/desktop/src/shared/theme/CommunityThemeController.tsx b/desktop/src/shared/theme/CommunityThemeController.tsx index 8ab652fe74..cc37535bc6 100644 --- a/desktop/src/shared/theme/CommunityThemeController.tsx +++ b/desktop/src/shared/theme/CommunityThemeController.tsx @@ -19,6 +19,7 @@ import { } from "./communityThemePreference"; import { CommunityThemeSyncManager, + isNewerCommunityThemeCoordinate, shouldSeedCommunityTheme, type RemoteCommunityTheme, } from "./communityThemeSync"; @@ -86,8 +87,15 @@ export function CommunityThemeController() { const scope = `${pubkey}:${relayUrl}`; scopeRef.current = scope; lastRemoteRef.current = { createdAt: 0, eventId: "" }; - const manager = new CommunityThemeSyncManager(pubkey, (acknowledged) => { - clearCommunityThemeOutbox(pubkey, relayUrl, acknowledged); + const manager = new CommunityThemeSyncManager(pubkey, (published) => { + const last = lastRemoteRef.current; + if (isNewerCommunityThemeCoordinate(published, last)) { + lastRemoteRef.current = { + createdAt: published.createdAt, + eventId: published.eventId, + }; + } + clearCommunityThemeOutbox(pubkey, relayUrl, published.preference); }); managerRef.current = manager; const durablePending = readCommunityThemeOutbox(pubkey, relayUrl); @@ -101,10 +109,7 @@ export function CommunityThemeController() { return; } const last = lastRemoteRef.current; - if ( - remote.createdAt < last.createdAt || - (remote.createdAt === last.createdAt && remote.eventId <= last.eventId) - ) { + if (!isNewerCommunityThemeCoordinate(remote, last)) { return; } lastRemoteRef.current = { diff --git a/desktop/src/shared/theme/communityThemeSync.test.mjs b/desktop/src/shared/theme/communityThemeSync.test.mjs index d85a8cf39d..0a0031163e 100644 --- a/desktop/src/shared/theme/communityThemeSync.test.mjs +++ b/desktop/src/shared/theme/communityThemeSync.test.mjs @@ -3,6 +3,7 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { CommunityThemeSyncManager, + isNewerCommunityThemeCoordinate, shouldSeedCommunityTheme, } from "./communityThemeSync.ts"; @@ -16,22 +17,27 @@ const preference = { function installFakeTimer() { globalThis.window ??= {}; let callback = null; + let delay = null; const originalSet = window.setTimeout; const originalClear = window.clearTimeout; - window.setTimeout = (fn) => { + window.setTimeout = (fn, requestedDelay) => { callback = fn; + delay = requestedDelay; return 1; }; window.clearTimeout = () => { callback = null; + delay = null; }; return { fire: () => { const fn = callback; callback = null; + delay = null; fn?.(); }, pending: () => callback !== null, + delay: () => delay, restore: () => { window.setTimeout = originalSet; window.clearTimeout = originalClear; @@ -115,3 +121,84 @@ test("only confirmed absence permits seeding relay state", () => { assert.equal(shouldSeedCommunityTheme({ status: "invalid" }), false); assert.equal(shouldSeedCommunityTheme({ status: "unavailable" }), false); }); + +test("acknowledged coordinates reject delayed same-second remotes", () => { + const acknowledged = { createdAt: 123, eventId: "published" }; + assert.equal( + isNewerCommunityThemeCoordinate( + { createdAt: 123, eventId: "older" }, + acknowledged, + ), + false, + ); + assert.equal( + isNewerCommunityThemeCoordinate( + { createdAt: 123, eventId: "z-newer" }, + acknowledged, + ), + true, + ); +}); + +test("transient publish failure retries and acknowledges exact event", async () => { + const timer = installFakeTimer(); + const published = []; + const acknowledgements = []; + let attempts = 0; + globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "sign_event") { + return Promise.resolve( + JSON.stringify( + relayEvent({ + id: "published-event", + content: args.content, + created_at: args.createdAt, + }), + ), + ); + } + throw new Error(`unexpected command: ${command}`); + }, + }; + mock.method(relayClient, "publishEvent", (event) => { + attempts += 1; + if (attempts === 1) return Promise.reject(new Error("timeout")); + published.push(event); + return Promise.resolve(); + }); + try { + const manager = new CommunityThemeSyncManager("alice", (event) => { + acknowledgements.push(event); + }); + manager.publish(preference); + timer.fire(); + await waitUntil(() => timer.pending()); + assert.equal(timer.delay(), 1_000); + assert.deepEqual(manager.getPending(), preference); + + timer.fire(); + await waitUntil(() => acknowledgements.length === 1); + assert.equal(attempts, 2); + assert.equal(published.length, 1); + assert.equal(manager.getPending(), null); + assert.deepEqual(acknowledgements[0], { + preference, + createdAt: published[0].created_at, + eventId: "published-event", + }); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + timer.restore(); + mock.reset(); + } +}); + +async function waitUntil(condition) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (condition()) return; + await new Promise((resolve) => setImmediate(resolve)); + } + assert.fail("condition not met"); +} diff --git a/desktop/src/shared/theme/communityThemeSync.ts b/desktop/src/shared/theme/communityThemeSync.ts index 26627df358..4738121f31 100644 --- a/desktop/src/shared/theme/communityThemeSync.ts +++ b/desktop/src/shared/theme/communityThemeSync.ts @@ -14,6 +14,14 @@ import { const D_TAG = "community-theme"; const DEBOUNCE_MS = 2_000; +const PUBLISH_RETRY_BASE_MS = 1_000; +const PUBLISH_RETRY_MAX_MS = 30_000; + +export type PublishedCommunityTheme = { + preference: CommunityThemePreference; + createdAt: number; + eventId: string; +}; export type RemoteCommunityTheme = { preference: CommunityThemePreference; @@ -25,6 +33,17 @@ export type RemoteCommunityThemeResult = | { status: "valid"; remote: RemoteCommunityTheme } | { status: "absent" | "invalid" | "unavailable" }; +export function isNewerCommunityThemeCoordinate( + candidate: { createdAt: number; eventId: string }, + current: { createdAt: number; eventId: string }, +): boolean { + return ( + candidate.createdAt > current.createdAt || + (candidate.createdAt === current.createdAt && + candidate.eventId > current.eventId) + ); +} + export function shouldSeedCommunityTheme( result: RemoteCommunityThemeResult, ): boolean { @@ -50,13 +69,14 @@ export class CommunityThemeSyncManager { private debounceTimer: number | null = null; private destroyed = false; private lastRemoteCreatedAt = 0; - private lastPublished: CommunityThemePreference | null = null; + private lastPublished: PublishedCommunityTheme | null = null; private pending: CommunityThemePreference | null = null; - private readonly onPublished: (preference: CommunityThemePreference) => void; + private publishRetryAttempt = 0; + private readonly onPublished: (published: PublishedCommunityTheme) => void; constructor( pubkey: string, - onPublished: (preference: CommunityThemePreference) => void = () => {}, + onPublished: (published: PublishedCommunityTheme) => void = () => {}, ) { this.pubkey = pubkey; this.onPublished = onPublished; @@ -87,13 +107,22 @@ export class CommunityThemeSyncManager { publish(preference: CommunityThemePreference): void { if (this.destroyed) return; this.pending = preference; + this.publishRetryAttempt = 0; + this.schedulePublish(preference, DEBOUNCE_MS); + } + + private schedulePublish( + preference: CommunityThemePreference, + delayMs: number, + ): void { + if (this.destroyed) return; if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); } this.debounceTimer = window.setTimeout(() => { this.debounceTimer = null; void this.doPublish(preference); - }, DEBOUNCE_MS); + }, delayMs); } getPending(): CommunityThemePreference | null { @@ -110,17 +139,18 @@ export class CommunityThemeSyncManager { private async doPublish(preference: CommunityThemePreference): Promise { try { + const lastPublished = this.lastPublished; if ( this.destroyed || - (this.lastPublished && - sameCommunityThemePreference(this.lastPublished, preference)) + (lastPublished && + sameCommunityThemePreference(lastPublished.preference, preference)) ) { if ( this.pending && sameCommunityThemePreference(this.pending, preference) ) { this.pending = null; - this.onPublished(preference); + if (lastPublished) this.onPublished(lastPublished); } return; } @@ -145,16 +175,35 @@ export class CommunityThemeSyncManager { "Failed to publish community theme.", ); this.lastRemoteCreatedAt = event.created_at; - this.lastPublished = preference; + const published = { + preference, + createdAt: event.created_at, + eventId: event.id, + }; + this.lastPublished = published; + this.publishRetryAttempt = 0; if ( this.pending && sameCommunityThemePreference(this.pending, preference) ) { this.pending = null; } - this.onPublished(preference); + this.onPublished(published); } catch (error) { console.warn("[communityThemeSync] publish failed:", error); + if ( + this.destroyed || + !this.pending || + !sameCommunityThemePreference(this.pending, preference) + ) { + return; + } + const delay = Math.min( + PUBLISH_RETRY_BASE_MS * 2 ** this.publishRetryAttempt, + PUBLISH_RETRY_MAX_MS, + ); + this.publishRetryAttempt += 1; + this.schedulePublish(preference, delay); } } From 3c9d4c6decb6d203510ae523f498deb607b20486 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 14:22:19 -0700 Subject: [PATCH 6/9] fix(desktop): converge theme replacements Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../shared/theme/CommunityThemeController.tsx | 11 ++-- .../shared/theme/communityThemeSync.test.mjs | 64 +++++++++++++++++-- .../src/shared/theme/communityThemeSync.ts | 17 ++++- 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/desktop/src/shared/theme/CommunityThemeController.tsx b/desktop/src/shared/theme/CommunityThemeController.tsx index cc37535bc6..518883a47e 100644 --- a/desktop/src/shared/theme/CommunityThemeController.tsx +++ b/desktop/src/shared/theme/CommunityThemeController.tsx @@ -103,11 +103,6 @@ export function CommunityThemeController() { const applyRemote = (remote: RemoteCommunityTheme) => { if (scopeRef.current !== scope) return; - const dirty = readCommunityThemeOutbox(pubkey, relayUrl); - if (dirty) { - manager.publish(dirty); - return; - } const last = lastRemoteRef.current; if (!isNewerCommunityThemeCoordinate(remote, last)) { return; @@ -116,6 +111,12 @@ export function CommunityThemeController() { createdAt: remote.createdAt, eventId: remote.eventId, }; + manager.acceptRemote(remote); + const dirty = readCommunityThemeOutbox(pubkey, relayUrl); + if (dirty) { + manager.publish(dirty); + return; + } scopedPreferenceRef.current = remote.preference; manager.cancelPendingPublish(); cacheAndApplyCommunityTheme( diff --git a/desktop/src/shared/theme/communityThemeSync.test.mjs b/desktop/src/shared/theme/communityThemeSync.test.mjs index 0a0031163e..c2de8cd8c2 100644 --- a/desktop/src/shared/theme/communityThemeSync.test.mjs +++ b/desktop/src/shared/theme/communityThemeSync.test.mjs @@ -122,24 +122,78 @@ test("only confirmed absence permits seeding relay state", () => { assert.equal(shouldSeedCommunityTheme({ status: "unavailable" }), false); }); -test("acknowledged coordinates reject delayed same-second remotes", () => { +test("acknowledged coordinates use relay same-second ordering", () => { const acknowledged = { createdAt: 123, eventId: "published" }; assert.equal( isNewerCommunityThemeCoordinate( - { createdAt: 123, eventId: "older" }, + { createdAt: 123, eventId: "a-winner" }, acknowledged, ), - false, + true, ); assert.equal( isNewerCommunityThemeCoordinate( - { createdAt: 123, eventId: "z-newer" }, + { createdAt: 123, eventId: "z-loser" }, acknowledged, ), - true, + false, ); }); +test("new remote invalidates no-op suppression for A to B to A", async () => { + const timer = installFakeTimer(); + const published = []; + const acknowledgements = []; + let signedEventId = "published-z"; + globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "sign_event") { + return Promise.resolve( + JSON.stringify( + relayEvent({ + id: signedEventId, + content: args.content, + created_at: args.createdAt, + }), + ), + ); + } + throw new Error(`unexpected command: ${command}`); + }, + }; + mock.method(relayClient, "publishEvent", (event) => { + published.push(event); + return Promise.resolve(); + }); + try { + const manager = new CommunityThemeSyncManager("alice", (event) => { + acknowledgements.push(event); + }); + manager.publish(preference); + timer.fire(); + await waitUntil(() => published.length === 1); + + manager.acceptRemote({ + preference: { ...preference, theme: "dracula" }, + createdAt: published[0].created_at, + eventId: "remote-a", + }); + signedEventId = "republished-a"; + manager.publish(preference); + timer.fire(); + await waitUntil(() => published.length === 2); + + assert.equal(acknowledgements.length, 2); + assert.equal(acknowledgements[1].eventId, "republished-a"); + assert.equal(manager.getPending(), null); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + timer.restore(); + mock.reset(); + } +}); + test("transient publish failure retries and acknowledges exact event", async () => { const timer = installFakeTimer(); const published = []; diff --git a/desktop/src/shared/theme/communityThemeSync.ts b/desktop/src/shared/theme/communityThemeSync.ts index 4738121f31..9fc97a1f94 100644 --- a/desktop/src/shared/theme/communityThemeSync.ts +++ b/desktop/src/shared/theme/communityThemeSync.ts @@ -40,7 +40,7 @@ export function isNewerCommunityThemeCoordinate( return ( candidate.createdAt > current.createdAt || (candidate.createdAt === current.createdAt && - candidate.eventId > current.eventId) + (current.eventId === "" || candidate.eventId < current.eventId)) ); } @@ -129,6 +129,21 @@ export class CommunityThemeSyncManager { return this.pending; } + acceptRemote(remote: RemoteCommunityTheme): void { + this.lastRemoteCreatedAt = Math.max( + this.lastRemoteCreatedAt, + remote.createdAt, + ); + const lastPublished = this.lastPublished; + if ( + lastPublished && + (lastPublished.createdAt !== remote.createdAt || + lastPublished.eventId !== remote.eventId) + ) { + this.lastPublished = null; + } + } + cancelPendingPublish(): void { if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); From 045cce9cd0f257d847fa2c54a3eaa8cac23988d0 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 18:22:40 -0700 Subject: [PATCH 7/9] fix(desktop): serialize theme publishes Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../shared/theme/communityThemeSync.test.mjs | 54 +++++++++++++++++++ .../src/shared/theme/communityThemeSync.ts | 29 +++++++--- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/desktop/src/shared/theme/communityThemeSync.test.mjs b/desktop/src/shared/theme/communityThemeSync.test.mjs index c2de8cd8c2..8989a0d9e2 100644 --- a/desktop/src/shared/theme/communityThemeSync.test.mjs +++ b/desktop/src/shared/theme/communityThemeSync.test.mjs @@ -194,6 +194,60 @@ test("new remote invalidates no-op suppression for A to B to A", async () => { } }); +test("serializes an in-flight publish before sending the latest edit", async () => { + const timer = installFakeTimer(); + const first = Promise.withResolvers(); + const published = []; + let signed = 0; + globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "sign_event") { + signed += 1; + return Promise.resolve( + JSON.stringify( + relayEvent({ + id: `event-${signed}`, + content: args.content, + created_at: args.createdAt, + }), + ), + ); + } + throw new Error(`unexpected command: ${command}`); + }, + }; + mock.method(relayClient, "publishEvent", (event) => { + published.push(event); + return published.length === 1 ? first.promise : Promise.resolve(); + }); + try { + const manager = new CommunityThemeSyncManager("alice"); + manager.publish(preference); + timer.fire(); + await waitUntil(() => published.length === 1); + + const latest = { ...preference, theme: "dracula" }; + manager.publish(latest); + timer.fire(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(published.length, 1); + + first.resolve(); + await waitUntil(() => timer.pending()); + assert.equal(timer.delay(), 0); + timer.fire(); + await waitUntil(() => published.length === 2); + + assert.ok(published[1].created_at > published[0].created_at); + assert.deepEqual(manager.getPending(), null); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + timer.restore(); + mock.reset(); + } +}); + test("transient publish failure retries and acknowledges exact event", async () => { const timer = installFakeTimer(); const published = []; diff --git a/desktop/src/shared/theme/communityThemeSync.ts b/desktop/src/shared/theme/communityThemeSync.ts index 9fc97a1f94..4c01355369 100644 --- a/desktop/src/shared/theme/communityThemeSync.ts +++ b/desktop/src/shared/theme/communityThemeSync.ts @@ -71,6 +71,7 @@ export class CommunityThemeSyncManager { private lastRemoteCreatedAt = 0; private lastPublished: PublishedCommunityTheme | null = null; private pending: CommunityThemePreference | null = null; + private publishInFlight = false; private publishRetryAttempt = 0; private readonly onPublished: (published: PublishedCommunityTheme) => void; @@ -108,23 +109,37 @@ export class CommunityThemeSyncManager { if (this.destroyed) return; this.pending = preference; this.publishRetryAttempt = 0; - this.schedulePublish(preference, DEBOUNCE_MS); + this.schedulePublish(DEBOUNCE_MS); } - private schedulePublish( - preference: CommunityThemePreference, - delayMs: number, - ): void { + private schedulePublish(delayMs: number): void { if (this.destroyed) return; if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); } this.debounceTimer = window.setTimeout(() => { this.debounceTimer = null; - void this.doPublish(preference); + this.startPublish(); }, delayMs); } + private startPublish(): void { + if (this.destroyed || this.publishInFlight || !this.pending) return; + this.publishInFlight = true; + const preference = this.pending; + void this.doPublish(preference).finally(() => { + this.publishInFlight = false; + if ( + !this.destroyed && + this.pending && + !sameCommunityThemePreference(this.pending, preference) && + this.debounceTimer === null + ) { + this.schedulePublish(0); + } + }); + } + getPending(): CommunityThemePreference | null { return this.pending; } @@ -218,7 +233,7 @@ export class CommunityThemeSyncManager { PUBLISH_RETRY_MAX_MS, ); this.publishRetryAttempt += 1; - this.schedulePublish(preference, delay); + this.schedulePublish(delay); } } From 52574fbf088d211fcb6a794ebf4b1f9aafaf0aba Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 10:08:09 -0700 Subject: [PATCH 8/9] fix(desktop): republish themes after remote races Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../shared/theme/communityThemeSync.test.mjs | 58 ++++++++++++++++++ .../src/shared/theme/communityThemeSync.ts | 61 +++++++++++++++---- 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/desktop/src/shared/theme/communityThemeSync.test.mjs b/desktop/src/shared/theme/communityThemeSync.test.mjs index 8989a0d9e2..9a9c71b2b8 100644 --- a/desktop/src/shared/theme/communityThemeSync.test.mjs +++ b/desktop/src/shared/theme/communityThemeSync.test.mjs @@ -248,6 +248,64 @@ test("serializes an in-flight publish before sending the latest edit", async () } }); +test("republishes above a newer remote observed while publish is in flight", async () => { + const timer = installFakeTimer(); + const first = Promise.withResolvers(); + const published = []; + const acknowledgements = []; + let signed = 0; + globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "sign_event") { + signed += 1; + return Promise.resolve( + JSON.stringify( + relayEvent({ + id: `event-${signed}`, + content: args.content, + created_at: args.createdAt, + }), + ), + ); + } + throw new Error(`unexpected command: ${command}`); + }, + }; + mock.method(relayClient, "publishEvent", (event) => { + published.push(event); + return published.length === 1 ? first.promise : Promise.resolve(); + }); + try { + const manager = new CommunityThemeSyncManager("alice", (event) => { + acknowledgements.push(event); + }); + manager.publish(preference); + timer.fire(); + await waitUntil(() => published.length === 1); + + manager.acceptRemote({ + preference: { ...preference, theme: "dracula" }, + createdAt: published[0].created_at + 100, + eventId: "remote-winner", + }); + first.resolve(); + await waitUntil(() => timer.pending()); + assert.equal(acknowledgements.length, 0); + assert.deepEqual(manager.getPending(), preference); + + timer.fire(); + await waitUntil(() => acknowledgements.length === 1); + assert.equal(published.length, 2); + assert.ok(published[1].created_at > published[0].created_at + 100); + assert.deepEqual(manager.getPending(), null); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + timer.restore(); + mock.reset(); + } +}); + test("transient publish failure retries and acknowledges exact event", async () => { const timer = installFakeTimer(); const published = []; diff --git a/desktop/src/shared/theme/communityThemeSync.ts b/desktop/src/shared/theme/communityThemeSync.ts index 4c01355369..e6b4ad7403 100644 --- a/desktop/src/shared/theme/communityThemeSync.ts +++ b/desktop/src/shared/theme/communityThemeSync.ts @@ -69,6 +69,7 @@ export class CommunityThemeSyncManager { private debounceTimer: number | null = null; private destroyed = false; private lastRemoteCreatedAt = 0; + private lastRemoteEventId = ""; private lastPublished: PublishedCommunityTheme | null = null; private pending: CommunityThemePreference | null = null; private publishInFlight = false; @@ -95,10 +96,15 @@ export class CommunityThemeSyncManager { if (events[0].pubkey !== this.pubkey) return { status: "invalid" }; const remote = await decryptAndParse(events[0]); if (!remote) return { status: "invalid" }; - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - remote.createdAt, - ); + if ( + isNewerCommunityThemeCoordinate(remote, { + createdAt: this.lastRemoteCreatedAt, + eventId: this.lastRemoteEventId, + }) + ) { + this.lastRemoteCreatedAt = remote.createdAt; + this.lastRemoteEventId = remote.eventId; + } return { status: "valid", remote }; } catch { return { status: "unavailable" }; @@ -145,10 +151,15 @@ export class CommunityThemeSyncManager { } acceptRemote(remote: RemoteCommunityTheme): void { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - remote.createdAt, - ); + if ( + isNewerCommunityThemeCoordinate(remote, { + createdAt: this.lastRemoteCreatedAt, + eventId: this.lastRemoteEventId, + }) + ) { + this.lastRemoteCreatedAt = remote.createdAt; + this.lastRemoteEventId = remote.eventId; + } const lastPublished = this.lastPublished; if ( lastPublished && @@ -204,12 +215,31 @@ export class CommunityThemeSyncManager { "Timed out publishing community theme.", "Failed to publish community theme.", ); - this.lastRemoteCreatedAt = event.created_at; const published = { preference, createdAt: event.created_at, eventId: event.id, }; + const eventLostToRemote = isNewerCommunityThemeCoordinate( + { + createdAt: this.lastRemoteCreatedAt, + eventId: this.lastRemoteEventId, + }, + published, + ); + if (eventLostToRemote) { + this.lastPublished = null; + this.publishRetryAttempt = 0; + if ( + this.pending && + sameCommunityThemePreference(this.pending, preference) + ) { + this.schedulePublish(0); + } + return; + } + this.lastRemoteCreatedAt = event.created_at; + this.lastRemoteEventId = event.id; this.lastPublished = published; this.publishRetryAttempt = 0; if ( @@ -251,10 +281,15 @@ export class CommunityThemeSyncManager { if (event.pubkey !== this.pubkey || this.destroyed) return; void decryptAndParse(event).then((remote) => { if (!remote || this.destroyed) return; - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - remote.createdAt, - ); + if ( + isNewerCommunityThemeCoordinate(remote, { + createdAt: this.lastRemoteCreatedAt, + eventId: this.lastRemoteEventId, + }) + ) { + this.lastRemoteCreatedAt = remote.createdAt; + this.lastRemoteEventId = remote.eventId; + } onUpdate(remote); }); }, From 34f5694ada8e5811c13b99214c6b452648e3a70f Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 4 Aug 2026 11:12:04 -0700 Subject: [PATCH 9/9] fix(desktop): fence theme publish acknowledgements Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../shared/theme/communityThemeSync.test.mjs | 75 +++++++++++++++++++ .../src/shared/theme/communityThemeSync.ts | 22 +++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/desktop/src/shared/theme/communityThemeSync.test.mjs b/desktop/src/shared/theme/communityThemeSync.test.mjs index 9a9c71b2b8..567319d58d 100644 --- a/desktop/src/shared/theme/communityThemeSync.test.mjs +++ b/desktop/src/shared/theme/communityThemeSync.test.mjs @@ -306,6 +306,81 @@ test("republishes above a newer remote observed while publish is in flight", asy } }); +test("delayed live decryption fences publish acknowledgement and preserves local intent", async () => { + const timer = installFakeTimer(); + const firstPublish = Promise.withResolvers(); + const remotePlaintext = Promise.withResolvers(); + const published = []; + const acknowledgements = []; + let liveCallback; + let signed = 0; + globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "nip44_decrypt_from_self") return remotePlaintext.promise; + if (command === "sign_event") { + signed += 1; + return Promise.resolve( + JSON.stringify( + relayEvent({ + id: `event-${signed}`, + content: args.content, + created_at: args.createdAt, + }), + ), + ); + } + throw new Error(`unexpected command: ${command}`); + }, + }; + mock.method(relayClient, "subscribeLive", (_filter, callback) => { + liveCallback = callback; + return Promise.resolve(async () => {}); + }); + mock.method(relayClient, "publishEvent", (event) => { + published.push(event); + return published.length === 1 ? firstPublish.promise : Promise.resolve(); + }); + try { + const manager = new CommunityThemeSyncManager("alice", (event) => { + acknowledgements.push(event); + }); + await manager.subscribe(() => {}); + manager.publish(preference); + timer.fire(); + await waitUntil(() => published.length === 1); + + liveCallback( + relayEvent({ + id: "remote-winner", + content: "delayed-ciphertext", + created_at: published[0].created_at + 100, + }), + ); + firstPublish.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(acknowledgements.length, 0); + assert.deepEqual(manager.getPending(), preference); + + remotePlaintext.resolve( + JSON.stringify({ ...preference, theme: "dracula" }), + ); + await waitUntil(() => timer.pending()); + assert.equal(acknowledgements.length, 0); + assert.deepEqual(manager.getPending(), preference); + + timer.fire(); + await waitUntil(() => acknowledgements.length === 1); + assert.equal(published.length, 2); + assert.ok(published[1].created_at > published[0].created_at + 100); + assert.deepEqual(manager.getPending(), null); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + timer.restore(); + mock.reset(); + } +}); + test("transient publish failure retries and acknowledges exact event", async () => { const timer = installFakeTimer(); const published = []; diff --git a/desktop/src/shared/theme/communityThemeSync.ts b/desktop/src/shared/theme/communityThemeSync.ts index e6b4ad7403..934e3d55ce 100644 --- a/desktop/src/shared/theme/communityThemeSync.ts +++ b/desktop/src/shared/theme/communityThemeSync.ts @@ -74,6 +74,7 @@ export class CommunityThemeSyncManager { private pending: CommunityThemePreference | null = null; private publishInFlight = false; private publishRetryAttempt = 0; + private readonly remoteProcessing = new Set>(); private readonly onPublished: (published: PublishedCommunityTheme) => void; constructor( @@ -94,7 +95,9 @@ export class CommunityThemeSyncManager { }); if (events.length === 0) return { status: "absent" }; if (events[0].pubkey !== this.pubkey) return { status: "invalid" }; - const remote = await decryptAndParse(events[0]); + const processing = decryptAndParse(events[0]); + this.trackRemoteProcessing(processing); + const remote = await processing; if (!remote) return { status: "invalid" }; if ( isNewerCommunityThemeCoordinate(remote, { @@ -215,6 +218,8 @@ export class CommunityThemeSyncManager { "Timed out publishing community theme.", "Failed to publish community theme.", ); + await this.waitForDeliveredRemotes(); + if (this.destroyed) return; const published = { preference, createdAt: event.created_at, @@ -267,6 +272,18 @@ export class CommunityThemeSyncManager { } } + private trackRemoteProcessing(task: Promise): void { + this.remoteProcessing.add(task); + void task.then( + () => this.remoteProcessing.delete(task), + () => this.remoteProcessing.delete(task), + ); + } + + private async waitForDeliveredRemotes(): Promise { + await Promise.allSettled([...this.remoteProcessing]); + } + async subscribe( onUpdate: (remote: RemoteCommunityTheme) => void, ): Promise<() => Promise> { @@ -279,7 +296,7 @@ export class CommunityThemeSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey || this.destroyed) return; - void decryptAndParse(event).then((remote) => { + const processing = decryptAndParse(event).then((remote) => { if (!remote || this.destroyed) return; if ( isNewerCommunityThemeCoordinate(remote, { @@ -292,6 +309,7 @@ export class CommunityThemeSyncManager { } onUpdate(remote); }); + this.trackRemoteProcessing(processing); }, ); }