From ed23f81f3a7845793eff20197eca4ea7b1c65bc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 00:45:43 +0000 Subject: [PATCH 1/3] fix: refresh NIP-82 app version chip when a new release arrives The Apps feed version chip read the latest SoftwareReleaseEvent (kind 30063) once via a produceState keyed only on the app event id. NIP-82 releases point back to the app through an `i` tag rather than an `a` tag, so they are never indexed as replies to the app note and never ping its flows. As a result a newer release arriving while the card was visible left the chip showing the old version. Re-scan LocalCache on every new-event bundle (the same pattern used by the calendar RSVP/calendar scans) so the chip updates without a manual refresh. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EbCTsBoGtBCJ1rTKCcar6w --- .../amethyst/ui/note/types/SoftwareApp.kt | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt index 65f9e9495d..af85b59512 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt @@ -189,9 +189,12 @@ fun RenderSoftwareApplication( /** * Looks up the latest NIP-82 [SoftwareReleaseEvent] for [app] from - * [LocalCache] and exposes the version string. Recomputes on event identity - * change; relay-driven recompositions of the surrounding feed will pick up - * newer releases via re-keying. + * [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. */ @Composable fun produceLatestReleaseVersion(app: SoftwareApplicationEvent) = @@ -200,6 +203,16 @@ fun produceLatestReleaseVersion(app: SoftwareApplicationEvent) = withContext(Dispatchers.Default) { findLatestNip82Release(app)?.version() } + + LocalCache.live.newEventBundles.collect { + val newVersion = + withContext(Dispatchers.Default) { + findLatestNip82Release(app)?.version() + } + if (newVersion != value) { + value = newVersion + } + } } fun findLatestNip82Release(app: SoftwareApplicationEvent): SoftwareReleaseEvent? { From cbbba279a767d6d944b7d5ba4d71aa32d8902399 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 03:12:02 +0000 Subject: [PATCH 2/3] 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) Claude-Session: https://claude.ai/code/session_01EbCTsBoGtBCJ1rTKCcar6w --- .../amethyst/ui/note/types/SoftwareApp.kt | 119 ++++++++++-------- 1 file changed, 66 insertions(+), 53 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt index af85b59512..e55f1b9bf1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt @@ -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(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 { + 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, + 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 `@`. */ +private fun nip82ReleaseNotesFor(app: SoftwareApplicationEvent): Set { + 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] (`@`). 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 { 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 From 9da1df57b9ca398d4e16f81752bfe3d0aa83909b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 03:25:05 +0000 Subject: [PATCH 3/3] fix: narrow app release observer to the app's i-tag The version-chip observer filtered releases by author only, so an author with many apps pulled every release they ever published into the observer's working set. Narrow the filter on the release `i` tag (the app id) so only this app's releases are loaded. A blind limit is avoided on purpose: LocalCache.filter applies take(limit) before sorting by created_at, so a limit could drop the latest release the chip needs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EbCTsBoGtBCJ1rTKCcar6w --- .../amethyst/ui/note/types/SoftwareApp.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt index e55f1b9bf1..b6830f35c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt @@ -92,6 +92,7 @@ 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.experimental.nip82SoftwareApps.release.tags.AppIdTag import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import kotlinx.collections.immutable.toImmutableList @@ -198,10 +199,15 @@ fun RenderSoftwareApplication( * 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. + * [LocalCache.observeNotes] observer, so a newer release arriving while the + * card is visible updates the version chip without a manual refresh. + * + * The filter narrows on the release's `i` tag (the app id), not just the + * author, so the observer only ever loads *this* app's releases — an author + * with many apps would otherwise pull every release they ever published into + * the observer's working set. A blind `limit` is deliberately avoided: + * [LocalCache.filter] applies `take(limit)` before sorting by `created_at`, + * so it could drop the very release we are looking for. */ @Composable fun produceLatestReleaseVersion(app: SoftwareApplicationEvent): State { @@ -211,6 +217,7 @@ fun produceLatestReleaseVersion(app: SoftwareApplicationEvent): State { Filter( kinds = listOf(SoftwareReleaseEvent.KIND), authors = listOf(app.pubKey), + tags = mapOf(AppIdTag.TAG_NAME to listOf(app.appId())), ) LocalCache .observeNotes(filter)