feat: add NIP-61 nutzap support to NIP-29 groups (#59)

* feat: add NIP-61 nutzap support to NIP-29 groups

Fetch and render nutzap events (kind 9321) in NIP-29 relay groups
using the same visual styling as lightning zaps. Nutzaps are P2PK
locked Cashu token transfers defined in NIP-61.

- Add nutzap filter subscription in loadMessages
- Combine chat and nutzap observables with RxJS combineLatest
- Add nutzapToMessage helper to parse NIP-61 event structure
- Extract amount by summing proof amounts from proof tag JSON
- Add nutzapUnit metadata field for future multi-currency support

* fix: improve zap/nutzap rendering in chat

- Add mb-1 margin bottom to zap messages for spacing
- Show inline reply preview for zaps that target specific messages
- Fix nutzap amount extraction to handle multiple proof tags
- Extract replyTo from e-tag for nutzaps
- Pass nutzap event to RichText for custom emoji rendering

* fix: pass event only to RichText for proper emoji rendering

* refactor: consolidate NIP-29 chat and nutzap into single REQ

- Use single filter with kinds [9, 9000, 9001, 9321] instead of
  separate subscriptions with combineLatest
- Enables proper pagination for "load older" with single page fetches
- Add rounded corners to zap gradient border for consistent rendering

* refactor: consolidate NIP-53 chat and zap into single REQ

- Use single filter with kinds [1311, 9735] instead of separate
  subscriptions with combineLatest
- Enables proper pagination for "load older" with single page fetches
- Filter invalid zaps inline during event mapping

* feat: add load older messages support to chat adapters

- Implement loadMoreMessages in NIP-29 adapter using pool.request
- Implement loadMoreMessages in NIP-53 adapter using pool.request
- Add "Load older messages" button to ChatViewer header
- Use firstValueFrom + toArray to convert Observable to Promise
- Track loading state and hasMore for pagination UI

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-12 15:26:41 +01:00
committed by GitHub
parent e50fcca386
commit b24810074d
4 changed files with 278 additions and 82 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, Zap } from "lucide-react";
import { Loader2, Reply, Zap } from "lucide-react";
import { getZapRequest } from "applesauce-common/helpers/zap";
import accountManager from "@/services/accounts";
import eventStore from "@/services/event-store";
@@ -181,17 +181,23 @@ const MessageItem = memo(function MessageItem({
// Zap messages have special styling with gradient border
if (message.type === "zap") {
const zapRequest = message.event ? getZapRequest(message.event) : null;
// For NIP-57 zaps, reply target is in the zap request's e-tag
// For NIP-61 nutzaps, reply target is already in message.replyTo
const zapReplyTo =
message.replyTo ||
zapRequest?.tags.find((t) => t[0] === "e")?.[1] ||
undefined;
return (
<div className="pl-2">
<div className="pl-2 mb-1">
<div
className="p-[1px]"
className="p-[1px] rounded"
style={{
background:
"linear-gradient(to right, rgb(250 204 21), rgb(251 146 60), rgb(168 85 247), rgb(34 211 238))",
}}
>
<div className="bg-background px-1">
<div className="bg-background px-1 rounded-sm">
<div className="flex items-center gap-2">
<UserName
pubkey={message.author}
@@ -213,11 +219,18 @@ const MessageItem = memo(function MessageItem({
<Timestamp timestamp={message.timestamp} />
</span>
</div>
{zapReplyTo && (
<ReplyPreview
replyToId={zapReplyTo}
adapter={adapter}
conversation={conversation}
onScrollToMessage={onScrollToMessage}
/>
)}
{message.content && (
<RichText
content={message.content}
event={zapRequest || undefined}
className="mt-1 text-sm leading-tight break-words"
event={zapRequest || message.event}
className="text-sm leading-tight break-words"
options={{ showMedia: false, showEventEmbeds: false }}
/>
)}
@@ -345,6 +358,10 @@ export function ChatViewer({
// Track reply context (which message is being replied to)
const [replyTo, setReplyTo] = useState<string | undefined>();
// State for loading older messages
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
const [hasMore, setHasMore] = useState(true);
// Ref to Virtuoso for programmatic scrolling
const virtuosoRef = useRef<VirtuosoHandle>(null);
@@ -386,6 +403,32 @@ export function ChatViewer({
[messages],
);
// Handle loading older messages
const handleLoadOlder = useCallback(async () => {
if (!conversation || !messages || messages.length === 0 || isLoadingOlder) {
return;
}
setIsLoadingOlder(true);
try {
// Get the timestamp of the oldest message
const oldestMessage = messages[0];
const olderMessages = await adapter.loadMoreMessages(
conversation,
oldestMessage.timestamp,
);
// If we got fewer messages than expected, there might be no more
if (olderMessages.length < 50) {
setHasMore(false);
}
} catch (error) {
console.error("Failed to load older messages:", error);
} finally {
setIsLoadingOlder(false);
}
}, [conversation, messages, adapter, isLoadingOlder]);
// Handle NIP badge click
const handleNipClick = useCallback(() => {
if (conversation?.protocol === "nip-29") {
@@ -551,6 +594,27 @@ export function ChatViewer({
data={messagesWithMarkers}
initialTopMostItemIndex={messagesWithMarkers.length - 1}
followOutput="smooth"
components={{
Header: () =>
hasMore ? (
<div className="flex justify-center py-2">
<button
onClick={handleLoadOlder}
disabled={isLoadingOlder}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-50 flex items-center gap-1"
>
{isLoadingOlder ? (
<>
<Loader2 className="size-3 animate-spin" />
Loading...
</>
) : (
"Load older messages"
)}
</button>
</div>
) : null,
}}
itemContent={(_index, item) => {
if (item.type === "day-marker") {
return (

View File

@@ -1,5 +1,5 @@
import { Observable } from "rxjs";
import { map, first } from "rxjs/operators";
import { Observable, firstValueFrom } from "rxjs";
import { map, first, toArray } from "rxjs/operators";
import type { Filter } from "nostr-tools";
import { nip19 } from "nostr-tools";
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
@@ -312,12 +312,13 @@ export class Nip29Adapter extends ChatProtocolAdapter {
console.log(`[NIP-29] Loading messages for ${groupId} from ${relayUrl}`);
// Subscribe to group messages (kind 9) and admin events (9000-9022)
// Single filter for all group events:
// kind 9: chat messages
// kind 9000: put-user (admin adds user)
// kind 9001: remove-user (admin removes user)
// kind 9321: nutzaps (NIP-61)
const filter: Filter = {
kinds: [9, 9000, 9001],
kinds: [9, 9000, 9001, 9321],
"#h": [groupId],
limit: options?.limit || 50,
};
@@ -330,20 +331,17 @@ export class Nip29Adapter extends ChatProtocolAdapter {
}
// Start a persistent subscription to the group relay
// This will feed new messages into the EventStore in real-time
pool
.subscription([relayUrl], [filter], {
eventStore, // Automatically add to store
eventStore,
})
.subscribe({
next: (response) => {
if (typeof response === "string") {
// EOSE received
console.log("[NIP-29] EOSE received for messages");
console.log("[NIP-29] EOSE received");
} else {
// Event received
console.log(
`[NIP-29] Received message: ${response.id.slice(0, 8)}...`,
`[NIP-29] Received event k${response.kind}: ${response.id.slice(0, 8)}...`,
);
}
},
@@ -352,10 +350,17 @@ export class Nip29Adapter extends ChatProtocolAdapter {
// Return observable from EventStore which will update automatically
return eventStore.timeline(filter).pipe(
map((events) => {
console.log(`[NIP-29] Timeline has ${events.length} messages`);
return events
.map((event) => this.eventToMessage(event, conversation.id))
.sort((a, b) => a.timestamp - b.timestamp); // Oldest first for flex-col-reverse
const messages = events.map((event) => {
// Convert nutzaps (kind 9321) using nutzapToMessage
if (event.kind === 9321) {
return this.nutzapToMessage(event, conversation.id);
}
// All other events use eventToMessage
return this.eventToMessage(event, conversation.id);
});
console.log(`[NIP-29] Timeline has ${messages.length} events`);
return messages.sort((a, b) => a.timestamp - b.timestamp);
}),
);
}
@@ -364,11 +369,44 @@ export class Nip29Adapter extends ChatProtocolAdapter {
* Load more historical messages (pagination)
*/
async loadMoreMessages(
_conversation: Conversation,
_before: number,
conversation: Conversation,
before: number,
): Promise<Message[]> {
// For now, return empty - pagination to be implemented in Phase 6
return [];
const groupId = conversation.metadata?.groupId;
const relayUrl = conversation.metadata?.relayUrl;
if (!groupId || !relayUrl) {
throw new Error("Group ID and relay URL required");
}
console.log(
`[NIP-29] Loading older messages for ${groupId} before ${before}`,
);
// Same filter as loadMessages but with until for pagination
const filter: Filter = {
kinds: [9, 9000, 9001, 9321],
"#h": [groupId],
until: before,
limit: 50,
};
// One-shot request to fetch older messages
const events = await firstValueFrom(
pool.request([relayUrl], [filter], { eventStore }).pipe(toArray()),
);
console.log(`[NIP-29] Loaded ${events.length} older events`);
// Convert events to messages
const messages = events.map((event) => {
if (event.kind === 9321) {
return this.nutzapToMessage(event, conversation.id);
}
return this.eventToMessage(event, conversation.id);
});
return messages.sort((a, b) => a.timestamp - b.timestamp);
}
/**
@@ -628,4 +666,68 @@ export class Nip29Adapter extends ChatProtocolAdapter {
event,
};
}
/**
* Helper: Convert nutzap event (kind 9321) to Message
* NIP-61 nutzaps are P2PK-locked Cashu token transfers
*/
private nutzapToMessage(event: NostrEvent, conversationId: string): Message {
// Sender is the event author
const sender = event.pubkey;
// Recipient is the p-tag value
const pTag = event.tags.find((t) => t[0] === "p");
const recipient = pTag?.[1] || "";
// Reply target is the e-tag (the event being nutzapped)
const eTag = event.tags.find((t) => t[0] === "e");
const replyTo = eTag?.[1];
// Amount is sum of proof amounts from all proof tags
// NIP-61 allows multiple proof tags, each containing a JSON-encoded Cashu proof
let amount = 0;
for (const tag of event.tags) {
if (tag[0] === "proof" && tag[1]) {
try {
const proof = JSON.parse(tag[1]);
// Proof can be a single object or an array of proofs
if (Array.isArray(proof)) {
amount += proof.reduce(
(sum: number, p: { amount?: number }) => sum + (p.amount || 0),
0,
);
} else if (typeof proof === "object" && proof.amount) {
amount += proof.amount;
}
} catch {
// Invalid proof JSON, skip this tag
}
}
}
// Unit defaults to "sat" per NIP-61
const unitTag = event.tags.find((t) => t[0] === "unit");
const unit = unitTag?.[1] || "sat";
// Comment is in the content field
const comment = event.content || "";
return {
id: event.id,
conversationId,
author: sender,
content: comment,
timestamp: event.created_at,
type: "zap", // Render the same as zaps
replyTo,
protocol: "nip-29",
metadata: {
encrypted: false,
zapAmount: amount, // In the unit specified (usually sats)
zapRecipient: recipient,
nutzapUnit: unit, // Store unit for potential future use
},
event,
};
}
}

View File

@@ -1,5 +1,5 @@
import { Observable, combineLatest } from "rxjs";
import { map, first } from "rxjs/operators";
import { Observable, firstValueFrom } from "rxjs";
import { map, first, toArray } from "rxjs/operators";
import type { Filter } from "nostr-tools";
import { nip19 } from "nostr-tools";
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
@@ -254,80 +254,55 @@ export class Nip53Adapter extends ChatProtocolAdapter {
`[NIP-53] Loading messages for ${aTagValue} from ${relays.length} relays`,
);
// 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],
// Single filter for live chat messages (kind 1311) and zaps (kind 9735)
const filter: Filter = {
kinds: [1311, 9735],
"#a": [aTagValue],
limit: options?.limit || 50,
};
if (options?.before) {
chatFilter.until = options.before;
zapFilter.until = options.before;
filter.until = options.before;
}
if (options?.after) {
chatFilter.since = options.after;
zapFilter.since = options.after;
filter.since = options.after;
}
// Start persistent subscriptions to the relays for both chat and zaps
// Start a persistent subscription to the relays
pool
.subscription(relays, [chatFilter], {
.subscription(relays, [filter], {
eventStore,
})
.subscribe({
next: (response) => {
if (typeof response === "string") {
console.log("[NIP-53] EOSE received for messages");
console.log("[NIP-53] EOSE received");
} else {
console.log(
`[NIP-53] Received message: ${response.id.slice(0, 8)}...`,
`[NIP-53] Received event k${response.kind}: ${response.id.slice(0, 8)}...`,
);
}
},
});
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)}...`);
}
},
});
// Return observable from EventStore which will update automatically
return eventStore.timeline(filter).pipe(
map((events) => {
const messages = events
.map((event) => {
// Convert zaps (kind 9735) using zapToMessage
if (event.kind === 9735) {
// Only include valid zaps
if (!isValidZap(event)) return null;
return this.zapToMessage(event, conversation.id);
}
// All other events (kind 1311) use eventToMessage
return this.eventToMessage(event, conversation.id);
})
.filter((msg): msg is Message => msg !== null);
// 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);
console.log(`[NIP-53] Timeline has ${messages.length} events`);
return messages.sort((a, b) => a.timestamp - b.timestamp);
}),
);
}
@@ -336,11 +311,64 @@ export class Nip53Adapter extends ChatProtocolAdapter {
* Load more historical messages (pagination)
*/
async loadMoreMessages(
_conversation: Conversation,
_before: number,
conversation: Conversation,
before: number,
): Promise<Message[]> {
// Pagination to be implemented later
return [];
const activityAddress = conversation.metadata?.activityAddress;
const liveActivity = conversation.metadata?.liveActivity as
| {
relays?: string[];
}
| undefined;
if (!activityAddress) {
throw new Error("Activity address required");
}
const { pubkey, identifier } = activityAddress;
const aTagValue = `30311:${pubkey}:${identifier}`;
// Get relays from live activity metadata or fall back to relayUrl
const relays = liveActivity?.relays || [];
if (relays.length === 0 && conversation.metadata?.relayUrl) {
relays.push(conversation.metadata.relayUrl);
}
if (relays.length === 0) {
throw new Error("No relays available for live chat");
}
console.log(
`[NIP-53] Loading older messages for ${aTagValue} before ${before}`,
);
// Same filter as loadMessages but with until for pagination
const filter: Filter = {
kinds: [1311, 9735],
"#a": [aTagValue],
until: before,
limit: 50,
};
// One-shot request to fetch older messages
const events = await firstValueFrom(
pool.request(relays, [filter], { eventStore }).pipe(toArray()),
);
console.log(`[NIP-53] Loaded ${events.length} older events`);
// Convert events to messages
const messages = events
.map((event) => {
if (event.kind === 9735) {
if (!isValidZap(event)) return null;
return this.zapToMessage(event, conversation.id);
}
return this.eventToMessage(event, conversation.id);
})
.filter((msg): msg is Message => msg !== null);
return messages.sort((a, b) => a.timestamp - b.timestamp);
}
/**

View File

@@ -93,6 +93,8 @@ export interface MessageMetadata {
// Zap-specific metadata (for type: "zap" messages)
zapAmount?: number; // Amount in sats
zapRecipient?: string; // Pubkey of zap recipient
// NIP-61 nutzap-specific metadata
nutzapUnit?: string; // Unit for nutzap amount (sat, usd, eur, etc.)
}
/**