refactor: use indexed LocalCache.observeNotes for app version chip

Replace the global newEventBundles + full-cache rescan with an
index-driven LocalCache.observeNotes(kind 30063 / author) observer, the
established idiom (NestLobbyScreen, OpenPollsState, DvmContentDiscovery).
The FilterIndex only wakes the observer when a matching release is
inserted, instead of re-scanning the whole addressables map on every
event app-wide.

Extract the NIP-82 release parsing (dTag-prefix + kind-30063 collision
handling) into shared helpers reused by findLatestNip82Release and
findAllNip82Releases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbCTsBoGtBCJ1rTKCcar6w
This commit is contained in:
Claude
2026-06-20 03:12:02 +00:00
parent ed23f81f3a
commit cbbba279a7
@@ -43,6 +43,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.produceState
@@ -59,6 +60,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
@@ -90,10 +92,13 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAss
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.asSoftwareRelease
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
/**
* NIP-82 kind 32267 — compact feed card. Renders icon, name, latest version
@@ -188,65 +193,73 @@ fun RenderSoftwareApplication(
}
/**
* Looks up the latest NIP-82 [SoftwareReleaseEvent] for [app] from
* [LocalCache] and exposes the version string. Releases (kind 30063) are
* separate events that point back to the app via an `i` tag, so they are not
* indexed as replies to the app note and never ping its flows. We therefore
* re-scan on every [LocalCache.live] new-event bundle so a newer release that
* arrives while the card is visible updates the version chip without a manual
* refresh.
* Latest NIP-82 [SoftwareReleaseEvent] version for [app], kept live.
*
* Releases (kind 30063) are separate events that point back to the app via an
* `i` tag rather than an `a` tag, so they are never indexed as replies to the
* app note and never ping its flows. Instead we register an index-driven
* [LocalCache.observeNotes] observer keyed on kind-30063 / author, so a newer
* release arriving while the card is visible updates the version chip without
* a manual refresh. The observer only wakes on matching insertions, so it is
* far cheaper than re-scanning the cache on every new-event bundle.
*/
@Composable
fun produceLatestReleaseVersion(app: SoftwareApplicationEvent) =
produceState<String?>(initialValue = null, key1 = app.id) {
value =
withContext(Dispatchers.Default) {
findLatestNip82Release(app)?.version()
}
LocalCache.live.newEventBundles.collect {
val newVersion =
withContext(Dispatchers.Default) {
findLatestNip82Release(app)?.version()
}
if (newVersion != value) {
value = newVersion
}
fun produceLatestReleaseVersion(app: SoftwareApplicationEvent): State<String?> {
val flow =
remember(app.pubKey, app.id) {
val filter =
Filter(
kinds = listOf(SoftwareReleaseEvent.KIND),
authors = listOf(app.pubKey),
)
LocalCache
.observeNotes(filter)
.map { notes -> latestNip82Release(notes, app)?.version() }
.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
}
fun findLatestNip82Release(app: SoftwareApplicationEvent): SoftwareReleaseEvent? {
val prefix = "${app.dTag()}@"
val notes =
LocalCache.addressables.filterIntoSet(SoftwareReleaseEvent.KIND, app.pubKey) { _, addr ->
val ev = addr.event ?: return@filterIntoSet false
ev.isNip82SoftwareRelease() && ev.dTag().startsWith(prefix)
}
return notes
.mapNotNull {
when (val ev = it.event) {
is SoftwareReleaseEvent -> ev
null -> null
else -> if (ev.isNip82SoftwareRelease()) ev.asSoftwareRelease() else null
}
}.maxByOrNull { it.createdAt }
return flow.collectAsStateWithLifecycle(initialValue = null)
}
fun findLatestNip82Release(app: SoftwareApplicationEvent): SoftwareReleaseEvent? = latestNip82Release(nip82ReleaseNotesFor(app), app)
/** Picks the newest NIP-82 release for [app] out of an already-narrowed [notes] collection. */
private fun latestNip82Release(
notes: Collection<Note>,
app: SoftwareApplicationEvent,
): SoftwareReleaseEvent? {
val prefix = "${app.dTag()}@"
return notes
.mapNotNull { it.asNip82ReleaseFor(prefix) }
.maxByOrNull { it.createdAt }
}
/** kind-30063 addressables authored by [app] whose `d` tag is `<app-id>@<version>`. */
private fun nip82ReleaseNotesFor(app: SoftwareApplicationEvent): Set<Note> {
val prefix = "${app.dTag()}@"
return LocalCache.addressables.filterIntoSet(SoftwareReleaseEvent.KIND, app.pubKey) { _, addr ->
val ev = addr.event ?: return@filterIntoSet false
ev.isNip82SoftwareRelease() && ev.dTag().startsWith(prefix)
}
}
/**
* Re-reads this note as the NIP-82 release for [prefix] (`<app-id>@`). kind 30063
* collides with NIP-51 `ReleaseArtifactSetEvent`, which is what `EventFactory`
* builds, so we re-parse matching tag arrays as the NIP-82 form.
*/
private fun Note.asNip82ReleaseFor(prefix: String): SoftwareReleaseEvent? =
when (val ev = event) {
null -> null
is SoftwareReleaseEvent -> ev.takeIf { it.dTag().startsWith(prefix) }
else -> if (ev.isNip82SoftwareRelease() && ev.dTag().startsWith(prefix)) ev.asSoftwareRelease() else null
}
fun findAllNip82Releases(app: SoftwareApplicationEvent): List<SoftwareReleaseEvent> {
val prefix = "${app.dTag()}@"
val notes =
LocalCache.addressables.filterIntoSet(SoftwareReleaseEvent.KIND, app.pubKey) { _, addr ->
val ev = addr.event ?: return@filterIntoSet false
ev.isNip82SoftwareRelease() && ev.dTag().startsWith(prefix)
}
return notes
.mapNotNull {
when (val ev = it.event) {
is SoftwareReleaseEvent -> ev
null -> null
else -> if (ev.isNip82SoftwareRelease()) ev.asSoftwareRelease() else null
}
}.sortedByDescending { it.createdAt }
return nip82ReleaseNotesFor(app)
.mapNotNull { it.asNip82ReleaseFor(prefix) }
.sortedByDescending { it.createdAt }
}
@Composable