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.)
}
/**