diff --git a/.claude/skills/searchable-events/references/searchable-kinds.md b/.claude/skills/searchable-events/references/searchable-kinds.md index 02761064ca..6c5ef8797d 100644 --- a/.claude/skills/searchable-events/references/searchable-kinds.md +++ b/.claude/skills/searchable-events/references/searchable-kinds.md @@ -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 | diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/lists/AddressLists.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/lists/AddressLists.kt index 2108e431d0..59cd172234 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/lists/AddressLists.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/lists/AddressLists.kt @@ -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) { + 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(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) } } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/lists/ListCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/lists/ListCard.kt index b33418dfaa..fb59be7b2c 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/lists/ListCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/lists/ListCard.kt @@ -75,6 +75,8 @@ fun ListCard( /** True when the event carries private members this viewer has no key for. */ hasUnreadablePrivateItems: Boolean, backgroundColor: MutableState, + /** 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 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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt index b9a679d8b6..dc43fd3943 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt @@ -391,7 +391,7 @@ object SearchFieldExtractor { } is AppCurationSetEvent -> { - tiers(event, event.title(), event.description(), null) + tiers(event, event.titleOrName(), event.description(), null) } is RelaySetEvent -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/appCurationSet/AppCurationSetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/appCurationSet/AppCurationSetEvent.kt index 7514635cc7..6e19f36a13 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/appCurationSet/AppCurationSetEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/appCurationSet/AppCurationSetEvent.kt @@ -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 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/appCurationSet/AppCurationSetEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/appCurationSet/AppCurationSetEventTest.kt new file mode 100644 index 0000000000..6508980466 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/appCurationSet/AppCurationSetEventTest.kt @@ -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(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()) + } +}