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 (
+
+
+
+ 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;