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
This commit is contained in:
Claude
2026-01-12 14:21:31 +00:00
parent 4aba8e3cca
commit 3974dfc29b
3 changed files with 150 additions and 13 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";
@@ -358,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);
@@ -399,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") {
@@ -564,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";
@@ -369,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);
}
/**

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";
@@ -311,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);
}
/**