From 7215ac6277418e99b647fcb8c1cd30daed0a6052 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 11:03:39 +0000 Subject: [PATCH] fix: improve NIP-17 inbox UX and chat command support Inbox Improvements: - Fix stats not updating properly with debounced updates (500ms) - Add relay connected/auth status indicators in dropdown - Display connected/total relay count (e.g., "3/5") - Use UserName component for conversation authors - Reduce gap between message preview and timestamp (gap-1) Chat Command Enhancements: - Add support for 'chat $me' to DM yourself - Add NIP-05 resolution support (e.g., 'chat alice@example.com') - Improve parseIdentifier to handle all formats - Update help text with new command variations Technical Changes: - Add debouncedUpdateStats() to GiftWrapManager - Subscribe to relay state changes in InboxViewer - Import relay state manager and types - Add connection and auth status icons (Plug, PlugZap, Lock) - Clean up unused imports (useProfile, getDisplayName) All tests passing (980/980). Build successful. --- src/components/InboxViewer.tsx | 110 ++++++++++++++++++++---- src/lib/chat-parser.ts | 4 +- src/lib/chat/adapters/nip-17-adapter.ts | 42 ++++++++- src/services/gift-wrap.ts | 17 ++++ 4 files changed, 151 insertions(+), 22 deletions(-) diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx index 5e7a46c..91942e4 100644 --- a/src/components/InboxViewer.tsx +++ b/src/components/InboxViewer.tsx @@ -8,7 +8,7 @@ * - Conversation list (compact view) */ -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { use$ } from "applesauce-react/hooks"; import { nip19 } from "nostr-tools"; import { useGrimoire } from "@/core/state"; @@ -17,9 +17,7 @@ import { useGiftWrapStats, useGiftWrapConversations, } from "@/hooks/useGiftWrap"; -import { useProfile } from "@/hooks/useProfile"; import eventStore from "@/services/event-store"; -import { getDisplayName } from "@/lib/nostr-utils"; import { Settings, MessageSquare, @@ -27,10 +25,16 @@ import { ShieldCheck, ShieldAlert, Loader2, + Plug, + PlugZap, + Lock, } from "lucide-react"; import { toast } from "sonner"; import giftWrapManager from "@/services/gift-wrap"; +import relayStateManager from "@/services/relay-state-manager"; +import type { GlobalRelayState } from "@/types/relay-state"; import { RelayLink } from "@/components/nostr/RelayLink"; +import { UserName } from "@/components/nostr/UserName"; import { DropdownMenu, DropdownMenuContent, @@ -56,10 +60,24 @@ export function InboxViewer(_props: InboxViewerProps) { const conversations = useGiftWrapConversations(); const [conversationsPage, setConversationsPage] = useState(1); const [isLoadingOlder, setIsLoadingOlder] = useState(false); + const [relayState, setRelayState] = useState(null); const syncEnabled = state.giftWrapSettings?.syncEnabled ?? false; const autoDecrypt = state.giftWrapSettings?.autoDecrypt ?? false; + // Subscribe to relay state changes + useEffect(() => { + // Get initial state + setRelayState(relayStateManager.getState()); + + // Subscribe to updates + const unsubscribe = relayStateManager.subscribe((newState) => { + setRelayState(newState); + }); + + return unsubscribe; + }, []); + // Get DM relays (kind 10050) const dmRelayEvent = use$(() => { if (!pubkey) return undefined; @@ -73,6 +91,15 @@ export function InboxViewer(_props: InboxViewerProps) { .map((t: string[]) => t[1]); }, [dmRelayEvent]); + // Calculate connected relay count + const connectedRelayCount = useMemo(() => { + if (!relayState || dmRelays.length === 0) return 0; + return dmRelays.filter((url) => { + const state = relayState.relays[url]; + return state?.connectionState === "connected"; + }).length; + }, [relayState, dmRelays]); + // Convert conversations map to sorted array with pagination const { conversationsList, totalConversations, hasMoreConversations } = useMemo(() => { @@ -270,7 +297,9 @@ export function InboxViewer(_props: InboxViewerProps) { @@ -278,13 +307,61 @@ export function InboxViewer(_props: InboxViewerProps) {
{dmRelays.length > 0 ? ( - dmRelays.map((relay) => ( - - )) + dmRelays.map((relay) => { + const state = relayState?.relays[relay]; + const isConnected = state?.connectionState === "connected"; + const isAuth = state?.authStatus === "authenticated"; + + return ( +
+
+ +
+
+ {/* Connection status */} + + +
+ {isConnected ? ( + + ) : ( + + )} +
+
+ +

+ {isConnected + ? "Connected" + : state?.connectionState || "Disconnected"} +

+
+
+ + {/* Auth status */} + {isAuth && ( + + +
+ +
+
+ +

Authenticated

+
+
+ )} +
+
+ ); + }) ) : (

No DM relays configured. Using general relays from kind @@ -451,9 +528,6 @@ function ConversationRow({ latestMessage, onClick, }: ConversationRowProps) { - const profile = useProfile(otherPubkey); - const displayName = getDisplayName(otherPubkey, profile); - // Format timestamp const timestamp = new Date(latestMessage.createdAt * 1000); const now = new Date(); @@ -475,12 +549,12 @@ function ConversationRow({ return (

{/* Name */} - - {displayName} - +
+ +
{/* Message preview */} diff --git a/src/lib/chat-parser.ts b/src/lib/chat-parser.ts index 7de436d..4d39a6e 100644 --- a/src/lib/chat-parser.ts +++ b/src/lib/chat-parser.ts @@ -91,8 +91,10 @@ Currently supported formats: Examples: chat nevent1qqsxyz... (thread with relay hints) chat note1abc... (thread with event ID only) - - npub1.../nprofile1.../hex (NIP-17 private DMs) + - $me/NIP-05/npub1.../nprofile1.../hex (NIP-17 private DMs) Examples: + chat $me (DM with yourself) + chat alice@example.com (NIP-05 identifier) chat npub1abc... (DM with pubkey) chat nprofile1xyz... (DM with relay hints) chat 1a2b3c... (DM with hex pubkey) diff --git a/src/lib/chat/adapters/nip-17-adapter.ts b/src/lib/chat/adapters/nip-17-adapter.ts index 4ff1e28..60445a0 100644 --- a/src/lib/chat/adapters/nip-17-adapter.ts +++ b/src/lib/chat/adapters/nip-17-adapter.ts @@ -24,6 +24,7 @@ import eventStore from "@/services/event-store"; import accountManager from "@/services/accounts"; import { publishEventToRelays } from "@/services/hub"; import type { UnsealedDM } from "@/services/db"; +import { isNip05, resolveNip05 } from "@/lib/nip05"; /** * NIP-17 Adapter - Private Direct Messages @@ -38,13 +39,35 @@ export class Nip17Adapter extends ChatProtocolAdapter { readonly type = "dm" as const; /** - * Parse identifier - accepts npub, nprofile, or hex pubkey + * Parse identifier - accepts $me, NIP-05, npub, nprofile, or hex pubkey * Examples: + * - $me (DM with yourself) + * - alice@example.com (NIP-05 identifier) * - npub1abc... (public key) * - nprofile1xyz... (profile with relay hints) * - 1a2b3c... (hex pubkey) */ parseIdentifier(input: string): ProtocolIdentifier | null { + // Handle $me alias (DM with yourself) + if (input === "$me") { + const activePubkey = accountManager.active$.value?.pubkey; + if (!activePubkey) { + throw new Error("No active account. Log in to use $me."); + } + return { + type: "dm-recipient", + value: activePubkey, + }; + } + + // Try NIP-05 format (user@domain.com) + if (isNip05(input)) { + return { + type: "chat-partner-nip05", + value: input, + }; + } + // Try npub format if (input.startsWith("npub1")) { try { @@ -89,17 +112,30 @@ export class Nip17Adapter extends ChatProtocolAdapter { /** * Resolve conversation from DM identifier + * Handles both direct pubkeys and NIP-05 identifiers */ async resolveConversation( identifier: ProtocolIdentifier, ): Promise { - if (identifier.type !== "dm-recipient") { + let recipientPubkey: string; + + // Resolve NIP-05 identifier to pubkey + if (identifier.type === "chat-partner-nip05") { + const resolvedPubkey = await resolveNip05(identifier.value); + if (!resolvedPubkey) { + throw new Error( + `Failed to resolve NIP-05 identifier: ${identifier.value}`, + ); + } + recipientPubkey = resolvedPubkey; + } else if (identifier.type === "dm-recipient") { + recipientPubkey = identifier.value; + } else { throw new Error( `NIP-17 adapter cannot handle identifier type: ${identifier.type}`, ); } - const recipientPubkey = identifier.value; const activePubkey = accountManager.active$.value?.pubkey; if (!activePubkey) { diff --git a/src/services/gift-wrap.ts b/src/services/gift-wrap.ts index 12047f8..2cccbb7 100644 --- a/src/services/gift-wrap.ts +++ b/src/services/gift-wrap.ts @@ -61,6 +61,7 @@ class GiftWrapManager { private isAuthenticating = false; private authenticated = new Set(); // Track which relays are authenticated private isSyncing = false; // Prevent concurrent sync attempts + private statsUpdateTimer: NodeJS.Timeout | null = null; // Debounce stats updates /** * Start syncing gift wraps for the active account @@ -610,6 +611,9 @@ class GiftWrapManager { `[GiftWrap] Failed to decrypt ${giftWrapId.slice(0, 8)}:`, error, ); + } finally { + // Update stats after processing (debounced to batch multiple updates) + this.debouncedUpdateStats(); } } @@ -781,6 +785,19 @@ class GiftWrapManager { this.stats$.next(stats); } + /** + * Debounced stats update - delays stats calculation to batch multiple updates + */ + private debouncedUpdateStats(): void { + if (this.statsUpdateTimer) { + clearTimeout(this.statsUpdateTimer); + } + this.statsUpdateTimer = setTimeout(() => { + this.updateStats(); + this.statsUpdateTimer = null; + }, 500); // Wait 500ms after last gift wrap before updating stats + } + /** * Get statistics observable */