mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 02:01:22 +02:00
feat: add client tag support to all event creation (#191)
* feat: add client tag support to all event creation
Implements a global settings system to control whether the Grimoire client tag
should be included in all published events. This allows users to opt-in or
opt-out of identifying their client in published events.
Changes:
- Created global settings service (src/services/settings.ts) with reactive
BehaviorSubject for app-wide configuration
- Created useSettings hook (src/hooks/useSettings.ts) for React components
- Migrated PostViewer from local settings to global settings system
- Added client tag support to:
- Post publishing (PostViewer.tsx)
- Spell publishing (publish-spell.ts)
- Event deletion (delete-event.ts)
- NIP-29 chat messages, reactions, join/leave, and group bookmarks
(nip-29-adapter.ts)
- Zap requests (create-zap-request.ts)
The client tag setting defaults to enabled (true) for backward compatibility.
Users can toggle this in the post composer settings dropdown.
All event creation locations now check settingsManager.getSetting("includeClientTag")
before adding the GRIMOIRE_CLIENT_TAG to event tags.
* refactor: exclude client tags from NIP-29 and zap requests
Remove client tag support from NIP-29 adapter events and zap requests
as these events may be rejected by servers with large tags or have
specific size constraints.
Changes:
- Removed client tag from NIP-29 chat messages (kind 9)
- Removed client tag from NIP-29 reactions (kind 7)
- Removed client tag from NIP-29 join/leave requests (kind 9021, 9022)
- Removed client tag from NIP-29 group bookmarks (kind 10009)
- Removed client tag from zap requests (kind 9734)
Client tags remain enabled for:
- Regular posts (kind 1)
- Spell publishing (kind 777)
- Event deletion (kind 5)
This ensures maximum compatibility with relay servers and LNURL endpoints
while still providing client identification for standard events.
* feat: implement comprehensive namespaced settings system
Redesigned the settings system with proper namespacing, type safety, validation,
and migration support. This provides a solid foundation for all app configuration.
Settings Structure:
- post: Post composition settings (client tag, relay selection)
- appearance: UI/theme settings (theme, compact mode, font size, animations)
- relay: Relay configuration (fallback, discovery, outbox, timeouts)
- privacy: Privacy settings (read receipts, content warnings, link warnings)
- database: Caching settings (max events, cleanup, IndexedDB options)
- notifications: Browser notifications preferences
- developer: Debug and experimental features
Key Features:
- Fully typed with TypeScript interfaces for each namespace
- Automatic validation with fallback to defaults for invalid data
- Migration system from old flat structure to namespaced structure
- Backwards compatible with old "grimoire-settings" localStorage key
- Import/export functionality for settings backup/restore
- Reactive updates via RxJS BehaviorSubject
- Section-level and individual setting updates
- Reset individual sections or all settings
Changes:
- Created comprehensive AppSettings interface with 7 namespaced sections
- Implemented SettingsManager class with reactive updates and persistence
- Updated useSettings hook to support namespaced API
- Updated PostViewer, publish-spell, and delete-event to use new API
(settingsManager.getSetting("post", "includeClientTag"))
- Added extensive inline documentation for all settings
Migration:
- Automatically migrates old includeClientTag setting to post.includeClientTag
- Moves data from "grimoire-settings" to "grimoire-settings-v2" key
- Validates all loaded settings and fills in defaults for missing values
This foundation will support future settings UI with tabbed interface.
* feat: add comprehensive settings UI with Post and Appearance sections
Created a minimal MVP settings system accessible via command palette and user menu.
Settings are organized in a clean tabbed interface with two initial sections.
UI Features:
- SettingsViewer component with sidebar navigation
- Post section: Toggle to include Grimoire client tag in published events
- Appearance section:
- Theme selector (light/dark/system)
- Toggle to show/hide client tags in event UI ("via Grimoire" etc)
Integration:
- Added "settings" command to command palette
- Added "Settings" option to user menu (before Support Grimoire)
- Registered "settings" as new AppId in window system
Display Logic:
- BaseEventRenderer now honors settings.appearance.showClientTags
- When disabled, "via Grimoire" and other client tags are hidden from events
- Setting applies instantly across all event renderers
Technical Details:
- SettingsViewer uses existing UI components (Checkbox, Button, Label)
- Leverages useSettings hook for reactive updates
- Settings persist to localStorage via settingsManager
- Simple button group for theme selection instead of dropdown
- Clean two-column layout with icons for each section
This provides a solid foundation for adding more settings sections later
(relay config, privacy, database, notifications, developer options).
* refactor: redesign settings UI with shadcn Tabs and cleaner layout
Simplified the settings UI to be more minimal and follow Grimoire patterns:
Changes:
- Use shadcn Tabs component instead of custom sidebar navigation
- Remove non-working theme selector (no theme system implemented yet)
- Use regular <label> elements with htmlFor instead of custom Label component
- Shorter, cleaner copy throughout
- Properly remove unused theme-related imports from user menu
- Follow Grimoire UI patterns (similar to SettingsDialog)
Settings sections:
- Post: Include client tag setting
- Appearance: Show client tags setting
The UI is now cleaner, uses proper form semantics, and matches the rest
of Grimoire's design system.
* feat: add working theme selector and improve settings UI
Added fully functional theme selector and improved the settings UI with
better components and cleaner copy.
Changes:
- Created Switch component (shadcn/radix-ui) for boolean settings
- Added working theme selector that integrates with existing theme system
- Uses useTheme hook to display available themes (Dark, Light, Plan9)
- Reordered sections: Appearance first, Post second
- Reordered settings: Theme first in Appearance section
- Replaced Checkbox with Switch for better UX on boolean toggles
- Simplified copy: "Add Grimoire tag to published events" instead of listing kinds
- Simplified copy: "Display client identifiers in events" instead of "via Grimoire" mention
- Better layout: Label/description on left, Switch on right
Settings now use proper form components:
- Switch for boolean toggles (include client tag, show client tags)
- Button group for theme selection
- Clean justify-between layout for settings rows
The theme selector works immediately - clicking Dark/Light/Plan9 applies
the theme instantly via the existing ThemeProvider context.
* refactor: improve settings UI with Select, icons, and better spacing
Enhanced the settings UI with shadcn Select component, tab icons, and
improved typography and spacing.
Changes:
- Created Select component (shadcn/radix-ui) for dropdowns
- Added icons to tabs: Palette for Appearance, FileEdit for Post
- Replaced button group theme selector with Select dropdown (w-48)
- Added gap-4 between labels and switches for better spacing
- Increased setting names from text-sm to text-base (bigger)
- Reduced help text from text-sm to text-xs (smaller)
- Added gap-3 between "Theme" label and Select
- Added font-medium to "Theme" label for consistency
Layout improvements:
- Theme selector now uses Select with 192px width
- All setting rows have gap-4 between content and controls
- Consistent text hierarchy: font-medium for labels, text-xs for descriptions
- Tab triggers have gap-2 between icon and text
The Select component is properly integrated with the theme system and
displays Dark, Light, and Plan9 options with checkmark indicators.
* refactor: use horizontal layout for theme selector
Aligned theme selector with other settings by using horizontal layout:
- Label and description on left, Select on right
- Added gap-4 for consistent spacing
- Changed label from text-sm to text-base font-medium
- Added helpful description: 'Choose your color scheme'
- Added id='theme' for accessibility
Now all settings follow the same visual pattern.
* refactor: reduce theme selector width for compact display
Changed theme selector from w-48 (192px) to w-32 (128px) since
theme names are short (Dark, Light, Plan9). More compact and
better proportioned for the content.
* fix: pass 'settings' command string when opening from menu
Changed the command string from 'Settings' to 'settings' when opening
the settings window from the user menu. This ensures clicking edit on
the settings window shows the correct command: 'settings'
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ import { mergeRelaySets } from "applesauce-core/helpers";
|
||||
import { grimoireStateAtom } from "@/core/state";
|
||||
import { getDefaultStore } from "jotai";
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
import { settingsManager } from "@/services/settings";
|
||||
import { GRIMOIRE_CLIENT_TAG } from "@/constants/app";
|
||||
|
||||
export class DeleteEventAction {
|
||||
type = "delete-event";
|
||||
@@ -27,6 +29,12 @@ export class DeleteEventAction {
|
||||
const factory = new EventFactory({ signer });
|
||||
|
||||
const draft = await factory.delete([item.event], reason);
|
||||
|
||||
// Add client tag if enabled in settings
|
||||
if (settingsManager.getSetting("post", "includeClientTag")) {
|
||||
draft.tags.push(GRIMOIRE_CLIENT_TAG);
|
||||
}
|
||||
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
// Get write relays from cache and state
|
||||
|
||||
@@ -9,6 +9,8 @@ import { relayListCache } from "@/services/relay-list-cache";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
import { mergeRelaySets } from "applesauce-core/helpers";
|
||||
import eventStore from "@/services/event-store";
|
||||
import { settingsManager } from "@/services/settings";
|
||||
import { GRIMOIRE_CLIENT_TAG } from "@/constants/app";
|
||||
|
||||
export class PublishSpellAction {
|
||||
type = "publish-spell";
|
||||
@@ -40,12 +42,18 @@ export class PublishSpellAction {
|
||||
|
||||
const factory = new EventFactory({ signer });
|
||||
|
||||
// Add client tag if enabled in settings
|
||||
const tags = [...encoded.tags];
|
||||
if (settingsManager.getSetting("post", "includeClientTag")) {
|
||||
tags.push(GRIMOIRE_CLIENT_TAG);
|
||||
}
|
||||
|
||||
const draft = await factory.build({
|
||||
kind: 777,
|
||||
|
||||
content: encoded.content,
|
||||
|
||||
tags: encoded.tags,
|
||||
tags,
|
||||
});
|
||||
|
||||
event = (await factory.sign(draft)) as SpellEvent;
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useProfileSearch } from "@/hooks/useProfileSearch";
|
||||
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
|
||||
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { RichEditor, type RichEditorHandle } from "./editor/RichEditor";
|
||||
import type { BlobAttachment, EmojiTag } from "./editor/MentionEditor";
|
||||
import { RelayLink } from "./nostr/RelayLink";
|
||||
@@ -53,15 +54,6 @@ interface RelayPublishState {
|
||||
|
||||
// Storage keys
|
||||
const DRAFT_STORAGE_KEY = "grimoire-post-draft";
|
||||
const SETTINGS_STORAGE_KEY = "grimoire-post-settings";
|
||||
|
||||
interface PostSettings {
|
||||
includeClientTag: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: PostSettings = {
|
||||
includeClientTag: true,
|
||||
};
|
||||
|
||||
interface PostViewerProps {
|
||||
windowId?: string;
|
||||
@@ -73,6 +65,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
|
||||
const { searchEmojis } = useEmojiSearch();
|
||||
const { state } = useGrimoire();
|
||||
const { getRelay } = useRelayState();
|
||||
const { settings, updateSetting } = useSettings();
|
||||
|
||||
// Editor ref for programmatic control
|
||||
const editorRef = useRef<RichEditorHandle>(null);
|
||||
@@ -86,36 +79,9 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
|
||||
const [showPublishedPreview, setShowPublishedPreview] = useState(false);
|
||||
const [newRelayInput, setNewRelayInput] = useState("");
|
||||
|
||||
// Load settings from localStorage
|
||||
const [settings, setSettings] = useState<PostSettings>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : DEFAULT_SETTINGS;
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
});
|
||||
|
||||
// Get relay pool state for connection status
|
||||
const relayPoolMap = use$(pool.relays$);
|
||||
|
||||
// Persist settings to localStorage when they change
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (err) {
|
||||
console.error("Failed to save post settings:", err);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Update a single setting
|
||||
const updateSetting = useCallback(
|
||||
<K extends keyof PostSettings>(key: K, value: PostSettings[K]) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Get active account's write relays from Grimoire state, fallback to aggregators
|
||||
const writeRelays = useMemo(() => {
|
||||
if (!state.activeAccount?.relays) return AGGREGATOR_RELAYS;
|
||||
@@ -403,7 +369,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
|
||||
}
|
||||
|
||||
// Add client tag (if enabled)
|
||||
if (settings.includeClientTag) {
|
||||
if (settings?.post?.includeClientTag) {
|
||||
additionalTags.push(GRIMOIRE_CLIENT_TAG);
|
||||
}
|
||||
|
||||
@@ -692,9 +658,9 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={settings.includeClientTag}
|
||||
checked={settings?.post?.includeClientTag ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSetting("includeClientTag", checked)
|
||||
updateSetting("post", "includeClientTag", checked)
|
||||
}
|
||||
>
|
||||
Include client tag
|
||||
|
||||
128
src/components/SettingsViewer.tsx
Normal file
128
src/components/SettingsViewer.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./ui/select";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useTheme } from "@/lib/themes";
|
||||
import { Palette, FileEdit } from "lucide-react";
|
||||
|
||||
export function SettingsViewer() {
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const { themeId, setTheme, availableThemes } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs defaultValue="appearance" className="flex-1 flex flex-col">
|
||||
<div className="border-b px-6 py-3">
|
||||
<TabsList>
|
||||
<TabsTrigger value="appearance" className="gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
Appearance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="post" className="gap-2">
|
||||
<FileEdit className="h-4 w-4" />
|
||||
Post
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<TabsContent value="appearance" className="m-0 p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-1">Appearance</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize display preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="theme"
|
||||
className="text-base font-medium cursor-pointer"
|
||||
>
|
||||
Theme
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose your color scheme
|
||||
</p>
|
||||
</div>
|
||||
<Select value={themeId} onValueChange={setTheme}>
|
||||
<SelectTrigger id="theme" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableThemes.map((theme) => (
|
||||
<SelectItem key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="show-client-tags"
|
||||
className="text-base font-medium cursor-pointer"
|
||||
>
|
||||
Show client tags
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Display client identifiers in events
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="show-client-tags"
|
||||
checked={settings?.appearance?.showClientTags ?? true}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
updateSetting("appearance", "showClientTags", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="post" className="m-0 p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-1">Post Settings</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure event publishing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="include-client-tag"
|
||||
className="text-base font-medium cursor-pointer"
|
||||
>
|
||||
Include client tag
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add Grimoire tag to published events
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="include-client-tag"
|
||||
checked={settings?.post?.includeClientTag ?? true}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
updateSetting("post", "includeClientTag", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,9 @@ const CountViewer = lazy(() => import("./CountViewer"));
|
||||
const PostViewer = lazy(() =>
|
||||
import("./PostViewer").then((m) => ({ default: m.PostViewer })),
|
||||
);
|
||||
const SettingsViewer = lazy(() =>
|
||||
import("./SettingsViewer").then((m) => ({ default: m.SettingsViewer })),
|
||||
);
|
||||
|
||||
// Loading fallback component
|
||||
function ViewerLoading() {
|
||||
@@ -247,6 +250,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
case "post":
|
||||
content = <PostViewer windowId={window.id} />;
|
||||
break;
|
||||
case "settings":
|
||||
content = <SettingsViewer />;
|
||||
break;
|
||||
default:
|
||||
content = (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { JsonViewer } from "@/components/JsonViewer";
|
||||
import { EmojiPickerDialog } from "@/components/chat/EmojiPickerDialog";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
@@ -531,6 +532,7 @@ export function BaseEventContainer({
|
||||
}) {
|
||||
const { locale, addWindow } = useGrimoire();
|
||||
const { canSign, signer, pubkey } = useAccount();
|
||||
const { settings } = useSettings();
|
||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
||||
|
||||
const handleReactClick = () => {
|
||||
@@ -612,7 +614,7 @@ export function BaseEventContainer({
|
||||
>
|
||||
{relativeTime}
|
||||
</span>
|
||||
{clientName && (
|
||||
{settings?.appearance?.showClientTags && clientName && (
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
via{" "}
|
||||
{clientAppPointer ? (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
User,
|
||||
Palette,
|
||||
Wallet,
|
||||
X,
|
||||
RefreshCw,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
Zap,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import accounts from "@/services/accounts";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
@@ -26,9 +26,6 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -44,7 +41,6 @@ import SettingsDialog from "@/components/SettingsDialog";
|
||||
import LoginDialog from "./LoginDialog";
|
||||
import ConnectWalletDialog from "@/components/ConnectWalletDialog";
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "@/lib/themes";
|
||||
import { toast } from "sonner";
|
||||
import { useWallet } from "@/hooks/useWallet";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
@@ -94,7 +90,6 @@ export default function UserMenu() {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [showConnectWallet, setShowConnectWallet] = useState(false);
|
||||
const [showWalletInfo, setShowWalletInfo] = useState(false);
|
||||
const { themeId, setTheme, availableThemes } = useTheme();
|
||||
|
||||
// Calculate monthly donations reactively from DB (last 30 days)
|
||||
const monthlyDonations =
|
||||
@@ -488,32 +483,15 @@ export default function UserMenu() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* App Preferences - Theme */}
|
||||
{/* Settings */}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="cursor-crosshair">
|
||||
<Palette className="size-4 text-muted-foreground mr-2" />
|
||||
Theme
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{availableThemes.map((theme) => (
|
||||
<DropdownMenuItem
|
||||
key={theme.id}
|
||||
className="cursor-crosshair"
|
||||
onClick={() => setTheme(theme.id)}
|
||||
>
|
||||
<span
|
||||
className={`size-2 rounded-full mr-2 ${
|
||||
themeId === theme.id
|
||||
? "bg-primary"
|
||||
: "bg-muted-foreground/30"
|
||||
}`}
|
||||
/>
|
||||
{theme.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
className="cursor-crosshair"
|
||||
onClick={() => addWindow("settings", {}, "settings")}
|
||||
>
|
||||
<Settings className="size-4 text-muted-foreground mr-2" />
|
||||
<span className="text-sm">Settings</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Support Grimoire */}
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
157
src/components/ui/select.tsx
Normal file
157
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
27
src/components/ui/switch.tsx
Normal file
27
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
54
src/hooks/useSettings.ts
Normal file
54
src/hooks/useSettings.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* React hook for accessing and updating global app settings
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { settingsManager, type AppSettings } from "@/services/settings";
|
||||
|
||||
export function useSettings() {
|
||||
const settings = use$(settingsManager.stream$);
|
||||
|
||||
const updateSection = useCallback(
|
||||
<K extends keyof Omit<AppSettings, "__version">>(
|
||||
section: K,
|
||||
updates: Partial<AppSettings[K]>,
|
||||
) => {
|
||||
settingsManager.updateSection(section, updates);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateSetting = useCallback(
|
||||
<
|
||||
S extends keyof Omit<AppSettings, "__version">,
|
||||
K extends keyof AppSettings[S],
|
||||
>(
|
||||
section: S,
|
||||
key: K,
|
||||
value: AppSettings[S][K],
|
||||
) => {
|
||||
settingsManager.updateSetting(section, key, value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
settingsManager.reset();
|
||||
}, []);
|
||||
|
||||
const resetSection = useCallback(
|
||||
<K extends keyof Omit<AppSettings, "__version">>(section: K) => {
|
||||
settingsManager.resetSection(section);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
settings,
|
||||
updateSection,
|
||||
updateSetting,
|
||||
reset,
|
||||
resetSection,
|
||||
};
|
||||
}
|
||||
455
src/services/settings.ts
Normal file
455
src/services/settings.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* Global application settings with namespaced structure
|
||||
* Manages user preferences with localStorage persistence, validation, and migrations
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
// ============================================================================
|
||||
// Settings Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Post composition settings
|
||||
*/
|
||||
export interface PostSettings {
|
||||
/** Include Grimoire client tag in published events */
|
||||
includeClientTag: boolean;
|
||||
/** Default relay selection preference (user-relays, aggregators, custom) */
|
||||
defaultRelayMode: "user-relays" | "aggregators" | "custom";
|
||||
/** Custom relay list for posting (when defaultRelayMode is "custom") */
|
||||
customPostRelays: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Appearance and theme settings
|
||||
*/
|
||||
export interface AppearanceSettings {
|
||||
/** Theme mode (light, dark, or system) */
|
||||
theme: "light" | "dark" | "system";
|
||||
/** Show client tags in event UI */
|
||||
showClientTags: boolean;
|
||||
/** Event kinds to render in compact mode */
|
||||
compactModeKinds: number[];
|
||||
/** Font size multiplier (0.8 = 80%, 1.0 = 100%, 1.2 = 120%) */
|
||||
fontSizeMultiplier: number;
|
||||
/** Enable UI animations */
|
||||
animationsEnabled: boolean;
|
||||
/** Accent color (hue value 0-360) */
|
||||
accentHue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay configuration settings
|
||||
*/
|
||||
export interface RelaySettings {
|
||||
/** Fallback aggregator relays when user has no relay list */
|
||||
fallbackRelays: string[];
|
||||
/** Discovery relays for bootstrapping (NIP-05, relay lists, etc.) */
|
||||
discoveryRelays: string[];
|
||||
/** Enable NIP-65 outbox model for finding events */
|
||||
outboxEnabled: boolean;
|
||||
/** Fallback to aggregators if outbox fails */
|
||||
outboxFallbackEnabled: boolean;
|
||||
/** Relay connection timeout in milliseconds */
|
||||
relayTimeout: number;
|
||||
/** Maximum concurrent relay connections per query */
|
||||
maxRelaysPerQuery: number;
|
||||
/** Automatically connect to inbox relays when viewing DMs */
|
||||
autoConnectInbox: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Privacy and security settings
|
||||
*/
|
||||
export interface PrivacySettings {
|
||||
/** Share read receipts (NIP-15) */
|
||||
shareReadReceipts: boolean;
|
||||
/** Blur wallet balances in UI */
|
||||
blurWalletBalances: boolean;
|
||||
/** Blur sensitive content (marked with content-warning tag) */
|
||||
blurSensitiveContent: boolean;
|
||||
/** Warn before opening external links */
|
||||
warnExternalLinks: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local database and caching settings
|
||||
*/
|
||||
export interface DatabaseSettings {
|
||||
/** Maximum events to cache in IndexedDB (0 = unlimited) */
|
||||
maxEventsCached: number;
|
||||
/** Auto-cleanup old events after N days (0 = never) */
|
||||
autoCleanupDays: number;
|
||||
/** Enable IndexedDB caching */
|
||||
cacheEnabled: boolean;
|
||||
/** Cache profile metadata */
|
||||
cacheProfiles: boolean;
|
||||
/** Cache relay lists */
|
||||
cacheRelayLists: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification preferences
|
||||
*/
|
||||
export interface NotificationSettings {
|
||||
/** Enable browser notifications */
|
||||
enabled: boolean;
|
||||
/** Notify on mentions */
|
||||
notifyOnMention: boolean;
|
||||
/** Notify on zaps received */
|
||||
notifyOnZap: boolean;
|
||||
/** Notify on replies */
|
||||
notifyOnReply: boolean;
|
||||
/** Play sound on notification */
|
||||
soundEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Developer and debug settings
|
||||
*/
|
||||
export interface DeveloperSettings {
|
||||
/** Enable debug mode */
|
||||
debugMode: boolean;
|
||||
/** Show event IDs in UI */
|
||||
showEventIds: boolean;
|
||||
/** Console log level */
|
||||
logLevel: "none" | "error" | "warn" | "info" | "debug";
|
||||
/** Enable experimental features */
|
||||
experimentalFeatures: boolean;
|
||||
/** Show performance metrics */
|
||||
showPerformanceMetrics: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete application settings structure
|
||||
* Version 1: Initial namespaced structure
|
||||
*/
|
||||
export interface AppSettings {
|
||||
__version: 1;
|
||||
post: PostSettings;
|
||||
appearance: AppearanceSettings;
|
||||
relay: RelaySettings;
|
||||
privacy: PrivacySettings;
|
||||
database: DatabaseSettings;
|
||||
notifications: NotificationSettings;
|
||||
developer: DeveloperSettings;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Settings
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_POST_SETTINGS: PostSettings = {
|
||||
includeClientTag: true,
|
||||
defaultRelayMode: "user-relays",
|
||||
customPostRelays: [],
|
||||
};
|
||||
|
||||
const DEFAULT_APPEARANCE_SETTINGS: AppearanceSettings = {
|
||||
theme: "dark",
|
||||
showClientTags: true,
|
||||
compactModeKinds: [6, 7, 16, 9735], // reactions, reposts, zaps
|
||||
fontSizeMultiplier: 1.0,
|
||||
animationsEnabled: true,
|
||||
accentHue: 280, // Purple
|
||||
};
|
||||
|
||||
const DEFAULT_RELAY_SETTINGS: RelaySettings = {
|
||||
fallbackRelays: [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.primal.net",
|
||||
],
|
||||
discoveryRelays: [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://purplepag.es",
|
||||
],
|
||||
outboxEnabled: true,
|
||||
outboxFallbackEnabled: true,
|
||||
relayTimeout: 5000,
|
||||
maxRelaysPerQuery: 10,
|
||||
autoConnectInbox: true,
|
||||
};
|
||||
|
||||
const DEFAULT_PRIVACY_SETTINGS: PrivacySettings = {
|
||||
shareReadReceipts: false,
|
||||
blurWalletBalances: false,
|
||||
blurSensitiveContent: true,
|
||||
warnExternalLinks: false,
|
||||
};
|
||||
|
||||
const DEFAULT_DATABASE_SETTINGS: DatabaseSettings = {
|
||||
maxEventsCached: 50000,
|
||||
autoCleanupDays: 30,
|
||||
cacheEnabled: true,
|
||||
cacheProfiles: true,
|
||||
cacheRelayLists: true,
|
||||
};
|
||||
|
||||
const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
|
||||
enabled: false,
|
||||
notifyOnMention: true,
|
||||
notifyOnZap: true,
|
||||
notifyOnReply: true,
|
||||
soundEnabled: false,
|
||||
};
|
||||
|
||||
const DEFAULT_DEVELOPER_SETTINGS: DeveloperSettings = {
|
||||
debugMode: false,
|
||||
showEventIds: false,
|
||||
logLevel: "warn",
|
||||
experimentalFeatures: false,
|
||||
showPerformanceMetrics: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
__version: 1,
|
||||
post: DEFAULT_POST_SETTINGS,
|
||||
appearance: DEFAULT_APPEARANCE_SETTINGS,
|
||||
relay: DEFAULT_RELAY_SETTINGS,
|
||||
privacy: DEFAULT_PRIVACY_SETTINGS,
|
||||
database: DEFAULT_DATABASE_SETTINGS,
|
||||
notifications: DEFAULT_NOTIFICATION_SETTINGS,
|
||||
developer: DEFAULT_DEVELOPER_SETTINGS,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Storage and Validation
|
||||
// ============================================================================
|
||||
|
||||
const SETTINGS_STORAGE_KEY = "grimoire-settings-v2";
|
||||
|
||||
/**
|
||||
* Validate settings structure and return valid settings
|
||||
* Falls back to defaults for invalid sections
|
||||
*/
|
||||
function validateSettings(settings: any): AppSettings {
|
||||
if (!settings || typeof settings !== "object") {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
// Ensure all namespaces exist
|
||||
return {
|
||||
__version: 1,
|
||||
post: { ...DEFAULT_POST_SETTINGS, ...(settings.post || {}) },
|
||||
appearance: {
|
||||
...DEFAULT_APPEARANCE_SETTINGS,
|
||||
...(settings.appearance || {}),
|
||||
},
|
||||
relay: { ...DEFAULT_RELAY_SETTINGS, ...(settings.relay || {}) },
|
||||
privacy: { ...DEFAULT_PRIVACY_SETTINGS, ...(settings.privacy || {}) },
|
||||
database: { ...DEFAULT_DATABASE_SETTINGS, ...(settings.database || {}) },
|
||||
notifications: {
|
||||
...DEFAULT_NOTIFICATION_SETTINGS,
|
||||
...(settings.notifications || {}),
|
||||
},
|
||||
developer: { ...DEFAULT_DEVELOPER_SETTINGS, ...(settings.developer || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate settings from old format to current version
|
||||
*/
|
||||
function migrateSettings(stored: any): AppSettings {
|
||||
// If it's already v2 format, validate and return
|
||||
if (stored && stored.__version === 1) {
|
||||
return validateSettings(stored);
|
||||
}
|
||||
|
||||
// Migrate from v1 (flat structure with only includeClientTag)
|
||||
const migrated: AppSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
};
|
||||
|
||||
if (stored && typeof stored === "object") {
|
||||
// Migrate old includeClientTag setting
|
||||
if ("includeClientTag" in stored) {
|
||||
migrated.post.includeClientTag = stored.includeClientTag;
|
||||
}
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from localStorage with migration support
|
||||
*/
|
||||
function loadSettings(): AppSettings {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
return migrateSettings(parsed);
|
||||
}
|
||||
|
||||
// Check for old settings key
|
||||
const oldStored = localStorage.getItem("grimoire-settings");
|
||||
if (oldStored) {
|
||||
const parsed = JSON.parse(oldStored);
|
||||
const migrated = migrateSettings(parsed);
|
||||
// Save to new key
|
||||
saveSettings(migrated);
|
||||
// Clean up old key
|
||||
localStorage.removeItem("grimoire-settings");
|
||||
return migrated;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load settings:", err);
|
||||
}
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings to localStorage with error handling
|
||||
*/
|
||||
function saveSettings(settings: AppSettings): void {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (err) {
|
||||
console.error("Failed to save settings:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings Manager
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Global settings manager with reactive updates
|
||||
* Use settings$ to reactively observe settings changes
|
||||
* Use getSection() for non-reactive access to a settings section
|
||||
* Use updateSection() to update an entire section
|
||||
* Use updateSetting() to update a specific setting within a section
|
||||
*/
|
||||
class SettingsManager {
|
||||
private settings$ = new BehaviorSubject<AppSettings>(loadSettings());
|
||||
|
||||
/**
|
||||
* Observable stream of settings
|
||||
* Subscribe to get notified of changes
|
||||
*/
|
||||
get stream$() {
|
||||
return this.settings$.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current settings value (non-reactive)
|
||||
*/
|
||||
get value(): AppSettings {
|
||||
return this.settings$.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific settings section
|
||||
*/
|
||||
getSection<K extends keyof Omit<AppSettings, "__version">>(
|
||||
section: K,
|
||||
): AppSettings[K] {
|
||||
return this.settings$.value[section];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific setting within a section
|
||||
* @example getSetting("post", "includeClientTag")
|
||||
*/
|
||||
getSetting<
|
||||
S extends keyof Omit<AppSettings, "__version">,
|
||||
K extends keyof AppSettings[S],
|
||||
>(section: S, key: K): AppSettings[S][K] {
|
||||
return this.settings$.value[section][key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an entire settings section
|
||||
* Automatically persists to localStorage
|
||||
*/
|
||||
updateSection<K extends keyof Omit<AppSettings, "__version">>(
|
||||
section: K,
|
||||
updates: Partial<AppSettings[K]>,
|
||||
): void {
|
||||
const newSettings = {
|
||||
...this.settings$.value,
|
||||
[section]: {
|
||||
...this.settings$.value[section],
|
||||
...updates,
|
||||
},
|
||||
};
|
||||
this.settings$.next(newSettings);
|
||||
saveSettings(newSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific setting within a section
|
||||
* Automatically persists to localStorage
|
||||
* @example updateSetting("post", "includeClientTag", true)
|
||||
*/
|
||||
updateSetting<
|
||||
S extends keyof Omit<AppSettings, "__version">,
|
||||
K extends keyof AppSettings[S],
|
||||
>(section: S, key: K, value: AppSettings[S][K]): void {
|
||||
const newSettings = {
|
||||
...this.settings$.value,
|
||||
[section]: {
|
||||
...this.settings$.value[section],
|
||||
[key]: value,
|
||||
},
|
||||
};
|
||||
this.settings$.next(newSettings);
|
||||
saveSettings(newSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all settings to defaults
|
||||
*/
|
||||
reset(): void {
|
||||
this.settings$.next(DEFAULT_SETTINGS);
|
||||
saveSettings(DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a specific section to defaults
|
||||
*/
|
||||
resetSection<K extends keyof Omit<AppSettings, "__version">>(
|
||||
section: K,
|
||||
): void {
|
||||
const newSettings = {
|
||||
...this.settings$.value,
|
||||
[section]: DEFAULT_SETTINGS[section],
|
||||
};
|
||||
this.settings$.next(newSettings);
|
||||
saveSettings(newSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export settings as JSON string
|
||||
*/
|
||||
export(): string {
|
||||
return JSON.stringify(this.settings$.value, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import settings from JSON string
|
||||
* Validates and migrates imported settings
|
||||
*/
|
||||
import(json: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
const validated = validateSettings(parsed);
|
||||
this.settings$.next(validated);
|
||||
saveSettings(validated);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to import settings:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global settings manager instance
|
||||
* Import this to access settings throughout the app
|
||||
*/
|
||||
export const settingsManager = new SettingsManager();
|
||||
@@ -24,6 +24,7 @@ export type AppId =
|
||||
| "wallet"
|
||||
| "zap"
|
||||
| "post"
|
||||
| "settings"
|
||||
| "win";
|
||||
|
||||
export interface WindowInstance {
|
||||
|
||||
@@ -855,4 +855,16 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
category: "Nostr",
|
||||
defaultProps: {},
|
||||
},
|
||||
settings: {
|
||||
name: "settings",
|
||||
section: "1",
|
||||
synopsis: "settings",
|
||||
description:
|
||||
"Configure Grimoire application settings. Includes post composition settings (client tag), appearance settings (theme, show client tags), and more. Settings are persisted to localStorage and synchronized across all windows.",
|
||||
examples: ["settings Open settings panel"],
|
||||
seeAlso: ["post", "help"],
|
||||
appId: "settings",
|
||||
category: "System",
|
||||
defaultProps: {},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user