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 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gómez
2026-01-11 22:15:04 +01:00
parent 6c1c1bbf04
commit cfd897f96c
2 changed files with 40 additions and 13 deletions

View File

@@ -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<void>();
// 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

View File

@@ -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<void>();
// 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),
),
);
}
/**