mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 02:31:13 +02:00
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:
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user