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:
Alejandro
2026-01-21 23:11:25 +01:00
committed by GitHub
parent 53f29b9b63
commit 7b7b24d41a
15 changed files with 1032 additions and 72 deletions

156
package-lock.json generated
View File

@@ -20,8 +20,10 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tiptap/core": "^3.15.3",
@@ -3279,6 +3281,90 @@
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
@@ -3371,6 +3457,76 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",

View File

@@ -28,8 +28,10 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tiptap/core": "^3.15.3",

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View 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>
);
}

View File

@@ -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">

View File

@@ -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 ? (

View File

@@ -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 />

View 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,
};

View 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
View 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
View 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();

View File

@@ -24,6 +24,7 @@ export type AppId =
| "wallet"
| "zap"
| "post"
| "settings"
| "win";
export interface WindowInstance {

View File

@@ -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: {},
},
};