diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 104edcbaaf..0f9085e5fc 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 { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; @@ -547,6 +548,7 @@ function CommunityApp({ if (appContent === null && (!transaction || isEnteringCurtain)) { appContent = communityApplied ? ( + { + 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 162150e7c6..901c9152b7 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -38,7 +38,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, @@ -69,6 +72,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"; @@ -429,6 +433,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. @@ -529,6 +540,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 */}
{( @@ -684,6 +723,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, @@ -693,7 +737,15 @@ function ThreadLayoutSetting() {
-

Thread layout

+

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

{activeOption.description}

diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index ef3234f4c5..f6fd3d8476 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -46,6 +46,7 @@ export const KIND_CHANNEL_SECTIONS = 30078; export const KIND_CHANNEL_MUTES = 30078; export const KIND_CHANNEL_STARS = 30078; export const KIND_CHANNEL_SORT = 30078; +export const KIND_COMMUNITY_THEME = 30078; // NIP-33 persona/team/managed-agent projection events (d-tag keyed). Published // backend-side as secrets-stripped snapshots; the inbound sync hook subscribes // to all three to patch local records. Mirror of buzz-core's KIND_PERSONA etc. diff --git a/desktop/src/shared/theme/CommunityThemeController.tsx b/desktop/src/shared/theme/CommunityThemeController.tsx new file mode 100644 index 0000000000..518883a47e --- /dev/null +++ b/desktop/src/shared/theme/CommunityThemeController.tsx @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { relayClient } from "@/shared/api/relayClient"; +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"; +import { + CommunityThemeSyncManager, + isNewerCommunityThemeCoordinate, + shouldSeedCommunityTheme, + type RemoteCommunityTheme, +} from "./communityThemeSync"; +import { useTheme } from "./ThemeProvider"; + +export function CommunityThemeController() { + const { activeCommunity } = useCommunities(); + const identity = useIdentityQuery(); + const theme = useTheme(); + const pubkey = identity.data?.pubkey; + const relayUrl = activeCommunity?.relayUrl; + 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, + theme: theme.selectedThemeName as CommunityThemePreference["theme"], + accent: theme.accentColor, + 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 = communityThemeApplyExpectation( + preference, + currentPreferenceRef.current, + ); + theme.applyAppearance(preference); + }, + [theme.applyAppearance], + ); + + 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; + const scopedPreference = dirty ?? local ?? fallback; + scopedPreferenceRef.current = scopedPreference; + applyPreference(scopedPreference); + }, [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, (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); + if (durablePending) manager.publish(durablePending); + + const applyRemote = (remote: RemoteCommunityTheme) => { + if (scopeRef.current !== scope) return; + const last = lastRemoteRef.current; + if (!isNewerCommunityThemeCoordinate(remote, last)) { + return; + } + lastRemoteRef.current = { + 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( + 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 = + readCommunityThemeOutbox(pubkey, relayUrl) ?? + readCommunityThemePreference(pubkey, relayUrl) ?? + scopedPreferenceRef.current ?? + DEFAULT_COMMUNITY_THEME; + writeCommunityThemePreference(pubkey, relayUrl, local); + writeCommunityThemeOutbox(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 = readCommunityThemeOutbox(pubkey, relayUrl); + 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 persistenceAction = communityThemePersistenceAction( + expectedAppliedRef.current, + preference, + ); + if (persistenceAction === "defer") return; + if (persistenceAction === "acknowledge") { + expectedAppliedRef.current = null; + return; + } + 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, + 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 5704676b5c..4596694345 100644 --- a/desktop/src/shared/theme/ThemeProvider.tsx +++ b/desktop/src/shared/theme/ThemeProvider.tsx @@ -60,6 +60,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 = { @@ -618,6 +623,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, @@ -630,6 +660,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..4764de06f7 --- /dev/null +++ b/desktop/src/shared/theme/communityThemePreference.test.mjs @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + DEFAULT_COMMUNITY_THEME, + cacheAndApplyCommunityTheme, + clearCommunityThemeOutbox, + communityThemeApplyExpectation, + communityThemeOutboxKey, + communityThemePersistenceAction, + 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("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 new file mode 100644 index 0000000000..e525c95d49 --- /dev/null +++ b/desktop/src/shared/theme/communityThemePreference.ts @@ -0,0 +1,199 @@ +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 OUTBOX_KEY_PREFIX = "buzz-community-theme-outbox.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 communityThemeOutboxKey( + pubkey: string, + relayUrl: string, +): string { + return `${OUTBOX_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 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 ( + 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 + ); +} + +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"; +} diff --git a/desktop/src/shared/theme/communityThemeSync.test.mjs b/desktop/src/shared/theme/communityThemeSync.test.mjs new file mode 100644 index 0000000000..567319d58d --- /dev/null +++ b/desktop/src/shared/theme/communityThemeSync.test.mjs @@ -0,0 +1,445 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; +import { relayClient } from "@/shared/api/relayClient"; +import { + CommunityThemeSyncManager, + isNewerCommunityThemeCoordinate, + shouldSeedCommunityTheme, +} from "./communityThemeSync.ts"; + +const preference = { + version: 1, + theme: "houston", + accent: "#3b82f6", + followSystem: false, +}; + +function installFakeTimer() { + globalThis.window ??= {}; + let callback = null; + let delay = null; + const originalSet = window.setTimeout; + const originalClear = window.clearTimeout; + 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; + }, + }; +} + +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); +}); + +test("acknowledged coordinates use relay same-second ordering", () => { + const acknowledged = { createdAt: 123, eventId: "published" }; + assert.equal( + isNewerCommunityThemeCoordinate( + { createdAt: 123, eventId: "a-winner" }, + acknowledged, + ), + true, + ); + assert.equal( + isNewerCommunityThemeCoordinate( + { createdAt: 123, eventId: "z-loser" }, + acknowledged, + ), + 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("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("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("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 = []; + 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 new file mode 100644 index 0000000000..934e3d55ce --- /dev/null +++ b/desktop/src/shared/theme/communityThemeSync.ts @@ -0,0 +1,321 @@ +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; +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; + createdAt: number; + eventId: string; +}; + +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 && + (current.eventId === "" || candidate.eventId < current.eventId)) + ); +} + +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 lastRemoteEventId = ""; + private lastPublished: PublishedCommunityTheme | null = null; + private pending: CommunityThemePreference | null = null; + private publishInFlight = false; + private publishRetryAttempt = 0; + private readonly remoteProcessing = new Set>(); + private readonly onPublished: (published: PublishedCommunityTheme) => void; + + constructor( + pubkey: string, + onPublished: (published: PublishedCommunityTheme) => void = () => {}, + ) { + this.pubkey = pubkey; + this.onPublished = onPublished; + } + + 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 processing = decryptAndParse(events[0]); + this.trackRemoteProcessing(processing); + const remote = await processing; + if (!remote) return { status: "invalid" }; + 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" }; + } + } + + publish(preference: CommunityThemePreference): void { + if (this.destroyed) return; + this.pending = preference; + this.publishRetryAttempt = 0; + this.schedulePublish(DEBOUNCE_MS); + } + + private schedulePublish(delayMs: number): void { + if (this.destroyed) return; + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + } + this.debounceTimer = window.setTimeout(() => { + this.debounceTimer = null; + 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; + } + + acceptRemote(remote: RemoteCommunityTheme): void { + if ( + isNewerCommunityThemeCoordinate(remote, { + createdAt: this.lastRemoteCreatedAt, + eventId: this.lastRemoteEventId, + }) + ) { + this.lastRemoteCreatedAt = remote.createdAt; + this.lastRemoteEventId = remote.eventId; + } + 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); + this.debounceTimer = null; + } + this.pending = null; + } + + private async doPublish(preference: CommunityThemePreference): Promise { + try { + const lastPublished = this.lastPublished; + if ( + this.destroyed || + (lastPublished && + sameCommunityThemePreference(lastPublished.preference, preference)) + ) { + if ( + this.pending && + sameCommunityThemePreference(this.pending, preference) + ) { + this.pending = null; + if (lastPublished) this.onPublished(lastPublished); + } + 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.", + ); + await this.waitForDeliveredRemotes(); + if (this.destroyed) return; + 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 ( + this.pending && + sameCommunityThemePreference(this.pending, preference) + ) { + this.pending = null; + } + 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(delay); + } + } + + 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> { + 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; + const processing = decryptAndParse(event).then((remote) => { + if (!remote || this.destroyed) return; + if ( + isNewerCommunityThemeCoordinate(remote, { + createdAt: this.lastRemoteCreatedAt, + eventId: this.lastRemoteEventId, + }) + ) { + this.lastRemoteCreatedAt = remote.createdAt; + this.lastRemoteEventId = remote.eventId; + } + onUpdate(remote); + }); + this.trackRemoteProcessing(processing); + }, + ); + } + + destroy(): void { + this.destroyed = true; + this.cancelPendingPublish(); + } +}