Add NIP-17 gift-wrapped DMs, follow-from-menu, fix new conversation crash

DMs now send via NIP-17 (kind 1059 gift-wrap) with self-copy for sent
messages. Receive supports both NIP-17 and legacy NIP-04 for backward
compat. Protocol indicator shown in conversation list and compose footer.

Note card context menu (⋯) now includes follow/unfollow option so users
can follow authors directly from the feed without visiting their profile.

Fix crash (black screen) when starting a new DM conversation — empty
event array caused undefined access on lastEvent.
This commit is contained in:
Jure
2026-03-15 21:30:54 +01:00
parent ad79ab99de
commit 5b4f6381da
4 changed files with 115 additions and 30 deletions

2
src-tauri/Cargo.lock generated
View File

@@ -5824,7 +5824,7 @@ dependencies = [
[[package]]
name = "wrystr"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"keyring",
"rusqlite",

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { NDKEvent, nip19 } from "@nostr-dev-kit/ndk";
import { NDKEvent, NDKKind, nip19 } from "@nostr-dev-kit/ndk";
import { useUserStore } from "../../stores/user";
import { useUIStore } from "../../stores/ui";
import { useNotificationsStore } from "../../stores/notifications";
@@ -68,7 +68,9 @@ function ConvRow({
<span className="text-text text-[12px] font-medium truncate">{name}</span>
<span className="text-text-dim text-[10px] shrink-0">{time}</span>
</div>
<div className="text-text-dim text-[11px] truncate">🔒 encrypted</div>
<div className="text-text-dim text-[11px] truncate">
{lastEvent.kind === NDKKind.PrivateDirectMessage ? "🔒 NIP-17" : "🔒 NIP-04"}
</div>
</div>
</button>
);
@@ -139,6 +141,7 @@ function ThreadPanel({
setMessages([]);
fetchDMThread(myPubkey, partnerPubkey)
.then(setMessages)
.catch((err) => console.error("Failed to fetch DM thread:", err))
.finally(() => setLoading(false));
}, [partnerPubkey, myPubkey]);
@@ -222,7 +225,7 @@ function ThreadPanel({
{sending ? "…" : "send"}
</button>
</div>
<p className="text-text-dim text-[10px] mt-1">Ctrl+Enter to send · NIP-04 encrypted</p>
<p className="text-text-dim text-[10px] mt-1">Ctrl+Enter to send · gift-wrapped (NIP-17)</p>
</div>
</div>
);
@@ -314,6 +317,7 @@ export function DMView() {
}));
useNotificationsStore.getState().computeDMUnread(convList);
})
.catch((err) => console.error("Failed to fetch DM conversations:", err))
.finally(() => setLoading(false));
}, [pubkey, hasSigner]);
@@ -355,18 +359,34 @@ export function DMView() {
No messages yet.
</div>
)}
{sortedPartners.map(([partner, events]) => (
<ConvRow
key={partner}
partnerPubkey={partner}
lastEvent={events[0]}
selected={selectedPubkey === partner}
onSelect={() => {
setSelectedPubkey(partner);
useNotificationsStore.getState().markDMRead(partner);
}}
/>
))}
{sortedPartners.map(([partner, events]) =>
events[0] ? (
<ConvRow
key={partner}
partnerPubkey={partner}
lastEvent={events[0]}
selected={selectedPubkey === partner}
onSelect={() => {
setSelectedPubkey(partner);
useNotificationsStore.getState().markDMRead(partner);
}}
/>
) : (
<button
key={partner}
onClick={() => setSelectedPubkey(partner)}
className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-left transition-colors ${
selectedPubkey === partner ? "bg-accent/10 border-l-2 border-accent" : "hover:bg-bg-hover border-l-2 border-transparent"
}`}
>
<div className="w-8 h-8 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-xs shrink-0">?</div>
<div className="flex-1 min-w-0">
<span className="text-text text-[12px] font-medium truncate block">{shortenPubkey(partner)}</span>
<span className="text-text-dim text-[11px]">New conversation</span>
</div>
</button>
)
)}
</div>
<NewConvInput

View File

@@ -39,7 +39,7 @@ export function NoteCard({ event, focused }: NoteCardProps) {
const nip05 = profile?.nip05;
const time = event.created_at ? timeAgo(event.created_at) : "";
const { loggedIn, pubkey: ownPubkey } = useUserStore();
const { loggedIn, pubkey: ownPubkey, follows, follow, unfollow } = useUserStore();
const { mutedPubkeys, mute, unmute } = useMuteStore();
const isMuted = mutedPubkeys.includes(event.pubkey);
const { bookmarkedIds, addBookmark, removeBookmark } = useBookmarkStore();
@@ -175,6 +175,12 @@ export function NoteCard({ event, focused }: NoteCardProps) {
<>
<div className="fixed inset-0 z-[9]" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 top-5 bg-bg-raised border border-border shadow-lg z-10 w-32">
<button
onClick={() => { setMenuOpen(false); follows.includes(event.pubkey) ? unfollow(event.pubkey) : follow(event.pubkey); }}
className="w-full text-left px-3 py-2 text-[11px] text-text-muted hover:text-accent hover:bg-bg-hover transition-colors"
>
{follows.includes(event.pubkey) ? `unfollow` : `follow`}
</button>
<button
onClick={() => { setMenuOpen(false); isMuted ? unmute(event.pubkey) : mute(event.pubkey); }}
className="w-full text-left px-3 py-2 text-[11px] text-text-muted hover:text-danger hover:bg-bg-hover transition-colors"

View File

@@ -1,4 +1,4 @@
import NDK, { NDKEvent, NDKFilter, NDKKind, NDKRelay, NDKRelaySet, NDKSubscriptionCacheUsage, nip19 } from "@nostr-dev-kit/ndk";
import NDK, { NDKEvent, NDKFilter, NDKKind, NDKRelay, NDKRelaySet, NDKSubscriptionCacheUsage, nip19, giftWrap, giftUnwrap } from "@nostr-dev-kit/ndk";
const RELAY_STORAGE_KEY = "wrystr_relays";
@@ -336,11 +336,30 @@ export async function publishContactList(pubkeys: string[]): Promise<void> {
await event.publish();
}
// ── Direct Messages (NIP-04) ─────────────────────────────────────────────────
// ── Direct Messages (NIP-17 gift-wrap + NIP-04 legacy) ───────────────────────
async function unwrapGiftWraps(events: NDKEvent[]): Promise<NDKEvent[]> {
const instance = getNDK();
if (!instance.signer) return [];
const rumors: NDKEvent[] = [];
for (const wrap of events) {
try {
const rumor = await giftUnwrap(wrap, undefined, instance.signer);
if (rumor && rumor.kind === NDKKind.PrivateDirectMessage) {
// Preserve wrapper ID for dedup, but use rumor's created_at for ordering
rumors.push(rumor);
}
} catch {
// Not for us or corrupted — skip silently
}
}
return rumors;
}
export async function fetchDMConversations(myPubkey: string): Promise<NDKEvent[]> {
const instance = getNDK();
const [received, sent] = await Promise.all([
// Fetch NIP-04 (legacy) and NIP-17 (gift-wrap) in parallel
const [nip04Received, nip04Sent, giftWraps] = await Promise.all([
instance.fetchEvents(
{ kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], limit: 500 },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
@@ -349,16 +368,24 @@ export async function fetchDMConversations(myPubkey: string): Promise<NDKEvent[]
{ kinds: [NDKKind.EncryptedDirectMessage], authors: [myPubkey], limit: 500 },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
),
instance.fetchEvents(
{ kinds: [NDKKind.GiftWrap], "#p": [myPubkey], limit: 500 },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
),
]);
const nip17Rumors = await unwrapGiftWraps(Array.from(giftWraps));
const seen = new Set<string>();
return [...Array.from(received), ...Array.from(sent)]
return [...Array.from(nip04Received), ...Array.from(nip04Sent), ...nip17Rumors]
.filter((e) => { if (seen.has(e.id!)) return false; seen.add(e.id!); return true; })
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
}
export async function fetchDMThread(myPubkey: string, theirPubkey: string): Promise<NDKEvent[]> {
const instance = getNDK();
const [fromThem, fromMe] = await Promise.all([
// Fetch NIP-04 and NIP-17 in parallel
const [fromThem, fromMe, giftWraps] = await Promise.all([
instance.fetchEvents(
{ kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], authors: [theirPubkey], limit: 200 },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
@@ -367,27 +394,59 @@ export async function fetchDMThread(myPubkey: string, theirPubkey: string): Prom
{ kinds: [NDKKind.EncryptedDirectMessage], "#p": [theirPubkey], authors: [myPubkey], limit: 200 },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
),
instance.fetchEvents(
{ kinds: [NDKKind.GiftWrap], "#p": [myPubkey], limit: 200 },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
),
]);
return [...Array.from(fromThem), ...Array.from(fromMe)]
// Unwrap NIP-17 and filter to only messages from/to this partner
const allRumors = await unwrapGiftWraps(Array.from(giftWraps));
const partnerRumors = allRumors.filter((r) => {
const pTag = r.tags.find((t) => t[0] === "p")?.[1];
return r.pubkey === theirPubkey || pTag === theirPubkey;
});
return [...Array.from(fromThem), ...Array.from(fromMe), ...partnerRumors]
.sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0));
}
export async function sendDM(recipientPubkey: string, content: string): Promise<void> {
const instance = getNDK();
if (!instance.signer) throw new Error("Not logged in");
const myUser = await instance.signer.user();
const recipient = instance.getUser({ pubkey: recipientPubkey });
const encrypted = await instance.signer.encrypt(recipient, content, "nip04");
const event = new NDKEvent(instance);
event.kind = NDKKind.EncryptedDirectMessage;
event.content = encrypted;
event.tags = [["p", recipientPubkey]];
await event.publish();
// Create unsigned rumor (kind 14)
const rumor = new NDKEvent(instance);
rumor.kind = NDKKind.PrivateDirectMessage;
rumor.content = content;
rumor.tags = [["p", recipientPubkey]];
rumor.pubkey = myUser.pubkey;
rumor.created_at = Math.floor(Date.now() / 1000);
// Gift-wrap to recipient and self (so sent messages appear in our inbox)
const [wrappedForRecipient, wrappedForSelf] = await Promise.all([
giftWrap(rumor, recipient, instance.signer),
giftWrap(rumor, myUser, instance.signer),
]);
await Promise.all([
wrappedForRecipient.publish(),
wrappedForSelf.publish(),
]);
}
export async function decryptDM(event: NDKEvent, myPubkey: string): Promise<string> {
// Kind 14 (NIP-17 rumor) — content is already plaintext after unwrapping
if (event.kind === NDKKind.PrivateDirectMessage) {
return event.content;
}
// Kind 4 (NIP-04 legacy) — decrypt as before
const instance = getNDK();
if (!instance.signer) throw new Error("No signer");
// ECDH shared secret is symmetric — always pass the OTHER party
const otherPubkey =
event.pubkey === myPubkey
? (event.tags.find((t) => t[0] === "p")?.[1] ?? "")