From cfd897f96c362e8eadeb6aa4672adaebd541e4f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sun, 11 Jan 2026 22:15:04 +0100 Subject: [PATCH] feat: wait for EOSE before rendering messages to prevent scroll jumping Message Loading Improvements: - Use RxJS Subject to track EOSE (End Of Stored Events) - Use skipUntil operator to delay timeline emissions until EOSE received - Prevents scroll position jumping during initial message load - Messages still update reactively after initial EOSE NIP-29 Adapter: - Create eoseSubject to track EOSE state - Emit from subject when EOSE string received from relay subscription - Apply skipUntil(eoseSubject) to eventStore.timeline() observable NIP-C7 Adapter: - Add relay subscription to track EOSE (was missing) - Use same EOSE tracking pattern as NIP-29 - Apply skipUntil to prevent premature timeline emissions Benefits: - Smooth initial load experience without scroll jumping - All messages appear together after EOSE - Maintains reactive updates for new messages - Consistent behavior across both chat protocols Co-Authored-By: Claude Sonnet 4.5 --- src/lib/chat/adapters/nip-29-adapter.ts | 11 +++++-- src/lib/chat/adapters/nip-c7-adapter.ts | 42 ++++++++++++++++++------- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index f41047a..69ddf40 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, Subject } from "rxjs"; +import { map, first, skipUntil } from "rxjs/operators"; import type { Filter } from "nostr-tools"; import { ChatProtocolAdapter } from "./base-adapter"; import type { @@ -292,6 +292,9 @@ export class Nip29Adapter extends ChatProtocolAdapter { filter.since = options.after; } + // Create a subject to track EOSE + const eoseSubject = new Subject(); + // Start a persistent subscription to the group relay // This will feed new messages into the EventStore in real-time pool @@ -303,6 +306,8 @@ export class Nip29Adapter extends ChatProtocolAdapter { if (typeof response === "string") { // EOSE received console.log("[NIP-29] EOSE received for messages"); + eoseSubject.next(); + eoseSubject.complete(); } else { // Event received console.log( @@ -313,7 +318,9 @@ export class Nip29Adapter extends ChatProtocolAdapter { }); // Return observable from EventStore which will update automatically + // Wait for EOSE before emitting to prevent scroll jumping during initial load return eventStore.timeline(filter).pipe( + skipUntil(eoseSubject), map((events) => { console.log(`[NIP-29] Timeline has ${events.length} messages`); return events diff --git a/src/lib/chat/adapters/nip-c7-adapter.ts b/src/lib/chat/adapters/nip-c7-adapter.ts index d5a9306..f241b7e 100644 --- a/src/lib/chat/adapters/nip-c7-adapter.ts +++ b/src/lib/chat/adapters/nip-c7-adapter.ts @@ -1,5 +1,5 @@ -import { Observable, firstValueFrom } from "rxjs"; -import { map, first } from "rxjs/operators"; +import { Observable, firstValueFrom, Subject } from "rxjs"; +import { map, first, skipUntil } from "rxjs/operators"; import { nip19 } from "nostr-tools"; import type { Filter } from "nostr-tools"; import { ChatProtocolAdapter } from "./base-adapter"; @@ -156,15 +156,35 @@ export class NipC7Adapter extends ChatProtocolAdapter { filter.since = options.after; } - return eventStore - .timeline(filter) - .pipe( - map((events) => - events - .map((event) => this.eventToMessage(event, conversation.id)) - .sort((a, b) => a.timestamp - b.timestamp), - ), - ); + // Create a subject to track EOSE + const eoseSubject = new Subject(); + + // Start subscription to populate EventStore and track EOSE + pool + .subscription([], [filter], { + eventStore, // Automatically add to store + }) + .subscribe({ + next: (response) => { + if (typeof response === "string") { + // EOSE received + console.log("[NIP-C7] EOSE received for messages"); + eoseSubject.next(); + eoseSubject.complete(); + } + }, + }); + + // Return observable from EventStore + // Wait for EOSE before emitting to prevent scroll jumping during initial load + return eventStore.timeline(filter).pipe( + skipUntil(eoseSubject), + map((events) => + events + .map((event) => this.eventToMessage(event, conversation.id)) + .sort((a, b) => a.timestamp - b.timestamp), + ), + ); } /**