mirror of
https://github.com/hoornet/vega.git
synced 2026-04-24 06:40:01 -07:00
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:
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -5824,7 +5824,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wrystr"
|
name = "wrystr"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"keyring",
|
"keyring",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 { useUserStore } from "../../stores/user";
|
||||||
import { useUIStore } from "../../stores/ui";
|
import { useUIStore } from "../../stores/ui";
|
||||||
import { useNotificationsStore } from "../../stores/notifications";
|
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 text-[12px] font-medium truncate">{name}</span>
|
||||||
<span className="text-text-dim text-[10px] shrink-0">{time}</span>
|
<span className="text-text-dim text-[10px] shrink-0">{time}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -139,6 +141,7 @@ function ThreadPanel({
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
fetchDMThread(myPubkey, partnerPubkey)
|
fetchDMThread(myPubkey, partnerPubkey)
|
||||||
.then(setMessages)
|
.then(setMessages)
|
||||||
|
.catch((err) => console.error("Failed to fetch DM thread:", err))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [partnerPubkey, myPubkey]);
|
}, [partnerPubkey, myPubkey]);
|
||||||
|
|
||||||
@@ -222,7 +225,7 @@ function ThreadPanel({
|
|||||||
{sending ? "…" : "send"}
|
{sending ? "…" : "send"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -314,6 +317,7 @@ export function DMView() {
|
|||||||
}));
|
}));
|
||||||
useNotificationsStore.getState().computeDMUnread(convList);
|
useNotificationsStore.getState().computeDMUnread(convList);
|
||||||
})
|
})
|
||||||
|
.catch((err) => console.error("Failed to fetch DM conversations:", err))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [pubkey, hasSigner]);
|
}, [pubkey, hasSigner]);
|
||||||
|
|
||||||
@@ -355,18 +359,34 @@ export function DMView() {
|
|||||||
No messages yet.
|
No messages yet.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sortedPartners.map(([partner, events]) => (
|
{sortedPartners.map(([partner, events]) =>
|
||||||
<ConvRow
|
events[0] ? (
|
||||||
key={partner}
|
<ConvRow
|
||||||
partnerPubkey={partner}
|
key={partner}
|
||||||
lastEvent={events[0]}
|
partnerPubkey={partner}
|
||||||
selected={selectedPubkey === partner}
|
lastEvent={events[0]}
|
||||||
onSelect={() => {
|
selected={selectedPubkey === partner}
|
||||||
setSelectedPubkey(partner);
|
onSelect={() => {
|
||||||
useNotificationsStore.getState().markDMRead(partner);
|
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>
|
</div>
|
||||||
|
|
||||||
<NewConvInput
|
<NewConvInput
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function NoteCard({ event, focused }: NoteCardProps) {
|
|||||||
const nip05 = profile?.nip05;
|
const nip05 = profile?.nip05;
|
||||||
const time = event.created_at ? timeAgo(event.created_at) : "";
|
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 { mutedPubkeys, mute, unmute } = useMuteStore();
|
||||||
const isMuted = mutedPubkeys.includes(event.pubkey);
|
const isMuted = mutedPubkeys.includes(event.pubkey);
|
||||||
const { bookmarkedIds, addBookmark, removeBookmark } = useBookmarkStore();
|
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="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">
|
<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
|
<button
|
||||||
onClick={() => { setMenuOpen(false); isMuted ? unmute(event.pubkey) : mute(event.pubkey); }}
|
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"
|
className="w-full text-left px-3 py-2 text-[11px] text-text-muted hover:text-danger hover:bg-bg-hover transition-colors"
|
||||||
|
|||||||
@@ -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";
|
const RELAY_STORAGE_KEY = "wrystr_relays";
|
||||||
|
|
||||||
@@ -336,11 +336,30 @@ export async function publishContactList(pubkeys: string[]): Promise<void> {
|
|||||||
await event.publish();
|
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[]> {
|
export async function fetchDMConversations(myPubkey: string): Promise<NDKEvent[]> {
|
||||||
const instance = getNDK();
|
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(
|
instance.fetchEvents(
|
||||||
{ kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], limit: 500 },
|
{ kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], limit: 500 },
|
||||||
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
|
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
|
||||||
@@ -349,16 +368,24 @@ export async function fetchDMConversations(myPubkey: string): Promise<NDKEvent[]
|
|||||||
{ kinds: [NDKKind.EncryptedDirectMessage], authors: [myPubkey], limit: 500 },
|
{ kinds: [NDKKind.EncryptedDirectMessage], authors: [myPubkey], limit: 500 },
|
||||||
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
|
{ 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>();
|
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; })
|
.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));
|
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDMThread(myPubkey: string, theirPubkey: string): Promise<NDKEvent[]> {
|
export async function fetchDMThread(myPubkey: string, theirPubkey: string): Promise<NDKEvent[]> {
|
||||||
const instance = getNDK();
|
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(
|
instance.fetchEvents(
|
||||||
{ kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], authors: [theirPubkey], limit: 200 },
|
{ kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], authors: [theirPubkey], limit: 200 },
|
||||||
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
|
{ 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 },
|
{ kinds: [NDKKind.EncryptedDirectMessage], "#p": [theirPubkey], authors: [myPubkey], limit: 200 },
|
||||||
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
|
{ 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));
|
.sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendDM(recipientPubkey: string, content: string): Promise<void> {
|
export async function sendDM(recipientPubkey: string, content: string): Promise<void> {
|
||||||
const instance = getNDK();
|
const instance = getNDK();
|
||||||
if (!instance.signer) throw new Error("Not logged in");
|
if (!instance.signer) throw new Error("Not logged in");
|
||||||
|
|
||||||
|
const myUser = await instance.signer.user();
|
||||||
const recipient = instance.getUser({ pubkey: recipientPubkey });
|
const recipient = instance.getUser({ pubkey: recipientPubkey });
|
||||||
const encrypted = await instance.signer.encrypt(recipient, content, "nip04");
|
|
||||||
const event = new NDKEvent(instance);
|
// Create unsigned rumor (kind 14)
|
||||||
event.kind = NDKKind.EncryptedDirectMessage;
|
const rumor = new NDKEvent(instance);
|
||||||
event.content = encrypted;
|
rumor.kind = NDKKind.PrivateDirectMessage;
|
||||||
event.tags = [["p", recipientPubkey]];
|
rumor.content = content;
|
||||||
await event.publish();
|
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> {
|
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();
|
const instance = getNDK();
|
||||||
if (!instance.signer) throw new Error("No signer");
|
if (!instance.signer) throw new Error("No signer");
|
||||||
// ECDH shared secret is symmetric — always pass the OTHER party
|
|
||||||
const otherPubkey =
|
const otherPubkey =
|
||||||
event.pubkey === myPubkey
|
event.pubkey === myPubkey
|
||||||
? (event.tags.find((t) => t[0] === "p")?.[1] ?? "")
|
? (event.tags.find((t) => t[0] === "p")?.[1] ?? "")
|
||||||
|
|||||||
Reference in New Issue
Block a user