diff --git a/src/App.tsx b/src/App.tsx index d00cc8a..afe9101 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { ThreadView } from "./components/thread/ThreadView"; import { ArticleEditor } from "./components/article/ArticleEditor"; import { OnboardingFlow } from "./components/onboarding/OnboardingFlow"; import { AboutView } from "./components/shared/AboutView"; +import { ZapHistoryView } from "./components/zap/ZapHistoryView"; import { useUIStore } from "./stores/ui"; function App() { @@ -33,6 +34,7 @@ function App() { {currentView === "thread" && } {currentView === "article-editor" && } {currentView === "about" && } + {currentView === "zaps" && } ); diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 143fb51..511d817 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -7,6 +7,7 @@ import { AccountSwitcher } from "./AccountSwitcher"; const NAV_ITEMS = [ { id: "feed" as const, label: "feed", icon: "◈" }, { id: "search" as const, label: "search", icon: "⌕" }, + { id: "zaps" as const, label: "zaps", icon: "⚡" }, { id: "relays" as const, label: "relays", icon: "⟐" }, { id: "settings" as const, label: "settings", icon: "⚙" }, { id: "about" as const, label: "support", icon: "♥" }, diff --git a/src/components/zap/ZapHistoryView.tsx b/src/components/zap/ZapHistoryView.tsx new file mode 100644 index 0000000..3cd181b --- /dev/null +++ b/src/components/zap/ZapHistoryView.tsx @@ -0,0 +1,240 @@ +import { useEffect, useState } 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 { useProfile } from "../../hooks/useProfile"; +import { timeAgo, shortenPubkey } from "../../lib/utils"; + +// ── Parsing helpers ────────────────────────────────────────────────────────── + +function parseReceipt(receipt: NDKEvent): { amount: number | null; senderPubkey: string | null; comment: string; noteId: string | null } { + let amount: number | null = null; + let senderPubkey: string | null = null; + let comment = ""; + const noteId = receipt.tags.find((t) => t[0] === "e")?.[1] ?? null; + + // Sender pubkey: uppercase 'P' tag is the zapper + senderPubkey = receipt.tags.find((t) => t[0] === "P")?.[1] ?? null; + + // Amount + comment come from the embedded zap request in the "description" tag + const desc = receipt.tags.find((t) => t[0] === "description")?.[1]; + if (desc) { + try { + const zapReq = JSON.parse(desc) as { pubkey?: string; content?: string; tags?: string[][] }; + if (!senderPubkey && zapReq.pubkey) senderPubkey = zapReq.pubkey; + comment = zapReq.content ?? ""; + const amountTag = zapReq.tags?.find((t) => t[0] === "amount"); + if (amountTag?.[1]) amount = Math.round(parseInt(amountTag[1]) / 1000); + } catch { /* malformed */ } + } + + return { amount, senderPubkey, comment, noteId }; +} + +function parseRequest(zapReq: NDKEvent): { amount: number | null; recipientPubkey: string | null; comment: string; noteId: string | null } { + const recipientPubkey = zapReq.tags.find((t) => t[0] === "p")?.[1] ?? null; + const noteId = zapReq.tags.find((t) => t[0] === "e")?.[1] ?? null; + const amountTag = zapReq.tags.find((t) => t[0] === "amount"); + const amount = amountTag?.[1] ? Math.round(parseInt(amountTag[1]) / 1000) : null; + return { amount, recipientPubkey, comment: zapReq.content ?? "", noteId }; +} + +// ── Row component ──────────────────────────────────────────────────────────── + +function ZapRow({ + pubkey, + amount, + comment, + createdAt, + direction, +}: { + pubkey: string | null; + amount: number | null; + comment: string; + createdAt: number; + direction: "received" | "sent"; +}) { + const { openProfile } = useUIStore(); + const profile = useProfile(pubkey ?? ""); + const name = pubkey + ? profile?.displayName || profile?.name || shortenPubkey(pubkey) + : "anonymous"; + const avatar = profile?.picture; + + return ( +
+ {/* Avatar */} +
pubkey && openProfile(pubkey)} + > + {avatar ? ( + { (e.target as HTMLImageElement).style.display = "none"; }} /> + ) : ( +
+ {name.charAt(0).toUpperCase()} +
+ )} +
+ + {/* Content */} +
+
+ + ⚡ {amount !== null ? `${amount.toLocaleString()} sats` : "? sats"} + + + {direction === "received" ? "from" : "to"} + + pubkey && openProfile(pubkey)} + > + {name} + + {timeAgo(createdAt)} +
+ {comment && ( +

{comment}

+ )} +
+
+ ); +} + +// ── Main view ───────────────────────────────────────────────────────────────── + +type Tab = "received" | "sent"; + +export function ZapHistoryView() { + const { pubkey, loggedIn } = useUserStore(); + const { goBack } = useUIStore(); + const [tab, setTab] = useState("received"); + const [received, setReceived] = useState([]); + const [sent, setSent] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!pubkey) return; + setLoading(true); + setError(null); + Promise.all([ + fetchZapsReceived(pubkey, 50), + fetchZapsSent(pubkey, 50), + ]) + .then(([rec, snt]) => { + setReceived(rec); + setSent(snt); + }) + .catch((err) => setError(String(err))) + .finally(() => setLoading(false)); + }, [pubkey]); + + if (!loggedIn || !pubkey) { + return ( +
+
+

