diff --git a/package-lock.json b/package-lock.json index 05e8e20..7e2cc7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 160f261..486e42f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/actions/delete-event.ts b/src/actions/delete-event.ts index 7b19cbd..6bf127c 100644 --- a/src/actions/delete-event.ts +++ b/src/actions/delete-event.ts @@ -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 diff --git a/src/actions/publish-spell.ts b/src/actions/publish-spell.ts index 0a56152..be1255a 100644 --- a/src/actions/publish-spell.ts +++ b/src/actions/publish-spell.ts @@ -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; diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx index af75d4a..149787e 100644 --- a/src/components/PostViewer.tsx +++ b/src/components/PostViewer.tsx @@ -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(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(() => { - 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( - (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 = {}) { - updateSetting("includeClientTag", checked) + updateSetting("post", "includeClientTag", checked) } > Include client tag diff --git a/src/components/SettingsViewer.tsx b/src/components/SettingsViewer.tsx new file mode 100644 index 0000000..cc4eb85 --- /dev/null +++ b/src/components/SettingsViewer.tsx @@ -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 ( +
+ +
+ + + + Appearance + + + + Post + + +
+ +
+ +
+

Appearance

+

+ Customize display preferences +

+
+ +
+
+
+ +

+ Choose your color scheme +

+
+ +
+ +
+
+ +

+ Display client identifiers in events +

+
+ + updateSetting("appearance", "showClientTags", checked) + } + /> +
+
+
+ + +
+

Post Settings

+

+ Configure event publishing +

+
+ +
+
+
+ +

+ Add Grimoire tag to published events +

+
+ + updateSetting("post", "includeClientTag", checked) + } + /> +
+
+
+
+
+
+ ); +} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index fa366d9..ad307ef 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -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 = ; break; + case "settings": + content = ; + break; default: content = (
diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 6b36d11..5c3bade 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -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} - {clientName && ( + {settings?.appearance?.showClientTags && clientName && ( via{" "} {clientAppPointer ? ( diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 5a7667c..8d6806a 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -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 */} - - - - Theme - - - {availableThemes.map((theme) => ( - setTheme(theme.id)} - > - - {theme.name} - - ))} - - + addWindow("settings", {}, "settings")} + > + + Settings + {/* Support Grimoire */} diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..b34659e --- /dev/null +++ b/src/components/ui/select.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..6338184 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts new file mode 100644 index 0000000..5ead8ee --- /dev/null +++ b/src/hooks/useSettings.ts @@ -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( + >( + section: K, + updates: Partial, + ) => { + settingsManager.updateSection(section, updates); + }, + [], + ); + + const updateSetting = useCallback( + < + S extends keyof Omit, + 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( + >(section: K) => { + settingsManager.resetSection(section); + }, + [], + ); + + return { + settings, + updateSection, + updateSetting, + reset, + resetSection, + }; +} diff --git a/src/services/settings.ts b/src/services/settings.ts new file mode 100644 index 0000000..b0f45fa --- /dev/null +++ b/src/services/settings.ts @@ -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(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>( + 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, + 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>( + section: K, + updates: Partial, + ): 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, + 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>( + 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(); diff --git a/src/types/app.ts b/src/types/app.ts index 779ff89..5b114e5 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -24,6 +24,7 @@ export type AppId = | "wallet" | "zap" | "post" + | "settings" | "win"; export interface WindowInstance { diff --git a/src/types/man.ts b/src/types/man.ts index 139b568..319765c 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -855,4 +855,16 @@ export const manPages: Record = { 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: {}, + }, };