Commit Graph

65 Commits

Author SHA1 Message Date
Alejandro Gómez
b25c2db89d feat: add useStableRelayFilterMap with structural filter comparison
Stabilizes relay filter map references using isFilterEqual per relay
instead of JSON.stringify. Avoids serialization overhead for large
filter maps with many relays and pubkeys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:49:39 +01:00
Alejandro Gómez
0851cb03e9 perf: reduce Map allocations and subscription churn in REQ timeline hook
- Skip duplicate events in setEventsMap (return prev if event.id exists)
- Only create new relayStates Map on actual state transitions (waiting→receiving),
  not on every event — counter increments applied in-place
- Don't add unknown relays to the state map (skip defensive init)
- Cap streaming eventsMap at 2000 with 25% batch eviction of oldest events
- Decouple relay filter map from subscription lifecycle: store in ref,
  only tear down subscriptions when the relay SET changes (not filter content)
- Use useStableRelayFilterMap for structural comparison instead of JSON.stringify

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:49:33 +01:00
Alejandro Gómez
62785fa336 feat: add inbox (#p) chunking to relay filter splitting and clean up logging
Extend relay filter chunking to route #p tags to inbox/read relays,
matching the existing outbox/write routing for authors. Remove debug
console.log statements across the codebase while preserving error-level
logging. Delete unused logger utility. Expand test coverage for all
chunking scenarios.
2026-03-23 09:59:24 +01:00
Alejandro Gómez
9af896127e wip: relay filter chunking for REQ subscriptions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:59:24 +01:00
Alejandro Gómez
cd889d70cb feat: add outbox relay resolution to spellbook loading
Spellbook URLs only queried hardcoded aggregator relays, missing events
published to other relays. Now fetches the author's kind:10002 relay list
and includes their outbox relays when loading kind:30777 spellbook events.

Extract useUserRelays hook from inline pattern and refactor
useRepositoryRelays to use it.
2026-03-18 11:07:46 +01:00
Alejandro Gómez
400e60107f fix: don't transition away from eose state 2026-03-17 10:25:25 +01:00
Alejandro Gómez
80bd6c4e72 fix: normalize supported NIPs to strings 2026-03-06 15:09:02 +01:00
Alejandro Gómez
a7c70fa0a6 fix: simplify nip-29 group message and metadata fetching and avoid inconsistencies 2026-03-06 13:25:58 +01:00
Alejandro
80a421b9fe Centralize relay publishing via PublishService (#211)
* feat: centralize publish flow with RxJS-based PublishService

Create a unified PublishService that:
- Provides consistent relay selection (outbox + state + hints + fallbacks)
- Emits RxJS observables for per-relay status updates
- Handles EventStore integration automatically
- Supports both fire-and-forget and observable-based publishing

Refactor all publish locations to use the centralized service:
- hub.ts: Use PublishService for ActionRunner publish
- delete-event.ts: Use PublishService (fixes missing eventStore.add)
- publish-spell.ts: Use PublishService with relay hint support
- PostViewer.tsx: Use publishWithUpdates() for per-relay UI tracking

This lays the groundwork for the event log feature by providing
observable hooks into all publish operations.

* feat: add LOG command for relay event introspection

Add an ephemeral event log system that tracks relay operations:

- EventLogService (src/services/event-log.ts):
  - Subscribes to PublishService for PUBLISH events with per-relay status
  - Monitors relay pool for CONNECT/DISCONNECT events
  - Tracks AUTH challenges and results
  - Captures NOTICE messages from relays
  - Uses RxJS BehaviorSubject for reactive updates
  - Circular buffer with configurable max entries (default 500)

- useEventLog hook (src/hooks/useEventLog.ts):
  - React hook for filtering and accessing log entries
  - Filter by type, relay, or limit
  - Retry failed relays directly from the hook

- EventLogViewer component (src/components/EventLogViewer.tsx):
  - Tab-based filtering (All/Publish/Connect/Auth/Notice)
  - Expandable PUBLISH entries showing per-relay status
  - Click to retry failed relays
  - Auto-scroll to new entries (pause on scroll)
  - Clear log button

- LOG command accessible via Cmd+K palette

* fix: prevent duplicate log entries and check relay OK response

- EventLogService: Check for existing entry before creating new one
  when handling publish events (prevents duplicates from start/complete)
- PublishService: Check response.ok from pool.publish() to detect
  relay rejections instead of assuming success on resolve
- Update test mock to return proper publish response format

* feat: keep relay selection in call site, compact logs

* chore: cleanup

* fix: make Timestamp component locale-aware via formatTimestamp

Timestamp was hardcoded to "es" locale. Now uses formatTimestamp()
from useLocale.ts for consistent locale-aware time formatting.
Added Timestamp to CLAUDE.md shared components documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: improve event-log reliability, add ERROR type and per-relay timing

Service improvements:
- Fix notice$ duplicate logging with per-relay dedup tracking
- Remove dead Array.isArray code path (notice$ emits strings)
- Increase relay poll interval from 1s to 5s
- Clean publishIdToEntryId map on terminal state, not just overflow
- Immutable entry updates (spread instead of in-place mutation)
- Extract NewEntry<T>/AddEntryInput helper types for clean addEntry signature
- Clear lastNoticePerRelay on log clear

New capabilities:
- ERROR log type: subscribes to relay.error$ for connection failure reasons
- RelayStatusEntry with updatedAt timestamp for per-relay response timing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: improve EventLogViewer with virtualization, timing, and error display

- Virtualize log list with react-virtuoso for 500-entry buffer performance
- Add ErrorEntry renderer for new ERROR log type (AlertTriangle icon)
- Show per-relay response time (e.g. "142ms", "2.3s") in publish details
- Make all entry types expandable (connect/disconnect now have details)
- Show absolute timestamp in all expanded detail views
- Group ERROR events under Connect tab filter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prevent duplicate PUBLISH log entries from completion event

PublishService emits publish$ twice: once at start, once on completion.
The eager publishIdToEntryId cleanup in handleStatusUpdate fired before
the completion emission, causing handlePublishEvent to create a second
entry. Removed eager cleanup — overflow eviction is sufficient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-04 17:35:44 +01:00
Alejandro Gómez
15fe8b6c59 fix: parse address 2026-03-03 22:22:35 +01:00
Alejandro Gómez
772e1b1404 refactor: extract useRepositoryRelays hook from NIP-34 renderers
Removes ~45 lines of identical relay resolution boilerplate duplicated
across 6 renderers (Issue, Patch, PR - feed and detail views).

The hook encapsulates the 3-tier fallback: repo relays → owner outbox →
aggregators, and also returns the repository event needed for
getValidStatusAuthors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 21:58:11 +01:00
Alejandro
8c9ecd574c refactor(editor): replace DOM manipulation with React node views and floating-ui (#253)
* refactor(editor): replace DOM manipulation with React node views and floating-ui

- Convert all inline node views (emoji, blob attachment, event preview) from
  imperative document.createElement() to React components via ReactNodeViewRenderer
- Replace tippy.js with @floating-ui/react-dom for suggestion popup positioning
- Create useSuggestionRenderer hook bridging Tiptap suggestion callbacks to React state
- Extract shared EmojiMention, SubmitShortcut, and inline node extensions to separate files
- Extract types (EmojiTag, BlobAttachment, SerializedContent) to editor/types.ts
- Extract serialization logic to editor/utils/serialize.ts
- Remove redundant DOM keydown listener from RichEditor (handled by SubmitShortcut extension)
- Remove tippy.js dependency (-1045 lines net, RichEditor 632→297, MentionEditor 1038→354)

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* fix(editor): fix suggestion popover positioning, scrolling, and profile click behavior

- Replace UserName component in ProfileSuggestionList with plain text display
  so clicking a suggestion autocompletes instead of opening their profile
  (UserName has an onClick that calls addWindow and stopPropagation)
- Add react-virtuoso to ProfileSuggestionList for efficient lazy rendering
  of up to 20 search results with fixed item height scrolling
- Add profile avatars with lazy loading and initial-letter fallback
- Fix SuggestionPopover positioning with autoUpdate for scroll/resize tracking
- Add size middleware to constrain popover max-height to available viewport space

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* refactor(editor): convert emoji suggestion from grid to scrollable list with Virtuoso

Replace the 8-column grid layout with a vertical list matching the profile
suggestion style — each row shows the emoji preview alongside its :shortcode:
name. Uses react-virtuoso with fixedItemHeight for lazy rendering and smooth
keyboard-driven scrolling through large emoji sets.

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* fix(editor): set mentionSuggestionChar to ':' for emoji nodes

When backspacing over a mention-based node, Tiptap inserts the node's
mentionSuggestionChar attribute as undo text. The EmojiMention extension
inherits Mention's default of '@', so deleting an emoji left '@' instead
of ':'. Fix by explicitly setting mentionSuggestionChar: ':' in the emoji
command's attrs for both RichEditor and MentionEditor.

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* test(editor): add comprehensive test suite for custom TipTap extensions

Tests all 8 custom extensions using headless TipTap Editor instances in
jsdom environment (TipTap has no official testing package):

- EmojiMention: schema, renderText (unicode vs custom), mentionSuggestionChar
  attribute handling, backspace behavior regression test
- BlobAttachmentRichNode/InlineNode: schema (block vs inline), attributes,
  renderText URL serialization, parseHTML selectors
- NostrEventPreviewRichNode/InlineNode: schema, renderText encoding for
  note/nevent/naddr back to nostr: URIs
- SubmitShortcut: Mod-Enter always submits, Enter behavior with
  enterSubmits flag
- FilePasteHandler: media type filtering (image/video/audio), non-media
  rejection, mixed paste filtering, edge cases (no files, no callback)
- NostrPasteHandler: bech32 regex matching (npub/note/nevent/naddr/nprofile),
  nostr: prefix handling, URL exclusion, node creation (mention vs preview),
  surrounding text preservation, multiple entities
- Serialization: formatBlobSize, serializeRichContent (emoji tag extraction,
  blob dedup, address refs), serializeInlineContent (mention→nostr: URI,
  emoji→shortcode, blob→URL, event preview encoding)

90 new tests total.

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* fix(editor): paste handler and serialization bugs found via adversarial testing

NostrPasteHandler fixes:
- Punctuation after bech32 now matches (npub1..., npub1...! etc.)
  Changed trailing lookahead from (?=$|\s) to (?=$|\s|[.,!?;:)\]}>])
- Fixed double-space between entities — unconditional " " after every
  entity caused doubled spaces. Now only adds trailing space when entity
  is at the very end of pasted text (for cursor positioning).
- Tightened regex character class from [\w] to [a-z0-9] to match actual
  bech32 charset (rejects uppercase, underscore)
- Wrapped dispatch in try/catch to handle block-node-at-inline-position
  errors gracefully (falls back to default paste)

Serialization fix:
- serializeRichContent now guards blob collection with `url && sha256`
  matching the defensive checks already in serializeInlineContent.
  Previously null sha256 would corrupt the dedup Set and null url would
  produce invalid BlobAttachment entries.

Added 22 new edge case tests:
- Paste handler: punctuation boundaries, double-space regression,
  malformed bech32 fallback, uppercase rejection, error resilience
- Serialization: empty editor, null sha256/url blobs, invalid pubkey
  fallback, missing mention attrs, inline dedup, multi-paragraph

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* fix(editor): raise suggestion search limits for profiles and emojis

Both suggestion dropdowns use Virtuoso for virtualized rendering, so
they can handle large result sets without performance issues. The
previous limits (20 profiles, 24 emojis) were too restrictive — users
with many custom emojis sharing a substring or large contact lists
couldn't scroll to find the right match.

Raised both limits to 200 to allow thorough browsing while still
bounding the result set.

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* refactor(chat): rework emoji picker to scrollable list with search

Replace the fixed 1-row grid (8 emojis) with a scrollable virtualized
list matching the editor's EmojiSuggestionList look & feel:

- Search box at top with magnifying glass icon
- Virtuoso-backed scrollable list (8 visible items, unlimited results)
- Each row shows emoji icon + :shortcode: label
- Keyboard navigation: arrow keys to select, Enter to confirm
- Mouse hover highlights, click selects
- Frequently used emojis still shown first when no search query
- Narrower dialog (max-w-xs) for a compact picker feel

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* fix: add address field to EmojiTag in editor types, fix GroupMessageOptions

- Add optional `address` field to EmojiTag in editor/types.ts to match
  NIP-30 changes from main (30030 emoji set address)
- Extend GroupMessageOptions with MetaTagOptions to fix type error in
  GroupMessageBlueprint's setMetaTags call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editor): restore address attr, fix serialization, UserName, no-scroll

- Restore `address` attribute in shared EmojiMention extension (emoji.ts)
  that was dropped during refactor — required for NIP-30 emoji set tracking
- Extract `address` from emoji nodes in both serializeRichContent and
  serializeInlineContent so it makes it into published events
- Fix MentionEditorProps.onSubmit signature: use EmojiTag[] (not the narrower
  inline type) so address field flows through to callers
- Restore UserName component in ProfileSuggestionList for proper display
  with Grimoire member badges and supporter flame
- Remove scrollbar when all items fit: set overflow:hidden on Virtuoso when
  items.length <= MAX_VISIBLE (profile list, emoji list, emoji picker dialog)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-03 21:50:32 +01:00