Zap History

+
+
+ Log in to see your zap history. +
+
+ ); + } + + const activeEvents = tab === "received" ? received : sent; + + // Compute totals + const totalReceived = received.reduce((sum, e) => { + const { amount } = parseReceipt(e); + return sum + (amount ?? 0); + }, 0); + const totalSent = sent.reduce((sum, e) => { + const { amount } = parseRequest(e); + return sum + (amount ?? 0); + }, 0); + + return ( +
+ {/* Header */} +
+
+ +

Zap History

+
+
+ ⚡ {totalReceived.toLocaleString()} in + ⚡ {totalSent.toLocaleString()} out +
+
+ + {/* Tabs */} +
+ {(["received", "sent"] as Tab[]).map((t) => ( + + ))} +
+ + {/* Body */} +
+ {loading && ( +
Loading zap history…
+ )} + {error && ( +
{error}
+ )} + {!loading && activeEvents.length === 0 && ( +
+ {tab === "received" ? "No zaps received yet." : "No zaps sent yet."} +
+ )} + {!loading && + activeEvents.map((event) => { + if (tab === "received") { + const { amount, senderPubkey, comment } = parseReceipt(event); + return ( + + ); + } else { + const { amount, recipientPubkey, comment } = parseRequest(event); + return ( + + ); + } + })} +
+
+ ); +} diff --git a/src/lib/nostr/client.ts b/src/lib/nostr/client.ts index c5cc38e..b6c9f91 100644 --- a/src/lib/nostr/client.ts +++ b/src/lib/nostr/client.ts @@ -305,6 +305,24 @@ export async function publishContactList(pubkeys: string[]): Promise { await event.publish(); } +export async function fetchZapsReceived(pubkey: string, limit = 50): Promise { + const instance = getNDK(); + const filter: NDKFilter = { kinds: [NDKKind.Zap], "#p": [pubkey], limit }; + const events = await instance.fetchEvents(filter, { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, + }); + return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); +} + +export async function fetchZapsSent(pubkey: string, limit = 50): Promise { + const instance = getNDK(); + const filter: NDKFilter = { kinds: [NDKKind.ZapRequest], authors: [pubkey], limit }; + const events = await instance.fetchEvents(filter, { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, + }); + return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); +} + export async function fetchMuteList(pubkey: string): Promise { const instance = getNDK(); const filter: NDKFilter = { kinds: [10000 as NDKKind], authors: [pubkey], limit: 1 }; diff --git a/src/lib/nostr/index.ts b/src/lib/nostr/index.ts index 73dbb9b..8b5596b 100644 --- a/src/lib/nostr/index.ts +++ b/src/lib/nostr/index.ts @@ -1 +1 @@ -export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchReactionCount, fetchUserNotes, fetchProfile, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers } from "./client"; +export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchReactionCount, fetchUserNotes, fetchProfile, fetchZapsReceived, fetchZapsSent, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers } from "./client"; diff --git a/src/stores/ui.ts b/src/stores/ui.ts index c260292..868843c 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; import { NDKEvent } from "@nostr-dev-kit/ndk"; -type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "about"; +type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "about" | "zaps"; interface UIState { currentView: View;