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") } ?: ""` | | 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") } ?: ""` | | 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") } ?: ""` | | 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 | | 30296 | InteractiveStoryPrologueEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL |
| 30297 | InteractiveStorySceneEvent | 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 | | 30311 | LiveActivitiesEvent | nip53LiveActivities/streaming | `listOfNotNull(title(), summary(), content)` NL |
@@ -20,21 +20,48 @@
*/ */
package com.vitorpamplona.amethyst.commons.ui.note.types.lists 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.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.remember 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.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.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.Res
import com.vitorpamplona.amethyst.commons.resources.kind_app_curation_set 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_favorite_algo_feeds
import com.vitorpamplona.amethyst.commons.resources.kind_git_repositories import com.vitorpamplona.amethyst.commons.resources.kind_git_repositories
import com.vitorpamplona.amethyst.commons.resources.kind_simple_groups import com.vitorpamplona.amethyst.commons.resources.kind_simple_groups
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav 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.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.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.appCurationSet.AppCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent 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 @Composable
fun RenderAppCurationSet( fun RenderAppCurationSet(
baseNote: Note, baseNote: Note,
@@ -106,19 +140,92 @@ fun RenderAppCurationSet(
val noteEvent = baseNote.event as? AppCurationSetEvent ?: return val noteEvent = baseNote.event as? AppCurationSetEvent ?: return
val apps = remember(noteEvent) { noteEvent.apps() } val apps = remember(noteEvent) { noteEvent.apps() }
val platforms = remember(noteEvent) { noteEvent.platforms() }
ListCard( 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(), description = noteEvent.description(),
items = apps, items = apps,
// Not a PrivateTagArrayEvent: an app set has no encrypted half to be locked out of. // Not a PrivateTagArrayEvent: an app set has no encrypted half to be locked out of.
hasUnreadablePrivateItems = false, hasUnreadablePrivateItems = false,
backgroundColor = backgroundColor, backgroundColor = backgroundColor,
subtitle = if (platforms.isNotEmpty()) ({ PlatformChips(platforms) }) else null,
) { app -> ) { app ->
key(app.address.toValue()) { key(app.address.toValue()) {
if (app.address.kind == SoftwareApplicationEvent.KIND) {
SoftwareAppMemberRow(app.address, accountViewModel, nav)
} else {
AddressMemberRow(app.address, quotesLeft, backgroundColor, accountViewModel, nav) 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)
}
}
} }
/** /**
@@ -75,6 +75,8 @@ fun <T> ListCard(
/** True when the event carries private members this viewer has no key for. */ /** True when the event carries private members this viewer has no key for. */
hasUnreadablePrivateItems: Boolean, hasUnreadablePrivateItems: Boolean,
backgroundColor: MutableState<Color>, 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, itemContent: @Composable (T) -> Unit,
) { ) {
var expanded by rememberSaveable { mutableStateOf(false) } 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, // 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 // 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, // 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 -> { is AppCurationSetEvent -> {
tiers(event, event.title(), event.description(), null) tiers(event, event.titleOrName(), event.description(), null)
} }
is RelaySetEvent -> { is RelaySetEvent -> {
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip51Lists.appCurationSet package com.vitorpamplona.quartz.nip51Lists.appCurationSet
import androidx.compose.runtime.Immutable 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.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.remove
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag 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.nip51Lists.tags.TitleTag
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
@@ -52,12 +54,12 @@ class AppCurationSetEvent(
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider, AddressHintProvider,
SearchableEvent { 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 // The read path: the same fields indexableContent() joins, handed over without
// building the joined string a scan would throw away. // building the joined string a scan would throw away.
override fun forEachIndexableField(visitor: IndexableFieldVisitor) { override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
if (!visitor.visit(title())) return if (!visitor.visit(titleOrName())) return
visitor.visit(description()) visitor.visit(description())
} }
@@ -67,12 +69,20 @@ class AppCurationSetEvent(
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) 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 description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun apps() = tags.mapNotNull(AddressBookmark::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 { companion object {
const val KIND = 30267 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())
}
}