Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow";
Expand Down Expand Up @@ -547,6 +548,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 @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 ? (
<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 @@ -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,
Expand All @@ -693,7 +737,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
208 changes: 208 additions & 0 deletions desktop/src/shared/theme/CommunityThemeController.tsx
Original file line number Diff line number Diff line change
@@ -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<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, (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<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