Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -546,6 +547,7 @@ function CommunityApp({
if (appContent === null && (!transaction || isEnteringCurtain)) {
appContent = communityApplied ? (
<CommunityQueryProvider key={communityKey}>
<CommunityThemeController />
<AppReady
isCommunitySwitch={isCommunitySwitch}
key={communityKey}
Expand Down
19 changes: 19 additions & 0 deletions desktop/src/features/settings/lib/appearanceScopeCopy.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
20 changes: 20 additions & 0 deletions desktop/src/features/settings/lib/appearanceScopeCopy.ts
Original file line number Diff line number Diff line change
@@ -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";
}
54 changes: 53 additions & 1 deletion desktop/src/features/settings/ui/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 ? (
<SectionHeader
className="mb-4"
title={
<span className="flex min-w-0 items-center gap-2">
Theme{" "}
<span className="font-normal text-muted-foreground">
(per community)
</span>
{activeCommunity ? (
<Badge
className="max-w-56 shrink-0 font-medium normal-case tracking-normal"
data-testid="appearance-community-badge"
variant="outline"
>
<span className="truncate">{communityLabel}</span>
</Badge>
) : null}
</span>
}
/>
) : null}

{/* Mode selector: System / Light / Dark */}
<div className="mb-4 flex gap-2">
{(
Expand Down Expand Up @@ -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,
Expand All @@ -684,7 +728,15 @@ function ThreadLayoutSetting() {
<SettingsOptionGroup className="mt-8">
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">Thread layout</p>
<p className="text-sm font-medium">
Thread layout
{showCommunityScope ? (
<span className="font-normal text-muted-foreground">
{" "}
(all communities)
</span>
) : null}
</p>
<p className="text-sm font-normal text-muted-foreground">
{activeOption.description}
</p>
Expand Down
1 change: 1 addition & 0 deletions desktop/src/shared/constants/kinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
202 changes: 202 additions & 0 deletions desktop/src/shared/theme/CommunityThemeController.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
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,
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<CommunityThemeSyncManager | null>(null);
const scopeRef = useRef("");
const expectedAppliedRef = useRef<CommunityThemePreference | null>(null);
const scopedPreferenceRef = useRef<CommunityThemePreference | null>(null);
const lastRemoteRef = useRef({ createdAt: 0, eventId: "" });
const initialPreferenceRef = useRef<CommunityThemePreference>({
version: 1,
theme: theme.selectedThemeName as CommunityThemePreference["theme"],
accent: theme.accentColor,
followSystem: theme.followSystem,
});

const currentPreferenceRef = useRef<CommunityThemePreference>({
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, (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 ||
(remote.createdAt === last.createdAt && remote.eventId <= last.eventId)
) {
return;
}
lastRemoteRef.current = {
createdAt: remote.createdAt,
eventId: remote.eventId,
};
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<void>) | 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;
}
Loading
Loading