feat: render Zapstore app curation sets (kind 30267)

- AppCurationSetEvent reads the `name` tag Zapstore uses instead of
  NIP-51's `title` (titleOrName, also used for NIP-50 indexing) and the
  NIP-82 `f` platform tags (platforms()).
- The set card shows the name and platform chips, and lists each kind
  32267 app as a compact row (icon, name, summary) that opens the app
  detail screen, instead of a full app card with screenshots per member.
  Rows show the app id until the app event loads.
- ListCard gains an optional subtitle slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cHtfJhTUzHEgKXy7hQvmo
This commit is contained in:
Claude
2026-10-01 21:11:31 +00:00
parent 5b71dc735b
commit 15a205b307
6 changed files with 206 additions and 7 deletions
@@ -95,7 +95,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 30175 | PersonaEvent | buzz/apPersonas | `personaOrNull()?.let { listOfNotNull(it.displayName, it.systemPrompt).joinToString("\n") } ?: ""` |
| 30176 | TeamEvent | buzz/teams | `teamOrNull()?.let { listOfNotNull(it.name, it.description, it.instructions).joinToString("\n") } ?: ""` |
| 30177 | ManagedAgentEvent | buzz/managedAgents | `agentOrNull()?.let { listOfNotNull(it.name, it.systemPrompt).joinToString("\n") } ?: ""` |
| 30267 | AppCurationSetEvent | nip51Lists/appCurationSet | `listOfNotNull(title(), description())` NL |
| 30267 | AppCurationSetEvent | nip51Lists/appCurationSet | `listOfNotNull(titleOrName(), description())` NL |
| 30296 | InteractiveStoryPrologueEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL |
| 30297 | InteractiveStorySceneEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL |
| 30311 | LiveActivitiesEvent | nip53LiveActivities/streaming | `listOfNotNull(title(), summary(), content)` NL |
@@ -20,21 +20,48 @@
*/
package com.vitorpamplona.amethyst.commons.ui.note.types.lists
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteEvent
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.kind_app_curation_set
import com.vitorpamplona.amethyst.commons.resources.kind_favorite_algo_feeds
import com.vitorpamplona.amethyst.commons.resources.kind_git_repositories
import com.vitorpamplona.amethyst.commons.resources.kind_simple_groups
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.commons.ui.note.types.AppIcon
import com.vitorpamplona.amethyst.commons.ui.note.types.Chip
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent
@@ -94,7 +121,14 @@ fun RenderFavoriteAlgoFeedsList(
}
}
/** NIP-51 kind 30267: "references to multiple software applications". */
/**
* NIP-51 kind 30267: "references to multiple software applications".
*
* Zapstore names these with a `name` tag instead of `title`, and pins them to the platforms the
* apps were picked for with NIP-82 `f` tags. Each app is a compact row — icon, name, summary —
* rather than the full app card, which carries screenshots and a reactions row that would turn a
* twenty-app set into a wall.
*/
@Composable
fun RenderAppCurationSet(
baseNote: Note,
@@ -106,20 +140,93 @@ fun RenderAppCurationSet(
val noteEvent = baseNote.event as? AppCurationSetEvent ?: return
val apps = remember(noteEvent) { noteEvent.apps() }
val platforms = remember(noteEvent) { noteEvent.platforms() }
ListCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), Res.string.kind_app_curation_set),
title = listTitle(noteEvent.titleOrName(), noteEvent.dTag(), Res.string.kind_app_curation_set),
description = noteEvent.description(),
items = apps,
// Not a PrivateTagArrayEvent: an app set has no encrypted half to be locked out of.
hasUnreadablePrivateItems = false,
backgroundColor = backgroundColor,
subtitle = if (platforms.isNotEmpty()) ({ PlatformChips(platforms) }) else null,
) { app ->
key(app.address.toValue()) {
if (app.address.kind == SoftwareApplicationEvent.KIND) {
SoftwareAppMemberRow(app.address, accountViewModel, nav)
} else {
AddressMemberRow(app.address, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun PlatformChips(platforms: List<String>) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp, vertical = 4.dp),
) {
platforms.forEach { Chip(it) }
}
}
/**
* A NIP-82 app (kind 32267) as one line of a set: its icon, name and summary, opening the app's
* detail screen. Until the app event arrives the row shows its app id, so the list keeps its
* shape instead of jumping as nineteen apps load one by one.
*/
@Composable
private fun SoftwareAppMemberRow(
address: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadAddressableNote(address) { note ->
val app = note?.let { observeNoteEvent<SoftwareApplicationEvent>(it, accountViewModel).value }
val name = app?.name()?.takeIf { it.isNotBlank() } ?: address.dTag
val summary = app?.summary()?.takeIf { it.isNotBlank() }
Column(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { nav.nav(Route.SoftwareAppDetail(address)) }
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
AppIcon(icon = app?.icon(), name = name, sizeDp = 40)
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (app == null) MaterialTheme.colorScheme.grayText else Color.Unspecified,
)
summary?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
HorizontalDivider(thickness = DividerThickness)
}
}
}
/**
* NIP-51 kind 10009: the simple groups this user belongs to.
@@ -75,6 +75,8 @@ fun <T> ListCard(
/** True when the event carries private members this viewer has no key for. */
hasUnreadablePrivateItems: Boolean,
backgroundColor: MutableState<Color>,
/** Extra lines under the description that only some list kinds carry, e.g. an app set's platforms. */
subtitle: (@Composable () -> Unit)? = null,
itemContent: @Composable (T) -> Unit,
) {
var expanded by rememberSaveable { mutableStateOf(false) }
@@ -100,6 +102,8 @@ fun <T> ListCard(
)
}
subtitle?.invoke()
// Only when there is nothing else to show. A card with visible members already reads as a list,
// and most lists keep something private, so saying so on every one of them is noise. The line
// exists for the case that would otherwise be a title and blank space — an all-private list,
@@ -391,7 +391,7 @@ object SearchFieldExtractor {
}
is AppCurationSetEvent -> {
tiers(event, event.title(), event.description(), null)
tiers(event, event.titleOrName(), event.description(), null)
}
is RelaySetEvent -> {
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip51Lists.appCurationSet
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.shared.PlatformTag
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
@@ -52,12 +54,12 @@ class AppCurationSetEvent(
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider,
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n")
override fun indexableContent() = listOfNotNull(titleOrName(), description()).joinToString("\n")
// The read path: the same fields indexableContent() joins, handed over without
// building the joined string a scan would throw away.
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
if (!visitor.visit(title())) return
if (!visitor.visit(titleOrName())) return
visitor.visit(description())
}
@@ -67,12 +69,20 @@ class AppCurationSetEvent(
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
/** Zapstore names its sets with a `name` tag rather than NIP-51's `title`. */
fun name() = tags.firstNotNullOfOrNull(NameTag::parse)
fun titleOrName() = title() ?: name()
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun apps() = tags.mapNotNull(AddressBookmark::parse)
/** NIP-82 `f` tags: the platforms the curated apps were picked for (e.g. `android-arm64-v8a`). */
fun platforms() = tags.mapNotNull(PlatformTag::parse)
companion object {
const val KIND = 30267
@@ -0,0 +1,78 @@
/*
* 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.quartz.nip51Lists.appCurationSet
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.shared.Platform
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class AppCurationSetEventTest {
private val zapstoreGames =
"""
{"id":"0389c8b10882df52226b116745e65060561d5165abd842953d9a7bc466cae479","pubkey":"aa1f96f685d0ac3e28a52feb87a20399a91afb3ac3137afeb7698dfcc99bc454","created_at":1775252298,"kind":30267,"tags":[["name","Games"],["d","games"],["f","android-arm64-v8a"],["h","acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:com.gitlab.ardash.appleflinger.android"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:com.adilhanney.ricochlime"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:net.minetest.minetest"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:de.rainerhock.eightbitwonders"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:org.moire.opensudoku"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:de.chadenas.cpudefense"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:de.sesu8642.feudaltactics"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:com.peaceray.codeword"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:com.serwylo.retrowars"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:de.mlex.same"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:com.github.thewierdnut.endless_mobile"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:com.tacticmaster"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:io.github.lime3ds.android"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:com.unciv.app"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:org.wesnoth.Wesnoth"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:com.agateau.burgerparty"],["a","32267:b95162b5280fa639f779d1e96d05bfa4a00159214c2a578b5244b2fb75b642d3:com.magius.lightningreaction"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:de.z11.roboyard"],["a","32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:me.lecaro.breakout"]],"content":"","sig":"b46c90094ba55ea46e86dba9e3ec47f77b6e7369e0244644da7d1b4a3d13031d9a385861d976529a47887ed2063bcbc8176e6dc40d3b305fddd81c2688a7b38e"}
""".trimIndent()
@Test
fun parsesZapstoreCurationSet() {
val event = Event.fromJson(zapstoreGames)
assertIs<AppCurationSetEvent>(event)
assertTrue(event.verify())
assertEquals("games", event.dTag())
// Zapstore names the set with `name`, not NIP-51's `title`.
assertNull(event.title())
assertEquals("Games", event.titleOrName())
assertNull(event.description())
assertEquals(listOf(Platform.ANDROID_ARM64_V8A), event.platforms())
val apps = event.apps()
assertEquals(19, apps.size)
assertTrue(apps.all { it.address.kind == SoftwareApplicationEvent.KIND })
assertEquals("com.gitlab.ardash.appleflinger.android", apps.first().address.dTag)
assertEquals("me.lecaro.breakout", apps.last().address.dTag)
assertEquals(
"b95162b5280fa639f779d1e96d05bfa4a00159214c2a578b5244b2fb75b642d3",
apps.single { it.address.dTag == "com.magius.lightningreaction" }.address.pubKeyHex,
)
assertEquals("Games", event.indexableContent())
}
@Test
fun titleWinsOverName() {
val template =
AppCurationSetEvent.build(title = "Nostr apps", dTag = "nostr", createdAt = 1729302793) {
add(arrayOf("name", "ignored"))
}
val tags = template.tags
val event = AppCurationSetEvent("0".repeat(64), "1".repeat(64), template.createdAt, tags, "", "2".repeat(128))
assertEquals("Nostr apps", event.titleOrName())
assertEquals("ignored", event.name())
}
}