fix: buffer NIP-29 messages until EOSE to prevent partial renders

The loadMessages observable was emitting on every event as they streamed
in before EOSE, causing multiple re-renders with partial message lists.
This resulted in incorrect scroll positions during initial chat load.

Now uses combineLatest with a BehaviorSubject to track EOSE state and
only emits the message list after the initial batch is fully loaded.
New messages after EOSE continue to trigger immediate updates.

https://claude.ai/code/session_0174r1Ddh5e2RuhHf2ZrFiNc
This commit is contained in:
Claude
2026-02-02 10:36:30 +00:00
parent 23a54fda02
commit ff68392f59

View File

@@ -1,5 +1,10 @@
import { Observable, firstValueFrom } from "rxjs";
import { map, first, toArray } from "rxjs/operators";
import {
Observable,
firstValueFrom,
BehaviorSubject,
combineLatest,
} from "rxjs";
import { map, first, toArray, filter as filterOp } from "rxjs/operators";
import type { Filter } from "nostr-tools";
import { nip19 } from "nostr-tools";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
@@ -332,6 +337,9 @@ export class Nip29Adapter extends ChatProtocolAdapter {
const conversationId = `nip-29:${relayUrl}'${groupId}`;
this.cleanup(conversationId);
// Track EOSE state - don't emit until initial batch is loaded
const eoseReceived$ = new BehaviorSubject<boolean>(false);
// Start a persistent subscription to the group relay
const subscription = pool
.subscription([relayUrl], [filter], {
@@ -341,6 +349,7 @@ export class Nip29Adapter extends ChatProtocolAdapter {
next: (response) => {
if (typeof response === "string") {
console.log("[NIP-29] EOSE received");
eoseReceived$.next(true);
} else {
console.log(
`[NIP-29] Received event k${response.kind}: ${response.id.slice(0, 8)}...`,
@@ -352,9 +361,10 @@ export class Nip29Adapter extends ChatProtocolAdapter {
// Store subscription for cleanup
this.subscriptions.set(conversationId, subscription);
// Return observable from EventStore which will update automatically
return eventStore.timeline(filter).pipe(
map((events) => {
// Return observable that only emits after EOSE (prevents partial renders during initial load)
return combineLatest([eventStore.timeline(filter), eoseReceived$]).pipe(
filterOp(([, eose]) => eose), // Only emit after EOSE received
map(([events]) => {
const messages = events.map((event) => {
// Convert nutzaps (kind 9321) using nutzapToMessage
if (event.kind === 9321) {