mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
+33
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
|
||||
+33
-143
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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.
|
||||
*/
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+12
-37
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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),
|
||||
)
|
||||
|
||||
+115
@@ -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() }
|
||||
}
|
||||
}
|
||||
-194
@@ -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() }
|
||||
}
|
||||
}
|
||||
+22
-31
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user