diff --git a/src/actions/delete-event.ts b/src/actions/delete-event.ts index 7b19cbd..e45a1e5 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("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..0b38791 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("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..255371e 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?.includeClientTag) { additionalTags.push(GRIMOIRE_CLIENT_TAG); } @@ -692,7 +658,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) { updateSetting("includeClientTag", checked) } diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts new file mode 100644 index 0000000..18b98c8 --- /dev/null +++ b/src/hooks/useSettings.ts @@ -0,0 +1,33 @@ +/** + * 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 updateSetting = useCallback( + (key: K, value: AppSettings[K]) => { + settingsManager.updateSetting(key, value); + }, + [], + ); + + const updateSettings = useCallback((updates: Partial) => { + settingsManager.updateSettings(updates); + }, []); + + const reset = useCallback(() => { + settingsManager.reset(); + }, []); + + return { + settings, + updateSetting, + updateSettings, + reset, + }; +} diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index fd6b826..81f0bc1 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -25,6 +25,8 @@ import { GroupMessageBlueprint, ReactionBlueprint, } from "applesauce-common/blueprints"; +import { settingsManager } from "@/services/settings"; +import { GRIMOIRE_CLIENT_TAG } from "@/constants/app"; /** * NIP-29 Adapter - Relay-Based Groups @@ -471,6 +473,11 @@ export class Nip29Adapter extends ChatProtocolAdapter { } } + // Add client tag if enabled in settings + if (settingsManager.getSetting("includeClientTag")) { + draft.tags.push(GRIMOIRE_CLIENT_TAG); + } + // Sign the event const event = await factory.sign(draft); @@ -528,6 +535,11 @@ export class Nip29Adapter extends ChatProtocolAdapter { // Add h-tag for group context (NIP-29 specific) draft.tags.push(["h", groupId]); + // Add client tag if enabled in settings + if (settingsManager.getSetting("includeClientTag")) { + draft.tags.push(GRIMOIRE_CLIENT_TAG); + } + // Sign the event const event = await factory.sign(draft); @@ -868,6 +880,11 @@ export class Nip29Adapter extends ChatProtocolAdapter { ["relay", relayUrl], ]; + // Add client tag if enabled in settings + if (settingsManager.getSetting("includeClientTag")) { + tags.push(GRIMOIRE_CLIENT_TAG); + } + const draft = await factory.build({ kind: 9021, content: "", @@ -904,6 +921,11 @@ export class Nip29Adapter extends ChatProtocolAdapter { ["relay", relayUrl], ]; + // Add client tag if enabled in settings + if (settingsManager.getSetting("includeClientTag")) { + tags.push(GRIMOIRE_CLIENT_TAG); + } + const draft = await factory.build({ kind: 9022, content: "", @@ -984,6 +1006,11 @@ export class Nip29Adapter extends ChatProtocolAdapter { // Add the new group tag (use normalized URL for consistency) tags.push(["group", groupId, normalizedRelayUrl]); + // Add client tag if enabled in settings + if (settingsManager.getSetting("includeClientTag")) { + tags.push(GRIMOIRE_CLIENT_TAG); + } + // Create and publish the updated event const factory = new EventFactory(); factory.setSigner(activeSigner); @@ -1040,6 +1067,11 @@ export class Nip29Adapter extends ChatProtocolAdapter { throw new Error("Group is not in your list"); } + // Add client tag if enabled in settings + if (settingsManager.getSetting("includeClientTag")) { + tags.push(GRIMOIRE_CLIENT_TAG); + } + // Create and publish the updated event const factory = new EventFactory(); factory.setSigner(activeSigner); diff --git a/src/lib/create-zap-request.ts b/src/lib/create-zap-request.ts index 2381564..0e8cbf2 100644 --- a/src/lib/create-zap-request.ts +++ b/src/lib/create-zap-request.ts @@ -8,6 +8,8 @@ import type { NostrEvent } from "@/types/nostr"; import type { EventPointer, AddressPointer } from "./open-parser"; import accountManager from "@/services/accounts"; import { selectZapRelays } from "./zap-relay-selection"; +import { settingsManager } from "@/services/settings"; +import { GRIMOIRE_CLIENT_TAG } from "@/constants/app"; export interface EmojiTag { shortcode: string; @@ -128,6 +130,11 @@ export async function createZapRequest( } } + // Add client tag if enabled in settings + if (settingsManager.getSetting("includeClientTag")) { + tags.push(GRIMOIRE_CLIENT_TAG); + } + // Create event template const template = { kind: 9734, diff --git a/src/services/settings.ts b/src/services/settings.ts new file mode 100644 index 0000000..82368bb --- /dev/null +++ b/src/services/settings.ts @@ -0,0 +1,113 @@ +/** + * Global application settings + * Manages user preferences with localStorage persistence + */ + +import { BehaviorSubject } from "rxjs"; + +export interface AppSettings { + /** Whether to include client tag in published events */ + includeClientTag: boolean; +} + +const DEFAULT_SETTINGS: AppSettings = { + includeClientTag: true, +}; + +const SETTINGS_STORAGE_KEY = "grimoire-settings"; + +/** + * Load settings from localStorage with error handling + */ +function loadSettings(): AppSettings { + try { + const stored = localStorage.getItem(SETTINGS_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + return { ...DEFAULT_SETTINGS, ...parsed }; + } + } 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); + } +} + +/** + * Global settings manager + * Use settings$ to reactively observe settings changes + * Use getSetting() for non-reactive access + * Use updateSetting() to update a setting + */ +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 setting value + */ + getSetting(key: K): AppSettings[K] { + return this.settings$.value[key]; + } + + /** + * Update a specific setting + * Automatically persists to localStorage + */ + updateSetting( + key: K, + value: AppSettings[K], + ): void { + const newSettings = { ...this.settings$.value, [key]: value }; + this.settings$.next(newSettings); + saveSettings(newSettings); + } + + /** + * Update multiple settings at once + * Automatically persists to localStorage + */ + updateSettings(updates: Partial): void { + const newSettings = { ...this.settings$.value, ...updates }; + this.settings$.next(newSettings); + saveSettings(newSettings); + } + + /** + * Reset all settings to defaults + */ + reset(): void { + this.settings$.next(DEFAULT_SETTINGS); + saveSettings(DEFAULT_SETTINGS); + } +} + +/** + * Global settings manager instance + * Import this to access settings throughout the app + */ +export const settingsManager = new SettingsManager();