From 383634fb56ee8b373ebfdedf30decccd2e8a17f9 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:23:32 +0200 Subject: [PATCH] Fix zap hang from dead outbox relay, add note preview to zap history Override NDK default outbox relays (purplepag.es DNS failure was stalling getZapInfo), add 45s zap timeout, disable outbox on NWC instance, fix follower notification dedup in poller. Zap history rows now show a clickable preview of the original zapped note. --- src/components/zap/ZapHistoryView.tsx | 60 ++++++++++++++++++++++++--- src/lib/lightning/nwc.ts | 2 +- src/lib/nostr/core.ts | 9 ++++ src/lib/notificationPoller.ts | 4 ++ src/stores/lightning.ts | 12 +++++- 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/src/components/zap/ZapHistoryView.tsx b/src/components/zap/ZapHistoryView.tsx index 44cb07e..185b71e 100644 --- a/src/components/zap/ZapHistoryView.tsx +++ b/src/components/zap/ZapHistoryView.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import { useUserStore } from "../../stores/user"; import { useUIStore } from "../../stores/ui"; -import { fetchZapsReceived, fetchZapsSent } from "../../lib/nostr"; +import { fetchZapsReceived, fetchZapsSent, fetchNoteById } from "../../lib/nostr"; import { useProfile } from "../../hooks/useProfile"; import { timeAgo, shortenPubkey } from "../../lib/utils"; @@ -39,21 +39,53 @@ function ZapRow({ pubkey, amount, comment, + noteId, createdAt, direction, }: { pubkey: string | null; amount: number | null; comment: string; + noteId: string | null; createdAt: number; direction: "received" | "sent"; }) { - const { openProfile } = useUIStore(); + const { openProfile, openThread } = useUIStore(); const profile = useProfile(pubkey ?? ""); const name = pubkey ? profile?.displayName || profile?.name || shortenPubkey(pubkey) : "anonymous"; const avatar = profile?.picture; + const [notePreview, setNotePreview] = useState(null); + const [noteEvent, setNoteEvent] = useState(null); + const [loadingNote, setLoadingNote] = useState(false); + + useEffect(() => { + if (!noteId) return; + fetchNoteById(noteId).then((event) => { + if (event) { + setNoteEvent(event); + setNotePreview(event.content?.slice(0, 120) || null); + } + }).catch(() => {}); + }, [noteId]); + + const handleNoteClick = useCallback(async () => { + if (noteEvent) { + openThread(noteEvent); + return; + } + if (!noteId || loadingNote) return; + setLoadingNote(true); + try { + const event = await fetchNoteById(noteId); + if (event) { + setNoteEvent(event); + openThread(event); + } + } catch { /* ignore */ } + finally { setLoadingNote(false); } + }, [noteId, noteEvent, loadingNote, openThread]); return (
@@ -92,6 +124,22 @@ function ZapRow({ {comment && (

{comment}

)} + {noteId && ( + + )}
); @@ -202,13 +250,14 @@ export function ZapHistoryView() { {!loading && activeEvents.map((event) => { if (tab === "received") { - const { amount, senderPubkey, comment } = parseReceipt(event); + const { amount, senderPubkey, comment, noteId } = parseReceipt(event); return ( @@ -216,13 +265,14 @@ export function ZapHistoryView() { } else { // Sent zaps are also kind 9735 receipts; the recipient is in the lowercase "p" tag const recipientPubkey = event.tags.find((t) => t[0] === "p")?.[1] ?? null; - const { amount, comment } = parseReceipt(event); + const { amount, comment, noteId } = parseReceipt(event); return ( diff --git a/src/lib/lightning/nwc.ts b/src/lib/lightning/nwc.ts index 409ac90..95e53bc 100644 --- a/src/lib/lightning/nwc.ts +++ b/src/lib/lightning/nwc.ts @@ -32,7 +32,7 @@ export function isValidNwcUri(uri: string): boolean { export async function payInvoiceViaNWC(nwcUri: string, bolt11: string): Promise { const { walletPubkey, relayUrl, secret } = parseNwcUri(nwcUri); - const ndk = new NDK({ explicitRelayUrls: [relayUrl] }); + const ndk = new NDK({ explicitRelayUrls: [relayUrl], enableOutboxModel: false }); const signer = new NDKPrivateKeySigner(secret); ndk.signer = signer; await ndk.connect(); diff --git a/src/lib/nostr/core.ts b/src/lib/nostr/core.ts index efda371..7dfba5e 100644 --- a/src/lib/nostr/core.ts +++ b/src/lib/nostr/core.ts @@ -44,6 +44,13 @@ export const FALLBACK_RELAYS = [ "wss://relay.snort.social", ]; +// Override NDK's default outbox relays (purplepag.es can have DNS issues) +export const OUTBOX_RELAYS = [ + "wss://relay.damus.io/", + "wss://nos.lol/", + "wss://relay.nostr.band/", +]; + export function getStoredRelayUrls(): string[] { try { const stored = localStorage.getItem(RELAY_STORAGE_KEY); @@ -63,6 +70,7 @@ export function getNDK(): NDK { if (!ndk) { ndk = new NDK({ explicitRelayUrls: getStoredRelayUrls(), + outboxRelayUrls: OUTBOX_RELAYS, }); ndkCreatedAt = Date.now(); } @@ -92,6 +100,7 @@ export async function resetNDK(): Promise { // Create fresh instance ndk = new NDK({ explicitRelayUrls: getStoredRelayUrls(), + outboxRelayUrls: OUTBOX_RELAYS, }); ndkCreatedAt = Date.now(); diff --git a/src/lib/notificationPoller.ts b/src/lib/notificationPoller.ts index 884383e..582b449 100644 --- a/src/lib/notificationPoller.ts +++ b/src/lib/notificationPoller.ts @@ -89,6 +89,10 @@ async function pollOnce(pubkey: string) { const newFollowers = followers.filter((e) => e.pubkey !== pubkey && !existingFollowerPubkeys.has(e.pubkey)); if (newFollowers.length > 0) { dbSaveNotifications(newFollowers.map((e) => JSON.stringify(e.rawEvent())), pubkey, "follower"); + // Add to in-memory store so next poll cycle's pubkey dedup catches them + const store = useNotificationsStore.getState(); + const updated = [...store.notifications, ...newFollowers]; + useNotificationsStore.setState({ notifications: updated }); for (const e of newFollowers) { const name = await getProfileName(e.pubkey); notifyFollower(name).catch(() => {}); diff --git a/src/stores/lightning.ts b/src/stores/lightning.ts index 79dea6f..e7e9d67 100644 --- a/src/stores/lightning.ts +++ b/src/stores/lightning.ts @@ -78,7 +78,12 @@ export const useLightningStore = create(() => ({ }); return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Zap timed out after 45 seconds")); + }, 45000); + zapper.on("complete", (results) => { + clearTimeout(timeout); const errors = Array.from(results.values()).filter((r) => r instanceof Error); if (errors.length > 0) { reject(errors[0]); @@ -87,7 +92,12 @@ export const useLightningStore = create(() => ({ } }); - zapper.zap().catch(reject); + zapper.zap().then(() => { + // zap() resolved but "complete" event handles the result + }).catch((err) => { + clearTimeout(timeout); + reject(err); + }); }); }, }));