From b24810074d6e331206997677ccc7d09c07d77a98 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Mon, 12 Jan 2026 15:26:41 +0100 Subject: [PATCH] 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 --- src/components/ChatViewer.tsx | 78 +++++++++++-- src/lib/chat/adapters/nip-29-adapter.ts | 138 ++++++++++++++++++++--- src/lib/chat/adapters/nip-53-adapter.ts | 142 ++++++++++++++---------- src/types/chat.ts | 2 + 4 files changed, 278 insertions(+), 82 deletions(-) diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index f2b2061..b816d90 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -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 ( -
+
-
+
+ {zapReplyTo && ( + + )} {message.content && ( )} @@ -345,6 +358,10 @@ export function ChatViewer({ // Track reply context (which message is being replied to) const [replyTo, setReplyTo] = useState(); + // State for loading older messages + const [isLoadingOlder, setIsLoadingOlder] = useState(false); + const [hasMore, setHasMore] = useState(true); + // Ref to Virtuoso for programmatic scrolling const virtuosoRef = useRef(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 ? ( +
+ +
+ ) : null, + }} itemContent={(_index, item) => { if (item.type === "day-marker") { return ( diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index 569113b..6536b30 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -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 { - // 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, + }; + } } diff --git a/src/lib/chat/adapters/nip-53-adapter.ts b/src/lib/chat/adapters/nip-53-adapter.ts index 15c0b6f..20a8ee7 100644 --- a/src/lib/chat/adapters/nip-53-adapter.ts +++ b/src/lib/chat/adapters/nip-53-adapter.ts @@ -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 { - // 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); } /** diff --git a/src/types/chat.ts b/src/types/chat.ts index bb9198d..711333d 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -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.) } /**