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.
This commit is contained in:
Claude
2026-04-19 17:00:06 +00:00
parent d1fc49dc51
commit d942a624eb
3 changed files with 70 additions and 22 deletions
@@ -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<BadgeAwardEvent> { 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<BadgeDefinitionEvent>(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)
@@ -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<AcceptedBadge> = emptyList(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AcceptedBadgeSetEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
dTag(STANDARD_D_TAG)
alt(ALT_DESCRIPTION)
if (acceptedBadges.isNotEmpty()) {
acceptedBadges(acceptedBadges)
}
initializer()
): EventTemplate<AcceptedBadgeSetEvent> {
// 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<AcceptedBadgeSetEvent> {
dTag(STANDARD_D_TAG)
alt(ALT_DESCRIPTION)
initializer()
}
val pairs = AcceptedBadge.assemble(acceptedBadges).toTypedArray()
return EventTemplate(createdAt, KIND, prefix + pairs, "")
}
}
}
@@ -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<AcceptedBadge> = emptyList(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ProfileBadgesEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
if (acceptedBadges.isNotEmpty()) {
acceptedBadges(acceptedBadges)
}
initializer()
): EventTemplate<ProfileBadgesEvent> {
// 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<ProfileBadgesEvent> {
alt(ALT_DESCRIPTION)
initializer()
}
val pairs = AcceptedBadge.assemble(acceptedBadges).toTypedArray()
return EventTemplate(createdAt, KIND, prefix + pairs, "")
}
}
}