From 9332dcc35ad0887bf1e4668fb23a5e9efddd79d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 10:19:29 +0000 Subject: [PATCH] fix: Improve NIP-17 chat UI and fix decrypt handling - Fix garbled messages by creating synthetic events from rumors - Add rumor events to EventStore so ReplyPreview can find them - Fix decrypt toast showing success even on failure - Show profile names in chat title using DmTitle component - Support $me alias for saved messages (chat $me) - Make inbox conversations more compact, remove icon - Show "Saved Messages" for self-conversation in inbox - Wire up inbox conversation click to open chat window - Show per-participant inbox relays in RelaysDropdown - Add participantInboxRelays metadata type for NIP-17 --- src/components/ChatViewer.tsx | 54 ++++++++++- src/components/InboxViewer.tsx | 77 +++++++++------ src/components/chat/RelaysDropdown.tsx | 123 ++++++++++++++++++------ src/lib/chat/adapters/nip-17-adapter.ts | 83 ++++++++++++++-- src/types/chat.ts | 2 + 5 files changed, 266 insertions(+), 73 deletions(-) diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 21b8242..eb31516 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -121,6 +121,40 @@ function isDifferentDay(timestamp1: number, timestamp2: number): boolean { ); } +/** + * DmTitle - Renders profile names for NIP-17 DM conversations + */ +const DmTitle = memo(function DmTitle({ + participants, + activePubkey, +}: { + participants: { pubkey: string }[]; + activePubkey: string | undefined; +}) { + // Filter out the current user from participants + const others = participants.filter((p) => p.pubkey !== activePubkey); + + // Self-conversation (saved messages) + if (others.length === 0) { + return Saved Messages; + } + + // 1-on-1 or group + return ( + + {others.slice(0, 3).map((p, i) => ( + + {i > 0 && , } + + + ))} + {others.length > 3 && ( + +{others.length - 3} + )} + + ); +}); + /** * Type guard for LiveActivityMetadata */ @@ -794,7 +828,16 @@ export function ChatViewer({ className="text-sm font-semibold truncate cursor-help text-left" onClick={() => setTooltipOpen(!tooltipOpen)} > - {customTitle || conversation.title} + {customTitle ? ( + customTitle + ) : conversation.protocol === "nip-17" ? ( + + ) : ( + conversation.title + )} )} - {conversation.title} + {conversation.protocol === "nip-17" ? ( + + ) : ( + conversation.title + )} {/* Description */} diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx index 614c7b7..0494ec4 100644 --- a/src/components/InboxViewer.tsx +++ b/src/components/InboxViewer.tsx @@ -11,7 +11,6 @@ import { Clock, Radio, RefreshCw, - Users, MessageSquare, } from "lucide-react"; import { toast } from "sonner"; @@ -32,11 +31,13 @@ import accounts from "@/services/accounts"; import { cn } from "@/lib/utils"; import { formatTimestamp } from "@/hooks/useLocale"; import type { DecryptStatus } from "@/services/gift-wrap"; +import { useGrimoire } from "@/core/state"; /** * InboxViewer - Manage private messages (NIP-17/59 gift wraps) */ function InboxViewer() { + const { addWindow } = useGrimoire(); const account = use$(accounts.active$); const settings = use$(giftWrapService.settings$); const syncStatus = use$(giftWrapService.syncStatus$); @@ -293,9 +294,17 @@ function InboxViewer() { conversation={conv} currentUserPubkey={account.pubkey} onClick={() => { - // Open chat window - for now just show a toast - // In future, this would open the conversation in a chat viewer - toast.info("Chat viewer coming soon"); + // Build chat identifier from participants + // For self-chat, use $me; for others, use comma-separated npubs + const others = conv.participants.filter( + (p) => p !== account.pubkey, + ); + const identifier = + others.length === 0 ? "$me" : others.join(","); + addWindow("chat", { + identifier, + protocol: "nip-17", + }); }} /> ))} @@ -331,8 +340,14 @@ function InboxViewer() { giftWraps={giftWraps ?? []} onDecrypt={async (id) => { try { - await giftWrapService.decrypt(id); - toast.success("Message decrypted"); + const result = await giftWrapService.decrypt(id); + if (result) { + toast.success("Message decrypted"); + } else { + // Decryption failed but didn't throw + const state = giftWrapService.decryptStates$.value.get(id); + toast.error(state?.error || "Failed to decrypt message"); + } } catch (err) { toast.error( err instanceof Error ? err.message : "Decryption failed", @@ -398,39 +413,39 @@ function ConversationRow({ (p) => p !== currentUserPubkey, ); + // Self-conversation (saved messages) + const isSelfConversation = otherParticipants.length === 0; + return (
-
-
- {otherParticipants.length === 1 ? ( -
- -
- ) : ( -
- -
- )} -
+
-
- {otherParticipants.slice(0, 3).map((pubkey, i) => ( - - {i > 0 && , } - - - ))} - {otherParticipants.length > 3 && ( - - +{otherParticipants.length - 3} more - +
+ {isSelfConversation ? ( + Saved Messages + ) : ( + <> + {otherParticipants.slice(0, 3).map((pubkey, i) => ( + + {i > 0 && ( + , + )} + + + ))} + {otherParticipants.length > 3 && ( + + +{otherParticipants.length - 3} + + )} + )}
{conversation.lastMessage && ( -

+

{conversation.lastMessage.content}

)} diff --git a/src/components/chat/RelaysDropdown.tsx b/src/components/chat/RelaysDropdown.tsx index b6c0927..de1a70b 100644 --- a/src/components/chat/RelaysDropdown.tsx +++ b/src/components/chat/RelaysDropdown.tsx @@ -5,6 +5,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { RelayLink } from "@/components/nostr/RelayLink"; +import { UserName } from "@/components/nostr/UserName"; import { useRelayState } from "@/hooks/useRelayState"; import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils"; import { normalizeRelayURL } from "@/lib/relay-url"; @@ -17,21 +18,35 @@ interface RelaysDropdownProps { /** * RelaysDropdown - Shows relay count and list with connection status * Similar to relay indicators in ReqViewer + * For NIP-17 DMs, shows per-participant inbox relays */ export function RelaysDropdown({ conversation }: RelaysDropdownProps) { const { relays: relayStates } = useRelayState(); + // Check for per-participant inbox relays (NIP-17) + const participantInboxRelays = conversation.metadata?.participantInboxRelays; + const hasParticipantRelays = + participantInboxRelays && Object.keys(participantInboxRelays).length > 0; + // Get relays for this conversation (immutable pattern) + // Priority: liveActivity relays > inbox relays (NIP-17) > single relayUrl const liveActivityRelays = conversation.metadata?.liveActivity?.relays; + const inboxRelays = conversation.metadata?.inboxRelays; const relays: string[] = Array.isArray(liveActivityRelays) && liveActivityRelays.length > 0 ? liveActivityRelays - : conversation.metadata?.relayUrl - ? [conversation.metadata.relayUrl] - : []; + : Array.isArray(inboxRelays) && inboxRelays.length > 0 + ? inboxRelays + : conversation.metadata?.relayUrl + ? [conversation.metadata.relayUrl] + : []; - // Pre-compute normalized URLs and state lookups in a single pass (O(n)) - const relayData = relays.map((url) => { + // Get label for the relays section + const relayLabel = + conversation.protocol === "nip-17" ? "Inbox Relays" : "Relays"; + + // Helper to normalize and get state for a relay URL + const getRelayInfo = (url: string) => { let normalizedUrl: string; try { normalizedUrl = normalizeRelayURL(url); @@ -45,12 +60,15 @@ export function RelaysDropdown({ conversation }: RelaysDropdownProps) { state, isConnected: state?.connectionState === "connected", }; - }); + }; + + // Pre-compute relay data for all relays + const relayData = relays.map(getRelayInfo); // Count connected relays const connectedCount = relayData.filter((r) => r.isConnected).length; - if (relays.length === 0) { + if (relays.length === 0 && !hasParticipantRelays) { return null; // Don't show if no relays } @@ -64,32 +82,75 @@ export function RelaysDropdown({ conversation }: RelaysDropdownProps) { - -
- Relays ({relays.length}) -
-
- {relayData.map(({ url, state }) => { - const connIcon = getConnectionIcon(state); - const authIcon = getAuthIcon(state); + + {/* For NIP-17, show per-participant breakdown */} + {hasParticipantRelays ? ( +
+ {Object.entries(participantInboxRelays).map( + ([pubkey, pubkeyRelays]) => ( +
+
+ + + ({pubkeyRelays.length}) + +
+
+ {pubkeyRelays.map((url) => { + const info = getRelayInfo(url); + const connIcon = getConnectionIcon(info.state); + const authIcon = getAuthIcon(info.state); - return ( -
-
- {connIcon.icon} - {authIcon.icon} + return ( +
+
+ {connIcon.icon} + {authIcon.icon} +
+ +
+ ); + })} +
- -
- ); - })} -
+ ), + )} +
+ ) : ( + <> +
+ {relayLabel} ({relays.length}) +
+
+ {relayData.map(({ url, state }) => { + const connIcon = getConnectionIcon(state); + const authIcon = getAuthIcon(state); + + return ( +
+
+ {connIcon.icon} + {authIcon.icon} +
+ +
+ ); + })} +
+ + )}
); diff --git a/src/lib/chat/adapters/nip-17-adapter.ts b/src/lib/chat/adapters/nip-17-adapter.ts index 53dd5e2..5589722 100644 --- a/src/lib/chat/adapters/nip-17-adapter.ts +++ b/src/lib/chat/adapters/nip-17-adapter.ts @@ -14,6 +14,7 @@ import type { NostrEvent } from "@/types/nostr"; import giftWrapService, { type Rumor } from "@/services/gift-wrap"; import accountManager from "@/services/accounts"; import { resolveNip05 } from "@/lib/nip05"; +import eventStore from "@/services/event-store"; /** Kind 14: Private direct message (NIP-17) */ const PRIVATE_DM_KIND = 14; @@ -28,7 +29,7 @@ function computeConversationId(participants: string[]): string { /** * Parse participants from a comma-separated list or single identifier - * Supports: npub, nprofile, hex pubkey (32 bytes), NIP-05 + * Supports: npub, nprofile, hex pubkey (32 bytes), NIP-05, $me */ async function parseParticipants(input: string): Promise { const parts = input @@ -36,8 +37,17 @@ async function parseParticipants(input: string): Promise { .map((p) => p.trim()) .filter(Boolean); const pubkeys: string[] = []; + const activePubkey = accountManager.active$.value?.pubkey; for (const part of parts) { + // Handle $me alias + if (part === "$me") { + if (activePubkey && !pubkeys.includes(activePubkey)) { + pubkeys.push(activePubkey); + } + continue; + } + const pubkey = await resolveToPubkey(part); if (pubkey && !pubkeys.includes(pubkey)) { pubkeys.push(pubkey); @@ -117,12 +127,21 @@ export class Nip17Adapter extends ChatProtocolAdapter { readonly type = "dm" as const; /** - * Parse identifier - accepts pubkeys, npubs, nprofiles, NIP-05, or comma-separated list + * Parse identifier - accepts pubkeys, npubs, nprofiles, NIP-05, $me, or comma-separated list */ parseIdentifier(input: string): ProtocolIdentifier | null { // Quick check: must look like a pubkey identifier or NIP-05 const trimmed = input.trim(); + // Check for $me alias (for saved messages) + if (trimmed === "$me") { + return { + type: "dm-recipient", + value: trimmed, + relays: [], + }; + } + // Check for npub, nprofile, hex, or NIP-05 patterns const looksLikePubkey = trimmed.startsWith("npub1") || @@ -133,13 +152,14 @@ export class Nip17Adapter extends ChatProtocolAdapter { !trimmed.includes("'") && !trimmed.includes("/")); - // Also check for comma-separated list + // Also check for comma-separated list (may include $me) const looksLikeList = trimmed.includes(",") && trimmed .split(",") .some( (p) => + p.trim() === "$me" || p.trim().startsWith("npub1") || p.trim().startsWith("nprofile1") || /^[0-9a-fA-F]{64}$/.test(p.trim()) || @@ -226,6 +246,15 @@ export class Nip17Adapter extends ChatProtocolAdapter { role: pubkey === activePubkey ? "member" : undefined, })); + // Get inbox relays for the current user + const userInboxRelays = giftWrapService.inboxRelays$.value; + + // Build per-participant inbox relay map (start with current user) + const participantInboxRelays: Record = {}; + if (userInboxRelays.length > 0) { + participantInboxRelays[activePubkey] = userInboxRelays; + } + return { id: conversationId, type: "dm", @@ -235,6 +264,9 @@ export class Nip17Adapter extends ChatProtocolAdapter { metadata: { encrypted: true, giftWrapped: true, + // Store inbox relays for display in header + inboxRelays: userInboxRelays, + participantInboxRelays, }, unreadCount: 0, }; @@ -297,10 +329,11 @@ export class Nip17Adapter extends ChatProtocolAdapter { /** * Convert a rumor to a Message + * Creates a synthetic event from the rumor for display purposes */ private rumorToMessage( conversationId: string, - giftWrap: NostrEvent, + _giftWrap: NostrEvent, rumor: Rumor, ): Message { // Find reply-to from e tags @@ -312,6 +345,21 @@ export class Nip17Adapter extends ChatProtocolAdapter { } } + // Create a synthetic event from the rumor for display + // This allows RichText to parse content correctly + const syntheticEvent: NostrEvent = { + id: rumor.id, + pubkey: rumor.pubkey, + created_at: rumor.created_at, + kind: rumor.kind, + tags: rumor.tags, + content: rumor.content, + sig: "", // Empty sig - this is a display-only synthetic event + }; + + // Add to eventStore so ReplyPreview can find it by rumor ID + eventStore.add(syntheticEvent); + return { id: rumor.id, conversationId, @@ -324,8 +372,8 @@ export class Nip17Adapter extends ChatProtocolAdapter { encrypted: true, }, protocol: "nip-17", - // Use gift wrap as the event since rumor is unsigned - event: giftWrap, + // Use synthetic event with decrypted content + event: syntheticEvent, }; } @@ -370,18 +418,35 @@ export class Nip17Adapter extends ChatProtocolAdapter { } /** - * Load a replied-to message by ID + * Load a replied-to message by ID (rumor ID) + * Creates a synthetic event from the rumor if found */ async loadReplyMessage( _conversation: Conversation, eventId: string, ): Promise { + // First check if it's already in eventStore (synthetic event may have been added) + const existingEvent = eventStore.database.getEvent(eventId); + if (existingEvent) { + return existingEvent; + } + // Check decrypted rumors for the message const rumors = giftWrapService.decryptedRumors$.value; const found = rumors.find(({ rumor }) => rumor.id === eventId); if (found) { - // Return the gift wrap event - return found.giftWrap; + // Create and add synthetic event from rumor + const syntheticEvent: NostrEvent = { + id: found.rumor.id, + pubkey: found.rumor.pubkey, + created_at: found.rumor.created_at, + kind: found.rumor.kind, + tags: found.rumor.tags, + content: found.rumor.content, + sig: "", + }; + eventStore.add(syntheticEvent); + return syntheticEvent; } return null; } diff --git a/src/types/chat.ts b/src/types/chat.ts index f03bb6f..71e0bdb 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -76,6 +76,8 @@ export interface ConversationMetadata { // NIP-17 DM encrypted?: boolean; giftWrapped?: boolean; + inboxRelays?: string[]; // User's DM inbox relays (kind 10050) + participantInboxRelays?: Record; // Per-participant inbox relays } /**