refactor: one DM window, NIP-04 followers, shared listener

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.
This commit is contained in:
Claude
2026-06-01 14:12:02 +00:00
parent 46a8d1d490
commit 0fb6f6778d
10 changed files with 223 additions and 410 deletions
@@ -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<Filter>?) -> Unit): SubscriptionListener =
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
onRelayResponded(relay)
forward(relay, forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
onActivity()
if (isLive) {
onRelayResponded(relay)
forward(relay, forFilters)
}
}
}
@@ -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)
@@ -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<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(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<HexKey, TimeWindowPagination>()
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<Boolean> = 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<Boolean> = _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<RelayBasedFilter> {
// 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<Filter>?,
) {
windowLoad.onRelayResponded(relay)
super.newEose(key, relay, time, filters)
}
private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY
val userJobMap = mutableMapOf<User, List<Job>>()
// 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<HexKey, Long>()
private val bootEventCount = ConcurrentHashMap<HexKey, Int>()
private val bootEoseLogged = ConcurrentHashMap.newKeySet<HexKey>()
private val userJobMap = mutableMapOf<User, List<Job>>()
@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<Filter>?,
) {
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<Filter>?,
) {
// 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
}
}
@@ -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.
*/
@@ -38,7 +38,7 @@ class ChatroomFilterAssembler(
client: INostrClient,
giftWraps: AccountGiftWrapsEoseManager,
) : ComposeSubscriptionManager<ChatroomQueryState>() {
val nip04 = ChatroomFilterSubAssembler(client, ::allKeys, giftWraps)
val nip04 = ChatroomNip04SubAssembler(client, ::allKeys, giftWraps)
val group =
listOf(
@@ -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<ChatroomQueryState>,
// 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<ChatroomQueryState, String>(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<Boolean> = 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<Filter>?,
) {
windowLoad.onRelayResponded(relay)
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
windowLoad.onActivity()
if (isLive) {
windowLoad.onRelayResponded(relay)
newEose(key, relay, TimeUtils.now(), forFilters)
}
}
},
windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) },
)
}
}
@@ -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<ChatroomListState>() {
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),
)
@@ -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<ChatroomListState>,
private val giftWraps: AccountGiftWrapsEoseManager,
) : PerUserEoseManager<ChatroomListState>(client, allKeys) {
private val windowLoad = WindowLoadTracker()
val loadingMore: StateFlow<Boolean> = 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<RelayBasedFilter>? =
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<User, List<Job>>()
@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() }
}
}
@@ -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<ChatroomListState>,
) : PerUserEoseManager<ChatroomListState>(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<HexKey, TimeWindowPagination>()
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<Boolean> = windowLoad.loading
// True once the window has reached the maximum lookback: no older history to fetch.
private val _exhausted = MutableStateFlow(false)
val exhausted: StateFlow<Boolean> = _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<RelayBasedFilter>? =
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<Filter>?,
) {
windowLoad.onRelayResponded(relay)
super.newEose(key, relay, time, filters)
}
override fun user(key: ChatroomListState) = key.account.userProfile()
val userJobMap = mutableMapOf<User, List<Job>>()
@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<Filter>?,
) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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() }
}
}
@@ -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()
}
}
}