Alejandro
d630a72409 Add relay list management UI and settings integration (#254)
* feat(settings): add relay lists management section

- Fetch additional relay list kinds (10006, 10007, 10050) on login
- Add "Relays" tab to Settings with accordion UI for each relay list kind
- Support NIP-65 relay list (kind 10002) with read/write markers
- Support blocked relays (10006), search relays (10007), DM relays (10050)
- Add/remove relays with URL sanitization and normalization
- Explicit save button publishes only modified lists as replaceable events

https://claude.ai/code/session_01JHirYU56sKDKYhRx6aCQ54

* docs: add plan for honoring blocked & search relay lists

Detailed implementation plan for:
- Kind 10006: filter blocked relays from all connection paths
- Kind 10007: use search relays for NIP-50 queries

https://claude.ai/code/session_01JHirYU56sKDKYhRx6aCQ54

* refactor(settings): extract relay list logic into tested lib, fix UX issues

Extract parseRelayEntries, buildRelayListTags, sanitizeRelayInput, and
comparison/mode helpers into src/lib/relay-list-utils.ts with 52 tests
covering roundtrips, normalization, edge cases, and mode conversions.

UX fixes:
- Replace RelayLink (navigates away on click) with static RelaySettingsRow
- Remove redundant inbox/outbox icons (mode dropdown is sufficient)
- Always-visible delete button instead of hover-only opacity
- Per-accordion dirty indicator (CircleDot icon) for modified lists
- Discard button to reset all changes
- Read-only account explanation text
- Human-friendly descriptions (no NIP references or kind numbers)
- Separator between relay list and add input
- Larger relay icons and text for readability

https://claude.ai/code/session_01JHirYU56sKDKYhRx6aCQ54

* feat(settings): use KindBadge and NIPBadge in relay list accordions

Replace plain text kind names with KindBadge (full variant showing icon,
name, and kind number) and add NIPBadge next to each list description.
This gives power users the protocol context they expect.

Also document KindBadge and NIPBadge as shared components in CLAUDE.md.

https://claude.ai/code/session_01JHirYU56sKDKYhRx6aCQ54

* feat(settings): add favorite relays list (kind 10012) to relay settings

Add kind 10012 (Favorite Relays / Relay Feeds) to the settings UI and
account sync fetching. Uses "relay" tags like other NIP-51 lists.

https://claude.ai/code/session_01JHirYU56sKDKYhRx6aCQ54

* fix(kinds): use semantic icons for blocked and search relay lists

- Kind 10006 (Blocked Relays): Radio → ShieldBan
- Kind 10007 (Search Relays): Radio → Search

These icons propagate to KindBadge, settings accordions, and event
renderers via getKindInfo(). Generic relay kinds (10002, 30002, etc.)
keep the Radio icon.

https://claude.ai/code/session_01JHirYU56sKDKYhRx6aCQ54

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 13:11:27 +01:00
Alejandro
dd6b30b82e Add NIP-85 Trusted Assertions support with renderers (#252)
* feat: add NIP-85 Trusted Assertions feed & detail renderers

Add support for NIP-85 Trusted Assertion events (kinds 30382-30385) and
the Trusted Provider Declaration (kind 10040) with kind constants,
helper library, and feed + detail renderers.

- Add kind entries for 10040, 30382, 30383, 30384, 30385 to EVENT_KINDS
- Create src/lib/nip85-helpers.ts with cached helpers for parsing
  assertion data (user, event, address, external) and provider lists
- Create shared TrustedAssertionRenderer for all 4 assertion kinds with
  rank bar, subject display, and compact metrics preview
- Create TrustedAssertionDetailRenderer with full metrics table,
  rank visualization, topics, and raw tag fallback
- Create TrustedProviderListRenderer/DetailRenderer for kind 10040
  with provider table and encrypted entries indicator
- Register all renderers in kinds/index.tsx

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* refactor: extract NIP-73 helpers and shared ExternalIdentifierDisplay

Move getExternalIdentifierIcon() and getExternalIdentifierLabel() from
nip22-helpers.ts into a new nip73-helpers.ts since they are NIP-73
utilities, not NIP-22 specific. Add inferExternalIdentifierType() and
getExternalIdentifierHref() helpers.

Create shared ExternalIdentifierDisplay components (inline + block
variants) that use proper NIP-73 type-specific icons (Globe for web,
BookOpen for ISBN, Podcast for podcasts, Film for ISAN, etc.) instead
of a generic ExternalLink icon.

- Kind 1111 renderer now uses ExternalIdentifierInline for root scope
- Kind 30385 assertion renderer uses ExternalIdentifierInline (feed)
  and ExternalIdentifierBlock (detail) for NIP-73 subjects
- nip22-helpers.ts re-exports from nip73-helpers for compatibility

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* fix: UI polish for NIP-85 renderers

Address review feedback across all NIP-85 components:

Icon: Replace BarChart3 with ShieldCheck for assertion kind constants
(30382-30385) — communicates "verified trust data" vs generic analytics.

Feed renderer: Subject is now the visual anchor (ClickableEventTitle)
with kind label as a small outline Badge above it. Rank bar widened to
w-32, color-coded green/yellow/red by score threshold. Fix "Zaps Recd"
abbreviation to "Zaps In".

Detail renderer: Metrics grouped into Activity, Zaps, Moderation
sections with uppercase section headers. Kind 30384 addresses now
show "Kind X by <UserName> / d-tag" instead of raw hex. ExternalMetrics
type badges now show NIP-73 icons + friendly labels (getExternalTypeLabel)
instead of raw k-tag values like "podcast:item:guid".

Provider list: Feed uses compact Badge for kind:tag + count summary.
Detail uses stacked cards instead of 3-column table for narrow panels.

ExternalIdentifierBlock: Linked blocks now show dotted underline on
label + ExternalLink icon for clear click affordance.

Add getExternalTypeLabel() to nip73-helpers for k-value display names.

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* fix: use Progress component for rank bars, consistent sats formatting

Replace custom rank bar divs with the existing Progress UI component
(with new indicatorClassName prop for color-coded fills).

Make sat amount displays consistent with zap receipt renderer pattern:
value and "sats" unit are rendered as separate elements — numeric value
in font-medium, unit in smaller muted text — matching how Kind9735
displays amounts across the app.

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* fix: use RelayLink for relay URLs, remove redundant kindTag, add relay hints

Provider list renderers now use RelayLink instead of raw relay URL
strings — shows favicon, insecure ws:// warning, opens relay detail
on click.

Remove kindTag display from provider cards — it's an internal
protocol detail redundant in the UI context.

Pass relay hints from provider entries to UserName so profiles can
be fetched from the relay the provider actually publishes to.

Add UserName relayHints prop (forwarded to useProfile).

Add RelayLink and UserName to Shared Components section in CLAUDE.md
so they're consistently used across the codebase.

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* docs: expand shared components section in CLAUDE.md

Add BaseEventContainer, ClickableEventTitle, RichText, QuotedEvent,
and CustomEmoji to the shared components reference. These are the
core building blocks used across all kind renderers — documenting
them prevents re-implementation and ensures consistent patterns.

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* docs: trim shared components list in CLAUDE.md

Remove BaseEventContainer and QuotedEvent — these are internal
patterns that kind renderer authors already know from context,
not general-purpose components that get misused or forgotten.

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* feat: show metric type labels in trusted assertion feed view

Add Label components to the assertion feed renderer so you can see at a
glance which metrics an assertion carries (Followers, Posts, Zaps, etc.)
instead of just numeric values. Also swap Badge → Label for the kind
indicator for visual consistency.

Replace hardcoded green/yellow/red rank colors with theme variables
(success/warning/destructive) in both feed and detail renderers so the
rank bar works correctly across all themes.

Add Label to CLAUDE.md shared components list (22 imports across the
codebase).

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* feat: show provider kind tag in trusted provider list renderers

Add Label with formatKindTag() to both feed and detail views so each
provider row shows what it provides (e.g. "User Assertion: Rank").
Also swap Badge → Label for consistency with the assertion renderers.

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* fix: stabilize relayHints in useProfile to prevent fetch abort loop

relayHints was used directly in the useEffect dependency array, so
callers passing a new array literal (e.g. [p.relay]) on every render
caused the effect to re-run each cycle — aborting the previous network
fetch before it could complete. The IndexedDB fast-path masked this in
the feed view (profiles already cached), but the detail view showed
raw pubkeys because profiles were never fetched from the network.

Wrap relayHints in a JSON.stringify-based useMemo (same pattern as
useStableArray) so the effect only re-runs when the actual relay
values change.

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 09:14:14 +01:00
Alejandro
6c2adc01e0 refactor: extract relay auth manager into standalone package (#249)
* refactor: extract relay auth manager into standalone package

Decouple NIP-42 relay authentication from Grimoire internals into a
generic, framework-agnostic package at packages/relay-auth-manager/.

The new package uses dependency injection for pool, signer, and storage
(localStorage-like interface), making it reusable in any applesauce-based
app. Fixes the bug where auth prompts appeared even when the signer
couldn't sign events - now only emits pending challenges when a signer
is available.

Key changes:
- New package with RelayAuthManager class, pure auth state machine,
  and comprehensive test suite (103 tests)
- Grimoire's relay-state-manager now delegates all auth logic to the
  package, retaining only connection/notice tracking
- Auth preferences moved from Dexie to localStorage via pluggable storage
- Reactive signer lifecycle: auto-auth re-evaluates when signer appears
- Pool relay lifecycle via add$/remove$ observables (no polling)

https://claude.ai/code/session_01XqrjeQVtJKw9uC1XAw6rqd

* fix: prevent auth prompts when signer is unavailable

Three bugs fixed:

1. Race condition in emitState(): states$ was emitted before
   pendingChallenges$, so relay-state-manager's states$ subscriber
   would read stale pendingChallenges$.value. Now pendingChallenges$
   is emitted first for consistent reads.

2. relay-state-manager only subscribed to states$, missing
   pendingChallenges$ changes. Now subscribes to both.

3. canAccountSign used constructor.name which is fragile under
   minification. Now uses account.type !== "readonly" (stable
   property from applesauce-accounts).

Added 3 regression tests verifying pendingChallenges$.value
consistency when observed from states$ subscribers.

https://claude.ai/code/session_01XqrjeQVtJKw9uC1XAw6rqd

* fix: migrate auth preferences from Dexie to localStorage

One-time migration preserves existing user preferences: reads from
Dexie relayAuthPreferences table, writes to localStorage, injects
into the running manager, then clears the Dexie table. Skips if
localStorage already has data (idempotent).

Removes the DexieAuthStorage adapter — sync/async impedance mismatch
made it fragile. localStorage is the right fit for the sync
getItem/setItem interface.

https://claude.ai/code/session_01XqrjeQVtJKw9uC1XAw6rqd

* docs: add README for relay-auth-manager package

Covers constructor options, observables, methods, auth lifecycle,
preferences, storage interface, state machine, and dependency
interfaces.

https://claude.ai/code/session_01XqrjeQVtJKw9uC1XAw6rqd

* fix: address 3 bugs in relay-auth-manager

1. State machine: authenticated + CHALLENGE_RECEIVED with "never"
   preference now correctly auto-rejects instead of entering a dead
   challenge_received state that nobody acts on.

2. Auto-auth error handlers now guard with `status === "authenticating"`
   before overwriting state, preventing a late-resolving promise from
   clobbering a valid state transition (e.g. disconnect → "none").

3. API no longer leaks mutable internal references. getRelayState(),
   getAllStates(), getAllPreferences(), and states$ emissions all return
   shallow copies. Previously, consumers holding a reference to a
   previous emission would see it silently mutate.

All three fixes include regression tests (110 tests, up from 103).

https://claude.ai/code/session_01XqrjeQVtJKw9uC1XAw6rqd

* feat: make relay-auth-manager production-ready

- authenticate() now waits for authenticated$ confirmation via
  firstValueFrom(race(...)) — resolves only when relay confirms,
  rejects on disconnect. Uses Promise.all to avoid unhandled rejections.
- Add retry() for relays stuck in "failed" state.
- Add removePreference() to prevent unbounded preference growth.
- Add normalizeUrl option for custom URL normalization — preferences and
  session rejections now consistently normalized via normalizeForKey().
- Wire Grimoire's normalizeRelayURL as the normalizer in relay-auth.ts.
- Update README with new methods, normalizeUrl option, retry lifecycle.
- 123 package tests passing.

https://claude.ai/code/session_01XqrjeQVtJKw9uC1XAw6rqd

* chore: prepare relay-auth-manager for npm publishing

- Point exports/main/types to dist/ for correct npm resolution
- Add tsconfig.build.json for emitting ESM JS + declarations
- Add build/clean/prepublishOnly scripts
- Add LICENSE, description, keywords, author, files field
- Add Vite resolve alias + tsconfig paths for workspace dev
  (resolves source directly, no pre-build needed for dev)
- Fix TypeScript strict errors in test file
- Clean npm pack output: dist/, README.md, LICENSE only

https://claude.ai/code/session_01XqrjeQVtJKw9uC1XAw6rqd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:52:50 +01:00
Fernando López Guevara
a7395055af fix(time-format): avoid displaying "0m ago"; show weeks instead (#235) 2026-01-31 00:13:15 +01:00
Alejandro
a75dd2b3fb Remove compact mode settings and simplify settings architecture (#232)
* refactor: remove unused compactModeKinds from app state

The compactModeKinds feature was partially implemented but never used:
- Settings dialog allowed configuration but the value was never consumed
- REQ viewer uses --view flag for compact mode, not this state value

Removed:
- compactModeKinds from GrimoireState type and initial state
- setCompactModeKinds logic function and state hook callback
- Compact Events section from SettingsDialog (now shows placeholder)
- compactModeKinds from AppearanceSettings in settings service
- Migration now strips compactModeKinds if present instead of adding it

https://claude.ai/code/session_01DiWUxiS5BAzU9mrKvCUuMW

* chore: delete unused SettingsDialog component

The SettingsDialog was imported but never used - the Settings menu
item opens a window via addWindow("settings", ...) instead.

https://claude.ai/code/session_01DiWUxiS5BAzU9mrKvCUuMW

* refactor: simplify settings service to only used settings

Removed unused settings that were defined but never consumed:
- RelaySettings (fallbackRelays, discoveryRelays, outbox*, etc.)
- PrivacySettings (shareReadReceipts, blurWalletBalances, etc.)
- DatabaseSettings (maxEventsCached, autoCleanupDays, etc.)
- NotificationSettings (enabled, notifyOnMention, etc.)
- DeveloperSettings (debugMode, showEventIds, logLevel, etc.)
- Most of AppearanceSettings (theme, fontSizeMultiplier, etc.)
- Most of PostSettings (defaultRelayMode, customPostRelays)

Only kept settings that are actually used:
- post.includeClientTag
- appearance.showClientTags

Also simplified useSettings hook to match.

https://claude.ai/code/session_01DiWUxiS5BAzU9mrKvCUuMW

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-30 14:23:30 +01:00
Alejandro
8f2f055566 feat: repository tree visualization (#31)
* docs: add plan for repository tree visualization feature

Comprehensive plan covering:
- git-natural-api library analysis and API documentation
- useGitTree/useGitBlob hooks for fetching git data
- FileTreeView component using Radix Collapsible
- Shiki migration with on-demand language loading
- Multi-server fallback for redundant clone URLs
- Dexie caching for offline access

* docs: add comprehensive Shiki migration plan

Detailed plan for migrating from Prism.js to Shiki with lazy loading:
- Analysis of all 8 components using SyntaxHighlight
- Shiki service with singleton highlighter and on-demand language loading
- Custom Grimoire dark theme matching current Prism styles
- Language alias mapping for 50+ extensions
- React hook for async highlighting with loading states
- CSS theme preserving minimalistic dark aesthetics
- Migration steps with rollback plan

* feat: migrate syntax highlighting from Prism.js to Shiki

Replace Prism.js with Shiki for syntax highlighting with several key improvements:

- Lazy loading: Languages loaded on-demand via dynamic imports instead of bundling all upfront
- Broader language support: 200+ TextMate grammars vs 11 statically imported
- Singleton highlighter: Core languages (JS, TS, JSON, diff, bash) preloaded, others loaded on first use

New files:
- src/lib/shiki.ts: Shiki service with highlightCode(), normalizeLanguage(), language aliases
- src/hooks/useHighlightedCode.ts: React hook for async highlighting with loading states
- src/styles/shiki-theme.css: Grimoire dark theme matching previous minimalistic style

Updated components:
- SyntaxHighlight: Now uses Shiki with graceful loading/error states
- CodeSnippetRenderer/DetailRenderer: Simplified, removed manual language mapping
- MarkdownContent: Removed type casts, any language now supported

Removed:
- prismjs and @types/prismjs dependencies
- src/styles/prism-theme.css

* feat: add repository file tree visualization

Add file tree explorer to the Repository detail renderer (kind 30617)
using @fiatjaf/git-natural-api for fetching git trees via HTTP.

New files:
- src/lib/git-types.ts: TypeScript types for DirectoryTree, SelectedFile, etc.
- src/hooks/useGitTree.ts: Hook to fetch git repository tree from clone URLs
  - Tries multiple clone URLs in sequence
  - Uses getDirectoryTreeAt with filter capability when available
  - Falls back to shallowCloneRepositoryAt otherwise
- src/hooks/useGitBlob.ts: Hook to fetch individual file content by hash
  - Detects binary files
  - Returns both raw Uint8Array and decoded text
- src/components/ui/FileTreeView.tsx: Recursive tree view component
  - Collapsible directories with chevron icons
  - File icons based on extension (code, json, text, image, etc.)
  - Alphabetical sorting with directories first
- src/components/nostr/kinds/RepositoryFilesSection.tsx: Main integration
  - Side-by-side tree and file preview layout
  - Syntax-highlighted file content using existing SyntaxHighlight
  - Binary file detection with appropriate UI
  - Loading/error states

Modified:
- RepositoryDetailRenderer.tsx: Added RepositoryFilesSection below relays

Dependencies:
- Added @fiatjaf/git-natural-api from JSR

* fix: improve repository tree visualization UX

- Collapse directories by default in file tree
- Hide files section on tree loading error
- Add code-like skeleton loader with file header
- Fix syntax highlight size jump between loading/loaded states
- Replace purple accent with grayscale theme
- Preload Rust and Markdown languages for reliable highlighting

* refactor: improve git tree and syntax highlighting

- Remove shallow clone fallback from useGitTree, only use no-blobs fetch
  Servers without filter capability are skipped instead of downloading blobs
- Add light theme support for Shiki syntax highlighting
  Theme is automatically selected based on current color scheme

* fix: improve dark theme contrast for syntax highlighting

* fix: address code review issues

- useGitTree: use useStableArray for cloneUrls to fix dependency tracking
- useGitTree/useGitBlob: add isMounted checks to prevent state updates after unmount
- RepositoryFilesSection: remove unnecessary useMemo for language
- FileTreeView: use path instead of hash for React keys
- shiki: track failed languages to avoid repeated console warnings

* fix: improve dark theme contrast for syntax highlighting

- Add CSS variables for syntax highlighting instead of hardcoded colors
- Add --syntax-constant and --syntax-tag variables to light and dark themes
- Use high contrast colors for dark mode (bright green strings, purple keywords)
- Simplify Shiki transformer to output CSS classes instead of inline styles
- Remove unused parameters from transformer callback

* fix: restore syntax highlighting colors

Revert the CSS class-based approach which was failing to classify tokens.
Instead, keep Shiki's inline styles from the theme and only remove
backgrounds to let CSS handle those. The theme colors now provide
syntax highlighting directly.

* feat: add copy button and CSS variable-based syntax highlighting

- Add copy button next to file name in file viewer header (icon-only)
- Use Shiki's createCssVariablesTheme for proper theme integration
- Map Shiki CSS variables to our theme system variables
- Syntax highlighting now works correctly across all themes (light/dark)

* refactor: create IconCopyButton component and use CopyCheck consistently

- Add IconCopyButton component for reusable icon-only copy buttons
- Refactor RepositoryFilesSection to use IconCopyButton
- Replace Check with CopyCheck in ChatMessageContextMenu
- Replace Check with CopyCheck in BaseEventRenderer
- Use text-success instead of text-green-500 for consistency

* fix: add HTML, CSS, TOML to core languages and expand token mappings

- Add html, css, toml to CORE_LANGUAGES for eager loading
- Add variableDefaults to cssVarsTheme for proper initialization
- Expand shiki-theme.css with more token type mappings:
  - HTML/XML: tag, attribute, attr-value
  - CSS: selector, property
  - Additional: variable, operator, number, boolean, regex, etc.

* fix: improve diff line spacing with flex layout

- Use flex-col with gap-0 on code element for tight line packing
- Reduce line-height from 1.5 to 1.4 for tighter spacing
- Add .line display:block with min-height for consistent sizing
- Simplify diff background styling (remove negative margin hack)

* fix: improve code block line spacing and wrap long lines

- Increase line-height from 1.4 to 1.5 for better readability
- Use pre-wrap instead of pre to allow long line wrapping
- Add overflow-wrap: break-word to break long URLs/strings

* chore: remove planning docs

* chore: update @fiatjaf/git-natural-api to 0.2.3

* fix: make code blocks horizontally scrollable with full-width diff backgrounds

- Use white-space: pre for horizontal scrolling instead of wrapping
- Add width: fit-content and min-width: 100% to code element
- Ensure diff line backgrounds extend full width when scrolling

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-30 12:35:59 +01:00
Alejandro
f3cc7779e3 feat: make NWC connections more robust and wallet state reactive (#227)
* fix(nwc): improve connection reliability and add health tracking

- Add connection status observable (disconnected/connecting/connected/error)
- Validate wallet connection on restore using support$ observable with 10s timeout
- Add notification subscription error recovery with exponential backoff (5 retries)
- Add retry logic for balance refresh (3 retries with backoff)
- Use library's support$ observable for wallet capabilities (cached by applesauce)
- Replace manual getInfo() calls with reactive support$ subscription
- Add visual connection status indicator in WalletViewer header
- Add reconnect button when connection is in error state
- Store network info in cached connection for display

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* feat(wallet): add copy NWC connection string button to header

Adds a copy button (Copy/Check icons) in the wallet header before the
refresh button that copies the NWC connection string to clipboard for
easy sharing or backup.

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* fix(wallet): use CopyCheck icon and fix transaction loading

- Change Check to CopyCheck icon for copy NWC string button
- Add walletMethods computed value that combines support$ observable
  with cached info fallback from initial connection
- Fix transaction history not loading because support$ waits for
  kind 13194 events which many NWC wallets don't publish
- The cached info from getInfo() RPC call is now used as fallback

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* refactor(nwc): simplify with derived state from observables

Production-ready refactor of NWC implementation:

nwc.ts:
- Add wallet$ observable for reactive wallet instance access
- Remove redundant subscribeToSupport() - only needed for validation
- Cleaner code organization with clear sections

useWallet.ts:
- All state derived from observables (no useState for wallet)
- Move walletMethods computation to hook (reusable)
- isConnected derived from connectionStatus
- Simplified from 240 to 170 lines

WalletViewer.tsx:
- Use walletMethods from hook instead of local useMemo
- Simpler connection state tracking via connectionStatus
- Remove redundant wallet variable from destructuring
- No color change on copy NWC string (per feedback)

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* refactor(wallet): use useCopy hook for clipboard operations

Replace manual clipboard state management with useCopy hook:
- copyInvoice/invoiceCopied for generated invoice
- copyRawTx/rawTxCopied for transaction JSON
- copyNwc/nwcCopied for NWC connection string

Benefits:
- Cleaner code (removed manual setTimeout calls)
- Automatic timeout cleanup on unmount
- Consistent copy behavior across all clipboard operations

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* feat(wallet): implement lazy-loaded transactions observable

- Add shared wallet types (Transaction, TransactionsState) in src/types/wallet.ts
- Add transactionsState$ observable to NWC service for shared tx state
- Implement loadTransactions, loadMoreTransactions, and retryLoadTransactions
- Auto-refresh transactions on payment notifications
- Simplify WalletViewer to use observable state instead of local state
- Remove manual transaction loading logic from component

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* fix(wallet): update balance observable on initial connect

- Call refreshBalance() in createWalletFromURI to fetch initial balance
- Update balance$ directly when ConnectWalletDialog gets balance
- Fixes issue where WalletViewer showed "-" after connecting while
  user menu showed correct balance (different data sources)

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* refactor(wallet): use single data source for balance across UI

Remove fallback to nwcConnection.balance in user-menu - now both
WalletViewer and user-menu use balance$ observable as the single
source of truth for wallet balance.

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* fix(wallet): address code review issues and simplify user menu

- Fix memory leak: track retry timeout and clear on disconnect
- Add explicit WalletSupport type for support observable
- Add comments explaining balance refresh error handling behavior
- Add comment about restoreWallet not being awaited intentionally
- User menu now uses connectionStatus observable (shows connecting/error states)
- Remove wallet name display from user menu (simplifies UI)
- Remove unused walletServiceProfile hook and getWalletName function

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* refactor(wallet): extract WalletConnectionStatus component

- Create reusable WalletConnectionStatus component for connection indicator
- Remove rounded borders from indicator (now square)
- Export getConnectionStatusColor helper for custom usage
- Use component in both user-menu and WalletViewer
- Supports size (sm/md), showLabel, and className props

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-29 18:02:40 +01:00
Alejandro
2ad3f90174 Improve mobile UX with larger touch targets (#223)
- Add useIsMobile hook for viewport detection
- TabBar: larger height (48px), disable reorder on mobile, hide workspace numbers
- Header buttons: larger touch targets for SpellbookDropdown, UserMenu, LayoutControls
- Window toolbar: larger buttons (40px) on mobile
- Mosaic dividers: wider (12px) on mobile for easier dragging
- CommandLauncher: larger items, footer text, hide kbd hints on mobile
- Editor suggestions: responsive widths, min-height 44px touch targets
- EventFooter: larger kind/relay buttons on mobile
- CodeCopyButton: larger padding and icon on mobile
- MembersDropdown: larger trigger and list items on mobile

All changes use mobile-first Tailwind (base styles for mobile, md: for desktop)
to meet Apple HIG 44px minimum touch target recommendation.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:19:31 +01:00
Alejandro
e008d76021 feat: NIP-34 status events (#209)
* feat(nip34): Add NIP-34 issue status renderers and locale-aware formatting

- Add IssueStatusRenderer for feed view (kinds 1630-1633: Open/Resolved/Closed/Draft)
- Add IssueStatusDetailRenderer for detail view with status badge and embedded issue
- Update IssueRenderer/IssueDetailRenderer to fetch and display current issue status
- Status validation respects issue author, repo owner, and maintainers
- Add status helper functions to nip34-helpers.ts (getStatusType, findCurrentStatus, etc.)
- Use parseReplaceableAddress from applesauce-core for coordinate parsing
- Expand formatTimestamp utility with 'long' and 'datetime' styles
- Fix locale-aware date formatting across all detail renderers
- Update CLAUDE.md with useLocale hook and formatTimestamp documentation

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* refactor(nip34): Use theme semantic colors for issue status

Replace hardcoded colors with theme semantic colors:
- Resolved/merged: accent (positive)
- Closed: destructive (negative)
- Draft: muted
- Open: neutral foreground

Also fixes import placement in nip34-helpers.ts.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(nip34): Use repository relays instead of AGGREGATOR_RELAYS

Status events for issues are now fetched from the relays configured
in the repository definition, not from hardcoded aggregator relays.

This respects the relay hints provided by repository maintainers for
better decentralization and reliability.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* perf(nip34): Add memoization caching to helper functions

Use getOrComputeCachedValue from applesauce-core to cache computed
values on event objects. This prevents redundant computation when
helpers are called multiple times for the same event.

Also added documentation in CLAUDE.md about best practices for
writing helper libraries that compute data from Nostr events.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(nip34): Add relay fallback chain for status event fetching

Status events now use a fallback chain for relay selection:
1. Repository configured relays (from "relays" tag)
2. Repo author's outbox relays (from kind:10002)
3. AGGREGATOR_RELAYS as final fallback

This ensures status events can be fetched even when repository
doesn't have relays configured.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* feat(nip34): Add status rendering to Patch and PR renderers

- PatchRenderer and PatchDetailRenderer now show merge/closed/draft status
- PullRequestRenderer and PullRequestDetailRenderer now show merge/closed/draft status
- Status events fetched from repository relays with author outbox fallback
- For patches and PRs, kind 1631 displays as "merged" instead of "resolved"
- Fixed destructive color contrast in dark theme (30.6% -> 50% lightness)

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* refactor(nip34): Extract StatusIndicator component, improve UI layout

- Create reusable StatusIndicator component for issues/patches/PRs
- Move status icon next to status text in feed renderers (not title)
- Place status badge below title in detail renderers
- Fix dark theme destructive color contrast (0 90% 65%)
- Remove duplicate getStatusIcon/getStatusColorClass functions

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(nip34): Make status badge width fit content

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(theme): Improve destructive color contrast on dark theme

Increase lightness from 65% to 70% for better readability.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(theme): Use lighter coral red for destructive on dark theme

Changed to 0 75% 75% (~#E89090) for better contrast against #020817 background.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* docs: Fix applesauce helper documentation in CLAUDE.md

- Fix parseCoordinate -> parseReplaceableAddress (correct function name)
- Clarify getTagValue (applesauce) vs getTagValues (custom Grimoire)
- Add getOrComputeCachedValue to helpers list
- Improve code example with proper imports and patterns

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(nip34): Render status event content as rich text

Use MarkdownContent component for status event content in
Issue, Patch, and PR detail renderers.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(nip34): Smaller status indicators, improve issue feed layout

- Use shared StatusIndicator in IssueStatusRenderer (smaller size)
- Render status event content as markdown
- Put status on its own line between title and repo in IssueRenderer

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(nip34): Use warning color for closed status instead of destructive

- Change closed status from red (destructive) to orange (warning)
- Improve dark theme status colors contrast (warning: 38 92% 60%)
- Less aggressive visual for closed issues/patches/PRs

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-23 15:31:20 +01:00
Alejandro
7b7b24d41a 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>
2026-01-21 23:11:25 +01:00
Alejandro
b1fb569250 feat: user profile search for resolving usernames when pasting (#184)
* fix: show display names when pasting npub/nprofile in editors

Previously, pasting npub/nprofile strings would only check the EventStore's
in-memory cache for profiles. If a profile was cached in IndexedDB but not
yet loaded into the EventStore, it would show a hex preview instead.

This adds a ProfileCache service that:
- Loads profiles from IndexedDB on startup for instant access
- Subscribes to EventStore for new profiles as they arrive
- Provides synchronous lookups for the paste handler

Also uses consistent fallback format (XXXX:YYYY) when no profile is found.

* refactor: make ProfileSearchService a singleton for shared profile lookups

Instead of creating a separate ProfileCache service, refactored
ProfileSearchService to be a singleton that:
- Auto-initializes on module load
- Loads profiles from IndexedDB for instant startup
- Subscribes to EventStore for new profiles

This allows both the paste handler and mention autocomplete to share
the same profile cache, eliminating duplicate data and subscriptions.

Removed the now-unnecessary profile-cache.ts.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-21 13:38:59 +01:00
Alejandro
94982ca7f4 feat(post): add POST command with rich text editor and relay selection (#180)
* feat(post): add POST command with rich text editor and relay selection

Phase 3: Create POST command for publishing kind 1 notes

Features:
- RichEditor component with @mentions, :emoji: autocomplete
- Image/video upload via Blossom with drag-and-drop
- Relay selection UI with write relays pre-selected by default
- Per-relay publish status tracking (loading/success/error)
- Submit with Ctrl/Cmd+Enter keyboard shortcut
- Multi-line editing with rich previews for attachments
- NIP-30 emoji tags and NIP-92 imeta tags for attachments

Implementation:
- Add 'post' to AppId type
- Add POST command to man pages
- Create PostViewer component using RichEditor
- Wire PostViewer into WindowRenderer
- Publish events using EventFactory and RelayPool
- Track per-relay status with visual indicators

Usage:
- Run 'post' command to open the composer
- Type content with @mentions and :emoji:
- Upload media via button or drag-and-drop
- Select/deselect relays before publishing
- Press Publish button or Ctrl/Cmd+Enter to post
- View per-relay publish status in real-time

* feat(editor): add nostr bech32 paste handler with inline previews

Implements paste handling for nostr: URIs (npub, note, nevent, naddr, nprofile)
that transforms them into rich inline preview chips in the chat composer.

Changes:
- Add NostrEventPreview TipTap node type for displaying bech32 previews
- Add NostrPasteHandler extension to detect and transform pasted bech32 strings
- Update serializeContent to convert previews back to nostr: URIs on submit
- Add CSS styling for preview chips with hover effects
- Support all major bech32 types: npub, note, nevent, naddr, nprofile

Features:
- Automatic detection of nostr: URIs in pasted text (with or without prefix)
- Visual preview chips with type icon, label, and truncated ID
- Maintains nostr: URI format in final message content (NIP-27 compatible)
- Simple implementation without event fetching (fast, no loading states)
- Styled with primary theme colors for consistency

Technical details:
- Uses ProseMirror Plugin API for paste interception
- Inline atomic nodes for previews (similar to mentions and blob attachments)
- Regex pattern matches all valid bech32 formats
- Proper error handling for invalid bech32 strings
- Extensible foundation for future rich metadata fetching

* refactor(editor): improve nostr preview display with kind icons

Updates the nostr event preview chips to show more meaningful information:

Changes:
- Add getKindIcon() helper function with 15+ kind mappings
- Display format: kind-icon + author/id (e.g., "📄 a1b2c3d4")
- For naddr: show kind icon + author pubkey (first 8 chars)
- For nevent: show kind icon + author if available, else event ID
- For note: show 📝 + event ID (first 8 chars)
- For npub/nprofile: show 👤 + pubkey (first 8 chars)
- Removed verbose type labels (NPUB, NEVENT, etc.)
- Increased label max-width to 120px for better readability

Kind icon mappings:
- 👤 Profile (kind 0)
- 📝 Note (kind 1)
- 👥 Contacts (kind 3)
- 🔁 Repost (kind 6)
- ❤️ Reaction (kind 7)
-  Zap (kind 9735)
- 📄 Long-form (kind 30023)
- 🎙️ Live event (kind 30311)
- 📦 File metadata (kind 1063)
- 📌 Addressable events (30000-39999)
- 🔄 Replaceable events (10000-19999)

Result: More compact, visually intuitive previews that immediately
convey the content type and author without requiring event fetching.

* refactor(editor): simplify nostr preview display and reuse mentions

Major simplification of the nostr bech32 preview display:

Profile handling (npub/nprofile):
- Now creates regular @mention nodes instead of custom preview nodes
- Reuses existing mention infrastructure, styling, and UserName component
- Displays as "@username" with existing mention chip styling
- Serializes to nostr:npub1... on submit (same as manual @mentions)

Event/Address display (note/nevent/naddr):
- Removed emoji icons for cleaner, more minimal appearance
- Display format: "event abc12345" for note/nevent
- Display format: "address article-slug" for naddr (shows d identifier)
- Falls back to short pubkey if naddr has no d identifier
- Simple text-only chips with type + identifier

Benefits:
- Less visual noise (no emojis)
- Consistent mention styling for all profiles
- Profile mentions can now be clicked/hovered like manual mentions
- Smaller code footprint (removed complex icon mapping logic)
- Better UX: profiles look and behave like regular mentions

Technical changes:
- Paste handler creates mention nodes for npub/nprofile
- NostrEventPreview only handles note/nevent/naddr now
- Removed npub/nprofile from serialization (handled by mention serializer)
- Updated type definitions to reflect reduced scope

* feat(editor): add drag-and-drop and paste file upload support

Adds comprehensive file upload support via drag-and-drop and paste
directly into the chat composer editor.

Changes to MentionEditor:
- Add FileDropHandler TipTap extension to intercept file drops/pastes
- Filter for valid media types (image/video/audio)
- Add onFileDrop callback prop to communicate with parent
- Handle both drag-and-drop and clipboard paste events

Changes to useBlossomUpload hook:
- Update open() method to accept optional File[] parameter
- Add initialFiles state to track pre-selected files
- Clear initialFiles when dialog closes
- Pass initialFiles to BlossomUploadDialog component

Changes to BlossomUploadDialog:
- Add initialFiles prop for pre-selecting files
- Auto-select first file when initialFiles provided
- Generate preview URL for images/video on initialization
- Seamless UX: dropped files immediately appear in dialog

Changes to ChatViewer:
- Wire up onFileDrop callback to open upload dialog
- Pass dropped files to upload dialog via open(files)
- Fix onClick handler to properly call openUpload()

User experience:
- Drag image/video/audio file onto chat composer → upload dialog opens
- Paste image from clipboard → upload dialog opens
- File automatically selected and previewed
- Click upload to complete (same as manual file selection)

Technical details:
- Uses ProseMirror Plugin API for drop/paste interception
- File type validation: /^(image|video|audio)\//
- Bonus: Also works with clipboard paste (Ctrl+V)
- Clean state management with automatic cleanup

* fix(editor): add dragover handler to enable file drag-and-drop

The drag-and-drop wasn't working because we need to handle dragover
events and call preventDefault() to signal that drops are allowed.

Changes:
- Add handleDOMEvents with dragover handler to FileDropHandler
- Check for Files type in dataTransfer
- Set dropEffect to 'copy' for visual feedback
- Call preventDefault() to allow drops

Without this, the browser blocks all drops by default and handleDrop
never fires. This is a standard requirement for HTML5 drag-and-drop API.

* feat(editor): show usernames when pasting npub/nprofile mentions

When pasting npub or nprofile URIs, the editor now looks up the profile
from the event store cache and uses the display name if available.

Changes:
- Add getDisplayNameForPubkey helper function
- Import eventStore and profile helpers
- Synchronously check if profile is cached via .value property
- Use getDisplayName from nostr-utils for consistent formatting
- Falls back to short pubkey (first 8 chars) if profile not cached

Result:
- Paste npub1abc... → shows "@alice" if profile is cached
- Paste npub1abc... → shows "@abc12345" if profile not cached
- Consistent with typed @mentions behavior
- No async delays or loading states needed

* refactor(editor): remove drag-and-drop, keep paste file support

Removes drag-and-drop file handling while keeping clipboard paste functionality.
Drag-and-drop will be implemented differently later.

Changes:
- Rename FileDropHandler → FilePasteHandler
- Remove handleDrop and dragover event handlers
- Keep handlePaste for clipboard file support
- Rename onFileDrop prop → onFilePaste for clarity
- Update ChatViewer to use onFilePaste callback
- Update all references and comments

Result:
- Paste images from clipboard (Ctrl+V) still works
- Drag-and-drop removed completely
- Clearer naming reflects actual functionality

* refactor(editor): extract shared extensions and create React node views

Phase 1 of RichEditor variant implementation:

- Extract NostrPasteHandler extension for reuse across editors
- Extract FilePasteHandler extension for clipboard file handling
- Create NostrEventPreviewRich React node view using DetailKindRenderer
- Create BlobAttachmentRich React node view with full-size media previews
- Create rich TipTap node extensions using ReactNodeViewRenderer
- Update MentionEditor to use shared extensions

These shared components will be used by the upcoming RichEditor
variant for long-form content composition with full previews.

* feat(editor): add RichEditor component for long-form content

Phase 2 of RichEditor variant implementation:

- Create RichEditor component based on MentionEditor
- No slash commands (removed SlashCommand extension)
- Multi-line support with Enter for newlines
- Ctrl/Cmd+Enter to submit
- Full-size image/video previews using BlobAttachmentRich
- Full event rendering using NostrEventPreviewRich
- Configurable min/max height (defaults: 200px-600px)
- Retains @mentions and :emoji: autocomplete
- Reuses shared extensions (NostrPasteHandler, FilePasteHandler)
- Targets kind 1 notes composition

Key differences from MentionEditor:
- Block-level rich previews instead of inline badges
- Multi-line editing without Enter-to-submit
- Resizable with overflow-y: auto
- No command palette functionality

* refactor(post): improve PostViewer layout and editor previews

UI improvements:
- Remove flex-1 from editor, use fixed min/max height
- Move upload button next to publish button (icon-only)
- Place relay selector below action buttons (not fixed)
- Limit image/video previews to max-h-96
- Add pointer-events-none to event previews to prevent accidental interaction
- Use EventDetailSkeleton for loading event previews

Layout changes:
- Single scrollable container with space-y-4
- Editor: 150-400px height range
- Action buttons in single row (upload icon + publish button)
- Relay list with max-h-64 scroll area
- Better spacing and visual hierarchy

* feat(post): add draft persistence and improve relay UI

Draft persistence:
- Save draft content to localStorage every 2 seconds
- Load draft on component mount (per-user with pubkey)
- Clear draft after successful publish
- Prevent data loss on tab change or page reload

UI improvements:
- Reset button now icon-only (RotateCcw) with tooltip
- Remove borders from relay list items
- Use RelayLink component for relay rendering
- Show relay icons and secure/insecure indicators
- Cleaner, more compact relay list appearance
- Increased spacing between relay items (space-y-1)

Storage key format: grimoire-post-draft-{pubkey}

* feat(post): enhance draft storage with full JSON state persistence

Draft persistence improvements:
- Store complete editor state as JSON (preserves blobs, emojis, mentions, formatting)
- Save selected relays array in draft
- Restore full editor content using setContent() method
- Maintain draft per-user with pubkey-based key

RichEditor enhancements:
- Add getJSON() method to export editor state
- Add setContent() method to restore from JSON
- Enables lossless draft save/restore

UI improvements:
- Change "Relays" label to plain text without Label component
- Keep unselected relays visible during/after publish
- Only update status for selected relays during publish

Draft storage format:
{
  editorState: {...},      // Full TipTap JSON state
  selectedRelays: [...],   // Array of selected relay URLs
  timestamp: 1234567890
}

* feat(post): add nostr tag extraction, retry failed relays, and disable empty publish

Nostr tag extraction:
- Extract p tags from @mentions (pubkey references)
- Extract e tags from note/nevent references (event ID references)
- Extract a tags from naddr references (address/parameterized replaceable)
- Update SerializedContent interface to include mentions, eventRefs, addressRefs
- Serialize editor content walks all node types to extract references
- Build complete tag array for kind 1 events with proper NIP compliance

Retry failed relays:
- Add retryRelay() function to republish to specific failed relay
- Make error icon clickable with hover state
- Show "Click to retry" in tooltip
- Rebuild event and attempt publish again
- Update status indicators in real-time

Disable publish when empty:
- Track isEditorEmpty state
- Update every 2 seconds along with draft save
- Disable publish button when editor isEmpty()
- Prevents publishing empty notes

Tag generation order:
1. p tags (mentions)
2. e tags (event references)
3. a tags (address references)
4. emoji tags (NIP-30)
5. imeta tags (NIP-92 blob attachments)

This ensures proper Nostr event structure with all referenced pubkeys, events, and addresses tagged.

* feat(post): add client tag and remove reset button

Client tag:
- Add ["client", "grimoire"] tag to all published events
- Added to both handlePublish and retryRelay functions
- Identifies posts as coming from Grimoire client

UI cleanup:
- Remove reset button from relay selection UI
- Relay selection persists across sessions via draft storage
- Simplify header to just show relay count
- Remove unused RotateCcw icon import

Tag generation order (final):
1. p tags (mentions)
2. e tags (event references)
3. a tags (address references)
4. client tag (grimoire)
5. emoji tags (NIP-30)
6. imeta tags (NIP-92)

* fix(post): trim content before publishing

Content trimming:
- Trim content in handlePublish() before building event
- Trim content in retryRelay() before building event
- Removes leading and trailing whitespace from published notes
- Prevents accidental whitespace-only or padded messages
- Applied via content.trim() before factory.build()

* fix(post): reuse signed event for retries instead of recreating

Event reuse for retries:
- Add lastPublishedEvent state to store the signed event
- Store signed event after creation in handlePublish()
- Simplify retryRelay() to republish the same signed event
- Remove duplicate event creation and signing in retryRelay()
- Remove duplicate client tag addition in retryRelay()

Benefits:
- Same event ID and signature across all relays
- More efficient - no need to re-sign
- Consistent event across retries
- Single source of truth for published event

Flow:
1. User publishes - event created, signed, stored
2. Some relays fail - event remains in state
3. User retries - same event republished to failed relay
4. New post overwrites lastPublishedEvent with new event

* feat(post): add published event preview and reset button

After successful publish, replace editor and action buttons with:
- Kind1Renderer preview of the published event
- Reset button to compose another post
- Relay list remains visible with publish status indicators

Users can click "Compose Another Post" to reset the form and
return to the editor for the next post.

* feat(post): improve layout and relay handling

- Fix double scrollbar issue by using flexbox layout instead of overflow-auto
- Disable relay checkboxes during preview mode (can't change after publish)
- Use aggregator relays as fallback when no write relays configured
- Remove conditional error message since fallback relays always available
- Improve layout responsiveness with flex-1 and flex-shrink-0 classes

Aggregator relays (nos.lol, relay.snort.social, relay.primal.net, relay.damus.io)
ensure users can always publish even without configured write relays.

* fix(editor): use feed renderer and skeleton for event previews

When pasting event URLs in the editor, use KindRenderer (feed) and
EventCardSkeleton instead of DetailKindRenderer and EventDetailSkeleton.

Event previews in the editor are inline/compact like feed items, so they
should use the feed renderer rather than the detail view renderer.

* fix(post): remove flex-1 and improve scrolling layout

- Change layout from flex-col with flex-1 to overflow-y-auto on outer container
- Remove flex-1 from all child elements to prevent layout issues with complex posts
- Hide scrollbar in RichEditor for cleaner appearance
- Single scrollbar on container prevents double-scrollbar issues
- Editor now truly full-width without extra padding

This creates a more predictable layout that avoids overlaps and weird
behavior when posts contain lots of content (images, videos, embedded events).

* feat(post): add max-width constraint to content

Add max-w-2xl (768px) with mx-auto to center and constrain post content
width, similar to ZapWindow. Prevents content from becoming too wide on
large screens while maintaining good readability.

* fix(editor): add remove button and fix cursor positioning for event embeds

- Add X button on hover to NostrEventPreviewRich (same pattern as BlobAttachmentRich)
- Fix cursor positioning after pasting nevent/naddr by tracking node sizes correctly
- Import TextSelection from prosemirror-state to properly set cursor position
- Change from `tr.insert(from + index, node)` to `tr.insert(insertPos, node)` with cumulative position tracking

Previously, cursor would stay behind the event embed node after pasting.
Now it correctly positions after the inserted content.

* feat(post): add discard button, settings dropdown, and improve button layout

- Add Discard button to clear editor state and draft
- Add Settings dropdown with client tag toggle (enabled by default)
- Limit publish button width to w-32 and push to right with spacer
- Conditionally include client tag based on setting
- Improve action button layout with better spacing

* feat(post): add relay connectivity status, input widget, and improve error handling

- Show relay connectivity status (Server/ServerOff icons) next to each relay in list
- Add input widget to add custom relays during post composition
  - Validates relay URLs (supports with/without wss:// prefix)
  - Normalizes URLs automatically (defaults to wss://)
  - Shows Plus button enabled only when input is valid
  - Press Enter or click Plus to add relay
- Improve signing failure handling: draft is only cleared after successful publish
- New relay input widget disabled during preview mode
- Connect to relay pool state using use$() hook for reactive connectivity status

Technical details:
- Import Server, ServerOff, Plus icons and Input component
- Add newRelayInput state for relay URL input
- Use pool.relays$ observable to get real-time connection state
- Create isValidRelayInput() validator with URL pattern matching
- Create handleAddRelay() to normalize and add relays to list
- Update relay list rendering to show connectivity icons
- Add input + button widget after relay list

* feat(post): add auth status icons and window-specific draft persistence

- Show auth status icon next to connectivity icon for each relay
  - Uses getAuthIcon() utility from relay-status-utils
  - Displays shield icons for authenticated/failed/rejected states
  - Includes tooltip with auth status label
- Make draft persistence window-specific using window ID
  - Draft key format: `grimoire-post-draft-{pubkey}-{windowId}`
  - Each post window maintains its own independent draft
  - Falls back to pubkey-only key when windowId not available
- Remove toast notification when adding relays (less noisy UX)
  - Still shows error toast if relay URL is invalid
- Pass windowId prop from WindowRenderer to PostViewer

Technical details:
- Import getAuthIcon from @/lib/relay-status-utils
- Import useRelayState hook to get relay auth status
- Add PostViewerProps interface with optional windowId
- Update all draft key computations to include windowId
- Update dependency arrays for useEffect/useCallback with windowId
- Get relay state via getRelay(url) for auth icon display

* feat(post): add global persistent settings and event JSON preview

Settings (persist in localStorage, global across all post windows):
- "Include client tag" - toggle whether to add client tag to events (default: true)
- "Show event JSON" - display copyable JSON preview of event (default: false)

Features:
- Settings stored in localStorage at "grimoire-post-settings"
- Settings persist across sessions and windows
- Event JSON preview shows unsigned event initially, updates to signed version after signing
- Preview displays "(Signed)" or "(Unsigned)" label
- Uses CopyableJsonViewer component for syntax highlighting and copy button
- Preview limited to max-h-64 with scrolling

Draft persistence improvements:
- Added relays now saved in draft state under "addedRelays" field
- On draft load, custom relays are restored to relay list
- Relays identified as "added" if not in user's write relay list

Technical details:
- Added PostSettings interface with includeClientTag and showEventJson fields
- Settings loaded on mount from localStorage, saved on change via useEffect
- updateSetting callback handles individual setting updates
- previewEvent state holds current event (unsigned or signed)
- setPreviewEvent called before and after signing in handlePublish
- Draft format: { editorState, selectedRelays, addedRelays, timestamp }
- Draft loading checks addedRelays and restores non-duplicate relays

* feat(post): add live draft JSON preview with 2-second updates

This commit enhances the POST command with a live JSON preview feature:

- Added generateDraftEventJson() that builds unsigned draft event from editor content
- JSON preview updates every 2 seconds when "Show event JSON" setting is enabled
- Extracts all tags from editor: mentions (p), events (e), addresses (a), emojis, blobs (imeta)
- Displays as "Event JSON (Draft - Unsigned)" to distinguish from signed events
- Uses CopyableJsonViewer component for syntax highlighting and copying

The live preview helps users understand event structure before publishing.

* feat(post): improve JSON preview responsiveness and layout

This commit addresses UX feedback on the JSON preview feature:

- Reduced update interval from 2000ms to 200ms for much more responsive UI
- Moved JSON preview below relay list for better visual flow
- Increased max-height from 256px to 400px for better visibility
- Separated JSON update logic into dedicated effect for cleaner code
- JSON preview now feels instant when typing (10x faster updates)

The JSON preview is now much more pleasant to use and doesn't feel
laggy when editing content.

* refactor(post): replace intervals with debounced onChange handlers

This commit improves the code quality and UX of the POST command:

**Debounced onChange Pattern:**
- Added onChange prop to RichEditor that fires on every content change
- Replaced interval-based updates with proper debounced handlers
- Draft saves debounced to 2000ms (unchanged behavior, cleaner code)
- JSON updates debounced to 200ms (same responsiveness, event-driven)
- Much cleaner React pattern - no weird intervals

**JSON Preview Improvements:**
- Removed "Event JSON" title header for cleaner look
- Shows signed event JSON when published and setting is enabled
- Shows draft (unsigned) JSON when composing
- Always scrollable up to 400px height

**Technical Improvements:**
- Added onUpdate handler to TipTap editor config
- Proper cleanup of debounce timeouts on unmount
- Type-safe timeout refs using ReturnType<typeof setTimeout>

* refactor(post): remove JSON preview and ensure proper content serialization

**Removed JSON preview feature:**
- Removed showEventJson setting and all related UI
- Removed draftEventJson state and generation logic
- Removed "Show event JSON" checkbox from settings
- Simplified PostViewer code by ~100 lines
- Cleaner, production-ready codebase

**Ensured proper content serialization:**
- Added renderText() to Mention extension to serialize as nostr:npub URIs
- Added renderText() to BlobAttachmentRichNode to serialize URLs
- NostrEventPreviewRichNode already had renderText() for nostr:note/nevent/naddr
- Editor now properly converts all rich content to plain text:
  - @mentions → nostr:npub...
  - Event references → nostr:note/nevent...
  - Address references → nostr:naddr...
  - Blob attachments → URLs
  - Custom emojis → :shortcode:

**Result:**
- Cleaner, simplified code ready for production
- All editor elements properly serialize to content string
- JSON preview can be re-added later with better implementation

* perf(post): reduce draft save debounce from 2s to 500ms

Makes the draft saving feel more responsive without hammering localStorage
on every keystroke.

* fix(post): preserve content when signing fails or is rejected

Critical bug fix: Separate signing from publishing to handle failures correctly.

**The Problem:**
- When user rejected signing (or signing failed), we treated it as a publishing failure
- Set all relay states to "error" even though we never tried to publish
- Destroyed the user's post content

**The Solution:**
- Split signing and publishing into separate try-catch blocks
- If signing fails: show toast, reset isPublishing, and RETURN EARLY
- User keeps their content and can try again
- Only update relay states if we actually attempted to publish

**User Experience:**
- User rejects signing → toast appears, post is preserved, can edit or try again
- Publishing fails → relay states show errors, can retry individual relays
- No more losing your carefully crafted post to a rejected signature!

* refactor(post): use Promise.allSettled for relay publishing

Use Promise.allSettled instead of Promise.all to wait for all relay
publish attempts to complete, regardless of individual success/failure.

More defensive and semantically correct since we're handling errors
individually per relay and want all attempts to finish.

* feat(post): use GRIMOIRE_CLIENT_TAG constant with NIP-89 app address

Update PostViewer to use the new GRIMOIRE_CLIENT_TAG constant from main
which includes the NIP-89 app definition address (31990) as the third
element.

Before: ["client", "grimoire"]
After: ["client", "grimoire", "31990:..."]

This allows Nostr clients to display a clickable link to Grimoire's
app definition event.

* fix(post): preserve content when publishing fails to all relays

Critical UX improvement: Only clear draft and show preview if at least
one relay succeeded.

**The Problem:**
- When publishing failed to ALL relays, we still cleared the draft
- User lost their content with no way to retry
- Frustrating experience when all relays are down

**The Solution:**
- Track success/failure by returning result from each publish promise
- Count successful publishes from Promise.allSettled results
- Only clear draft and show preview if successCount > 0
- Keep editor visible with content if all relays fail

**User Experience:**
- All relays succeed → Clear draft, show preview, success toast
- Some relays succeed → Clear draft, show preview, warning toast
- All relays fail → Keep editor with content, error toast, can retry

**Toast Messages:**
- All succeeded: "Published to all X relays"
- Partial success: "Published to X of Y relays"
- Total failure: "Failed to publish to any relay. Please check..."

* fix(post): preserve content when signing or publishing

Critical fix: Don't clear editor content until publish succeeds.

**The Problem:**
- RichEditor automatically cleared content on submit
- Content was wiped immediately when clicking "Publish"
- User lost their content before knowing if publish succeeded

**The Solution:**
- Remove automatic `clearContent()` from RichEditor's handleSubmit
- Let PostViewer control when to clear the editor
- Only clear editor after successful publish (successCount > 0)

**User Experience Now:**
- Click "Publish" → content stays visible while publishing
- Publishing succeeds → content clears, show preview
- Publishing fails → content stays, user can edit and retry
- User rejects signing → content stays, can try again

No more losing your post to a rejected signature or failed publish!

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-21 13:03:30 +01:00
Alejandro
13fec0345f feat: zap goals nip-75 (#174)
* feat: add NIP-75 Zap Goal rendering

Add feed and detail view rendering for kind 9041 (Zap Goals):

- GoalRenderer: Shows clickable title, description, and progress bar
  with target/raised amounts
- GoalDetailRenderer: Adds sorted contributor breakdown with
  individual contribution totals
- nip75-helpers: Helper functions for extracting goal metadata
  (amount, relays, deadline, beneficiaries)

Both views fetch and tally zaps from the goal's specified relays.

* fix: improve NIP-75 goal rendering

- Remove icons from goal renderers
- Remove "sats" suffixes from amounts
- Use muted text instead of destructive color for closed goals
- Content is the title, summary tag is the description
- Only show description if summary tag exists

* feat: add zap button to goal renderers

Show a 'Zap this Goal' button in both feed and detail views
when the goal is still open (not past its closed_at deadline).

* style: unify progress indicator styles

- Update user menu and welcome page progress indicators to match
  goal renderer style: bar on top, progress/total below with percentage
- Remove "sats" suffix from progress displays
- Make goal zap button primary variant and full-width

* fix: polish goal renderers for release

- Remove limit parameter from useTimeline (use whatever relays send)
- Add more spacing between "Support Grimoire" header and progress bar

* refactor: extract useGoalProgress hook for NIP-75 goals

- Create useGoalProgress hook with shared goal logic
- Handle relay selection: goal relays → user inbox → aggregators
- Calculate progress, contributors, and all metadata in one place
- Simplify both GoalRenderer and GoalDetailRenderer

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-20 17:28:57 +01:00
Alejandro
9e11fb590f Add donate call to action feature (#150)
* feat: add donate CTA and supporter recognition system

Adds donation call-to-action and visual recognition for Grimoire supporters who zap the project.

**UserMenu Changes:**
- Add "Support Grimoire " button that opens ZapWindow with preset donation address
- Add monthly goal tracker with progress bar (currently showing placeholder values)
- Integrate Lightning address (grimoire@coinos.io) and donation pubkey from members list

**Supporter Tracking System:**
- Create supporters service to monitor kind 9735 (zap receipt) events
- Track users who zap Grimoire donation address
- Cache supporter info (pubkey, total sats, zap count, last zap timestamp) in localStorage
- Reactive updates via RxJS BehaviorSubject
- Initialize tracking on app startup

**Visual Flair for Supporters:**
- Add useIsSupporter hook for checking supporter status
- Style supporter usernames with yellow/gold color
- Add filled  zap icon badge next to supporter names
- Only applies to non-Grimoire members (members keep their existing gradient badges)

**Implementation Details:**
- Constants: GRIMOIRE_DONATE_PUBKEY and GRIMOIRE_LIGHTNING_ADDRESS in grimoire-members.ts
- Service automatically processes zap receipts and persists supporter data
- Monthly goal tracker uses placeholder values (42k/500k sats, 8.4% progress)
- Future: Make goal dynamic by calculating from actual zap receipts

Related to zap feature implementation in #141, #144, #145

* feat: migrate donation tracking to IndexedDB with monthly calculations

Replaces in-memory Map + localStorage with proper Dexie database storage for accurate monthly donation tracking.

**Database Changes (Version 17):**
- Add `grimoireZaps` table to store individual zap receipts
- Schema: eventId (PK), senderPubkey, amountSats, timestamp, comment
- Indexes on senderPubkey and timestamp for efficient queries

**Supporters Service:**
- Store each zap receipt as separate DB record with full metadata
- Track individual zap timestamps (not just latest per user)
- Cache total and monthly donations for synchronous access
- Refresh cache on new zaps for reactive UI updates

**Monthly Calculations:**
- `getMonthlyDonations()` - Last 30 days (rolling window)
- `getCurrentMonthDonations()` - Current calendar month
- Both use indexed DB queries for efficiency
- Cached values updated on each new zap

**UserMenu:**
- Set monthly goal to 210M sats (2.1 BTC)
- Dynamic progress calculation from actual zap data
- Reactive updates when new donations arrive
- Number formatting: 1M+ → "2.1M", 1k+ → "42k"

**Benefits:**
- Accurate historical tracking with timestamps
- Efficient monthly queries using DB indexes
- No data loss on localStorage quota issues
- Foundation for supporter leaderboards and analytics

* fix: properly await async zap processing in subscription

Changes processZapReceipt calls from forEach to Promise.all to ensure async DB operations complete before processing next batch of events.

Prevents race conditions where zaps might not be properly stored in DB if multiple events arrive simultaneously.

* perf: optimize Dexie queries for donation tracking

Replaces inefficient toArray() + reduce patterns with direct Dexie iteration APIs for better memory efficiency and performance.

**Optimizations:**

1. **Supporter Count** - Use `uniqueKeys()` instead of loading all records
   - Before: Load all → create Set → get size
   - After: `orderBy('senderPubkey').uniqueKeys().length`
   - ~90% memory reduction for large datasets

2. **Aggregation Queries** - Use `.each()` iterator pattern
   - `getTotalDonationsAsync()` - Stream records, accumulate sum
   - `getMonthlyDonationsAsync()` - Indexed query + iteration
   - `getCurrentMonthDonations()` - Indexed query + iteration
   - `getSupporterInfo()` - Per-pubkey indexed query with iteration
   - `getAllSupporters()` - Stream all, group in Map, sort

3. **Cache Refresh** - Optimized `refreshSupporters()`
   - uniqueKeys for supporter set
   - Direct iteration for total/monthly sums
   - Single indexed query for monthly window

**Monthly Goal:**
- Update from 210M sats to 210k sats (0.0021 BTC)
- More achievable target for initial launch

**Benefits:**
- Lower memory usage (no intermediate arrays)
- Faster queries (direct iteration vs map/reduce)
- Better scalability with growing zap history
- Leverages Dexie's indexed cursors for efficiency

* refactor: singleton supporters service with optimized Dexie queries

Complete refactor of donation tracking to proper singleton pattern with relay-based subscriptions and zero in-memory caching.

**Singleton Service Pattern:**
- Class-based SupportersService matching relay-liveness/accounts patterns
- Single `init()` method initializes subscriptions
- Observable `supporters$` for reactive UI updates
- Proper cleanup with `destroy()` method

**Relay-Based Subscription:**
- Fetch Grimoire's inbox relays via relayListCache
- Subscribe to zaps using `#p` tag filter (NIP-57 recipient tag)
- Use createTimelineLoader with proper relay hints
- Fallback to aggregator relays if no inbox relays found
- Dual subscription: loader + event store timeline for comprehensive coverage

**Optimized Dexie Schema:**
- Add compound index: `[senderPubkey+timestamp]`
- Enables efficient per-user date range queries
- Schema: `&eventId, senderPubkey, timestamp, [senderPubkey+timestamp]`

**Zero In-Memory Caching:**
- Remove cachedTotalDonations and cachedMonthlyDonations
- All queries go directly to IndexedDB
- Use Dexie iteration APIs (`.each()`, `.uniqueKeys()`)
- Compound index queries for monthly aggregations

**Premium Supporter Detection:**
- New threshold: 2.1k sats/month = premium supporter
- `isPremiumSupporter()` uses compound index query
- `getMonthlySupporterInfo()` returns monthly stats per user

**Badge Logic Updates:**
- Premium supporters (2.1k+/month): Zap badge in username color
- Regular supporters: Yellow text + yellow filled zap icon
- useIsSupporter returns `{ isSupporter, isPremiumSupporter }`

**UserMenu Updates:**
- Use async `getMonthlyDonations()` with useState/useEffect
- Subscribe to `supporters$` to trigger monthly recalculation
- Remove synchronous function calls

**Key Benefits:**
- Proper singleton lifecycle management
- Accurate relay selection for zap discovery
- No memory overhead from caching
- Efficient compound index queries
- Scales to thousands of zaps without performance degradation

* fix: fetch Grimoire relay list before subscribing to zaps

Ensures kind 10002 relay list is loaded before subscribing to zap receipts, preventing fallback to aggregators only.

**Problem:**
At startup, relayListCache was empty, so getInboxRelays() returned null and service fell back to aggregator relays only, missing zaps published to Grimoire's actual inbox relays.

**Solution:**
1. Explicitly fetch Grimoire's kind 10002 relay list using addressLoader
2. Wait up to 5 seconds for relay list to load
3. Then get inbox relays from populated cache
4. Subscribe to those relays + aggregators

**Flow:**
init() → subscribeToZapReceipts()
  → fetch kind 10002 (5s timeout)
  → getInboxRelays() from cache
  → subscribe to inbox relays + aggregators
  → keep subscription open for live updates

Fixes startup zap loading issue.

* feat: compact support section with manual refresh and full clickability

Makes the entire support section clickable to open zap dialog and adds manual refresh button for donation stats.

**Changes:**
- Entire support section now clickable (opens zap dialog)
- Add refresh button with spinning animation
- Remove 'Help us build...' tagline for more compact design
- Keep just title, stats, and progress bar
- Manual refresh re-fetches Grimoire relay list and reloads zaps

**Layout:**

Click anywhere to donate, click refresh icon to manually sync zaps.

* refactor: replace BehaviorSubject with Dexie useLiveQuery for reactive supporter tracking

Replace manual BehaviorSubject pattern with Dexie's built-in useLiveQuery hook
for reactive database queries. This simplifies the code and leverages Dexie's
optimized change detection.

Changes:
- Remove BehaviorSubject from SupportersService
- Remove refreshSupporters() method and all calls to it
- Update useIsSupporter hook to use useLiveQuery for supporter pubkeys
- Update GrimoireWelcome to use useLiveQuery for monthly donations
- Update UserMenu to use useLiveQuery for monthly donations
- Remove unused imports (cn, useEffect, useState) and fields (initialized)

Benefits:
- Less code to maintain (no manual observable management)
- Automatic reactivity when DB changes
- Better performance with Dexie's built-in change detection

* fix: improve cold start zap loading and fix subscription memory leak

Fixes several issues with zap loading on cold start:

1. Memory leak fix: Timeline subscription wasn't being stored or cleaned up
   - Now properly add timeline subscription to main subscription for cleanup

2. Better cold start handling:
   - Increase timeout from 5s to 10s for relay list fetching
   - Add 100ms delay after addressLoader to let relayListCache update
   - Add more detailed logging to debug cold start issues

3. Improved logging:
   - Show which relays are being used for subscription
   - Log when processing zap events from eventStore
   - Better error messages with context

These changes should help diagnose why zaps aren't loading on cold start
and prevent memory leaks from unclosed subscriptions.

* feat: add hardcoded relay fallback for instant cold start zap loading

Add hardcoded relays (wss://nos.lol, wss://lightning.red) to immediately
start loading zaps on cold start, without waiting for relay list fetch.

Changes:
- Add GRIMOIRE_ZAP_RELAYS constant with hardcoded reliable relays
- Refactor subscribeToZapReceipts() to start with hardcoded relays immediately
- Move relay list fetching to separate non-blocking method fetchAndMergeRelayList()
- Relay list fetch now happens in parallel with subscription (non-blocking)
- Still fetch and log additional relays from kind 10002 in background

This ensures zaps load immediately on app start rather than waiting for
relay list to be fetched from network.

* fix: remove dead relay.nostr.band and add detailed zap processing logs

Remove relay.nostr.band from all relay lists as it's dead.

Changes:
- Remove from AGGREGATOR_RELAYS in loaders.ts
- Remove from NIP-53 fallback relays
- Update all test files to use alternative relays

Add detailed logging to debug progress bar showing 0:
- Log each zap processing step (validation, recipient check, sender check)
- Log duplicate zaps, invalid zaps, and 0-sat zaps
- Log existing zap count in DB on init
- Add timestamps to successful zap recordings

This will help diagnose why the progress bar shows 0 even though
zaps are being fetched.

* fix: reduce zap query limit from 1000 to 500 to avoid relay rejections

Many relays reject REQ filters with limit > 500. This was likely causing
the zap subscription to fail silently on some relays, resulting in no zaps
being fetched and the progress bar showing 0.

Reduced limit from 1000 to 500 to be compatible with more relays.

* fix: remove lightning.red and aggregator relays, add monthly calculation debug logs

Changes:
- Remove wss://lightning.red from hardcoded relays (only use wss://nos.lol)
- Remove aggregator relays from zap subscription (don't use for fetching zaps)
- Remove AGGREGATOR_RELAYS import

Add debug logging to diagnose progress bar issue:
- GrimoireWelcome: Log count and total of zaps found in last 30 days
- UserMenu: Log count and total of zaps found in last 30 days
- Show cutoff date in GrimoireWelcome log

This will help identify if:
1. Zaps are being stored in DB
2. Zaps are within the 30-day window
3. Monthly calculation is running and finding the zaps

* fix: subscribe to eventStore.insert$ to catch zaps in real-time

The previous approach using eventStore.timeline() was only emitting once
with the initial state (0 events) and not re-emitting when new zaps were
added to the store.

Changed to subscribe to eventStore.insert$ which fires for every new event
added to the store. This catches zaps as they arrive from relays in real-time.

Changes:
- Subscribe to eventStore.insert$ instead of eventStore.timeline()
- Filter events for kind 9735 with #p tag matching Grimoire pubkey
- Process each zap as it's inserted into the store
- Add logging to show when zaps are received from insert$ stream

This should fix the progress bar showing 0 even though zaps are being
fetched from relays.

* fix: also subscribe to eventStore.timeline() to process existing zaps

The insert$ stream only fires for NEW events being added. If events were
already in the eventStore (from cache or previous session), we wouldn't
see them.

Now subscribing to both:
- insert$ - catches new events in real-time as they're added
- timeline() - emits ALL matching events (existing + new)

This ensures we process:
1. Zaps already in the eventStore from previous sessions
2. Zaps that loaded from relays before our subscription was active
3. New zaps arriving in real-time

Added logging to distinguish between new (insert$) and all (timeline) events.

* fix: subscribe directly to loader observable to process zaps as they arrive

Complete rethink of the zap sync strategy. The issue was that we were
subscribing to eventStore.timeline() and eventStore.insert$ which were
not working as expected.

The TimelineLoader returns Observable<NostrEvent> that emits individual
events as they arrive from relays. We should subscribe directly to this.

Changes:
- Remove eventStore.timeline() and eventStore.insert$ subscriptions
- Subscribe directly to loader().subscribe() with next/error/complete handlers
- Process each event in the next() handler as it arrives from relays
- Log each zap received from loader for debugging
- Remove unused eventStore import

This is the simplest and most direct approach - process events exactly
as the loader emits them from the relays.

* chore: merge main and remove debug logging for production

Merged latest changes from main branch and removed all debug logging
added during development to make the code production-ready.

Changes:
- Merged origin/main into branch
- Removed console.log statements from supporters service init/subscribe
- Removed debug logging from processZapReceipt method
- Removed debug logging from fetchAndMergeRelayList method
- Removed debug logging from GrimoireWelcome monthly calculation
- Removed debug logging from UserMenu monthly calculation
- Kept only error logging for production debugging

The donation system is now production-ready with clean, minimal logging.

* fix: remove yellow text color from regular supporters, keep only badge

Regular supporters (who zapped but not 2.1k+/month) should only show the
yellow zap badge next to their name, not have their username colored yellow.

Changes:
- Remove yellow text color (text-yellow-500) from regular supporters
- Regular supporters now: normal username color + yellow zap badge
- Premium supporters still: normal username color + badge in username color
- Updated component documentation to reflect this change

This provides cleaner visual hierarchy where only Grimoire team members
get special username colors (gradient), while supporters are distinguished
by their badge alone.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-19 16:15:39 +01:00
Alejandro
ab64fc75f4 Restrict relay auth to account owner (#149)
* fix: only prompt relay auth for accounts that can sign

- Add canAccountSign() helper to check if account is read-only
- Block auth prompts for read-only accounts in shouldPromptAuth()
- Throw error when authenticateRelay() called with read-only account
- Document all major app hooks in CLAUDE.md for future reference

Read-only accounts cannot sign events, so they should never be prompted
for relay authentication or attempt to authenticate. This prevents
confusing UX where users are asked to sign but cannot.

* refactor: extract canAccountSign helper to useAccount

- Move canAccountSign function from relay-state-manager to useAccount.ts
- Import and reuse the shared helper in relay-state-manager
- Update useAccount hook to use the extracted helper internally
- Follows DRY principle by centralizing account sign capability logic

This keeps the account sign capability detection logic in one place,
making it easier to maintain and ensuring consistency across the app.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-19 10:19:16 +01:00
Alejandro
dbcbcf6181 feat: improve zap wallet payment flow UX (#144)
* feat: improve zap wallet payment flow UX

Improvements to the zap window to better communicate wallet payment status:

- Add clear "Paying with wallet..." message during NWC payment attempts
- Show QR code immediately on payment timeout or failure
- Improve error messages with actionable guidance
- Always display "Open in External Wallet" option in QR view
- Rename "Retry with Wallet" to "Retry with NWC Wallet" for clarity
- Generate QR code upfront to enable instant display on errors

This provides better feedback when wallet payments fail or timeout,
giving users clear fallback options without confusion.

* feat: add LNURL address caching for instant zap UI

Implements LNURL address caching similar to NIP-05 caching pattern:

**Database Changes** (v16):
- Add `lnurlCache` table with 24-hour TTL
- Stores LNURL-pay response data for Lightning addresses
- Indexed by address and fetchedAt for efficient queries

**New Hook** (`useLnurlCache`):
- Reactive hook using Dexie + useLiveQuery pattern
- Auto-fetches and caches LNURL data on first use
- Returns cached data instantly on subsequent calls
- Re-fetches when cache is stale (>24 hours)

**ZapWindow Optimization**:
- Uses cached LNURL data instead of network calls
- Eliminates 10-second delay on repeat zaps
- Shows zap amounts/limits instantly from cache
- Graceful error handling when cache is warming up

**Testing**:
- 11 comprehensive tests for LNURL validation
- Validates zap support, pubkey format, field requirements
- Tests edge cases (uppercase hex, missing fields, etc.)

**Benefits**:
- Instant zap UI for frequently zapped users
- Reduced load on LNURL servers
- Offline capability (show cached limits/amounts)
- Better UX with sub-100ms response time

Verification: All 950 tests pass, build succeeds

* fix: match comment input styling to amount input in zap window

Update MentionEditor styling to match Input component:
- Change padding from py-2 to py-1
- Add responsive text sizing (text-base md:text-sm)
- Add min-h-9 to match Input height

This creates visual consistency between the amount and comment fields.

* feat: add amount preview above invoice in zap QR view

Display the zap amount prominently above the invoice when showing
the QR code. This provides clear visual confirmation of what the
user is paying before they scan or copy the invoice.

Format:
- Large bold amount with k/m notation (e.g., "420", "2.1k", "100m")
- Smaller "sats" label underneath
- Positioned between QR code and invoice field

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-19 00:07:05 +01:00
Alejandro
3408872811 feat: add comprehensive NWC wallet viewer with dynamic UI (#135)
* feat: add comprehensive NWC wallet viewer with dynamic UI

Implements a full-featured Lightning wallet interface using Nostr Wallet
Connect (NWC/NIP-47) with method-based UI that adapts to wallet capabilities.

**New Features:**
- WalletViewer component with tabbed interface (Overview, Send, Receive, Transactions)
- Real-time balance display with manual refresh
- Send Lightning payments via BOLT11 invoices
- Generate invoices with QR codes for receiving payments
- Transaction history viewer (when supported by wallet)
- Wallet info and capabilities display
- Enhanced useWallet hook with additional NWC methods

**Enhanced Methods:**
- listTransactions() - View recent payment history
- lookupInvoice() - Check invoice status by payment hash
- payKeysend() - Direct keysend payments to node pubkeys

**UI Features:**
- Dynamic tabs based on wallet capabilities
- QR code generation for invoices
- Copy-to-clipboard for invoices
- Error handling with user-friendly messages
- Loading states for async operations
- Empty states for no wallet connection

**Command:**
- New `wallet` command to open the wallet viewer

**Technical Details:**
- Integrates with existing NWC service singleton
- Uses reactive balance$ observable for auto-updates
- Proper TypeScript types aligned with applesauce-wallet-connect
- Follows Grimoire patterns for window system integration
- Lazy-loaded component for optimal bundle size

All tests passing. Build verified.

* refactor: redesign wallet UI to single-view layout with virtualized transactions

Converts the tabbed wallet interface to a conventional single-view layout
with improved UX and performance optimizations.

**Layout Changes:**
- Removed tabs in favor of single-page layout
- Balance header at top with wallet name and refresh button
- Side-by-side Send/Receive cards for quick access
- Transaction history below with virtualized scrolling
- Disconnect button at bottom of page

**New Features:**
- Connect Wallet button when no wallet is connected (opens dialog in-app)
- Wallet capabilities shown in tooltip on info icon
- Virtualized transaction list using react-virtuoso
- Batched transaction loading (20 per batch)
- Automatic "load more" when scrolling to bottom
- Loading states for initial load and pagination
- "No more transactions" message when exhausted

**Performance Improvements:**
- Virtualized list rendering for smooth scrolling with many transactions
- Only renders visible transactions in viewport
- Lazy loads additional batches on demand
- Reduced initial load to 20 transactions instead of 50

**UX Improvements:**
- More conventional wallet UI pattern
- Send/Receive always visible (no tab switching)
- QR code and invoice appear inline when generated
- Info icon with tooltip for capabilities (cleaner than full card)
- Disconnect option always accessible at bottom

**Technical Details:**
- Fixed transaction loading race condition with separate useEffect
- Proper dependency tracking for loadMoreTransactions callback
- Footer component in Virtuoso for loading/end states
- Responsive grid layout for Send/Receive cards

All tests passing. Build verified.

* refactor: redesign wallet UI with ReqViewer-style header and dialogs

Implements a cleaner, more consistent wallet UI matching Grimoire patterns.

**Header Changes:**
- ReqViewer-style compact header (border-b, px-4, py-2, font-mono, text-xs)
- Left side: Wallet name · Balance with info tooltip
- Right side: Icon-only action buttons (Receive, Send, Refresh)
- All actions use tooltips for labels
- Consistent spacing and typography with rest of app

**Send/Receive as Dialogs:**
- Removed always-visible Send/Receive cards
- Converted to modal dialogs opened from header icons
- Send dialog: Invoice input + optional amount override
- Receive dialog: Two-step flow (generate → show QR + invoice)
- "Generate Another" button to create multiple invoices without closing
- Cleaner main view focused on transaction list

**Transaction List Cleanup:**
- Removed Card wrapper and extra padding
- Direct virtualized list fills viewport
- Removed "Transaction History" heading (redundant)
- Added flex-shrink-0 to prevent transaction items from shrinking
- Improved truncation handling for long descriptions
- Hover effect for better interactivity
- Footer messages use consistent border-b styling

**Disconnect Button:**
- Changed to destructive variant (red appearance)
- Moved to footer with border-t
- Full width for emphasis
- Clearer visual indication of destructive action

**UX Improvements:**
- More screen space for transactions (primary content)
- Consistent icon sizing (size-3 for header actions, size-4 for tx icons)
- Better visual hierarchy with header actions
- Dialog pattern matches other Grimoire components
- Receive dialog stays open to show generated invoice

**Technical Details:**
- Proper dialog state management
- Invoice QR with max-height scrolling for long invoices
- Reset form state when closing dialogs
- Maintained all existing functionality
- Optimized for virtualized scrolling performance

All tests passing. Build verified.

* feat: final wallet UI improvements with prominent balance and enhanced UX

Redesigns wallet viewer with cleaner layout and improved user experience.

**Layout Changes:**
- Big centered balance display (4xl font, prominent)
- Large Send/Receive buttons below balance (side by side)
- Single-line transaction items with better spacing
- Info dropdown next to refresh button in header

**Transaction List:**
- Single-line compact design (description + time + amount)
- No +/- signs on amounts (cleaner look)
- Generic "Payment"/"Received" labels when description missing
- Time displayed in compact format (HH:MM)
- Day markers between days (Today/Yesterday/Jan 15)
- Virtualized scrolling with batched loading

**Info Dropdown:**
- Wallet capabilities shown in dropdown (Info icon + ChevronDown)
- Network information
- Methods displayed as compact badges
- Notifications support

**User Menu Integration:**
- Wallet option always visible (regardless of account status)
- Clicking wallet opens wallet window (not info dialog)
- Balance shown inline when connected
- "Connect Wallet" option when not connected

**Dialog Improvements:**
- Send dialog with confirmation step
- Receive dialog with payment detection
- Auto-close on payment received
- QR code with loading overlay during payment check

**Visual Hierarchy:**
- Header: Wallet name (left) | Info dropdown + Refresh (right)
- Big centered balance with "sats" label
- Prominent action buttons (Send default, Receive outline)
- Clean transaction list with hover states
- Destructive disconnect button in footer

All tests passing  Build verified 

* fix: replace AlertDialog with Dialog for disconnect confirmation

- AlertDialog component doesn't exist in UI library
- Use regular Dialog with custom footer buttons instead
- All 929 tests passing, build successful

* refine: wallet UI improvements based on feedback

- Remove "sats" text from balance display
- Swap send/receive button positions (receive left, send right)
- Remove top border from transaction list
- Remove timestamps from transaction list items
- Add relay link to wallet info dropdown with external link icon
- Change disconnect button to destructive color (always red)
- Fix imports and remove unused formatTime function

* feat: enhance send/receive flows with invoice parsing and auto-confirm

Send flow improvements:
- Parse BOLT11 invoices using light-bolt11-decoder
- Auto-proceed to confirm step when valid invoice is entered
- Show parsed amount and description in confirmation dialog
- Validate invoice before allowing confirmation

Receive flow improvements:
- Fix invoice overflow with proper truncate display
- Use nested div structure for single-line truncation

All changes preserve type safety with proper Section type guards

* feat: add Lightning address support and refine auto-confirm behavior

Send flow enhancements:
- Only auto-proceed to confirm if invoice has an amount (not for zero-amount invoices)
- Add Lightning address (LNURL-pay) support with automatic resolution
- Fetch invoice from Lightning address with amount validation
- Show "Resolving..." loading state when processing Lightning addresses
- Update UI labels and placeholders to indicate Lightning address support
- Require amount field for Lightning address payments

Lightning address flow:
1. Detect @ symbol in input (and not starting with "ln")
2. Validate amount is provided
3. Fetch LNURL-pay endpoint from .well-known/lnurlp/{username}
4. Check min/max sendable amounts
5. Request invoice from callback with specified amount
6. Parse and confirm invoice details

Error handling:
- Invalid Lightning address format
- Failed to fetch Lightning address
- Amount out of range (too small/too large)
- Failed to generate invoice from callback

* fix: UI improvements for wallet viewer and mobile receive flow

Confirmation dialog improvements:
- Remove yellow warning styles (border, background, icon)
- Fix amount calculation: show proper sats conversion with Math.floor
- Clean layout with key-value pairs instead of cluttered text
- Show either invoice amount OR override amount (not both)

Mobile receive flow improvements:
- Large prominent "Copy Invoice" button (h-12, full width)
- Invoice box now tappable to copy
- Better touch targets for mobile users
- Clearer label: "Invoice (tap to view)"

Connection status:
- Show green dot + "Connected" next to wallet name in header
- Clear visual feedback that wallet is online

Auto-launch wallet:
- After successfully connecting wallet, automatically open wallet window
- Added onConnected callback to ConnectWalletDialog
- Wire up callback in user menu to call openWallet

Code cleanup:
- Remove unused AlertTriangle import

* fix: correct amount handling and mobile invoice display

Amount input fixes:
- Changed from millisats to sats for user input
- Updated label: "Amount (sats, optional)"
- Fixed Lightning address resolution: no division, amount is already in sats
- Fixed payInvoice: multiply by 1000 to convert sats to millisats for NWC
- Fixed confirmation dialog: removed incorrect division by 1000

Before: User enters 1000 (meant as sats) → system treats as 1000000 millisats → sends 1000 sats
After: User enters 1000 sats → system converts to 1000000 millisats → sends 1000 sats ✓

Invoice parsing was correct - already converting millisats to sats.
The bug was in the confirmation display and amount submission.

Mobile receive invoice fix:
- Removed nested div with truncate (was causing overflow)
- Changed to break-all + line-clamp-2
- Invoice now wraps properly on mobile (2 lines max)
- Still tappable to copy

* fix: convert sats to millisats when generating invoices

Critical bug fix - we were generating nearly amountless invoices.

Before:
- User enters 1000 sats in UI
- We passed 1000 to makeInvoice (thinking it's millisats)
- Generated invoice for 1000 millisats = 1 sat

After:
- User enters 1000 sats in UI
- We multiply by 1000 → 1,000,000 millisats
- Generated invoice for 1,000,000 millisats = 1000 sats ✓

This matches the send flow where we also convert sats to millisats
before passing to NWC protocol.

* refactor: production-readiness improvements for WalletViewer

Critical fixes:
- Wrap loadWalletInfo and loadInitialTransactions in useCallback to prevent infinite loops
- Fix dependency arrays in useEffect hooks
- Fix early return bug in handleConfirmSend that left sending state stuck
- Add async/await error handling to handleCopyInvoice
- Change throw new Error instead of early return in Lightning address resolution

Performance optimizations:
- Removed duplicate function definitions (moved to useCallback hooks)
- Proper memoization of wallet loading functions

Code quality:
- All functions properly handle errors with try/catch
- Loading states properly reset in finally blocks
- No memory leaks from useEffect dependencies

* fix: prevent infinite transaction loading loop in WalletViewer

Previously, the transaction list would try to load constantly and fail
repeatedly due to a circular dependency in the useEffect hooks. The
listTransactions function from useWallet wasn't wrapped in useCallback,
causing loadInitialTransactions to be recreated on every render, which
triggered the useEffect infinitely.

Changes:
- Add txLoadAttempted and txLoadFailed flags to prevent repeated attempts
- Only attempt to load transactions once on wallet connection
- Fail silently on initial load (no toast spam)
- Show retry button when transaction loading fails
- Reset flags when wallet connects/disconnects or after successful payments
- Make transaction list truly optional - wallet still works if loading fails

This ensures a better UX when wallets don't support list_transactions
or when the method fails for any reason.

* fix: remove circular dependency in loadInitialTransactions

Removed txLoadFailed from the dependency array of loadInitialTransactions
callback, which was causing a circular dependency:
- loadInitialTransactions depended on txLoadFailed
- Function sets txLoadFailed, triggering recreation
- New function reference triggers useEffect again
- Infinite loop

The txLoadAttempted flag in the useEffect is sufficient to prevent
repeated loads. No need to check txLoadFailed inside the callback.

* fix: resolve React error #185 by removing callbacks from useEffect dependencies

React error #185 occurs when state updates happen during render due to
unstable function references in useEffect dependencies. The root cause
was that functions from useWallet (getInfo, listTransactions) aren't
memoized, so they create new references on every render, causing the
useEffects that depend on them to run repeatedly.

Changes:
- Removed loadWalletInfo and loadInitialTransactions callbacks
- Use refs (walletInfoLoadedRef, lastConnectionStateRef) to track state
- Call async functions directly in useEffect without depending on them
- Created reloadTransactions() helper that just resets flags
- Simplified all reload logic to use the helper

This ensures:
- No circular dependencies in useEffect
- Functions only run once when conditions are met
- No state updates during render
- Clean, predictable loading behavior

* fix: properly clear wallet state on disconnect and update copy

Changes:
- Clear all wallet state when disconnecting (transactions, walletInfo, loading flags)
- Previously only cleared state on connect, leaving stale data visible
- Remove "Mutiny" mention from connection dialog copy
- Update to generic "NWC wallet provider" text

Now when you disconnect the wallet with the window open, it properly
clears all data and returns to the "No Wallet Connected" state.

* fix: prevent wallet from auto-reconnecting after disconnect

The bug was in the useWallet hook which automatically restores the wallet
if nwcConnection exists in state but the wallet instance is null. When
disconnecting, it only cleared the wallet instance but left nwcConnection
in state, causing an immediate reconnection.

Fix:
- Call disconnectNWCFromState() to clear nwcConnection from Grimoire state
- Then call disconnect() to clear the wallet service
- This prevents the auto-restore logic from triggering

Now when you disconnect the wallet, it stays disconnected until you
manually reconnect.

* security: add critical production-ready security fixes

Invoice Validation & Expiry Checks:
- Validate BOLT11 invoice format (must start with 'ln')
- Check invoice expiry before displaying/processing
- Validate amount is reasonable (< 21M BTC)
- Surface parse errors to user with toast notifications
- Prevent processing of expired invoices

Lightning Address Security:
- Enforce HTTPS-only for LNURL-pay requests
- Add 5-second timeout to all HTTP requests
- Validate callback URLs use HTTPS
- Proper AbortController cleanup on timeout
- Better error messages for network failures

Rate Limiting:
- Balance refresh: minimum 2 seconds between calls
- Transaction reload: minimum 5 seconds between reloads
- User-friendly warning messages with countdown
- Prevents spam to wallet service providers

Storage Security Warning:
- Add prominent security notice in ConnectWalletDialog
- Warn users about browser storage implications
- Advise to only connect on trusted devices

Capability Detection:
- Hide Send button if wallet doesn't support pay_invoice
- Hide Receive button if wallet doesn't support make_invoice
- Dynamic button rendering based on wallet capabilities
- Prevents errors from unsupported operations

Error Handling:
- WindowErrorBoundary already wraps all windows (verified)
- Proper error propagation with user-friendly messages
- No silent failures on critical operations

These changes significantly improve security and production-readiness
without breaking existing functionality.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-18 20:05:19 +01:00
Alejandro
7fae344dd9 feat: add Nostr Wallet Connect (NWC) integration (#131)
* feat: add Nostr Wallet Connect (NWC) integration

Add NWC (NIP-47) support to connect Lightning wallets:
- Add NWCConnection type and state management
- Implement custom NWC client service for wallet communication
- Create ConnectWalletDialog for entering connection strings
- Add wallet button to user menu showing balance
- Display wallet info (balance, alias) in user menu dropdown
- Support get_info, get_balance, pay_invoice, make_invoice commands
- Persist wallet connection to localStorage
- Install applesauce-wallet (for future NIP-60 support)

Note: Current implementation uses custom NWC client. Can optionally
switch to @getalby/sdk for official Alby SDK support.

* refactor: use applesauce-wallet-connect and move wallet to header

Replace custom NWC client implementation with applesauce-wallet-connect:
- Install applesauce-wallet-connect for official NIP-47 support
- Create nwc.ts service wrapper for WalletConnect singleton
- Update NWCConnection type to match WalletConnectURI interface
- Use service/relays/secret properties instead of custom names

Move wallet display from user menu to header:
- Create standalone WalletButton component
- Add WalletButton to header next to UserMenu
- Remove wallet UI from user menu dropdown
- Show balance in header with yellow zap icon
- Clicking wallet button opens connect dialog

This provides better UX with wallet status visible in header
and uses the official applesauce implementation for reliability.

* feat: improve wallet button UX and add detailed info dialog

UI improvements:
- Remove border and padding from wallet button (use ghost variant)
- Remove "sats" suffix from balance display (show just the number)
- Change click behavior to show detailed wallet info dialog

Wallet info dialog:
- Show balance prominently without suffix
- Display wallet alias if available
- Show lightning address (lud16) if present
- List all supported NWC methods as badges
- Display connected relay URLs
- Add disconnect button with confirmation toast

This provides a cleaner header appearance and better wallet
management with all details accessible in one place.

* refactor: move wallet functionality to user menu

Move wallet connection and info from separate header button into
the user menu dropdown for better organization.

Changes:
- Remove standalone WalletButton component
- Add wallet section to user menu dropdown
- Show "Connect Wallet" option when no wallet is connected
- Display wallet balance and alias when connected
- Clicking wallet info opens detailed dialog with:
  - Balance (without suffix)
  - Wallet name/alias
  - Lightning address (lud16)
  - Supported NWC methods
  - Connected relay URLs
  - Disconnect button

This consolidates all user-related settings (account, relays,
blossom servers, wallet) in one consistent location.

* feat: improve wallet UX with profile-based naming and better layout

UX improvements:
- Use service pubkey username/profile as wallet name instead of alias
- Remove format hint text from connection string input
- Move wallet to single row directly below user name (remove label)
- Use RelayLink component for relay URLs in wallet info dialog
- Show wallet name from service profile with fallback to alias/pubkey

This provides better integration with Nostr profiles and a cleaner,
more compact menu layout. The wallet service provider's identity is
now shown using their actual Nostr profile name.

* feat: add persistent wallet service with auto-updating balance

Implement comprehensive NWC wallet management architecture:

**Service Layer** (`src/services/nwc.ts`):
- Maintain singleton WalletConnect instance across app lifetime
- Poll balance every 30 seconds for automatic updates
- Subscribe to NIP-47 notifications (kind 23197) for real-time updates
- Expose RxJS observable (balance$) for reactive balance changes
- Restore wallet from saved connection on app startup
- Proper cleanup on disconnect

**Hook Layer** (`src/hooks/useWallet.ts`):
- useWallet() hook exposes wallet throughout the app
- Provides methods: payInvoice, makeInvoice, getBalance, etc.
- Auto-syncs balance updates with Jotai state
- Handles wallet restoration on mount
- Type-safe wrapper around WalletConnect API

**UI Updates**:
- Add refresh button to wallet info dialog
- Auto-update balance display when transactions occur
- Proper cleanup on wallet disconnect

**Architecture**:
```
User Action → useWallet() → NWC Service → WalletConnect
                ↓                ↓
         Update State ← balance$ observable ← Polling/Notifications
```

This enables:
- Real-time balance updates when paying/receiving
- Easy wallet access: `const { payInvoice, balance } = useWallet()`
- Persistent connection across page reloads
- Automatic polling as fallback for notifications

* refactor: make NWC fully reactive with notifications and graceful balance handling

Changes:
- Remove polling mechanism in favor of pure reactive notifications$ observable
- Subscribe to wallet.notifications$ for real-time balance updates
- Make balance display conditional (only show if available)
- Fix TypeScript errors (notification.type access, unused variable)
- Remove Jotai callback mechanism for balance updates
- Use use$() directly for reactive balance subscription
- Update comments to reflect reactive architecture (no polling)

The wallet now updates balance automatically when payments are sent/received
via NIP-47 notifications, with no polling overhead.

* feat: improve wallet UX with profile-based naming and better layout

Improvements to NWC wallet UI:
- Add separator between user info and wallet section in menu
- Show wallet icon instead of zap icon for better clarity
- Display connection status indicator (green/red dot) in both menu and dialog
- Make wallet service username clickable in wallet info dialog to open profile
- Use wallet relays as hints when fetching service profile for better resolution
- Enhanced useProfile hook to accept optional relay hints parameter

The wallet now properly resolves service profiles using the NWC relay
and shows visual connection status at a glance.

* fix: remove toast descriptions for better contrast

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-18 11:14:47 +01:00
Alejandro
ee2b62f2d6 feat: enhance login options with read-only and nsec support (#126)
* feat: enhance login options with read-only and nsec support

- Add read-only login mode supporting:
  - npub (bech32 public key)
  - nprofile (bech32 profile with relay hints)
  - hex public key
  - NIP-05 addresses (user@domain.com)

- Add private key (nsec) login with security warning
  - Supports nsec1... format
  - Supports 64-char hex private key
  - Shows prominent security warning about localStorage storage

- Reorganize user menu to show login before theme option

- Use ReadonlyAccount from applesauce-accounts for read-only mode
- Use PrivateKeyAccount from applesauce-accounts for nsec login
- Update LoginDialog with 4 tabs: Extension, Read-Only, Private Key, Remote
- All account types properly registered via registerCommonAccountTypes()

Technical notes:
- ReadonlySigner throws errors on sign/encrypt operations
- Existing components naturally handle accounts without signing capability
- Hub/ActionRunner already syncs with account signers automatically

* feat: add generate identity button to login dialog

- Add "Generate Identity" button above login tabs
- Uses Wand2 icon from lucide-react
- Creates new key pair using PrivateKeyAccount.generateNew()
- Automatically stores nsec in localStorage and sets as active account
- Provides quick onboarding for new users without external wallet setup

* feat: add useAccount hook for signing capability detection

Created a centralized hook to check account signing capabilities and
refactored components to distinguish between signing and read-only operations.

New hook (src/hooks/useAccount.ts):
- Returns account, pubkey, canSign, signer, isLoggedIn
- Detects ReadonlyAccount vs signing accounts
- Provides clear API for checking signing capability

Refactored components:
- ChatViewer: Use canSign for message composer, replying, actions
  - Show "Sign in to send messages" for read-only accounts
  - Disable message input for accounts without signing
- SpellDialog: Use canSign for publishing spells
  - Show clear warning for read-only accounts
  - Updated error messages to mention read-only limitation
- useEmojiSearch: Use pubkey for loading custom emoji lists
  - Works correctly with both signing and read-only accounts

Benefits:
- Clear separation between read (pubkey) and write (canSign, signer) operations
- Read-only accounts can browse, view profiles, load data
- Signing operations properly disabled for read-only accounts
- Consistent pattern across the codebase for account checks
- Better UX with specific messages about account capabilities

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-17 21:14:44 +01:00
Alejandro
16764e1aca Display user's blossom servers in menu (#90)
* feat: Display blossom servers in user menu with caching

Implements caching and display of user's blossom server lists (kind 10063) in the user menu dropdown.

**Key Changes:**

1. **Database Schema (db.ts)**:
   - Added `CachedBlossomServerList` interface
   - Added `blossomServers` table to Dexie v15
   - Indexed by pubkey and updatedAt for efficient querying

2. **Caching Service (blossom-server-cache.ts)**:
   - Dual-layer caching: LRU memory cache (100 entries) + Dexie persistent cache
   - 24-hour TTL to reduce network requests
   - Auto-caches kind:10063 events from EventStore
   - Mirrors relay-list-cache pattern for consistency

3. **Sync Hooks**:
   - `useBlossomServerCacheSync`: Subscribes to EventStore for auto-caching kind:10063 events
   - `useAccountSync`: Extended to fetch and watch active account's blossom servers

4. **State Management**:
   - Added `blossomServers` field to `activeAccount` in GrimoireState
   - Created `setActiveAccountBlossomServers` pure function and callback
   - Reactive updates when user's server list changes

5. **User Menu UI (user-menu.tsx)**:
   - Added blossom servers section with HardDrive icon
   - Shows server count badge (e.g., "3 servers")
   - Clickable links open servers in new tabs
   - Displays only when user has configured servers

**Architecture:**
- Follows existing relay list caching pattern for consistency
- Reactive: UI auto-updates when kind:10063 events arrive
- Incremental sync: Fetches on login, caches for 24h
- Performance: Memory cache for <1ms lookups, Dexie for persistence

**Testing:**
- Build: ✓ No TypeScript errors
- Tests: ✓ All 838 tests passing

* feat: Open blossom server file lists directly from menus

**User Menu & Profile Viewer Improvements:**

1. **Enhanced Click Behavior**:
   - Clicking a blossom server now opens the file list for that server
   - Shows blobs uploaded by the user (user menu) or profile owner (profile viewer)
   - Pre-selects the clicked server in the dropdown

2. **UX Improvements**:
   - Removed server count from user menu label (cleaner UI)
   - Added `cursor-crosshair` to blossom server items (consistent with other clickable items)
   - Removed external link icon (not opening external URL anymore)

3. **Technical Changes**:
   - Updated `ListBlobsView` to accept optional `serverUrl` prop for pre-selection
   - User menu: Opens `blossom list` with `serverUrl` for active user
   - Profile viewer: Opens `blossom list` with both `pubkey` and `serverUrl`

**Flow:**
- User menu → Click server → Opens files for active user on that server
- Profile viewer → Click server → Opens files for viewed user on that server

* fix: Properly fetch blossom servers for any profile view

**Problem:**
Blossom servers were only visible for the logged-in user's profile,
not for other users' profiles being viewed.

**Solution:**
Enhanced ProfileViewer blossom server fetching with multi-layer approach:

1. **Cache-first loading**: Check blossomServerCache for instant display
2. **EventStore check**: Use existing cached event if available
3. **Reactive subscription**: Subscribe to EventStore for real-time updates
4. **Network fetch**: Use addressLoader to fetch latest from relays
5. **Auto-caching**: Update cache when new events arrive

**Benefits:**
- Blossom servers now display for ANY user's profile
- Instant display from cache (< 1ms)
- Reactive updates when data changes
- Proper cache hydration for future visits
- Consistent with relay list fetching pattern

**Technical:**
- Imported and integrated blossomServerCache service
- Added cache check before network fetch
- Separated EventStore subscription from network fetch
- Added cache updates on event arrival

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-14 14:52:16 +01:00
Alejandro
9ef1fefd3d feat: BLOSSOM (#75)
* Add Blossom blob storage integration

- Add blossom-client-sdk dependency for blob storage operations
- Create blossom.ts service with upload, list, check, mirror, delete primitives
- Add kind 10063 server list fetching and parsing
- Create blossom-parser.ts for command argument parsing with subcommands
- Add BLOSSOM command to man.ts with subcommands:
  - servers: Show configured Blossom servers
  - check: Check server health
  - upload: Upload files to user's servers
  - list: List blobs for a user
  - mirror: Mirror blobs between servers
  - delete: Delete blobs from servers
- Create BlossomViewer component with views for each subcommand
- Wire up BlossomViewer in WindowRenderer
- Add Blossom servers dropdown to ProfileViewer header
- Upload primitives can be called programmatically for use in other components

* Enhance Blossom viewer with server selection and blob details

- Add server selection checkboxes to upload view for choosing target servers
- Add BlobDetailView with media preview (image/video/audio) and metadata display
- Add 'blob' subcommand to view individual blob details
- Remove unused 'check' subcommand

* Add Blossom upload dialog with chat integration

- Create BlossomUploadDialog component with file picker, server selection, and preview
- Create useBlossomUpload hook for easy integration in any component
- Add insertText method to MentionEditor for programmatic text insertion
- Integrate upload button (paperclip icon) in chat composer
- Supports image, video, and audio uploads with drag-and-drop

* Add rich blob attachments with imeta tags for chat

- Add BlobAttachment TipTap extension with inline preview (thumbnail for images, icons for video/audio)
- Store full blob metadata (sha256, url, mimeType, size, server) in editor nodes
- Convert blob nodes to URLs in content with NIP-92 imeta tags when sending
- Add insertBlob method to MentionEditor for programmatic blob insertion
- Update NIP-29 and NIP-53 adapters to include imeta tags with blob metadata
- Pass blob attachments through entire send flow (editor -> ChatViewer -> adapter)

* Add fallback public Blossom servers for users without server list

- Add well-known public servers as fallbacks (blossom.primal.net, nostr.download, files.v0l.io)
- Use fallbacks when user has no kind 10063 server list configured
- Show "Public Servers" label with Globe icon when using fallbacks
- Inform user that no server list was found
- Select first fallback server by default (vs all user servers)

* Fix: Don't show fallback servers when not logged in

Blossom uploads require signed auth events, so users must be logged in.
The 'Account required' message is already shown in this case.

* Remove files.v0l.io from fallback servers

* Add rich renderer for kind 10063 Blossom server list

- Create BlossomServerListRenderer.tsx with feed and detail views
- Show user's configured Blossom servers with clickable links
- Clicking a server opens the Blossom window with server info
- Register renderers for kind 10063 (BUD-03)
- Fix lint error by renaming useFallbackServers to applyFallbackServers

* Add individual server view and NIP-05 support for blossom commands

- Add 'server' subcommand to view info about a specific Blossom server
- Update BlossomServerListRenderer to open server view on click
- Make blossom parser async to support NIP-05 resolution in 'list' command
- Add kind 10063 (Blossom Server List) to EVENT_KINDS constants with BUD-03 reference
- Update command examples with NIP-05 identifier support

* Add comprehensive tests for blossom-parser

- 34 test cases covering all subcommands (servers, server, upload, list, blob, mirror, delete)
- Tests for NIP-05 resolution, npub/nprofile decoding, $me alias
- Tests for error handling and input validation
- Tests for case insensitivity and command aliases (ls, view, rm)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-13 17:16:31 +01:00
Alejandro
2bad592a3a feat: emoji autocompletion (#54)
* feat: add NIP-30 emoji autocompletion to editor

Implement emoji autocomplete triggered by `:` in the MentionEditor:

- EmojiSearchService: flexsearch-based indexing for emoji shortcodes
- useEmojiSearch hook: loads Unicode emojis + user's custom emoji (kind 10030/30030)
- EmojiSuggestionList: grid-based suggestion UI with keyboard nav
- Update MentionEditor with second Mention extension for emoji
- Serialize emoji as `:shortcode:` format with NIP-30 emoji tags
- Update chat adapters to include emoji tags in messages

Sources:
- Unicode: ~300 common emojis with shortcodes
- Custom: user's emoji list (kind 10030) and referenced sets (kind 30030)
- Context: emoji tags from events being replied to

* feat: add rich emoji preview in editor

Emoji inserted via the autocomplete now display as actual images/characters
instead of :shortcode: text:

- Custom emoji: renders as inline <img> with proper sizing
- Unicode emoji: renders as text with emoji font sizing
- Both show :shortcode: on hover via title attribute

CSS styles ensure proper vertical alignment with surrounding text.

* fix: store emoji url and source attributes in node schema

The TipTap Mention extension only defines `id` and `label` by default.
Added `addAttributes()` to EmojiMention extension to also store `url`
and `source` attributes, fixing emoji tags not being included in sent
messages.

* fix: improve emoji node rendering in editor

- Remove redundant renderLabel (nodeView handles display)
- Add renderText for proper clipboard behavior
- Make nodeView more robust with null checks
- Add fallback to shortcode if image fails to load
- Unicode emoji shows character, custom shows image

* fix: serialize unicode emoji as actual characters, not shortcodes

When sending messages:
- Unicode emoji (😄, 🔥) → outputs 😄, 🔥 (the actual character)
- Custom emoji (:pepe:) → outputs :pepe: with emoji tag for rendering

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-12 11:30:52 +01:00
Alejandro Gómez
0b20a628e2 feat: add mention editor and NIP-29 chat enhancements
Implements rich text editing with profile mentions, NIP-29 system messages,
day markers, and naddr support for a more complete chat experience.

Editor Features:
- TipTap-based rich text editor with @mention autocomplete
- FlexSearch-powered profile search (case-insensitive)
- Converts mentions to nostr:npub URIs on submission
- Keyboard navigation (Arrow keys, Enter, Escape)
- Fixed Enter key and Send button submission

NIP-29 Chat Improvements:
- System messages for join/leave events (kinds 9000, 9001, 9021, 9022)
- Styled system messages aligned left with muted text
- Shows "joined" instead of "was added" for consistency
- Accepts kind 39000 naddr (group metadata addresses)
- Day markers between messages from different days
- Day markers use locale-aware formatting (short month, no year)

Components:
- src/components/editor/MentionEditor.tsx - TipTap editor with mention support
- src/components/editor/ProfileSuggestionList.tsx - Autocomplete dropdown
- src/services/profile-search.ts - FlexSearch service for profile indexing
- src/hooks/useProfileSearch.ts - React hook for profile search

Dependencies:
- @tiptap/react, @tiptap/starter-kit, @tiptap/extension-mention
- @tiptap/extension-placeholder, @tiptap/suggestion
- flexsearch@0.7.43, tippy.js@6.3.7

Tests:
- Added 6 new tests for naddr parsing in NIP-29 adapter
- All 710 tests passing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 10:26:38 +01:00
Alejandro
b2b398b9fb docs: add applesauce v5 upgrade plan (#39)
* docs: add applesauce v5 upgrade plan

Comprehensive migration plan covering:
- Package updates (add applesauce-common, update to v5)
- EventFactory import migration (applesauce-factory → applesauce-core)
- Unified event loader setup
- ActionHub → ActionRunner migration
- useObservableMemo → use$ hook migration
- New features: casting system, encrypted content caching
- Documentation and skills updates needed

* feat: upgrade applesauce libraries to v5

Major upgrade from applesauce v4 to v5 with breaking changes:

Package updates:
- applesauce-core: ^4.0.0 → ^5.0.0
- applesauce-actions: ^4.0.0 → ^5.0.0
- applesauce-loaders: ^4.0.0 → ^5.0.0
- applesauce-react: ^4.0.0 → ^5.0.0
- applesauce-relay: ^4.0.0 → ^5.0.0
- applesauce-signers: ^4.0.0 → ^5.0.0
- applesauce-accounts: ^4.0.0 → ^5.0.0
- Added new applesauce-common: ^5.0.0 package

API migrations:
- EventFactory: applesauce-factory → applesauce-core/event-factory
- ActionHub → ActionRunner with async function pattern (not generators)
- useObservableMemo → use$ hook across all components
- Helper imports: article, highlight, threading, zap, comment, lists
  moved from applesauce-core to applesauce-common
- parseCoordinate → parseReplaceableAddress
- Subscription options: retries → reconnect
- getEventPointerFromETag now returns null instead of throwing

New features:
- Unified event loader via createEventLoaderForStore
- Updated loaders.ts to use v5 unified loader pattern

Documentation:
- Updated CLAUDE.md with v5 patterns and migration notes
- Updated applesauce-core skill for v5 changes
- Created new applesauce-common skill

Test fixes:
- Updated publish-spellbook.test.ts for v5 ActionRunner pattern
- Updated publish-spell.test.ts with eventStore mock
- Updated relay-selection.test.ts with valid test events
- Updated loaders.test.ts with valid 64-char hex event IDs
- Added createEventLoaderForStore mock

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-05 14:54:21 +01:00
Alejandro
32d394b398 feat: add preview routes for Nostr identifiers (npub, nevent, note, naddr) (#33)
* feat: add preview routes for Nostr identifiers (npub, nevent, note, naddr)

This commit adds dedicated preview routes for Nostr identifiers at the root level:
- /npub... - Shows a single profile view for npub identifiers
- /nevent... - Shows a single event detail view for nevent identifiers
- /note... - Shows a single event detail view for note identifiers
- /naddr... - Redirects spellbooks (kind 30777) to /:actor/:identifier route

Key changes:
- Created PreviewProfilePage component for npub identifiers
- Created PreviewEventPage component for nevent/note identifiers
- Created PreviewAddressPage component for naddr redirects
- Added hideBottomBar prop to AppShell to hide tabs in preview mode
- Added routes to root.tsx for all identifier types

Preview pages don't show bottom tabs and don't affect user's workspace layout.

* chore: update package-lock.json

* refactor: create reusable useNip19Decode hook and improve preview pages

This commit makes the preview pages production-ready by:

1. Created useNip19Decode hook (src/hooks/useNip19Decode.ts):
   - Reusable hook for decoding NIP-19 identifiers (npub, note, nevent, naddr, nprofile)
   - Type-safe with discriminated union for decoded entities
   - Comprehensive error handling with retry functionality
   - Loading states and error messages
   - Well-documented with JSDoc comments and usage examples

2. Comprehensive test coverage (src/hooks/useNip19Decode.test.ts):
   - 11 tests covering all entity types (npub, note, nevent, naddr)
   - Tests for error handling (missing identifier, invalid format, corrupted bech32)
   - Tests for retry functionality and state changes
   - Uses jsdom environment for React hook testing
   - All tests passing ✓

3. Refactored preview pages to use the hook:
   - PreviewProfilePage: Simplified from 80 to 81 lines with cleaner logic
   - PreviewEventPage: Improved type safety and error handling
   - PreviewAddressPage: Better separation of concerns
   - All pages now have consistent error handling and retry functionality
   - Better user experience with improved error messages

4. Dependencies added:
   - @testing-library/react for React hook testing
   - @testing-library/dom for DOM testing utilities
   - jsdom and happy-dom for browser environment simulation in tests

Benefits:
- Code deduplication: Preview pages share decoding logic
- Type safety: Discriminated union prevents type errors
- Testability: Hook can be tested independently
- Maintainability: Single source of truth for NIP-19 decoding
- User experience: Consistent error handling and retry across all preview pages
- Production-ready: Comprehensive tests and error handling

* refactor: simplify useNip19Decode to synchronous with memoization

NIP-19 decoding is synchronous - removed unnecessary async complexity:

Hook changes (src/hooks/useNip19Decode.ts):
- Removed loading states (isLoading, setIsLoading)
- Removed retry functionality (unnecessary for sync operations)
- Now uses useMemo for efficient memoization
- Returns { decoded, error } instead of { decoded, isLoading, error, retry }
- Same string always yields same result (memoized)
- Went from ~120 lines to ~115 lines, but much simpler

Preview page changes:
- Removed loading spinners and states
- Removed retry buttons
- Simplified error handling
- Cleaner, more readable code
- PreviewProfilePage: 55 lines (down from 81)
- PreviewEventPage: 83 lines (down from 105)
- PreviewAddressPage: 83 lines (down from 117)

Test changes (src/hooks/useNip19Decode.test.ts):
- Removed waitFor and async/await (not needed for sync)
- Tests run faster (39ms vs 77ms - 49% improvement)
- Added memoization tests to verify caching works
- Simplified from 11 async tests to 11 sync tests
- All 11 tests passing ✓

Benefits:
- Simpler mental model: decode happens instantly
- Better performance: no state updates, just memoization
- Easier to test: synchronous tests are simpler
- More correct: matches the actual synchronous nature of nip19.decode()
- Less code: removed ~150 lines of unnecessary complexity

* feat: show detail view for all addressable events in naddr preview

Previously, PreviewAddressPage only handled spellbooks (kind 30777) and
showed errors for other addressable events. Now:

- Spellbooks (kind 30777): Redirect to /:actor/:identifier (existing behavior)
- All other addressable events: Show in EventDetailViewer

This enables previewing any addressable event (long-form articles, live
events, community posts, etc.) via naddr links.

Changes:
- Import EventDetailViewer
- Removed error state for non-spellbook kinds
- Show EventDetailViewer with AddressPointer for all other kinds
- Simplified from 83 lines to 77 lines

* fix: correct route patterns for NIP-19 identifier previews

The previous route patterns (/npub:identifier) conflicted with the catch-all
/:actor/:identifier route and didn't properly match NIP-19 identifiers.

Fixed by:
1. Using wildcard routes with correct prefixes:
   - /npub1* (not /npub:identifier)
   - /nevent1* (not /nevent:identifier)
   - /note1* (not /note:identifier)
   - /naddr1* (not /naddr:identifier)

2. Updated preview components to use params['*'] for wildcard capture:
   - Reconstruct full identifier as prefix + captured part
   - e.g., npub1 + params['*'] = npub107jk7htfv...

This ensures routes properly match before the catch-all /:actor/:identifier
route and correctly capture the full bech32-encoded identifier.

Test URL: /npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg

* style: apply prettier formatting

* fix: use loader-based routing for NIP-19 identifiers in React Router v7

Previous attempts using wildcard routes didn't work properly in React Router v7.

Solution:
- Single /:identifier route with a loader that validates NIP-19 prefixes
- Loader throws 404 if identifier doesn't start with npub1/note1/nevent1/naddr1
- Created Nip19PreviewRouter component that routes to correct preview page
- Routes are properly ordered: /:identifier before /:actor/:identifier catch-all

This ensures /npub107jk... routes to profile preview, not spellbook route.

Benefits:
- Simpler routing configuration (1 route vs 4 duplicate routes)
- Proper validation via loader
- Clean separation of concerns with router component
- Works correctly in React Router v7

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-04 20:12:48 +01:00
Claude
af8cf427d6 fix: implement per-relay EOSE detection by subscribing to relays individually
**CRITICAL FIX for EOSE detection:**

**The Problem:**
- Used pool.subscription(relays, filters) which creates a RelayGroup
- RelayGroup tracks per-relay EOSE internally but only emits ONE "EOSE" when ALL relays finish
- This caused:
  1. EOSE indicators taking forever to appear (waiting for slowest relay)
  2. REQ stuck in LOADING state when fast relays finish but slow relays never do
  3. No way to show per-relay EOSE status accurately

**The Solution:**
Subscribe to each relay individually using pool.relay(url).subscription():
- Each relay subscription emits its own EOSE immediately when that relay finishes
- We track per-relay EOSE in relayStates map with accurate timing
- Overall EOSE is derived when ALL relays reach terminal state (eose/error/disconnected)
- EOSE indicators now appear immediately as each relay finishes

**Technical Details:**
- Changed from: pool.subscription(relays, filters)
- Changed to: relays.map(url => pool.relay(url).subscription(filters))
- Added eoseReceivedRef to track overall EOSE in closures
- Mark specific relay as EOSE when that relay emits "EOSE"
- Calculate overall EOSE when all relays in terminal states
- Use url from subscription context (more reliable than event._relay)

**Benefits:**
 Instant per-relay EOSE indicators (no waiting for slowest relay)
 Accurate relay state tracking (each relay independent)
 REQ transitions to LIVE/CLOSED as soon as all relays finish
 Better user feedback (see which relays are done vs still loading)

All 639 tests passing.
2025-12-22 18:10:52 +00:00
Claude
70651ae29f fix: improve relay state tracking and add relay type indicators
State Tracking Fixes:
- Sync connection state for ALL relays in query, not just initialized ones
- Defensively initialize missing relay states during sync
- Handle events from unknown relays (defensive initialization)
- Add debug console logs to track state transitions

Relay Type Indicators:
- Explicit relays: Blue link icon (relays specified directly)
- Outbox relays: Purple sparkles (NIP-65 selected)
- Fallback relays: Gray inbox icon (fallback when outbox incomplete)
- Each type has tooltip explaining source

This should fix:
- "0/4 relays but events coming in" bug
- "Stuck in LOADING" when events are arriving
- Missing visibility for relay source types

Tests: 634/634 passing
2025-12-22 16:36:56 +00:00
Claude
c60abe6df4 feat: implement production-grade REQ state machine with per-relay tracking
Core Infrastructure:
- Add ReqRelayState and ReqOverallState types for granular state tracking
- Implement deriveOverallState() state machine with 8 query states
- Create useReqTimelineEnhanced hook combining RelayStateManager + event tracking
- Add comprehensive unit tests (27 tests, all passing)

State Machine Logic:
- DISCOVERING: NIP-65 relay selection in progress
- CONNECTING: Waiting for first relay connection
- LOADING: Initial events loading
- LIVE: Streaming with active relays (only when actually connected!)
- PARTIAL: Some relays ok, some failed/disconnected
- OFFLINE: All relays disconnected after being live
- CLOSED: Query completed, all relays closed
- FAILED: All relays failed to connect

UI Updates:
- Single-word status indicators with detailed tooltips
- Condensed relay status into NIP-65 section (no duplicate lists)
- Per-relay subscription state badges (RECEIVING, EOSE, ERROR, OFFLINE)
- Event counts per relay
- Connection + Auth status integrated into single dropdown

Fixes Critical Bug:
- Solves "LIVE with 0 relays" issue (Scenario 5 from analysis)
- Distinguishes real EOSE from relay disconnections
- Accurate status for all 7 edge cases documented in analysis

Technical Approach:
- Hybrid: RelayStateManager for connections + event._relay for tracking
- Works around applesauce-relay catchError bug without forking
- No duplicate subscriptions
- Production-quality error handling

Tests: 27/27 passing including edge case scenarios
2025-12-22 16:18:15 +00:00
Claude
c40c4ae1f9 fix: TypeScript error in useStableFilters 2025-12-22 14:05:53 +00:00
Claude
912794f4e0 chore: lint fix 2025-12-22 14:04:46 +00:00
Claude
33539c291b refactor: use applesauce helpers for pointer parsing and filter comparison
Phase 2 & 3 of applesauce helpers refactoring plan.

**Phase 2: Replace manual pointer parsing**
- ReactionRenderer.tsx: replaced manual coordinate parsing with parseCoordinate helper
- Benefits: more robust, handles edge cases, consistent with applesauce patterns

**Phase 3: Improve filter comparison**
- useStable.ts: replaced JSON.stringify with isFilterEqual for filter comparison
- Benefits: handles undefined values correctly, supports NIP-ND AND operator
- Implementation uses ref pattern to maintain stable reference when filters are equal

All tests pass (607 tests).
2025-12-22 14:04:09 +00:00
Alejandro Gómez
32c895e150 chore: lint fix 2025-12-22 13:25:38 +01:00
Alejandro
d35345f720 Merge pull request #13 from purrgrammer/claude/useprofile-race-fix-EeWQZ
fix: prevent race conditions in useProfile hook
2025-12-22 13:12:25 +01:00
Claude
645e12cddc fix: prevent race conditions in useProfile hook
- Replace mounted boolean flag with AbortController pattern
- Check abort signal before initiating database writes
- Proper cleanup on unmount/pubkey change

This prevents stale data from being written to IndexedDB when:
- Component unmounts during async operations
- Pubkey changes while a fetch is in progress
2025-12-22 12:02:03 +00:00
Claude
bdfc634c54 feat: add dependency stabilization hooks
- Create src/hooks/useStable.ts with:
  - useStableValue<T>() - stabilizes any value using JSON.stringify
  - useStableArray<T>() - stabilizes string arrays (uses JSON.stringify
    for safety, handles arrays with commas in elements)
  - useStableFilters<T>() - specialized for Nostr filters

- Update timeline hooks to use stabilization:
  - useTimeline.ts - use useStableFilters for filter dependencies
  - useReqTimeline.ts - use useStableValue for filter dependencies
  - useLiveTimeline.ts - use useStableArray for relay dependencies

Prevents unnecessary re-renders and subscription restarts when
filter/relay objects are recreated with the same content.
2025-12-22 12:00:42 +00:00
Alejandro Gómez
812b719ea0 feat: debug command, simplify state 2025-12-18 23:32:00 +01:00
Alejandro Gómez
a7dd4635dc feat: kind schemas and better man pages 2025-12-18 10:18:53 +01:00
Alejandro Gómez
3b06e23686 ui: improve man page layout for options and examples
- Display option flags on separate lines with indented descriptions to prevent overflow
- Parse and separate example commands from their descriptions
- Highlight commands in accent color with muted descriptions below
- Increase spacing between items for better readability
2025-12-18 09:37:14 +01:00