From d942a624ebeee44a0bf63c2b1884a67fbc6ff3de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:00:06 +0000 Subject: [PATCH] fix(badges): preserve NIP-58 a/e pair order, clickable rows, accepted-first sort Three fixes to the Profile-badges flow: 1. TagArrayBuilder groups tags by name, so calling acceptedBadges() on the builder scrambled [a1, e1, a2, e2] into [a1, a2, e1, e2] when serializing. AcceptedBadge.parseAll expects adjacent (a, e) pairs, so only one mangled badge survived, making each Accept toggle appear to replace the previous one. Rewrite the build() of both ProfileBadgesEvent (kind 10008) and AcceptedBadgeSetEvent (kind 30008) to collect the prefix tags (d / alt / initializer) via the builder, then append AcceptedBadge.assemble(...) verbatim to keep the pair order intact. 2. Rows in ProfileBadgesScreen were not clickable. Thread nav through AwardRow / StaticAwardRow and wrap the row body in Modifier.clickable that routes to the badge definition's thread. The Switch keeps consuming its own clicks, so toggling still works. 3. Sort accepted badges to the top of the list, then the rest by createdAt desc, so the user can see at a glance which badges are currently shown on the profile. --- .../badges/profile/ProfileBadgesScreen.kt | 50 ++++++++++++++++--- .../accepted/AcceptedBadgeSetEvent.kt | 22 +++++--- .../nip58Badges/profile/ProfileBadgesEvent.kt | 20 +++++--- 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt index ab219fe1d..c4492435b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -56,6 +57,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -116,13 +118,17 @@ fun ProfileBadgesScreen( } val receivedAwards = - remember(myPubkey, bundleTick) { + remember(myPubkey, bundleTick, acceptedAwardIds) { LocalCache.notes .filterIntoSet { _, it -> val event = it.event event is BadgeAwardEvent && event.awardeeIds().contains(myPubkey) }.mapNotNull { it.event as? BadgeAwardEvent } - .sortedByDescending { it.createdAt } + .sortedWith( + // Accepted badges on top, then most recent first. + compareByDescending { acceptedAwardIds.contains(it.id) } + .thenByDescending { it.createdAt }, + ) } Scaffold( @@ -156,6 +162,7 @@ fun ProfileBadgesScreen( award = award, isAccepted = acceptedAwardIds.contains(award.id), accountViewModel = accountViewModel, + nav = nav, ) HorizontalDivider() } @@ -170,26 +177,43 @@ private fun AwardRow( award: BadgeAwardEvent, isAccepted: Boolean, accountViewModel: AccountViewModel, + nav: INav, ) { val defAddr = award.awardDefinition().firstOrNull() if (defAddr == null) { - StaticAwardRow(definition = null, isAccepted = isAccepted, accountViewModel = accountViewModel, award = award) + StaticAwardRow( + definition = null, + defNote = null, + isAccepted = isAccepted, + accountViewModel = accountViewModel, + award = award, + nav = nav, + ) return } LoadAddressableNote(defAddr, accountViewModel) { defNote -> if (defNote == null) { - StaticAwardRow(definition = null, isAccepted = isAccepted, accountViewModel = accountViewModel, award = award) + StaticAwardRow( + definition = null, + defNote = null, + isAccepted = isAccepted, + accountViewModel = accountViewModel, + award = award, + nav = nav, + ) } else { // Ask relays for the definition if we don't have it yet, then watch. EventFinderFilterAssemblerSubscription(defNote, accountViewModel) val definition by observeNoteEvent(defNote, accountViewModel) StaticAwardRow( definition = definition, + defNote = defNote, isAccepted = isAccepted, accountViewModel = accountViewModel, award = award, + nav = nav, ) } } @@ -198,15 +222,27 @@ private fun AwardRow( @Composable private fun StaticAwardRow( definition: BadgeDefinitionEvent?, + defNote: com.vitorpamplona.amethyst.model.AddressableNote?, isAccepted: Boolean, accountViewModel: AccountViewModel, award: BadgeAwardEvent, + nav: INav, ) { - Row( - modifier = + val rowModifier = + if (defNote != null) { Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp), + .clickable { + routeFor(defNote, accountViewModel.account)?.let { nav.nav(it) } + }.padding(horizontal = 20.dp, vertical = 12.dp) + } else { + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp) + } + + Row( + modifier = rowModifier, verticalAlignment = Alignment.CenterVertically, ) { BadgeThumb(definition) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt index fe5f01e65..451de1943 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt @@ -25,10 +25,11 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.tagArray import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider -import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag @@ -80,13 +81,18 @@ class AcceptedBadgeSetEvent( acceptedBadges: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { - dTag(STANDARD_D_TAG) - alt(ALT_DESCRIPTION) - if (acceptedBadges.isNotEmpty()) { - acceptedBadges(acceptedBadges) - } - initializer() + ): EventTemplate { + // TagArrayBuilder groups tags by name, which would scramble the + // alternating a/e pairs NIP-58 requires. Build the prefix tags via + // the builder, then append the ordered pair tags verbatim. + val prefix = + tagArray { + dTag(STANDARD_D_TAG) + alt(ALT_DESCRIPTION) + initializer() + } + val pairs = AcceptedBadge.assemble(acceptedBadges).toTypedArray() + return EventTemplate(createdAt, KIND, prefix + pairs, "") } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/ProfileBadgesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/ProfileBadgesEvent.kt index 1680530b6..e683cc51c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/ProfileBadgesEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/ProfileBadgesEvent.kt @@ -25,10 +25,11 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.tagArray import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider -import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -78,12 +79,17 @@ class ProfileBadgesEvent( acceptedBadges: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { - alt(ALT_DESCRIPTION) - if (acceptedBadges.isNotEmpty()) { - acceptedBadges(acceptedBadges) - } - initializer() + ): EventTemplate { + // TagArrayBuilder groups tags by name, which would scramble the + // alternating a/e pairs NIP-58 requires. Build the prefix tags via + // the builder, then append the ordered pair tags verbatim. + val prefix = + tagArray { + alt(ALT_DESCRIPTION) + initializer() + } + val pairs = AcceptedBadge.assemble(acceptedBadges).toTypedArray() + return EventTemplate(createdAt, KIND, prefix + pairs, "") } } }