From 0fb6f6778d338c286ea812531bc2a75cd079328a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 14:12:02 +0000 Subject: [PATCH] refactor: one DM window, NIP-04 followers, shared listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the DM windowing system to a single source of truth and remove the accumulated duplication. - One window: the gift-wrap (NIP-17) loader owns the only TimeWindowPagination. The rooms-list NIP-04 loader no longer keeps its own window advanced "in lockstep" — like the conversation loader, it now follows the gift-wrap window's `windowSince` and re-requests via `reload()`. Removes its window, loadMore, loadEverything and exhausted; the rooms screen drives giftWraps.loadMore() + nip04.reload() and reads giftWraps.exhausted alone. - Shared listener: extract WindowLoadTracker.trackingListener(forward) — the one place that feeds onActivity/onRelayResponded — replacing three copies of subscription-listener boilerplate and two newEose overrides. - Drop the cold-boot instrumentation (bootStartMs/bootEventCount/bootEoseLogged + verbose per-call logs) from the gift-wrap manager; it was development scaffolding. (The debug-gated DmRelayDiagnosticsLogger stays.) - Renames for clarity: DMsFromUserFilterSubAssembler -> ChatroomListNip04SubAssembler, ChatroomFilterSubAssembler -> ChatroomNip04SubAssembler, field nip04Dms -> nip04. Behavior is unchanged: same auto-fill/prefetch, same all-relays-or-idle gating, same gap-free conversation reveal. ~250 fewer lines and no more lockstep concept. --- .../eoseManagers/WindowLoadTracker.kt | 33 +++ .../RelaySubscriptionsCoordinator.kt | 2 +- .../AccountGiftWrapsEoseManager.kt | 176 +++------------- .../loggedIn/chats/privateDM/ChatroomView.kt | 2 +- .../datasource/ChatroomFilterAssembler.kt | 2 +- ...embler.kt => ChatroomNip04SubAssembler.kt} | 49 ++--- .../datasource/ChatroomListFilterAssembler.kt | 7 +- .../ChatroomListNip04SubAssembler.kt | 115 +++++++++++ .../DMsFromUserFilterSubAssembler.kt | 194 ------------------ .../chats/rooms/feed/ChatroomListFeedView.kt | 53 ++--- 10 files changed, 223 insertions(+), 410 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/{ChatroomFilterSubAssembler.kt => ChatroomNip04SubAssembler.kt} (61%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt index e50fe1514c..ce128884f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -139,3 +142,33 @@ class WindowLoadTracker( private const val IDLE_CHECK_MS = 500L } } + +/** + * Builds the standard [SubscriptionListener] that feeds this tracker: every event (stored backfill + * included) marks activity so a relay mid-flood is never mistaken for done, and an EOSE or live + * event marks that relay answered. [forward] is invoked with the same EOSE / live-event signal so + * the owning EOSE manager can record the relay's EOSE timestamp (its usual `newEose`). + */ +fun WindowLoadTracker.trackingListener(forward: (NormalizedRelayUrl, List?) -> Unit): SubscriptionListener = + object : SubscriptionListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onRelayResponded(relay) + forward(relay, forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onActivity() + if (isLive) { + onRelayResponded(relay) + forward(relay, forFilters) + } + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index d442aca7e4..a7b0fc7dae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -90,7 +90,7 @@ class RelaySubscriptionsCoordinator( // always running, feed assemblers. val home = HomeFilterAssembler(client) - val chatroomList = ChatroomListFilterAssembler(client) + val chatroomList = ChatroomListFilterAssembler(client, account.giftWraps) val video = VideoFilterAssembler(client) val discovery = DiscoveryFilterAssembler(client) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt index 6dd991b007..5308adab1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt @@ -25,17 +25,13 @@ import com.vitorpamplona.amethyst.commons.relayClient.pagination.TimeWindowPagin import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.trackingListener import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -48,48 +44,37 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap +/** + * Always-on loader for the account's NIP-17 gift wraps (kind 1059). It owns the single DM time + * window: boot opens a small window so the messages list is usable before the whole history is + * fetched and decrypted, and the screens widen it ([loadMore] / [loadEverything]) as the user + * scrolls. The NIP-04 loaders follow this window's [windowSince] so both DM protocols stay aligned. + */ class AccountGiftWrapsEoseManager( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { override fun user(key: AccountQueryState) = key.account.userProfile() - // How far back in time gift wraps are requested, per account. Boot opens a small - // window so the messages list is usable before the whole DM history is fetched and - // decrypted; the rooms screen widens it via [loadMore] to fill the screen and to - // prefetch as the user scrolls. The step grows geometrically so a sparse history - // (or confirming there is nothing older) converges in a handful of requests; it is - // kept in lockstep with the NIP-04 window so both DM protocols advance together. - // Concurrent: windowFor is reached from the UI thread (loadMore / loadEverything) and from - // Dispatchers.IO (updateFilter, via the bundled invalidation), so a plain HashMap would race. + // Per-account window floor. Concurrent: windowFor is reached from the UI thread (loadMore / + // loadEverything) and from Dispatchers.IO (updateFilter), so a plain HashMap would race. private val windows = ConcurrentHashMap() - private fun windowFor(user: User) = - windows.computeIfAbsent(user.pubkeyHex) { - TimeWindowPagination(growthFactor = WINDOW_GROWTH_FACTOR).also { - Log.d(TAG) { "opening initial gift-wrap window for pubkey=${user.pubkeyHex.take(8)}… since=${it.since} (${daysAgo(it.since)}d back)" } - } - } + private fun windowFor(user: User) = windows.computeIfAbsent(user.pubkeyHex) { TimeWindowPagination(growthFactor = WINDOW_GROWTH_FACTOR) } - /** - * The current lower bound (epoch seconds) of this account's gift-wrap window. Exposed so the - * per-conversation NIP-04 loader can request from the SAME floor, keeping both DM protocols - * aligned in a thread instead of one reaching deeper than the other. - */ + /** The current lower bound (epoch seconds) of this account's gift-wrap window. */ fun windowSince(user: User): Long = windowFor(user).since - // A window is "loading" until every dmRelay it was sent to has answered (EOSE / live event), - // or a timeout fires — not on the first EOSE, which a fast empty relay can trip prematurely. + // A window load is in flight until every dmRelay it was sent to has answered, the event stream + // goes quiet, or a cap fires — never on just the first EOSE (a fast empty relay would trip it). private val windowLoad = WindowLoadTracker() val loadingMore: StateFlow = windowLoad.loading - // True once the window has reached the maximum lookback: there is no older history to fetch, - // so the rooms screen can stop the auto-fill loop and show the real empty state. + // True once the window reached the maximum lookback: nothing older to fetch. private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() - // The account scope to run the window-load watchdog on, captured when the subscription opens. - // Volatile: written on Dispatchers.IO (newSub), read on the UI thread (loadMore/loadEverything). + // Account scope for the window-load watchdog. Volatile: written on IO (newSub), read on UI (loadMore). @Volatile private var scope: CoroutineScope? = null @@ -97,143 +82,56 @@ class AccountGiftWrapsEoseManager( key: AccountQueryState, since: SincePerRelayMap?, ): List { - // Only loads DMs if the account is writeable - return if (key.account.isWriteable()) { - val relays = key.account.dmRelays.flow.value - windowLoad.setExpectedRelays(relays.toSet()) - val windowSince = windowFor(user(key)).since - Log.d(TAG) { - "updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… requesting kind:1059 " + - "since=$windowSince (${daysAgo(windowSince)}d window) on ${relays.size} dmRelay(s): ${relays.map { it.url }}" - } - relays.flatMap { relay -> - filterGiftWrapsToPubkey( - relay = relay, - pubkey = user(key).pubkeyHex, - since = windowSince, - ) - } - } else { + if (!key.account.isWriteable()) { windowLoad.setExpectedRelays(emptySet()) - Log.d(TAG) { "updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… account not writeable, skipping" } - emptyList() + return emptyList() + } + val relays = key.account.dmRelays.flow.value + windowLoad.setExpectedRelays(relays.toSet()) + val windowSince = windowFor(user(key)).since + return relays.flatMap { relay -> + filterGiftWrapsToPubkey(relay = relay, pubkey = user(key).pubkeyHex, since = windowSince) } } - /** - * Widens the gift-wrap time window for [user] one step back and re-issues the - * subscription so older conversations stream in. Called by the rooms screen to - * fill the screen and to prefetch older history as the user scrolls. No-op once - * the window is [exhausted]. Kept in lockstep with the NIP-04 window. - */ + /** Widens the window one (geometric) step back and re-issues the subscription. No-op if exhausted. */ fun loadMore(user: User) { val window = windowFor(user) if (window.isExhausted()) return - val before = window.since window.loadMore() _exhausted.value = window.isExhausted() - Log.d(TAG) { - "loadMore: pubkey=${user.pubkeyHex.take(8)}… widening window since $before -> ${window.since} " + - "(${daysAgo(window.since)}d back, was ${daysAgo(before)}d, exhausted=${_exhausted.value}), re-issuing subscription" - } scope?.let { windowLoad.startLoading(it) } invalidateFilters() } - /** - * Jumps the gift-wrap window straight to the maximum lookback so a single REQ pulls the entire - * history (the pre-windowing behavior), and marks it [exhausted] so auto-fill stops. - */ + /** Jumps the window to the maximum lookback so a single REQ pulls the entire history. */ fun loadEverything(user: User) { val window = windowFor(user) if (window.isExhausted()) return window.loadAll() _exhausted.value = true - Log.d(TAG) { "loadEverything: pubkey=${user.pubkeyHex.take(8)}… loading full history since ${window.since}" } scope?.let { windowLoad.startLoading(it) } invalidateFilters() } - override fun newEose( - key: AccountQueryState, - relay: NormalizedRelayUrl, - time: Long, - filters: List?, - ) { - windowLoad.onRelayResponded(relay) - super.newEose(key, relay, time, filters) - } - - private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY - - val userJobMap = mutableMapOf>() - - // Cold-boot instrumentation: when the subscription opened (ms), how many gift - // wraps have arrived since, and whether we've already logged the first EOSE. - // Concurrent because the listener callbacks below run on the relay reader threads - // (several relays delivering events at once during the cold-boot flood). - private val bootStartMs = ConcurrentHashMap() - private val bootEventCount = ConcurrentHashMap() - private val bootEoseLogged = ConcurrentHashMap.newKeySet() + private val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) override fun newSub(key: AccountQueryState): Subscription { val user = user(key) scope = key.account.scope + windowLoad.startLoading(key.account.scope) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = listOf( key.account.scope.launch(Dispatchers.IO) { - key.account.dmRelays.flow.collectLatest { - invalidateFilters() - } + key.account.dmRelays.flow + .collectLatest { invalidateFilters() } }, ) - // Reset and start the cold-boot timer for this subscription. - val pubkey = user.pubkeyHex - bootStartMs[pubkey] = System.currentTimeMillis() - bootEventCount[pubkey] = 0 - bootEoseLogged.remove(pubkey) - windowLoad.startLoading(key.account.scope) - Log.d(TAG) { "cold boot: pubkey=${pubkey.take(8)}… opening gift-wrap subscription, starting to load messages" } - - // Custom listener so we can tell a real EOSE (load finished) apart from live - // events; the base class routes both into newEose, which can't distinguish them. return requestNewSubscription( - object : SubscriptionListener { - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - if (bootEoseLogged.add(pubkey)) { - val elapsed = System.currentTimeMillis() - (bootStartMs[pubkey] ?: System.currentTimeMillis()) - val count = bootEventCount[pubkey] ?: 0 - Log.d(TAG) { - "cold boot: pubkey=${pubkey.take(8)}… initial load complete — first EOSE from ${relay.url} " + - "after ${elapsed}ms, $count gift wrap(s) received so far" - } - } - newEose(key, relay, TimeUtils.now(), forFilters) - } - - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - // Every event (stored backfill included) keeps the window-load watchdog alive, - // so a relay mid-flood is never mistaken for a finished window. - windowLoad.onActivity() - if (pubkey !in bootEoseLogged) { - bootEventCount.merge(pubkey, 1, Int::plus) - } - if (isLive) { - newEose(key, relay, TimeUtils.now(), forFilters) - } - } - }, + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, ) } @@ -243,19 +141,11 @@ class AccountGiftWrapsEoseManager( ) { super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } - bootStartMs.remove(key.pubkeyHex) - bootEventCount.remove(key.pubkeyHex) - bootEoseLogged.remove(key.pubkeyHex) } companion object { - // Shared log tag for the DM time-window pagination. Filter logcat by this - // tag to watch the boot window and scroll-driven backfill in real time. - private const val TAG = "DMPagination" - - // The window doubles each widen so a sparse history (or confirming there is nothing - // older) reaches the 10-year backstop in ~10 requests instead of crawling weekly. - // Must match the NIP-04 window so both DM protocols advance in lockstep. - const val WINDOW_GROWTH_FACTOR = 2L + // The window doubles each widen so a sparse history (or confirming nothing older exists) + // reaches the 10-year backstop in ~10 requests instead of crawling a week at a time. + private const val WINDOW_GROWTH_FACTOR = 2L } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 701f6ff42b..91387a28f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -144,7 +144,7 @@ private const val GIFT_WRAP_OUTER_JITTER_SECONDS = 2L * 24 * 60 * 60 * of what's loaded — prefetching before the user reaches the top — and stops once exhausted. * * The account-wide gift-wrap window is the single source of truth for the floor: NIP-17 advances via - * [AccountGiftWrapsEoseManager.loadMore], and the NIP-04 loader [ChatroomFilterSubAssembler.reload] + * [AccountGiftWrapsEoseManager.loadMore], and the NIP-04 loader [ChatroomNip04SubAssembler.reload] * re-requests kind:4 from that same floor. The step is gated on BOTH loaders being idle, so it never * outruns the slower protocol. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt index 02d7210556..03c648dedf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -38,7 +38,7 @@ class ChatroomFilterAssembler( client: INostrClient, giftWraps: AccountGiftWrapsEoseManager, ) : ComposeSubscriptionManager() { - val nip04 = ChatroomFilterSubAssembler(client, ::allKeys, giftWraps) + val nip04 = ChatroomNip04SubAssembler(client, ::allKeys, giftWraps) val group = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt similarity index 61% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt index d27cf3e1b1..9905cf334e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt @@ -22,33 +22,32 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.trackingListener import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow -class ChatroomFilterSubAssembler( +/** + * Loads one conversation's NIP-04 DMs (kind 4). Like the rooms-list loader, it follows the account + * gift-wrap window's [AccountGiftWrapsEoseManager.windowSince] floor so a thread shows both DM + * protocols to the same depth. [reload] re-issues at the current floor; [loadingMore] reports when + * this protocol has covered it, which the conversation screen joins with the gift-wrap loader's flag + * to decide how deep the thread is safe to reveal. + */ +class ChatroomNip04SubAssembler( client: INostrClient, allKeys: () -> Set, - // The account-wide gift-wrap window is the single source of truth for how far back DMs are - // requested; NIP-04 here follows its floor so a thread shows both protocols to the same depth. private val giftWraps: AccountGiftWrapsEoseManager, ) : PerUserAndFollowListEoseManager(client, allKeys) { - // A NIP-04 load is "in flight" until every relay it was sent to has answered (or a timeout). - // The conversation screen reads this alongside the gift-wrap loader's flag to know when BOTH - // protocols have fully covered the current floor, so it never reveals a half-loaded depth. private val windowLoad = WindowLoadTracker() val loadingMore: StateFlow = windowLoad.loading - // Account scope to run the window-load watchdog on, captured when the subscription opens. + // Account scope for the watchdog. Volatile: written on IO (newSub), read on UI (reload). @Volatile private var scope: CoroutineScope? = null @@ -65,7 +64,7 @@ class ChatroomFilterSubAssembler( emptyList() } - /** Re-issues the NIP-04 subscription at the (now-wider) shared gift-wrap floor and tracks the load. */ + /** Re-issues at the (now-wider) shared gift-wrap floor and tracks the load. */ fun reload() { scope?.let { windowLoad.startLoading(it) } invalidateFilters() @@ -78,32 +77,8 @@ class ChatroomFilterSubAssembler( override fun newSub(key: ChatroomQueryState): Subscription { scope = key.account.scope windowLoad.startLoading(key.account.scope) - - // Custom listener (vs super.newSub) so every event — stored backfill included — keeps the - // window-load watchdog alive and EOSEs mark relays answered, feeding [loadingMore]. return requestNewSubscription( - object : SubscriptionListener { - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - windowLoad.onRelayResponded(relay) - newEose(key, relay, TimeUtils.now(), forFilters) - } - - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - windowLoad.onActivity() - if (isLive) { - windowLoad.onRelayResponded(relay) - newEose(key, relay, TimeUtils.now(), forFilters) - } - } - }, + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index d4876752b7..0f1823b71f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag @@ -34,12 +35,14 @@ class ChatroomListState( @Stable class ChatroomListFilterAssembler( client: INostrClient, + giftWraps: AccountGiftWrapsEoseManager, ) : ComposeSubscriptionManager() { - val nip04Dms = DMsFromUserFilterSubAssembler(client, ::allKeys) + // NIP-04 DMs follow the account gift-wrap window's floor (the single source of truth). + val nip04 = ChatroomListNip04SubAssembler(client, ::allKeys, giftWraps) val group = listOf( - nip04Dms, + nip04, FollowingPublicChatSubAssembler(client, ::allKeys), FollowingEphemeralChatSubAssembler(client, ::allKeys), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt new file mode 100644 index 0000000000..e19200ee55 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.trackingListener +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Loads the account's NIP-04 DMs (kind 4) for the rooms list. It does not own a time window: it + * follows the gift-wrap window's [AccountGiftWrapsEoseManager.windowSince] floor, so both DM + * protocols are requested to the same depth. [reload] re-issues at the current floor (called after + * the gift-wrap window widens); [loadingMore] reports when this protocol has covered that floor. + */ +class ChatroomListNip04SubAssembler( + client: INostrClient, + allKeys: () -> Set, + private val giftWraps: AccountGiftWrapsEoseManager, +) : PerUserEoseManager(client, allKeys) { + private val windowLoad = WindowLoadTracker() + val loadingMore: StateFlow = windowLoad.loading + + // Account scope for the watchdog. Volatile: written on IO (newSub), read on UI (reload). + @Volatile + private var scope: CoroutineScope? = null + + override fun updateFilter( + key: ChatroomListState, + since: SincePerRelayMap?, + ): List? = + if (key.account.isWriteable()) { + val homeRelays = key.account.homeRelays.flow.value + val dmRelays = key.account.dmRelays.flow.value + windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) + val windowSince = giftWraps.windowSince(user(key)) + homeRelays.map { filterNip04DMsFromMe(key.account.userProfile(), it, windowSince) } + + dmRelays.map { filterNip04DMsToMe(key.account.userProfile(), it, windowSince) } + } else { + windowLoad.setExpectedRelays(emptySet()) + emptyList() + } + + /** Re-issues at the (now-wider) shared gift-wrap floor and tracks the load. */ + fun reload() { + scope?.let { windowLoad.startLoading(it) } + invalidateFilters() + } + + override fun user(key: ChatroomListState) = key.account.userProfile() + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: ChatroomListState): Subscription { + val user = user(key) + scope = key.account.scope + windowLoad.startLoading(key.account.scope) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.IO) { + key.account.homeRelays.flow + .collectLatest { invalidateFilters() } + }, + key.account.scope.launch(Dispatchers.IO) { + key.account.dmRelays.flow + .collectLatest { invalidateFilters() } + }, + ) + + return requestNewSubscription( + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + ) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt deleted file mode 100644 index 1811c8d32f..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource - -import com.vitorpamplona.amethyst.commons.relayClient.pagination.TimeWindowPagination -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener -import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import java.util.concurrent.ConcurrentHashMap - -class DMsFromUserFilterSubAssembler( - client: INostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - // Same moving time window as the gift-wrap (NIP-17) loader, so the merged rooms list is - // bounded uniformly across both DM protocols. Without this, NIP-04 loaded all history while - // NIP-17 only loaded the recent window, so widening (which fills the screen / prefetches) - // landed new NIP-17 rooms in the middle of the NIP-04 tail instead of extending the list end. - // Same growth factor as the gift-wrap window keeps both advancing in lockstep. - // Concurrent: windowFor is reached from the UI thread (loadMore / loadEverything) and from - // Dispatchers.IO (updateFilter, via the bundled invalidation), so a plain HashMap would race. - private val windows = ConcurrentHashMap() - - private fun windowFor(user: User) = - windows.computeIfAbsent(user.pubkeyHex) { - TimeWindowPagination(growthFactor = AccountGiftWrapsEoseManager.WINDOW_GROWTH_FACTOR) - } - - // A window is "loading" until every relay it was sent to has answered (EOSE / live event), - // or a timeout fires — not on the first EOSE, which a fast empty relay can trip prematurely. - private val windowLoad = WindowLoadTracker() - val loadingMore: StateFlow = windowLoad.loading - - // True once the window has reached the maximum lookback: no older history to fetch. - private val _exhausted = MutableStateFlow(false) - val exhausted: StateFlow = _exhausted.asStateFlow() - - // The account scope to run the window-load watchdog on, captured when the subscription opens. - // Volatile: written on Dispatchers.IO (newSub), read on the UI thread (loadMore/loadEverything). - @Volatile - private var scope: CoroutineScope? = null - - override fun updateFilter( - key: ChatroomListState, - since: SincePerRelayMap?, - ): List? = - if (key.account.isWriteable()) { - val homeRelays = key.account.homeRelays.flow.value - val dmRelays = key.account.dmRelays.flow.value - windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) - val windowSince = windowFor(user(key)).since - homeRelays.map { - filterNip04DMsFromMe(key.account.userProfile(), it, windowSince) - } + - dmRelays.map { - filterNip04DMsToMe(key.account.userProfile(), it, windowSince) - } - } else { - windowLoad.setExpectedRelays(emptySet()) - emptyList() - } - - /** - * Widens the NIP-04 time window for [user] one step back, kept in lockstep with the - * gift-wrap window. No-op once the window is [exhausted]. - */ - fun loadMore(user: User) { - val window = windowFor(user) - if (window.isExhausted()) return - window.loadMore() - _exhausted.value = window.isExhausted() - scope?.let { windowLoad.startLoading(it) } - invalidateFilters() - } - - /** - * Jumps the NIP-04 window straight to the maximum lookback so a single REQ pulls the entire - * history (the pre-windowing behavior), and marks it [exhausted] so auto-fill stops. - */ - fun loadEverything(user: User) { - val window = windowFor(user) - if (window.isExhausted()) return - window.loadAll() - _exhausted.value = true - scope?.let { windowLoad.startLoading(it) } - invalidateFilters() - } - - override fun newEose( - key: ChatroomListState, - relay: NormalizedRelayUrl, - time: Long, - filters: List?, - ) { - windowLoad.onRelayResponded(relay) - super.newEose(key, relay, time, filters) - } - - override fun user(key: ChatroomListState) = key.account.userProfile() - - val userJobMap = mutableMapOf>() - - @OptIn(FlowPreview::class) - override fun newSub(key: ChatroomListState): Subscription { - val user = user(key) - scope = key.account.scope - windowLoad.startLoading(key.account.scope) - userJobMap[user]?.forEach { it.cancel() } - userJobMap[user] = - listOf( - key.account.scope.launch(Dispatchers.IO) { - key.account.homeRelays.flow.collectLatest { - invalidateFilters() - } - }, - key.account.scope.launch(Dispatchers.IO) { - key.account.dmRelays.flow.collectLatest { - invalidateFilters() - } - }, - ) - - // Custom listener (vs super.newSub) so every event — stored backfill included — keeps the - // window-load watchdog alive; otherwise a NIP-04 flood would look "done" mid-stream. - return requestNewSubscription( - object : SubscriptionListener { - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - newEose(key, relay, TimeUtils.now(), forFilters) - } - - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - windowLoad.onActivity() - if (isLive) { - newEose(key, relay, TimeUtils.now(), forFilters) - } - } - }, - ) - } - - override fun endSub( - key: User, - subId: String, - ) { - super.endSub(key, subId) - userJobMap[key]?.forEach { it.cancel() } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index d4977c07d1..4bd60478c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -67,7 +67,6 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter @@ -96,14 +95,11 @@ private fun CrossFadeState( ) { val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() - // Both DM windows reach the maximum lookback together (lockstep), so the rooms list has - // pulled everything there is once both report exhausted. Until then an empty feed means + // The gift-wrap window is the single DM window (NIP-04 follows it), so once it reaches the + // maximum lookback the rooms list has pulled everything there is. Until then an empty feed means // "still filling", not "no conversations" — keep the spinner up rather than flash empty. val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } - val nip04Dms = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04Dms } - val giftWrapsExhausted by giftWraps.exhausted.collectAsStateWithLifecycle() - val nip04Exhausted by nip04Dms.exhausted.collectAsStateWithLifecycle() - val historyExhausted = giftWrapsExhausted && nip04Exhausted + val historyExhausted by giftWraps.exhausted.collectAsStateWithLifecycle() // While the whole list is empty there is no LazyColumn to scroll, so keep widening the private // DM window here until rooms appear or it is exhausted. (Public / ephemeral / group rooms are @@ -151,13 +147,11 @@ private fun FeedLoaded( val myPubKey = accountViewModel.userProfile().pubkeyHex val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } - val nip04Dms = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04Dms } + val nip04 = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04 } val loadingGiftWraps by giftWraps.loadingMore.collectAsStateWithLifecycle() - val loadingNip04 by nip04Dms.loadingMore.collectAsStateWithLifecycle() + val loadingNip04 by nip04.loadingMore.collectAsStateWithLifecycle() val loadingMore = loadingGiftWraps || loadingNip04 - val exhaustedGiftWraps by giftWraps.exhausted.collectAsStateWithLifecycle() - val exhaustedNip04 by nip04Dms.exhausted.collectAsStateWithLifecycle() - val historyExhausted = exhaustedGiftWraps && exhaustedNip04 + val historyExhausted by giftWraps.exhausted.collectAsStateWithLifecycle() // Widen the private DM window only as the user approaches the oldest LOADED private chat — // ignoring public / group / ephemeral rooms below it. Those are membership-based and can be @@ -199,7 +193,7 @@ private fun FeedLoaded( PrivateChatsLoadMoreFooter(loadingMore, showLoadAll = !historyExhausted) { val user = accountViewModel.userProfile() giftWraps.loadEverything(user) - nip04Dms.loadEverything(user) + nip04.reload() } } } @@ -210,7 +204,7 @@ private fun FeedLoaded( PrivateChatsLoadMoreFooter(loadingMore, showLoadAll = !historyExhausted) { val user = accountViewModel.userProfile() giftWraps.loadEverything(user) - nip04Dms.loadEverything(user) + nip04.reload() } } } @@ -243,19 +237,19 @@ private fun PrivateChatsLoadMoreFooter( private const val PREFETCH_PRIVATE_CHATS = 5 /** - * Widens the private-DM windows (NIP-17 gift wraps + NIP-04, in lockstep) whenever [wantMore] - * becomes true and a previous widen isn't still loading, stopping once both are exhausted. + * Widens the DM window whenever [wantMore] becomes true and the previous step isn't still loading, + * stopping once the window is exhausted. Advances the single gift-wrap window ([loadMore]) and tells + * the NIP-04 follower to re-request at the new floor ([reload]). * * [wantMore] is evaluated inside a snapshotFlow, so it may read live Compose state (scroll position, * the feed list). Callers decide the policy: the empty feed widens to discover the first rooms; the * loaded feed widens as the user approaches the oldest loaded PRIVATE chat — public, group and * ephemeral rooms are membership-based (shown regardless of age) and deliberately excluded, so an - * old public chat at the bottom of the list never drags the private window back with it. + * old public chat at the bottom never drags the window back with it. * - * The two windows must move together: if only one were widened, the merged time-sorted list would - * mix a deep tail of one protocol with a shallow window of the other. The [loadingMore] guard gates - * each step on ALL of that window's relays answering (or a timeout), not the first EOSE, so a fast - * near-empty relay can't let the loop outrun the slow relay that holds the conversations. + * The guard waits on BOTH loaders, gated on all of each one's relays answering (or a timeout) rather + * than the first EOSE, so a fast near-empty relay can't let the loop outrun the slow relay that + * holds the conversations. */ @Composable private fun WidenPrivateWindowWhen( @@ -263,24 +257,21 @@ private fun WidenPrivateWindowWhen( wantMore: () -> Boolean, ) { val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } - val nip04Dms = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04Dms } + val nip04 = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04 } - LaunchedEffect(giftWraps, nip04Dms) { + LaunchedEffect(giftWraps, nip04) { combine( snapshotFlow { wantMore() }, giftWraps.loadingMore, - nip04Dms.loadingMore, + nip04.loadingMore, giftWraps.exhausted, - nip04Dms.exhausted, - ) { want, loadingGiftWraps, loadingNip04, giftWrapsExhausted, nip04Exhausted -> - want && !loadingGiftWraps && !loadingNip04 && !(giftWrapsExhausted && nip04Exhausted) + ) { want, loadingGiftWraps, loadingNip04, exhausted -> + want && !loadingGiftWraps && !loadingNip04 && !exhausted }.distinctUntilChanged() .filter { it } .collect { - Log.d("DMPagination") { "rooms list needs more private history, widening NIP-17 + NIP-04 windows one step" } - val user = accountViewModel.userProfile() - giftWraps.loadMore(user) - nip04Dms.loadMore(user) + giftWraps.loadMore(accountViewModel.userProfile()) + nip04.reload() } } }