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
This commit is contained in:
Claude
2026-01-16 10:19:29 +00:00
parent 6dca82d658
commit 9332dcc35a
5 changed files with 266 additions and 73 deletions

View File

@@ -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 <span>Saved Messages</span>;
}
// 1-on-1 or group
return (
<span className="inline-flex items-center gap-1 flex-wrap">
{others.slice(0, 3).map((p, i) => (
<span key={p.pubkey} className="inline-flex items-center">
{i > 0 && <span className="text-muted-foreground">, </span>}
<UserName pubkey={p.pubkey} className="font-semibold" />
</span>
))}
{others.length > 3 && (
<span className="text-muted-foreground">+{others.length - 3}</span>
)}
</span>
);
});
/**
* 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" ? (
<DmTitle
participants={conversation.participants}
activePubkey={activeAccount?.pubkey}
/>
) : (
conversation.title
)}
</button>
</TooltipTrigger>
<TooltipContent
@@ -817,7 +860,14 @@ export function ChatViewer({
/>
)}
<span className="font-semibold">
{conversation.title}
{conversation.protocol === "nip-17" ? (
<DmTitle
participants={conversation.participants}
activePubkey={activeAccount?.pubkey}
/>
) : (
conversation.title
)}
</span>
</div>
{/* Description */}

View File

@@ -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 (
<div
className="border-b border-border px-4 py-3 hover:bg-muted/30 cursor-crosshair transition-colors"
className="border-b border-border px-4 py-2 hover:bg-muted/30 cursor-pointer transition-colors"
onClick={onClick}
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
{otherParticipants.length === 1 ? (
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
<Users className="size-4 text-muted-foreground" />
</div>
) : (
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
<Users className="size-4 text-muted-foreground" />
</div>
)}
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{otherParticipants.slice(0, 3).map((pubkey, i) => (
<span key={pubkey}>
{i > 0 && <span className="text-muted-foreground">, </span>}
<UserName pubkey={pubkey} className="text-sm" />
</span>
))}
{otherParticipants.length > 3 && (
<span className="text-xs text-muted-foreground">
+{otherParticipants.length - 3} more
</span>
<div className="flex items-center gap-1 flex-wrap">
{isSelfConversation ? (
<span className="text-sm font-medium">Saved Messages</span>
) : (
<>
{otherParticipants.slice(0, 3).map((pubkey, i) => (
<span key={pubkey} className="inline-flex items-center">
{i > 0 && (
<span className="text-muted-foreground mr-1">,</span>
)}
<UserName pubkey={pubkey} className="text-sm font-medium" />
</span>
))}
{otherParticipants.length > 3 && (
<span className="text-xs text-muted-foreground ml-1">
+{otherParticipants.length - 3}
</span>
)}
</>
)}
</div>
{conversation.lastMessage && (
<p className="text-sm text-muted-foreground truncate mt-0.5">
<p className="text-xs text-muted-foreground truncate">
{conversation.lastMessage.content}
</p>
)}

View File

@@ -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) {
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Relays ({relays.length})
</div>
<div className="space-y-1 p-1">
{relayData.map(({ url, state }) => {
const connIcon = getConnectionIcon(state);
const authIcon = getAuthIcon(state);
<DropdownMenuContent align="end" className="w-72">
{/* For NIP-17, show per-participant breakdown */}
{hasParticipantRelays ? (
<div className="space-y-2 p-1">
{Object.entries(participantInboxRelays).map(
([pubkey, pubkeyRelays]) => (
<div key={pubkey}>
<div className="px-2 py-1 text-xs font-medium text-muted-foreground flex items-center gap-1">
<UserName pubkey={pubkey} className="font-medium" />
<span className="text-muted-foreground/60">
({pubkeyRelays.length})
</span>
</div>
<div className="space-y-0.5">
{pubkeyRelays.map((url) => {
const info = getRelayInfo(url);
const connIcon = getConnectionIcon(info.state);
const authIcon = getAuthIcon(info.state);
return (
<div
key={url}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-1 flex-shrink-0">
{connIcon.icon}
{authIcon.icon}
return (
<div
key={url}
className="flex items-center gap-2 px-2 py-1 rounded hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-1 flex-shrink-0">
{connIcon.icon}
{authIcon.icon}
</div>
<RelayLink
url={url}
className="text-sm truncate flex-1 min-w-0"
/>
</div>
);
})}
</div>
</div>
<RelayLink
url={url}
className="text-sm truncate flex-1 min-w-0"
/>
</div>
);
})}
</div>
),
)}
</div>
) : (
<>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{relayLabel} ({relays.length})
</div>
<div className="space-y-1 p-1">
{relayData.map(({ url, state }) => {
const connIcon = getConnectionIcon(state);
const authIcon = getAuthIcon(state);
return (
<div
key={url}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-1 flex-shrink-0">
{connIcon.icon}
{authIcon.icon}
</div>
<RelayLink
url={url}
className="text-sm truncate flex-1 min-w-0"
/>
</div>
);
})}
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -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<string[]> {
const parts = input
@@ -36,8 +37,17 @@ async function parseParticipants(input: string): Promise<string[]> {
.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<string, string[]> = {};
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<NostrEvent | null> {
// 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;
}

View File

@@ -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<string, string[]>; // Per-participant inbox relays
}
/**