mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
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>
861 lines
26 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|