mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
@@ -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 |
|
||||
|
||||
+110
-3
@@ -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,17 +140,90 @@ 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()) {
|
||||
AddressMemberRow(app.address, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+1
-1
@@ -391,7 +391,7 @@ object SearchFieldExtractor {
|
||||
}
|
||||
|
||||
is AppCurationSetEvent -> {
|
||||
tiers(event, event.title(), event.description(), null)
|
||||
tiers(event, event.titleOrName(), event.description(), null)
|
||||
}
|
||||
|
||||
is RelaySetEvent -> {
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
|
||||
+78
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user