feat: show zaps in NIP-53 live chat with gradient border

- Fetch kind 9735 zaps with #a tag matching the live activity
- Combine zaps and chat messages in the timeline, sorted by timestamp
- Display zap messages with gradient border (yellow → orange → purple → cyan)
- Show zapper, amount, recipient, and optional comment
- Add "zap" message type with zapAmount and zapRecipient metadata
This commit is contained in:
Claude
2026-01-12 11:29:30 +00:00
parent 116d7b5eb7
commit caab8e3c17
3 changed files with 141 additions and 17 deletions

View File

@@ -2,7 +2,7 @@ import { useMemo, useState, memo, useCallback, useRef } from "react";
import { use$ } from "applesauce-react/hooks";
import { from } from "rxjs";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
import { Reply } from "lucide-react";
import { Reply, Zap } from "lucide-react";
import accountManager from "@/services/accounts";
import eventStore from "@/services/event-store";
import type {
@@ -171,6 +171,49 @@ const MessageItem = memo(function MessageItem({
);
}
// Zap messages have special styling with gradient border
if (message.type === "zap") {
const zapAmount = message.metadata?.zapAmount || 0;
const zapRecipient = message.metadata?.zapRecipient;
return (
<div className="px-3 py-1">
<div
className="rounded-lg p-[1px]"
style={{
background:
"linear-gradient(to right, rgb(250 204 21), rgb(251 146 60), rgb(168 85 247), rgb(34 211 238))",
}}
>
<div className="rounded-lg bg-background px-3 py-1.5">
<div className="flex items-center gap-2 flex-wrap">
<UserName
pubkey={message.author}
className="font-semibold text-sm"
/>
<Zap className="size-4 fill-yellow-500 text-yellow-500" />
<span className="text-yellow-500 font-bold">
{zapAmount.toLocaleString("en", { notation: "compact" })}
</span>
{zapRecipient && (
<>
<span className="text-muted-foreground text-xs"></span>
<UserName pubkey={zapRecipient} className="text-sm" />
</>
)}
<span className="text-xs text-muted-foreground ml-auto">
<Timestamp timestamp={message.timestamp} />
</span>
</div>
{message.content && (
<div className="mt-1 text-sm break-words">{message.content}</div>
)}
</div>
</div>
</div>
);
}
// Regular user messages
return (
<div className="group flex items-start hover:bg-muted/50 px-3">

View File

@@ -1,4 +1,4 @@
import { Observable } from "rxjs";
import { Observable, combineLatest } from "rxjs";
import { map, first } from "rxjs/operators";
import type { Filter } from "nostr-tools";
import { nip19 } from "nostr-tools";
@@ -22,6 +22,12 @@ import {
getLiveStatus,
getLiveHost,
} from "@/lib/live-activity";
import {
getZapAmount,
getZapRequest,
getZapSender,
isValidZap,
} from "applesauce-common/helpers/zap";
import { EventFactory } from "applesauce-core/event-factory";
/**
@@ -222,6 +228,7 @@ export class Nip53Adapter extends ChatProtocolAdapter {
const liveActivity = conversation.metadata?.liveActivity as
| {
relays?: string[];
hostPubkey?: string;
}
| undefined;
@@ -246,23 +253,32 @@ export class Nip53Adapter extends ChatProtocolAdapter {
`[NIP-53] Loading messages for ${aTagValue} from ${relays.length} relays`,
);
// Subscribe to live chat messages (kind 1311)
const filter: Filter = {
// Filter for live chat messages (kind 1311)
const chatFilter: Filter = {
kinds: [1311],
"#a": [aTagValue],
limit: options?.limit || 50,
};
// Filter for zaps (kind 9735) targeting this activity
const zapFilter: Filter = {
kinds: [9735],
"#a": [aTagValue],
limit: options?.limit || 50,
};
if (options?.before) {
filter.until = options.before;
chatFilter.until = options.before;
zapFilter.until = options.before;
}
if (options?.after) {
filter.since = options.after;
chatFilter.since = options.after;
zapFilter.since = options.after;
}
// Start a persistent subscription to the relays
// Start persistent subscriptions to the relays for both chat and zaps
pool
.subscription(relays, [filter], {
.subscription(relays, [chatFilter], {
eventStore,
})
.subscribe({
@@ -277,13 +293,40 @@ export class Nip53Adapter extends ChatProtocolAdapter {
},
});
// Return observable from EventStore which will update automatically
return eventStore.timeline(filter).pipe(
map((events) => {
console.log(`[NIP-53] Timeline has ${events.length} messages`);
return events
.map((event) => this.eventToMessage(event, conversation.id))
.sort((a, b) => a.timestamp - b.timestamp);
pool
.subscription(relays, [zapFilter], {
eventStore,
})
.subscribe({
next: (response) => {
if (typeof response === "string") {
console.log("[NIP-53] EOSE received for zaps");
} else {
console.log(`[NIP-53] Received zap: ${response.id.slice(0, 8)}...`);
}
},
});
// Combine chat messages and zaps from EventStore
const chatMessages$ = eventStore.timeline(chatFilter);
const zapMessages$ = eventStore.timeline(zapFilter);
return combineLatest([chatMessages$, zapMessages$]).pipe(
map(([chatEvents, zapEvents]) => {
const chatMsgs = chatEvents.map((event) =>
this.eventToMessage(event, conversation.id),
);
const zapMsgs = zapEvents
.filter((event) => isValidZap(event))
.map((event) => this.zapToMessage(event, conversation.id));
const allMessages = [...chatMsgs, ...zapMsgs];
console.log(
`[NIP-53] Timeline has ${chatMsgs.length} messages, ${zapMsgs.length} zaps`,
);
return allMessages.sort((a, b) => a.timestamp - b.timestamp);
}),
);
}
@@ -523,4 +566,39 @@ export class Nip53Adapter extends ChatProtocolAdapter {
event,
};
}
/**
* Helper: Convert zap receipt to Message
*/
private zapToMessage(event: NostrEvent, conversationId: string): Message {
const zapSender = getZapSender(event);
const zapAmount = getZapAmount(event);
const zapRequest = getZapRequest(event);
// Convert from msats to sats
const amountInSats = zapAmount ? Math.floor(zapAmount / 1000) : 0;
// Get zap comment from request
const zapComment = zapRequest?.content || "";
// The recipient is the pubkey in the p tag of the zap receipt
const pTag = event.tags.find((t) => t[0] === "p");
const zapRecipient = pTag?.[1] || event.pubkey;
return {
id: event.id,
conversationId,
author: zapSender || event.pubkey,
content: zapComment,
timestamp: event.created_at,
type: "zap",
protocol: "nip-53",
metadata: {
encrypted: false,
zapAmount: amountInSats,
zapRecipient,
},
event,
};
}
}

View File

@@ -90,12 +90,15 @@ export interface MessageMetadata {
zaps?: NostrEvent[];
deleted?: boolean;
hidden?: boolean; // NIP-28 channel hide
// Zap-specific metadata (for type: "zap" messages)
zapAmount?: number; // Amount in sats
zapRecipient?: string; // Pubkey of zap recipient
}
/**
* Message type - system messages for events like join/leave, user messages for chat
* Message type - system messages for events like join/leave, user messages for chat, zaps for stream tips
*/
export type MessageType = "user" | "system";
export type MessageType = "user" | "system" | "zap";
/**
* Generic message abstraction