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
}
/**