feat(cache): Phase 1 — cache-centric architecture foundation
- Add BoundedLargeCache: LargeCache wrapper with size enforcement (50k notes, 25k users, 10k addressable). Lock-free reads via ConcurrentSkipListMap, evicts 10% when cap exceeded. - Switch DesktopLocalCache from ConcurrentHashMap to BoundedLargeCache. Exposes filterIntoSet, values, etc. matching Android's query patterns. - Add consume methods for kinds 1 (TextNote), 7 (Reaction), 9734 (ZapRequest), 9735 (Zap). Uses tagsWithoutCitations() for reply parsing, originalPost()+taggedAddresses() for reactions, zappedPost() for zaps. Follows Android LocalCache pattern. - Add consumeEvent() bridge in coordinator with BasicBundledInsert (250ms batching). Routes relay onEvent callbacks to cache. - Fix SharedFlow: extraBufferCapacity=64, DROP_OLDEST. Prevents blocking emitters and dropped events. - Set -Xmx2g JVM arg for long desktop sessions. - Clear cache on logout (coordinator first, then cache). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,8 @@ compose.desktop {
|
|||||||
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
|
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
|
||||||
jvmArgs += "--add-opens=java.base/java.nio=ALL-UNNAMED"
|
jvmArgs += "--add-opens=java.base/java.nio=ALL-UNNAMED"
|
||||||
|
|
||||||
|
jvmArgs += "-Xmx2g"
|
||||||
|
|
||||||
nativeDistributions {
|
nativeDistributions {
|
||||||
appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources"))
|
appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources"))
|
||||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||||
|
|||||||
@@ -460,6 +460,14 @@ fun App(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear cache and subscriptions on logout
|
||||||
|
LaunchedEffect(accountState) {
|
||||||
|
if (accountState is AccountState.LoggedOut) {
|
||||||
|
subscriptionsCoordinator.clear()
|
||||||
|
localCache.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try to load saved account on startup
|
// Try to load saved account on startup
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
relayManager.addDefaultRelays()
|
relayManager.addDefaultRelays()
|
||||||
|
|||||||
Vendored
+93
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* 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.desktop.cache
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.utils.cache.CacheCollectors
|
||||||
|
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bounded wrapper around [LargeCache] that enforces a maximum size.
|
||||||
|
*
|
||||||
|
* When the cache exceeds [maxSize], the oldest entries (by key order) are evicted.
|
||||||
|
* Uses [LargeCache] (ConcurrentSkipListMap) for lock-free reads and rich query APIs
|
||||||
|
* (filterIntoSet, mapNotNull, etc.) matching Android's LocalCache patterns.
|
||||||
|
*
|
||||||
|
* Chosen over LruCache because:
|
||||||
|
* - Lock-free reads via ConcurrentSkipListMap (vs synchronized on every get())
|
||||||
|
* - Rich query API matching Android's filter patterns
|
||||||
|
* - No snapshot copy overhead for iteration
|
||||||
|
*/
|
||||||
|
class BoundedLargeCache<K : Comparable<K>, V>(
|
||||||
|
private val maxSize: Int,
|
||||||
|
private val evictPercent: Float = 0.1f,
|
||||||
|
) {
|
||||||
|
private val inner = LargeCache<K, V>()
|
||||||
|
|
||||||
|
fun get(key: K): V? = inner.get(key)
|
||||||
|
|
||||||
|
fun put(
|
||||||
|
key: K,
|
||||||
|
value: V,
|
||||||
|
) {
|
||||||
|
inner.put(key, value)
|
||||||
|
enforceSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getOrCreate(
|
||||||
|
key: K,
|
||||||
|
builder: (K) -> V,
|
||||||
|
): V {
|
||||||
|
val result = inner.getOrCreate(key, builder)
|
||||||
|
enforceSize()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(key: K): V? = inner.remove(key)
|
||||||
|
|
||||||
|
fun containsKey(key: K): Boolean = inner.containsKey(key)
|
||||||
|
|
||||||
|
fun size(): Int = inner.size()
|
||||||
|
|
||||||
|
fun isEmpty(): Boolean = inner.isEmpty()
|
||||||
|
|
||||||
|
fun clear() = inner.clear()
|
||||||
|
|
||||||
|
fun keys(): Set<K> = inner.keys()
|
||||||
|
|
||||||
|
fun values(): Iterable<V> = inner.values()
|
||||||
|
|
||||||
|
fun filterIntoSet(consumer: CacheCollectors.BiFilter<K, V>): Set<V> = inner.filterIntoSet(consumer)
|
||||||
|
|
||||||
|
fun <R> mapNotNull(consumer: CacheCollectors.BiMapper<K, V, R?>): List<R> = inner.mapNotNull(consumer)
|
||||||
|
|
||||||
|
fun forEach(consumer: java.util.function.BiConsumer<K, V>) = inner.forEach(consumer)
|
||||||
|
|
||||||
|
fun count(consumer: CacheCollectors.BiFilter<K, V>): Int = inner.count(consumer)
|
||||||
|
|
||||||
|
private fun enforceSize() {
|
||||||
|
val currentSize = inner.size()
|
||||||
|
if (currentSize > maxSize) {
|
||||||
|
val toRemove = (maxSize * evictPercent).toInt().coerceAtLeast(1)
|
||||||
|
val keys = inner.keys().take(toRemove)
|
||||||
|
keys.forEach { inner.remove(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+166
-16
@@ -32,12 +32,18 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
|
||||||
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
|
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
|
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
|
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
|
||||||
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||||
import com.vitorpamplona.quartz.utils.DualCase
|
import com.vitorpamplona.quartz.utils.DualCase
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -50,28 +56,33 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
* Supports searching users by name prefix for the search functionality.
|
* Supports searching users by name prefix for the search functionality.
|
||||||
*/
|
*/
|
||||||
class DesktopLocalCache : ICacheProvider {
|
class DesktopLocalCache : ICacheProvider {
|
||||||
private val users = ConcurrentHashMap<HexKey, User>()
|
val users = BoundedLargeCache<HexKey, User>(MAX_USERS)
|
||||||
private val notes = ConcurrentHashMap<HexKey, Note>()
|
val notes = BoundedLargeCache<HexKey, Note>(MAX_NOTES)
|
||||||
private val addressableNotes = ConcurrentHashMap<String, AddressableNote>()
|
val addressableNotes = BoundedLargeCache<String, AddressableNote>(MAX_ADDRESSABLE)
|
||||||
private val deletedEvents = ConcurrentHashMap.newKeySet<HexKey>()
|
private val deletedEvents = ConcurrentHashMap.newKeySet<HexKey>()
|
||||||
|
|
||||||
private val eventStream = DesktopCacheEventStream()
|
val eventStream = DesktopCacheEventStream()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MAX_NOTES = 50_000
|
||||||
|
const val MAX_USERS = 25_000
|
||||||
|
const val MAX_ADDRESSABLE = 10_000
|
||||||
|
}
|
||||||
|
|
||||||
val paymentTracker = NwcPaymentTracker()
|
val paymentTracker = NwcPaymentTracker()
|
||||||
|
|
||||||
// ----- User operations -----
|
// ----- User operations -----
|
||||||
|
|
||||||
override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey]
|
override fun getUserIfExists(pubkey: HexKey): User? = users.get(pubkey)
|
||||||
|
|
||||||
override fun getOrCreateUser(pubkey: HexKey): User =
|
override fun getOrCreateUser(pubkey: HexKey): User =
|
||||||
users.getOrPut(pubkey) {
|
users.getOrCreate(pubkey) {
|
||||||
// Create placeholder notes for relay lists
|
|
||||||
val nip65Note = getOrCreateNote("nip65:$pubkey")
|
val nip65Note = getOrCreateNote("nip65:$pubkey")
|
||||||
val dmNote = getOrCreateNote("dm:$pubkey")
|
val dmNote = getOrCreateNote("dm:$pubkey")
|
||||||
User(pubkey, nip65Note, dmNote)
|
User(pubkey, nip65Note, dmNote)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { (key, user) -> predicate(key, user) }
|
override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { key, user -> predicate(key, user) }
|
||||||
|
|
||||||
override fun findUsersStartingWith(
|
override fun findUsersStartingWith(
|
||||||
prefix: String,
|
prefix: String,
|
||||||
@@ -92,7 +103,8 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Search by name/displayName/nip05/lud16
|
// Search by name/displayName/nip05/lud16
|
||||||
return users.values
|
return users
|
||||||
|
.values()
|
||||||
.filter { user ->
|
.filter { user ->
|
||||||
val metadata = user.metadataOrNull()
|
val metadata = user.metadataOrNull()
|
||||||
if (metadata == null) {
|
if (metadata == null) {
|
||||||
@@ -128,6 +140,134 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Event consumption (mirrors Android LocalCache pattern) -----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes an event to the appropriate consume method.
|
||||||
|
* Returns true if the event was consumed (new), false if already seen.
|
||||||
|
*/
|
||||||
|
fun consume(
|
||||||
|
event: Event,
|
||||||
|
relay: NormalizedRelayUrl?,
|
||||||
|
): Boolean =
|
||||||
|
when (event) {
|
||||||
|
is MetadataEvent -> {
|
||||||
|
consumeMetadata(event)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
is TextNoteEvent -> {
|
||||||
|
consumeTextNote(event, relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReactionEvent -> {
|
||||||
|
consumeReaction(event, relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
is LnZapRequestEvent -> {
|
||||||
|
consumeZapRequest(event, relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
is LnZapEvent -> {
|
||||||
|
consumeZap(event, relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumes a kind 1 text note event.
|
||||||
|
* Creates/updates Note in cache and links reply relationships.
|
||||||
|
*/
|
||||||
|
private fun consumeTextNote(
|
||||||
|
event: TextNoteEvent,
|
||||||
|
relay: NormalizedRelayUrl?,
|
||||||
|
): Boolean {
|
||||||
|
val note = getOrCreateNote(event.id)
|
||||||
|
if (note.event != null) return false
|
||||||
|
val author = getOrCreateUser(event.pubKey)
|
||||||
|
val repliesTo = event.tagsWithoutCitations().mapNotNull { getNoteIfExists(it) }
|
||||||
|
note.loadEvent(event, author, repliesTo)
|
||||||
|
relay?.let { note.addRelay(it) }
|
||||||
|
repliesTo.forEach { it.addReply(note) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumes a kind 7 reaction event.
|
||||||
|
* Links reaction to target notes via e-tags and a-tags.
|
||||||
|
*/
|
||||||
|
private fun consumeReaction(
|
||||||
|
event: ReactionEvent,
|
||||||
|
relay: NormalizedRelayUrl?,
|
||||||
|
): Boolean {
|
||||||
|
val note = getOrCreateNote(event.id)
|
||||||
|
if (note.event != null) return false
|
||||||
|
val author = getOrCreateUser(event.pubKey)
|
||||||
|
val reactedTo =
|
||||||
|
event.originalPost().mapNotNull { getNoteIfExists(it) } +
|
||||||
|
event.taggedAddresses().mapNotNull { addressableNotes.get(it.toValue()) }
|
||||||
|
note.loadEvent(event, author, reactedTo)
|
||||||
|
relay?.let { note.addRelay(it) }
|
||||||
|
reactedTo.forEach { it.addReaction(note) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumes a kind 9734 zap request event.
|
||||||
|
* Must be consumed before the corresponding LnZapEvent (kind 9735).
|
||||||
|
*/
|
||||||
|
private fun consumeZapRequest(
|
||||||
|
event: LnZapRequestEvent,
|
||||||
|
relay: NormalizedRelayUrl?,
|
||||||
|
): Boolean {
|
||||||
|
val note = getOrCreateNote(event.id)
|
||||||
|
if (note.event != null) return false
|
||||||
|
val author = getOrCreateUser(event.pubKey)
|
||||||
|
note.loadEvent(event, author, emptyList())
|
||||||
|
relay?.let { note.addRelay(it) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumes a kind 9735 zap receipt event.
|
||||||
|
* Links zap to target notes via the embedded zap request.
|
||||||
|
*/
|
||||||
|
private fun consumeZap(
|
||||||
|
event: LnZapEvent,
|
||||||
|
relay: NormalizedRelayUrl?,
|
||||||
|
): Boolean {
|
||||||
|
val note = getOrCreateNote(event.id)
|
||||||
|
if (note.event != null) return false
|
||||||
|
val author = getOrCreateUser(event.pubKey)
|
||||||
|
|
||||||
|
// Get or consume the embedded zap request
|
||||||
|
val zapRequestEvent = event.zapRequest
|
||||||
|
val zapRequestNote =
|
||||||
|
if (zapRequestEvent != null) {
|
||||||
|
consumeZapRequest(zapRequestEvent, relay)
|
||||||
|
getOrCreateNote(zapRequestEvent.id)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val zappedNotes =
|
||||||
|
event.zappedPost().mapNotNull { getNoteIfExists(it) } +
|
||||||
|
event.taggedAddresses().mapNotNull { addressableNotes.get(it.toValue()) }
|
||||||
|
|
||||||
|
note.loadEvent(event, author, zappedNotes)
|
||||||
|
relay?.let { note.addRelay(it) }
|
||||||
|
|
||||||
|
// Link zap to target notes
|
||||||
|
if (zapRequestNote != null) {
|
||||||
|
zappedNotes.forEach { it.addZap(zapRequestNote, note) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ----- NWC Payment operations -----
|
// ----- NWC Payment operations -----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,17 +339,17 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
|
|
||||||
// ----- Note operations -----
|
// ----- Note operations -----
|
||||||
|
|
||||||
override fun getNoteIfExists(hexKey: HexKey): Note? = notes[hexKey]
|
override fun getNoteIfExists(hexKey: HexKey): Note? = notes.get(hexKey)
|
||||||
|
|
||||||
override fun checkGetOrCreateNote(hexKey: HexKey): Note = getOrCreateNote(hexKey)
|
override fun checkGetOrCreateNote(hexKey: HexKey): Note = getOrCreateNote(hexKey)
|
||||||
|
|
||||||
fun getOrCreateNote(hexKey: HexKey): Note =
|
fun getOrCreateNote(hexKey: HexKey): Note =
|
||||||
notes.getOrPut(hexKey) {
|
notes.getOrCreate(hexKey) {
|
||||||
Note(hexKey)
|
Note(hexKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getOrCreateAddressableNote(key: Address): AddressableNote =
|
override fun getOrCreateAddressableNote(key: Address): AddressableNote =
|
||||||
addressableNotes.getOrPut(key.toValue()) {
|
addressableNotes.getOrCreate(key.toValue()) {
|
||||||
AddressableNote(key)
|
AddressableNote(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,9 +400,9 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
|
|
||||||
// ----- Stats -----
|
// ----- Stats -----
|
||||||
|
|
||||||
fun userCount(): Int = users.size
|
fun userCount(): Int = users.size()
|
||||||
|
|
||||||
fun noteCount(): Int = notes.size
|
fun noteCount(): Int = notes.size()
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
users.clear()
|
users.clear()
|
||||||
@@ -276,8 +416,18 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
* Desktop implementation of ICacheEventStream.
|
* Desktop implementation of ICacheEventStream.
|
||||||
*/
|
*/
|
||||||
class DesktopCacheEventStream : ICacheEventStream {
|
class DesktopCacheEventStream : ICacheEventStream {
|
||||||
private val _newEventBundles = MutableSharedFlow<Set<Note>>(replay = 0)
|
private val _newEventBundles =
|
||||||
private val _deletedEventBundles = MutableSharedFlow<Set<Note>>(replay = 0)
|
MutableSharedFlow<Set<Note>>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 64,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
private val _deletedEventBundles =
|
||||||
|
MutableSharedFlow<Set<Note>>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 64,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
|
||||||
override val newEventBundles: SharedFlow<Set<Note>> = _newEventBundles
|
override val newEventBundles: SharedFlow<Set<Note>> = _newEventBundles
|
||||||
override val deletedEventBundles: SharedFlow<Set<Note>> = _deletedEventBundles
|
override val deletedEventBundles: SharedFlow<Set<Note>> = _deletedEventBundles
|
||||||
|
|||||||
+31
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.Note
|
|||||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoordinator
|
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoordinator
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader
|
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter
|
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter
|
||||||
|
import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
@@ -34,6 +35,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
|||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop-specific relay subscriptions coordinator.
|
* Desktop-specific relay subscriptions coordinator.
|
||||||
@@ -86,6 +89,34 @@ class DesktopRelaySubscriptionsCoordinator(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Event bundler: batches consumed notes before emitting to SharedFlow
|
||||||
|
// 250ms for desktop (Android uses 1000ms to save battery)
|
||||||
|
private val eventBundler =
|
||||||
|
BasicBundledInsert<Note>(
|
||||||
|
delay = 250,
|
||||||
|
dispatcher = Dispatchers.IO,
|
||||||
|
scope = scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central event router — consumes an event into the cache and emits to event stream.
|
||||||
|
* Called from relay onEvent callbacks. Non-blocking (launches on IO dispatcher).
|
||||||
|
*/
|
||||||
|
fun consumeEvent(
|
||||||
|
event: Event,
|
||||||
|
relay: NormalizedRelayUrl?,
|
||||||
|
) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val consumed = localCache.consume(event, relay)
|
||||||
|
if (consumed) {
|
||||||
|
val note = localCache.getNoteIfExists(event.id) as? Note ?: return@launch
|
||||||
|
eventBundler.invalidateList(note) { batch ->
|
||||||
|
localCache.eventStream.emitNewNotes(batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the coordinator.
|
* Start the coordinator.
|
||||||
* Call once when app starts or user logs in.
|
* Call once when app starts or user logs in.
|
||||||
|
|||||||
Reference in New Issue
Block a user