Files
buzz/desktop/src/features/settings/ui/SettingsPanels.tsx
Tyler 61ba9dfaa0 refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910)
Relands **#2467** (extract `buzz-voice` crate) and **#3208** (Pocket
voice settings) onto main, after #3266 and #3180 merged.

## Why a fresh PR
The repo is squash-only with delete-branch-on-merge. Squashing #3266
deleted `jtennant/pocket-tts-2026-04`, which was #2467's base — GitHub
auto-closed #2467 and it cannot be reopened. Squash merges also sever
ancestry, so GitHub's natural merge-base reports phantom conflicts for
the whole remaining stack.

## Content provenance
- Byte-identical to the blessed `jt/buzz-voice-refactor` branch
(`93029c577`, tree `6729e0eff` — reviewed by Dawn (#2467) and Max
(#3208) at exact heads) **except** the three files where #3180 and #3208
genuinely interact.
- Three-file resolution (union of both sides):
- `huddle/mod.rs` — #3180's pipeline re-exports + #3208's
`agent_tts_routing` imports.
- `huddle/state.rs` — `reset_preserving_generation` preserves both
`huddle_generation` (#3180) and `tts_enabled` (#3208); test sets merged
into one `tests` module.
- `desktop/src/testing/e2eBridge.ts` — both switch arms kept; no
duplicate case labels.

## Verification at cf32dacd7
- `cargo test` (desktop/src-tauri, pinned 1.95.0): **2047 + 3 pass / 0
fail** (14 ignored: 8 keychain, 4 real_relay, 2 flag-gated)
- `cargo clippy --all-targets -- -D warnings`: clean; `cargo fmt
--check`: clean
- `cargo check --workspace` (root, includes new `buzz-voice` member):
clean; `cargo test -p buzz-voice`: 5/0
- `pnpm test`: **3885 / 0**; `tsc --noEmit`: clean; lint: clean

The 3180×3208 interaction resolution is getting an independent team
re-review before merge.

Buzz channel: buzz-desktop-voice `fd5fb402-b651-4238-89b1-bb3e2fa4dc96`,
thread `b4798ecc`.

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
2026-07-31 09:41:09 -04:00

861 lines
26 KiB
TypeScript

import { useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
Archive,
BellRing,
Bot,
Check,
ChevronDown,
Cpu,
Download,
FlaskConical,
Keyboard,
LayoutTemplate,
MessagesSquare,
MonitorCog,
Moon,
ShieldAlert,
Smartphone,
Smile,
Sun,
SunMoon,
Ticket,
UserRound,
Volume2,
type LucideIcon,
} from "lucide-react";
import type {
DesktopNotificationPermissionState,
NotificationSettings,
} from "@/features/notifications/hooks";
import type { SoundName, SoundSlot } from "@/features/notifications/lib/sound";
import { CommunityMembersSettingsCard } from "@/features/community-members/ui/CommunityMembersSettingsCard";
import { CustomEmojiSettingsCard } from "@/features/custom-emoji/ui/CustomEmojiSettingsCard";
import { LocalArchiveSettingsCard } from "@/features/local-archive/ui/LocalArchiveSettingsCard";
import {
setThreadViewMode,
useThreadViewMode,
type ThreadViewMode,
} from "@/features/channels/lib/threadViewModePreference";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import {
ACCENT_COLORS,
isBuzzTheme,
NEUTRAL_ACCENT,
useTheme,
} from "@/shared/theme/ThemeProvider";
import {
LIGHT_THEMES,
SYNTAX_THEMES,
type SyntaxThemeName,
getThemePair,
} from "@/shared/theme/theme-loader";
import {
BUZZ_GRADIENT_STOPS,
SystemPreferencePreviewFrame,
ThemePreviewFrame,
type ThemePreviewVars,
} from "@/shared/theme/ThemePreviewFrame";
import {
getThemeFallbackPreviewVars,
useThemePreviewVars,
withAccentPreviewVars,
} from "@/shared/theme/useThemePreviewVars";
import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
import { HarnessesSettingsPanel } from "./HarnessesSettingsPanel";
import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard";
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard";
import { MobilePairingCard } from "./MobilePairingCard";
import { ModerationQueueCard } from "./ModerationQueueCard";
import { NotificationSettingsCard } from "./NotificationSettingsCard";
import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard";
import { AgentDefaultsSettingsCard } from "./AgentDefaultsSettingsCard";
import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard";
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
import { ProfileSettingsCard } from "./ProfileSettingsCard";
import { UpdateChecker } from "../UpdateChecker";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import { VoiceSettingsCard } from "./VoiceSettingsCard";
export type SettingsSection =
| "profile"
| "notifications"
| "voice"
| "experimental"
| "agents"
| "channel-templates"
| "compute"
| "appearance"
| "shortcuts"
| "hosted-communities"
| "community-members"
| "moderation"
| "custom-emoji"
| "local-archive"
| "mobile"
| "updates";
export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile";
const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [
"profile",
"notifications",
"voice",
"experimental",
"agents",
"channel-templates",
"compute",
"appearance",
"shortcuts",
"hosted-communities",
"community-members",
"moderation",
"custom-emoji",
"local-archive",
"mobile",
"updates",
];
export function isSettingsSection(value: unknown): value is SettingsSection {
return (
typeof value === "string" &&
(SETTINGS_SECTION_VALUES as readonly string[]).includes(value)
);
}
export type SettingsSectionDescriptor = {
value: SettingsSection;
label: string;
icon: LucideIcon;
/** If set, this section is only visible when the feature is enabled */
featureGate?: string;
};
export type SettingsPanelProps = {
currentPubkey?: string;
fallbackDisplayName?: string;
isUpdatingDesktopNotifications: boolean;
notificationErrorMessage: string | null;
notificationPermission: DesktopNotificationPermissionState;
notificationSettings: NotificationSettings;
onSetDesktopNotificationsEnabled: (enabled: boolean) => Promise<boolean>;
onSetHomeBadgeEnabled: (enabled: boolean) => void;
onSetSlotAlertsEnabled: (slot: SoundSlot, enabled: boolean) => void;
onSetNotifyWhileViewing: (enabled: boolean) => void;
onSetAllSlotAlertsEnabled: (enabled: boolean) => void;
onSetSoundForSlot: (slot: SoundSlot, name: SoundName) => void;
};
export const settingsSections: SettingsSectionDescriptor[] = [
{
value: "appearance",
label: "Appearance",
icon: MonitorCog,
},
{
value: "profile",
label: "Profile",
icon: UserRound,
},
{
value: "notifications",
label: "Notifications",
icon: BellRing,
},
{
value: "voice",
label: "Voice",
icon: Volume2,
},
{
value: "experimental",
label: "Experiments",
icon: FlaskConical,
},
{
value: "agents",
label: "Agents",
icon: Bot,
featureGate: "managed-agents",
},
{
value: "channel-templates",
label: "Templates",
icon: LayoutTemplate,
featureGate: "channel-templates",
},
{
value: "compute",
label: "Compute",
icon: Cpu,
},
{
value: "shortcuts",
label: "Shortcuts",
icon: Keyboard,
},
{
value: "hosted-communities",
label: "Hosted communities",
icon: MessagesSquare,
},
{
value: "community-members",
label: "Invites",
icon: Ticket,
},
{
value: "moderation",
label: "Moderation",
icon: ShieldAlert,
},
{
value: "custom-emoji",
label: "Custom emoji",
icon: Smile,
featureGate: "custom-emoji",
},
{
value: "local-archive",
label: "Local archive",
icon: Archive,
},
{
value: "mobile",
label: "Mobile",
icon: Smartphone,
},
{
value: "updates",
label: "Updates",
icon: Download,
},
];
function formatThemeLabel(name: string): string {
return name
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
/**
* Derive a display label for a paired theme from its light variant name.
* Strips mode-specific tokens (light, latte, dawn, lotus, ochin, lighter, plus)
* from any position, handling names like "github-light-default", "light-plus",
* "material-theme-lighter", and "gruvbox-light-soft".
*/
function pairedThemeLabel(lightName: string): string {
const modeTokens = new Set([
"light",
"latte",
"dawn",
"lotus",
"ochin",
"lighter",
"plus",
]);
const parts = lightName.split("-").filter((t) => !modeTokens.has(t));
// If stripping removed everything (e.g. "light-plus"), fall back to the raw name
const base = parts.length > 0 ? parts.join("-") : lightName;
return formatThemeLabel(base);
}
/**
* Categorize themes into three groups:
* 1. Paired — themes with both a light and dark variant (auto-switches with system)
* 2. Light-only — light themes with no dark counterpart
* 3. Dark-only — dark themes with no light counterpart
*
* For paired themes, we deduplicate by only keeping the light member
* (the dark member is shown alongside it as a preview).
*/
function useThemeCategories() {
return useMemo(() => {
const pairedLight: SyntaxThemeName[] = [];
const lightOnly: SyntaxThemeName[] = [];
const darkOnly: SyntaxThemeName[] = [];
// Track which themes are the "dark side" of a pair so we skip them
const darkPairMembers = new Set<string>();
for (const name of SYNTAX_THEMES) {
if (LIGHT_THEMES.has(name)) {
const pair = getThemePair(name);
if (pair) {
darkPairMembers.add(pair);
}
}
}
for (const name of SYNTAX_THEMES) {
// Skip dark members of pairs — they'll be shown alongside their light counterpart
if (darkPairMembers.has(name)) continue;
if (LIGHT_THEMES.has(name)) {
const pair = getThemePair(name);
if (pair) {
pairedLight.push(name);
} else {
lightOnly.push(name);
}
} else {
darkOnly.push(name);
}
}
return { pairedLight, lightOnly, darkOnly };
}, []);
}
function PairedThemeTile({
isActive,
lightName,
lightVars,
darkVars,
onSelect,
}: {
isActive: boolean;
lightName: SyntaxThemeName;
lightVars: ThemePreviewVars | null;
darkVars: ThemePreviewVars | null;
onSelect: () => void;
}) {
const darkName = getThemePair(lightName);
return (
<button
aria-pressed={isActive}
className="group flex w-[168px] shrink-0 flex-col items-center text-center focus-visible:outline-hidden"
data-testid={`theme-pair-${lightName}`}
onClick={onSelect}
type="button"
>
<SystemPreferencePreviewFrame
className={cn(
"h-[112px] w-[168px] transition-shadow",
isActive
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
: "group-hover:ring-2 group-hover:ring-border",
)}
darkGradient={darkName ? BUZZ_GRADIENT_STOPS[darkName] : undefined}
darkVars={darkVars}
lightGradient={BUZZ_GRADIENT_STOPS[lightName]}
lightVars={lightVars}
/>
<span
className={cn(
"mt-1.5 w-full truncate text-xs",
isActive ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
{pairedThemeLabel(lightName)}
</span>
</button>
);
}
function SingleThemeTile({
isActive,
name,
vars,
onSelect,
}: {
isActive: boolean;
name: SyntaxThemeName;
vars: ThemePreviewVars | null;
onSelect: () => void;
}) {
return (
<button
aria-pressed={isActive}
className="group flex w-[168px] shrink-0 flex-col items-center text-center focus-visible:outline-hidden"
data-testid={`theme-option-${name}`}
onClick={onSelect}
type="button"
>
<ThemePreviewFrame
className={cn(
"h-[112px] w-[168px] transition-shadow",
isActive
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
: "group-hover:ring-2 group-hover:ring-border",
)}
sidebarGradient={BUZZ_GRADIENT_STOPS[name]}
vars={vars}
/>
<span
className={cn(
"mt-1.5 w-full truncate text-xs",
isActive ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
{formatThemeLabel(name)}
</span>
</button>
);
}
type AppearanceMode = "system" | "light" | "dark";
// Reveal/hide motion for the accent picker: a small translate + opacity fade.
// The picker sits below the theme grid and reads as tucking up behind it, so
// it enters from above (slides *down* into place when a non-Buzz theme reveals
// it) and exits upward (slides up behind the grid when Buzz hides it). No
// height/scale — height collapse clipped the swatches behind the grid's bottom
// fade (the "white bar"). Snappier than the modal 0.2s since this is a small
// settings control, sharing the modal/ProfileSettingsCard easing curve.
const ACCENT_PICKER_TRANSITION = {
duration: 0.16,
ease: [0.23, 1, 0.32, 1] as const,
};
function ThemeSettingsCard() {
const {
setTheme,
selectedThemeName,
themeName,
isDark,
accentColor,
setAccentColor,
followSystem,
setFollowSystem,
} = useTheme();
// 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.
const accentPickerHidden = isBuzzTheme(themeName);
const shouldReduceMotion = useReducedMotion();
const previewVarsByTheme = useThemePreviewVars();
const { pairedLight, lightOnly, darkOnly } = useThemeCategories();
// Determine the active mode from current state
const activeMode: AppearanceMode = followSystem
? "system"
: isDark
? "dark"
: "light";
const [selectedMode, setSelectedMode] = useState<AppearanceMode>(activeMode);
const getVars = (name: SyntaxThemeName) =>
withAccentPreviewVars(
previewVarsByTheme[name] ?? getThemeFallbackPreviewVars(name),
accentColor,
);
// All light themes (paired light + light-only)
const allLightThemes = useMemo(
() => [...pairedLight, ...lightOnly],
[pairedLight, lightOnly],
);
// All dark themes (paired dark + dark-only)
const allDarkThemes = useMemo(() => {
const pairedDark = pairedLight
.map((l) => getThemePair(l))
.filter(Boolean) as SyntaxThemeName[];
return [...pairedDark, ...darkOnly];
}, [pairedLight, darkOnly]);
const handleModeSelect = (mode: AppearanceMode) => {
setSelectedMode(mode);
if (mode === "system") {
setFollowSystem(true);
// If the current theme is unpaired, resolveSystemTheme can't switch it
// with the OS. Fall back to the first paired theme so System mode works.
const pair = getThemePair(selectedThemeName as SyntaxThemeName);
if (!pair && pairedLight.length > 0) {
setTheme(pairedLight[0]);
}
} else {
setFollowSystem(false);
// Switch to the counterpart theme when the current theme doesn't match
// the selected mode. E.g. if the stored theme is light and the user
// clicks Dark, apply the dark pair so the app immediately reflects the
// chosen mode. For unpaired themes (no counterpart), fall back to the
// first available theme in the target mode's list.
const currentIsLight = LIGHT_THEMES.has(
selectedThemeName as SyntaxThemeName,
);
const needsDark = mode === "dark" && currentIsLight;
const needsLight = mode === "light" && !currentIsLight;
if (needsDark || needsLight) {
const pair = getThemePair(selectedThemeName as SyntaxThemeName);
if (pair) {
setTheme(pair);
} else {
// Unpaired theme — pick the first theme from the target mode
const fallback = needsDark ? allDarkThemes[0] : allLightThemes[0];
if (fallback) {
setTheme(fallback);
}
}
}
}
};
const handleSelectTheme = (name: SyntaxThemeName) => {
setTheme(name);
if (selectedMode === "system") {
setFollowSystem(true);
} else {
setFollowSystem(false);
}
};
/** Check if a paired theme (by its light member) is the active selection */
const isPairActive = (lightName: SyntaxThemeName) => {
const darkName = getThemePair(lightName);
return selectedThemeName === lightName || selectedThemeName === darkName;
};
return (
<section
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
data-testid="settings-theme"
>
<SettingsSectionHeader
title="Appearance"
description="Choose a theme for Buzz."
/>
{/* Mode selector: System / Light / Dark */}
<div className="mb-4 flex gap-2">
{(
[
{ mode: "system" as const, label: "System", Icon: SunMoon },
{ mode: "light" as const, label: "Light", Icon: Sun },
{ mode: "dark" as const, label: "Dark", Icon: Moon },
] as const
).map(({ mode, label, Icon }) => (
<button
aria-pressed={selectedMode === mode}
className={cn(
"flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
selectedMode === mode
? "border-primary bg-primary/10 text-foreground"
: "border-border/70 text-muted-foreground hover:border-border hover:text-foreground",
)}
data-testid={`appearance-mode-${mode}`}
key={mode}
onClick={() => handleModeSelect(mode)}
type="button"
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
{/* Theme grid — constrained to ~3 rows, scrolls internally */}
<div className="relative mb-6">
{/* Top fade */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-10 h-3"
style={{
background:
"linear-gradient(to bottom, hsl(var(--background)), hsl(var(--background) / 0))",
}}
/>
{/* Bottom fade — hidden while the accent picker is visible so its
near-white gradient (Buzz light) can't mask the swatches below it
(the "white bar"). Kept only when the picker is hidden. */}
{accentPickerHidden ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-3"
style={{
background:
"linear-gradient(to top, hsl(var(--background)), hsl(var(--background) / 0))",
}}
/>
) : null}
<div className="max-h-[430px] overflow-y-auto rounded-lg pt-2">
<div className="flex flex-wrap gap-4 p-1">
{selectedMode === "system" &&
pairedLight.map((lightName) => {
const darkName = getThemePair(lightName);
if (!darkName) return null;
return (
<PairedThemeTile
darkVars={getVars(darkName)}
isActive={isPairActive(lightName)}
key={lightName}
lightName={lightName}
lightVars={getVars(lightName)}
onSelect={() => handleSelectTheme(lightName)}
/>
);
})}
{selectedMode === "light" &&
allLightThemes.map((name) => (
<SingleThemeTile
isActive={selectedThemeName === name}
key={name}
name={name}
onSelect={() => handleSelectTheme(name)}
vars={getVars(name)}
/>
))}
{selectedMode === "dark" &&
allDarkThemes.map((name) => (
<SingleThemeTile
isActive={selectedThemeName === name}
key={name}
name={name}
onSelect={() => handleSelectTheme(name)}
vars={getVars(name)}
/>
))}
</div>
</div>
</div>
{/* Accent color picker — hidden for Buzz themes (pinned neutral accent).
Reveal/hide with the translate-up + opacity fade defined by
ACCENT_PICKER_TRANSITION above. Reduced motion skips the transition
and just renders/unrenders. */}
{shouldReduceMotion ? (
accentPickerHidden ? null : (
<AccentPickerContent
accentColor={accentColor}
isDark={isDark}
setAccentColor={setAccentColor}
/>
)
) : (
<AnimatePresence initial={false}>
{accentPickerHidden ? null : (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="will-change-[opacity,transform]"
exit={{ opacity: 0, y: -10 }}
initial={{ opacity: 0, y: -10 }}
key="accent-picker"
transition={ACCENT_PICKER_TRANSITION}
>
<AccentPickerContent
accentColor={accentColor}
isDark={isDark}
setAccentColor={setAccentColor}
/>
</motion.div>
)}
</AnimatePresence>
)}
<ThreadLayoutSetting />
</section>
);
}
const THREAD_VIEW_MODE_OPTIONS: {
value: ThreadViewMode;
label: string;
description: string;
}[] = [
{
value: "focus",
label: "Focus",
description: "Threads open over the channel, full width",
},
{
value: "split",
label: "Split",
description: "Threads open in a side panel next to the channel",
},
];
/**
* Thread layout picker. Uses the same dropdown radio group vocabulary as the
* other enumerated Settings rows (e.g. {@link SoundPicker}) so each option can
* carry its own description.
*/
function ThreadLayoutSetting() {
const threadViewMode = useThreadViewMode();
const activeOption =
THREAD_VIEW_MODE_OPTIONS.find(
(option) => option.value === threadViewMode,
) ?? THREAD_VIEW_MODE_OPTIONS[0];
return (
<SettingsOptionGroup className="mt-8">
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">Thread layout</p>
<p className="text-sm font-normal text-muted-foreground">
{activeOption.description}
</p>
</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="h-7 min-w-28 justify-between gap-1.5 rounded-full border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
data-testid="thread-layout-trigger"
size="sm"
type="button"
variant="ghost"
>
<span className="truncate">{activeOption.label}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-72">
<DropdownMenuRadioGroup
onValueChange={(next) =>
setThreadViewMode(next as ThreadViewMode)
}
value={threadViewMode}
>
{THREAD_VIEW_MODE_OPTIONS.map((option) => (
<DropdownMenuRadioItem
data-testid={`thread-layout-${option.value}`}
key={option.value}
value={option.value}
>
<span className="flex min-w-0 flex-col">
<span className="font-medium">{option.label}</span>
<span className="text-2xs text-muted-foreground">
{option.description}
</span>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</SettingsOptionRow>
</SettingsOptionGroup>
);
}
/** Accent swatch grid — shared by the animated and reduced-motion reveal paths. */
function AccentPickerContent({
accentColor,
isDark,
setAccentColor,
}: {
accentColor: string;
isDark: boolean;
setAccentColor: (value: string) => void;
}) {
return (
<div className="shrink-0 px-1 pb-2 pt-1">
<h3 className="mb-2 text-sm font-medium">Accent color</h3>
<div className="flex flex-wrap gap-2 p-1">
{ACCENT_COLORS.map((color) => {
const isNeutral = color.value === NEUTRAL_ACCENT;
const swatchColor = isNeutral
? "hsl(var(--foreground))"
: color.value;
const checkClassName =
isNeutral && isDark ? "text-black" : "text-white";
return (
<button
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full border border-border/50 transition-transform hover:scale-110",
accentColor === color.value &&
"ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
data-testid={`accent-color-${color.name.toLowerCase()}`}
key={color.value}
onClick={() => setAccentColor(color.value)}
style={{ backgroundColor: swatchColor }}
title={color.name}
type="button"
>
{accentColor === color.value && (
<Check className={cn("h-4 w-4", checkClassName)} />
)}
</button>
);
})}
</div>
</div>
);
}
export function renderSettingsSection(
section: SettingsSection,
props: SettingsPanelProps,
): React.ReactNode {
switch (section) {
case "profile":
return (
<ProfileSettingsCard
currentPubkey={props.currentPubkey}
fallbackDisplayName={props.fallbackDisplayName}
/>
);
case "notifications":
return (
<NotificationSettingsCard
isUpdatingDesktopNotifications={props.isUpdatingDesktopNotifications}
notificationErrorMessage={props.notificationErrorMessage}
notificationPermission={props.notificationPermission}
notificationSettings={props.notificationSettings}
onSetDesktopNotificationsEnabled={
props.onSetDesktopNotificationsEnabled
}
onSetHomeBadgeEnabled={props.onSetHomeBadgeEnabled}
onSetSlotAlertsEnabled={props.onSetSlotAlertsEnabled}
onSetNotifyWhileViewing={props.onSetNotifyWhileViewing}
onSetAllSlotAlertsEnabled={props.onSetAllSlotAlertsEnabled}
onSetSoundForSlot={props.onSetSoundForSlot}
/>
);
case "voice":
return <VoiceSettingsCard />;
case "experimental":
return <ExperimentalFeaturesCard />;
case "agents":
return (
<div className="space-y-12">
<PreventSleepSettingsCard />
<HarnessesSettingsPanel />
<AgentDefaultsSettingsCard />
</div>
);
case "channel-templates":
return <ChannelTemplatesSettingsCard />;
case "compute":
return <MeshComputeSettingsCard />;
case "appearance":
return <ThemeSettingsCard />;
case "shortcuts":
return <KeyboardShortcutsCard />;
case "hosted-communities":
return <HostedCommunitiesSettingsCard />;
case "community-members":
return (
<CommunityMembersSettingsCard currentPubkey={props.currentPubkey} />
);
case "moderation":
return <ModerationQueueCard />;
case "custom-emoji":
return <CustomEmojiSettingsCard />;
case "local-archive":
return <LocalArchiveSettingsCard />;
case "mobile":
return <MobilePairingCard currentPubkey={props.currentPubkey} />;
case "updates":
return <UpdateChecker />;
default: {
const exhaustiveCheck: never = section;
return exhaustiveCheck;
}
}
}