From c5c0dccff29e03707447c431b00d274ffb3004de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 20:28:37 +0000 Subject: [PATCH] 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. --- src/actions/delete-event.ts | 8 ++ src/actions/publish-spell.ts | 10 ++- src/components/PostViewer.tsx | 42 +-------- src/hooks/useSettings.ts | 33 +++++++ src/lib/chat/adapters/nip-29-adapter.ts | 32 +++++++ src/lib/create-zap-request.ts | 7 ++ src/services/settings.ts | 113 ++++++++++++++++++++++++ 7 files changed, 206 insertions(+), 39 deletions(-) create mode 100644 src/hooks/useSettings.ts create mode 100644 src/services/settings.ts 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();