From af6053f741568e4b0af57d7730047a7eb6cf17dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 21:18:45 +0000 Subject: [PATCH 01/18] feat(nip58): browse, create, award, accept, and edit badges Adds a top-level Badges destination modeled after Polls, plus the full create/award/accept/edit lifecycle on top of the existing Quartz NIP-58 event classes. - Drawer entry + Route.Badges / Route.NewBadge / Route.AwardBadge - BadgesScreen with 4 tabs (Received / Mine / Awarded / Discover) backed by four AdditiveFeedFilters and a new FeedContentState registration. - BadgesFilterAssembler + BadgesSubAssembler subscribe kinds 30009 and 8 authored by me; received awards already stream via notifications. - NewBadgeScreen creates or edits kind 30009 (addressable, so republish with same d-tag == edit). - AwardBadgeScreen takes a list of npub/hex recipients and publishes kind 8. - RenderBadgeAward now shows Accept / Reject / Remove buttons for the current awardee, publishing kind 10008 via ProfileBadgesEvent with a fallback read of the legacy kind 30008 set. - BadgeDisplay surfaces an Award action for definitions authored by me. --- .../vitorpamplona/amethyst/model/Account.kt | 112 ++++++++++ .../RelaySubscriptionsCoordinator.kt | 3 + .../ui/feeds/RememberForeverStates.kt | 5 + .../amethyst/ui/navigation/AppNavigation.kt | 6 + .../ui/navigation/drawer/DrawerContent.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 18 ++ .../amethyst/ui/note/NoteCompose.kt | 4 +- .../amethyst/ui/note/types/Badge.kt | 107 ++++++++++ .../loggedIn/AccountFeedContentStates.kt | 19 ++ .../ui/screen/loggedIn/badges/BadgesScreen.kt | 196 ++++++++++++++++++ .../ui/screen/loggedIn/badges/BadgesTopBar.kt | 43 ++++ .../screen/loggedIn/badges/NewBadgeButton.kt | 53 +++++ .../loggedIn/badges/award/AwardBadgeScreen.kt | 149 +++++++++++++ .../badges/award/AwardBadgeViewModel.kt | 82 ++++++++ .../badges/dal/BadgesAwardedFeedFilter.kt | 60 ++++++ .../badges/dal/BadgesDiscoverFeedFilter.kt | 59 ++++++ .../badges/dal/BadgesMineFeedFilter.kt | 60 ++++++ .../badges/dal/BadgesReceivedFeedFilter.kt | 59 ++++++ .../datasource/BadgesFilterAssembler.kt | 46 ++++ .../BadgesFilterAssemblerSubscription.kt | 47 +++++ .../badges/datasource/BadgesSubAssembler.kt | 38 ++++ .../badges/datasource/FilterBadges.kt | 60 ++++++ .../loggedIn/badges/post/NewBadgeScreen.kt | 163 +++++++++++++++ .../loggedIn/badges/post/NewBadgeViewModel.kt | 107 ++++++++++ amethyst/src/main/res/values/strings.xml | 25 +++ 25 files changed, 1528 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesMineFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 025bf0ffc..47263bd67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -150,8 +150,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver @@ -194,6 +197,12 @@ import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser +import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent +import com.vitorpamplona.quartz.nip58Badges.accepted.tags.AcceptedBadge +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag +import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent @@ -1067,6 +1076,109 @@ class Account( client.publish(signedEvent, computeRelayListToBroadcast(signedEvent)) } + suspend fun sendBadgeDefinition( + badgeId: String, + name: String?, + imageUrl: String?, + imageDim: DimensionTag?, + description: String?, + thumbs: List = emptyList(), + ) { + if (!isWriteable()) return + + val template = + BadgeDefinitionEvent.build( + badgeId = badgeId, + name = name, + imageUrl = imageUrl, + imageDimensions = imageDim, + description = description, + thumbs = thumbs, + ) + val signedEvent = signer.sign(template) + + cache.justConsumeMyOwnEvent(signedEvent) + client.publish(signedEvent, outboxRelays.flow.value) + } + + suspend fun deleteBadgeDefinition(event: BadgeDefinitionEvent) { + if (!isWriteable()) return + if (event.pubKey != signer.pubKey) return + + val template = DeletionEvent.build(listOf(event)) + val signedEvent = signer.sign(template) + + cache.justConsumeMyOwnEvent(signedEvent) + client.publish(signedEvent, computeRelayListToBroadcast(signedEvent)) + } + + suspend fun sendBadgeAward( + definition: BadgeDefinitionEvent, + awardees: List, + ) { + if (!isWriteable()) return + if (awardees.isEmpty()) return + + val aTag = ATag(definition.kind, definition.pubKey, definition.dTag(), null) + val template = BadgeAwardEvent.build(aTag, awardees) + val signedEvent = signer.sign(template) + + val relays = + outboxRelays.flow.value + + awardees + .flatMap { cache.getOrCreateUser(it.pubKey).inboxRelays() ?: emptyList() } + .toSet() + + cache.justConsumeMyOwnEvent(signedEvent) + client.publish(signedEvent, relays) + } + + private fun loadCurrentAcceptedBadges(): List { + val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey)) + val newEvent = newNote?.event as? ProfileBadgesEvent + if (newEvent != null) return newEvent.acceptedBadges() + + val oldNote = cache.getAddressableNoteIfExists(AcceptedBadgeSetEvent.createAddress(signer.pubKey)) + val oldEvent = oldNote?.event as? AcceptedBadgeSetEvent + return oldEvent?.acceptedBadges() ?: emptyList() + } + + suspend fun addAcceptedBadge( + award: BadgeAwardEvent, + definition: BadgeDefinitionEvent, + ) { + if (!isWriteable()) return + + val aTag = ATag(definition.kind, definition.pubKey, definition.dTag(), null) + val eTag = ETag(award.id) + + val current = loadCurrentAcceptedBadges() + val alreadyAccepted = current.any { it.badgeAward.eventId == award.id } + if (alreadyAccepted) return + + val updated = current + AcceptedBadge(aTag, eTag) + + val template = ProfileBadgesEvent.build(updated) + val signedEvent = signer.sign(template) + + cache.justConsumeMyOwnEvent(signedEvent) + client.publish(signedEvent, outboxRelays.flow.value) + } + + suspend fun removeAcceptedBadge(award: BadgeAwardEvent) { + if (!isWriteable()) return + + val current = loadCurrentAcceptedBadges() + val updated = current.filterNot { it.badgeAward.eventId == award.id } + if (updated.size == current.size) return + + val template = ProfileBadgesEvent.build(updated) + val signedEvent = signer.sign(template) + + cache.justConsumeMyOwnEvent(signedEvent) + client.publish(signedEvent, outboxRelays.flow.value) + } + fun sendMyPublicAndPrivateOutbox(event: Event?) { if (event == null) return cache.justConsumeMyOwnEvent(event) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index b9b721b11..c315587fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler @@ -97,6 +98,7 @@ class RelaySubscriptionsCoordinator( val shorts = ShortsFilterAssembler(client) val longs = LongsFilterAssembler(client) val articles = ArticlesFilterAssembler(client) + val badges = BadgesFilterAssembler(client) // active when sending zaps via NWC val nwc = NWCPaymentFilterAssembler(client) @@ -114,6 +116,7 @@ class RelaySubscriptionsCoordinator( shorts, longs, articles, + badges, channelFinder, eventFinder, userFinder, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 77cbd110a..ddffb0e62 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -58,6 +58,10 @@ object ScrollStateKeys { const val POLLS_SCREEN = "PollsFeed" const val POLLS_OPEN = "PollsOpenFeed" const val POLLS_CLOSED = "PollsClosedFeed" + const val BADGES_RECEIVED = "BadgesReceivedFeed" + const val BADGES_MINE = "BadgesMineFeed" + const val BADGES_AWARDED = "BadgesAwardedFeed" + const val BADGES_DISCOVER = "BadgesDiscoverFeed" const val PICTURES_SCREEN = "PicturesFeed" const val PRODUCTS_SCREEN = "ProductsFeed" const val SHORTS_SCREEN = "ShortsFeed" @@ -73,6 +77,7 @@ object PagerStateKeys { const val HOME_SCREEN = "PagerHome" const val DISCOVER_SCREEN = "PagerDiscover" const val POLLS_SCREEN = "PagerPolls" + const val BADGES_SCREEN = "PagerBadges" } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 510234327..c199acadd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -64,6 +64,9 @@ import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen @@ -214,6 +217,9 @@ fun BuildNavigation( composable { DiscoverScreen(accountViewModel, nav) } composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } composableFromEnd { PollsScreen(accountViewModel, nav) } + composableFromEnd { BadgesScreen(accountViewModel, nav) } + composableFromBottomArgs { NewBadgeScreen(it.editDTag, accountViewModel, nav) } + composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } composableFromEnd { ProductsScreen(accountViewModel, nav) } composableFromEnd { ShortsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 9e94168f1..3e04757e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -59,6 +59,7 @@ import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.GroupAdd import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.MilitaryTech import androidx.compose.material.icons.outlined.Photo import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.Settings @@ -601,6 +602,14 @@ fun ListContent( route = Route.Polls, ) + NavigationRow( + title = R.string.badges, + icon = Icons.Outlined.MilitaryTech, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Badges, + ) + NavigationRow( title = R.string.discover_marketplace, icon = Icons.Outlined.Storefront, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index fdba69034..6f50d2d80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -45,6 +45,24 @@ sealed class Route { @Serializable object Polls : Route() + @Serializable object Badges : Route() + + @Serializable data class NewBadge( + val editDTag: String? = null, + ) : Route() + + @Serializable data class AwardBadge( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + @Serializable object Pictures : Route() @Serializable object Products : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 0e89d1199..b406352da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -397,7 +397,7 @@ fun AcceptableNote( } is BadgeDefinitionEvent -> { - BadgeDisplay(baseNote = baseNote, accountViewModel) + BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav) } else -> { @@ -455,7 +455,7 @@ fun AcceptableNote( } is BadgeDefinitionEvent -> { - BadgeDisplay(baseNote, accountViewModel) + BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav) } else -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt index 4a72e2cce..c130b1fa0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -21,13 +21,18 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.background +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.size +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -42,6 +47,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -53,13 +59,16 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent @Composable fun BadgeDisplay( baseNote: Note, accountViewModel: AccountViewModel, + nav: INav? = null, ) { val badgeData by observeNoteEvent(baseNote, accountViewModel) @@ -71,6 +80,27 @@ fun BadgeDisplay( MaterialTheme.colorScheme.onBackground, it.description(), ) + + if (nav != null && it.pubKey == accountViewModel.userProfile().pubkeyHex) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + Button( + onClick = { + nav.nav( + com.vitorpamplona.amethyst.ui.navigation.routes.Route.AwardBadge( + kind = it.kind, + pubKeyHex = it.pubKey, + dTag = it.dTag(), + ), + ) + }, + ) { + Text(stringRes(R.string.award_badge)) + } + } + } } } @@ -179,4 +209,81 @@ fun RenderBadgeAward( note.replyTo?.firstOrNull()?.let { BadgeDisplay(baseNote = it, accountViewModel) } + + AcceptBadgeControls(noteEvent, accountViewModel) +} + +@Composable +private fun AcceptBadgeControls( + award: BadgeAwardEvent, + accountViewModel: AccountViewModel, +) { + val myPubkey = accountViewModel.userProfile().pubkeyHex + val amAwardee = remember(award, myPubkey) { award.awardeeIds().contains(myPubkey) } + if (!amAwardee) return + + val newNote = accountViewModel.getOrCreateAddressableNote(ProfileBadgesEvent.createAddress(myPubkey)) + val oldNote = accountViewModel.getOrCreateAddressableNote(AcceptedBadgeSetEvent.createAddress(myPubkey)) + + val newState by newNote + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val oldState by oldNote + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + + val isAccepted = + remember(newState, oldState, award.id) { + val newEvent = newState.note.event as? ProfileBadgesEvent + val oldEvent = oldState.note.event as? AcceptedBadgeSetEvent + val awardIds = + newEvent?.badgeAwardEvents()?.map { it.eventId } + ?: oldEvent?.badgeAwardEvents()?.map { it.eventId } + ?: emptyList() + awardIds.contains(award.id) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + horizontalArrangement = Arrangement.End, + ) { + if (isAccepted) { + OutlinedButton( + onClick = { + accountViewModel.launchSigner { + accountViewModel.account.removeAcceptedBadge(award) + } + }, + ) { + Text(stringRes(R.string.unaccept_badge)) + } + } else { + OutlinedButton( + onClick = { + accountViewModel.launchSigner { + accountViewModel.account.removeAcceptedBadge(award) + } + }, + ) { + Text(stringRes(R.string.reject_badge)) + } + Spacer(modifier = Modifier.size(8.dp)) + Button( + onClick = { + accountViewModel.launchSigner { + val defAddr = award.awardDefinition().firstOrNull() ?: return@launchSigner + val defNote = + com.vitorpamplona.amethyst.model.LocalCache + .getAddressableNoteIfExists(defAddr) + val defEvent = defNote?.event as? BadgeDefinitionEvent ?: return@launchSigner + accountViewModel.account.addAcceptedBadge(award, defEvent) + } + }, + ) { + Text(stringRes(R.string.accept_badge)) + } + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index fc621ceb8..ee72b6faa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -28,6 +28,10 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesAwardedFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesDiscoverFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesMineFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesReceivedFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter @@ -81,6 +85,11 @@ class AccountFeedContentStates( val openPollsFeed = FeedContentState(OpenPollsFeedFilter(account), scope, LocalCache) val closedPollsFeed = FeedContentState(ClosedPollsFeedFilter(account), scope, LocalCache) + val badgesReceived = FeedContentState(BadgesReceivedFeedFilter(account), scope, LocalCache) + val badgesMine = FeedContentState(BadgesMineFeedFilter(account), scope, LocalCache) + val badgesAwarded = FeedContentState(BadgesAwardedFeedFilter(account), scope, LocalCache) + val badgesDiscover = FeedContentState(BadgesDiscoverFeedFilter(account), scope, LocalCache) + val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache) val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache) val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache) @@ -125,6 +134,11 @@ class AccountFeedContentStates( openPollsFeed.updateFeedWith(newNotes) closedPollsFeed.updateFeedWith(newNotes) + badgesReceived.updateFeedWith(newNotes) + badgesMine.updateFeedWith(newNotes) + badgesAwarded.updateFeedWith(newNotes) + badgesDiscover.updateFeedWith(newNotes) + picturesFeed.updateFeedWith(newNotes) productsFeed.updateFeedWith(newNotes) shortsFeed.updateFeedWith(newNotes) @@ -163,6 +177,11 @@ class AccountFeedContentStates( openPollsFeed.deleteFromFeed(newNotes) closedPollsFeed.deleteFromFeed(newNotes) + badgesReceived.deleteFromFeed(newNotes) + badgesMine.deleteFromFeed(newNotes) + badgesAwarded.deleteFromFeed(newNotes) + badgesDiscover.deleteFromFeed(newNotes) + picturesFeed.deleteFromFeed(newNotes) productsFeed.deleteFromFeed(newNotes) shortsFeed.deleteFromFeed(newNotes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt new file mode 100644 index 000000000..35a0d3cf4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt @@ -0,0 +1,196 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch + +@Composable +fun BadgesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedStates = accountViewModel.feedStates + + WatchLifecycleAndUpdateModel(feedStates.badgesReceived) + WatchLifecycleAndUpdateModel(feedStates.badgesMine) + WatchLifecycleAndUpdateModel(feedStates.badgesAwarded) + WatchLifecycleAndUpdateModel(feedStates.badgesDiscover) + + BadgesFilterAssemblerSubscription(accountViewModel) + + AssembleBadgesTabs( + received = feedStates.badgesReceived, + mine = feedStates.badgesMine, + awarded = feedStates.badgesAwarded, + discover = feedStates.badgesDiscover, + ) { pagerState, tabs -> + BadgesPages(pagerState, tabs, accountViewModel, nav) + } +} + +@Composable +private fun AssembleBadgesTabs( + received: FeedContentState, + mine: FeedContentState, + awarded: FeedContentState, + discover: FeedContentState, + inner: @Composable (PagerState, ImmutableList) -> Unit, +) { + val pagerState = rememberForeverPagerState(key = PagerStateKeys.BADGES_SCREEN) { 4 } + + val tabs by + remember(received, mine, awarded, discover) { + mutableStateOf( + listOf( + BadgesTabItem( + resource = R.string.received_badges, + feedState = received, + routeForLastRead = "BadgesReceivedFeed", + scrollStateKey = ScrollStateKeys.BADGES_RECEIVED, + ), + BadgesTabItem( + resource = R.string.my_badges, + feedState = mine, + routeForLastRead = "BadgesMineFeed", + scrollStateKey = ScrollStateKeys.BADGES_MINE, + ), + BadgesTabItem( + resource = R.string.awarded_badges, + feedState = awarded, + routeForLastRead = "BadgesAwardedFeed", + scrollStateKey = ScrollStateKeys.BADGES_AWARDED, + ), + BadgesTabItem( + resource = R.string.discover_badges, + feedState = discover, + routeForLastRead = "BadgesDiscoverFeed", + scrollStateKey = ScrollStateKeys.BADGES_DISCOVER, + ), + ).toImmutableList(), + ) + } + + inner(pagerState, tabs) +} + +@Composable +private fun BadgesPages( + pagerState: PagerState, + tabs: ImmutableList, + accountViewModel: AccountViewModel, + nav: INav, +) { + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + Column { + BadgesTopBar(accountViewModel, nav) + SecondaryTabRow( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + modifier = TabRowHeight, + selectedTabIndex = pagerState.currentPage, + ) { + val coroutineScope = rememberCoroutineScope() + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + text = { Text(text = stringRes(tab.resource)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, + ) + } + } + } + }, + bottomBar = { + AppBottomBar(Route.Badges, accountViewModel) { route -> + if (route == Route.Badges) { + tabs[pagerState.currentPage].feedState.sendToTop() + } else { + nav.newStack(route) + } + } + }, + floatingButton = { + NewBadgeButton(nav) + }, + accountViewModel = accountViewModel, + ) { + HorizontalPager( + contentPadding = it, + state = pagerState, + userScrollEnabled = true, + ) { page -> + RefresheableBox(tabs[page].feedState, true) { + SaveableFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState -> + RenderFeedContentState( + feedContentState = tabs[page].feedState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = tabs[page].routeForLastRead, + ) + } + } + } + } +} + +@Immutable +class BadgesTabItem( + val resource: Int, + val feedState: FeedContentState, + val routeForLastRead: String, + val scrollStateKey: String, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt new file mode 100644 index 000000000..ea22abc1b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt @@ -0,0 +1,43 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun BadgesTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + Text( + text = stringRes(R.string.badges), + style = MaterialTheme.typography.titleMedium, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt new file mode 100644 index 000000000..64c858db7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt @@ -0,0 +1,53 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun NewBadgeButton(nav: INav) { + FloatingActionButton( + onClick = { nav.nav(Route.NewBadge()) }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringRes(id = R.string.new_badge), + modifier = Size26Modifier, + tint = Color.White, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt new file mode 100644 index 000000000..8f971fb70 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt @@ -0,0 +1,149 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.award + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AwardBadgeScreen( + kind: Int, + pubKeyHex: HexKey, + dTag: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: AwardBadgeViewModel = viewModel() + + LaunchedEffect(accountViewModel, kind, pubKeyHex, dTag) { + vm.init(accountViewModel, kind, pubKeyHex, dTag) + } + + BackHandler { + vm.cancel() + nav.popBack() + } + + Scaffold( + topBar = { + SavingTopBar( + titleRes = R.string.award_badge, + isActive = vm::canPost, + onCancel = { + vm.cancel() + nav.popBack() + }, + onPost = { + accountViewModel.launchSigner { + vm.sendPost() + nav.popBack() + } + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + AwardBadgeBody(vm) + } + } +} + +@Composable +private fun AwardBadgeBody(vm: AwardBadgeViewModel) { + val scrollState = rememberScrollState() + + Column( + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + ) { + val def = vm.definition + if (def == null) { + Text( + text = stringRes(R.string.award_badge_loading), + style = MaterialTheme.typography.bodyMedium, + ) + } else { + Text( + text = def.name() ?: def.dTag(), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.height(4.dp)) + def.description()?.let { + Text(it, style = MaterialTheme.typography.bodyMedium) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = vm.awardeesText, + onValueChange = { vm.awardeesText = it }, + label = { Text(stringRes(R.string.award_badge_recipients_label)) }, + placeholder = { Text(stringRes(R.string.award_badge_recipients_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 4, + maxLines = 10, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val parsed = vm.parsedPubKeys() + Text( + text = stringRes(R.string.award_badge_recipient_count, parsed.size), + style = MaterialTheme.typography.bodySmall, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt new file mode 100644 index 000000000..cdef93f67 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt @@ -0,0 +1,82 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.award + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent + +@Stable +class AwardBadgeViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + var definition by mutableStateOf(null) + var awardeesText by mutableStateOf(TextFieldValue("")) + + fun init( + accountVM: AccountViewModel, + kind: Int, + pubKeyHex: HexKey, + dTag: String, + ) { + this.accountViewModel = accountVM + this.account = accountVM.account + + val ev = + LocalCache.getAddressableNoteIfExists(Address(kind, pubKeyHex, dTag))?.event as? BadgeDefinitionEvent + definition = ev + } + + fun parsedPubKeys(): List = + awardeesText.text + .split('\n', ',', ' ', ';') + .mapNotNull { raw -> + val trimmed = raw.trim() + if (trimmed.isEmpty()) null else decodePublicKeyAsHexOrNull(trimmed) + }.distinct() + + fun canPost(): Boolean = definition != null && parsedPubKeys().isNotEmpty() + + fun cancel() { + awardeesText = TextFieldValue("") + } + + suspend fun sendPost() { + val def = definition ?: return + val awardees = parsedPubKeys().map { PTag(it) } + if (awardees.isEmpty()) return + + account.sendBadgeAward(def, awardees) + cancel() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt new file mode 100644 index 000000000..76bf689e8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt @@ -0,0 +1,60 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent + +class BadgesAwardedFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "badges-awarded-" + account.userProfile().pubkeyHex + + override fun limit() = 200 + + override fun showHiddenKey(): Boolean = false + + private fun myPubkey(): String = account.userProfile().pubkeyHex + + override fun feed(): List { + val me = myPubkey() + val notes = + LocalCache.notes.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is BadgeAwardEvent && noteEvent.pubKey == me + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set { + val me = myPubkey() + return newItems.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is BadgeAwardEvent && noteEvent.pubKey == me + } + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt new file mode 100644 index 000000000..3732e680b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt @@ -0,0 +1,59 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent + +class BadgesDiscoverFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "badges-discover-" + account.userProfile().pubkeyHex + + override fun limit() = 200 + + override fun showHiddenKey(): Boolean = false + + private fun isHidden(pubKey: String): Boolean = + account.hiddenUsers.flow.value.hiddenUsers + .contains(pubKey) + + override fun feed(): List { + val notes = + LocalCache.addressables.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is BadgeDefinitionEvent && !isHidden(noteEvent.pubKey) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = + newItems.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is BadgeDefinitionEvent && !isHidden(noteEvent.pubKey) + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesMineFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesMineFeedFilter.kt new file mode 100644 index 000000000..94d1a2191 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesMineFeedFilter.kt @@ -0,0 +1,60 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent + +class BadgesMineFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "badges-mine-" + account.userProfile().pubkeyHex + + override fun limit() = 200 + + override fun showHiddenKey(): Boolean = false + + private fun myPubkey(): String = account.userProfile().pubkeyHex + + override fun feed(): List { + val me = myPubkey() + val notes = + LocalCache.addressables.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set { + val me = myPubkey() + return newItems.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me + } + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt new file mode 100644 index 000000000..af4c0dcc4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt @@ -0,0 +1,59 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent + +class BadgesReceivedFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "badges-received-" + account.userProfile().pubkeyHex + + override fun limit() = 200 + + override fun showHiddenKey(): Boolean = false + + private fun myPubkey(): String = account.userProfile().pubkeyHex + + private fun awardsMe(noteEvent: BadgeAwardEvent): Boolean = noteEvent.awardeeIds().contains(myPubkey()) + + override fun feed(): List { + val notes = + LocalCache.notes.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is BadgeAwardEvent && awardsMe(noteEvent) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = + newItems.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is BadgeAwardEvent && awardsMe(noteEvent) + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt new file mode 100644 index 000000000..774518ced --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt @@ -0,0 +1,46 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +class BadgesQueryState( + val account: Account, +) + +@Stable +class BadgesFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + BadgesSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssemblerSubscription.kt new file mode 100644 index 000000000..6490033a4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssemblerSubscription.kt @@ -0,0 +1,47 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun BadgesFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + BadgesFilterAssemblerSubscription( + accountViewModel.dataSources().badges, + accountViewModel, + ) +} + +@Composable +fun BadgesFilterAssemblerSubscription( + dataSource: BadgesFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + BadgesQueryState(accountViewModel.account) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt new file mode 100644 index 000000000..de15650f8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt @@ -0,0 +1,38 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class BadgesSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun updateFilter( + key: BadgesQueryState, + since: SincePerRelayMap?, + ): List = filterMyBadges(user(key), since) + + override fun user(key: BadgesQueryState) = key.account.userProfile() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt new file mode 100644 index 000000000..af71f1dba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt @@ -0,0 +1,60 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent + +/** + * Subscribes to: + * - Badge definitions (kind 30009) authored by me — covers "Mine" tab. + * - Badge awards (kind 8) authored by me — covers "Awarded" tab. + * + * Received badges (kind 8 with `#p`=me) are already pulled via the standard + * notifications subscription (FilterNotificationsToPubkey), so we do not + * duplicate that here. + */ +fun filterMyBadges( + user: User, + since: SincePerRelayMap?, +): List { + val relays = + user.outboxRelays()?.ifEmpty { null } + ?: user.allUsedRelaysOrNull() + ?: return emptyList() + + return relays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(BadgeDefinitionEvent.KIND, BadgeAwardEvent.KIND), + authors = listOf(user.pubkeyHex), + limit = 500, + since = since?.get(relay)?.time, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt new file mode 100644 index 000000000..ac8b6f4cb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt @@ -0,0 +1,163 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.post + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewBadgeScreen( + editDTag: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: NewBadgeViewModel = viewModel() + + LaunchedEffect(accountViewModel, editDTag) { + vm.init(accountViewModel, editDTag) + } + + BackHandler { + vm.cancel() + nav.popBack() + } + + Scaffold( + topBar = { + PostingTopBar( + titleRes = if (vm.isEdit) R.string.edit_badge else R.string.new_badge, + isActive = vm::canPost, + onCancel = { + vm.cancel() + nav.popBack() + }, + onPost = { + accountViewModel.launchSigner { + vm.sendPost() + nav.popBack() + } + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + NewBadgeBody(vm) + } + } +} + +@Composable +private fun NewBadgeBody(vm: NewBadgeViewModel) { + val scrollState = rememberScrollState() + + Column( + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + ) { + OutlinedTextField( + value = vm.badgeId, + onValueChange = { vm.badgeId = it }, + label = { Text(stringRes(R.string.badge_id_label)) }, + placeholder = { Text(stringRes(R.string.badge_id_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !vm.isEdit, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = vm.name, + onValueChange = { vm.name = it }, + label = { Text(stringRes(R.string.badge_name_label)) }, + placeholder = { Text(stringRes(R.string.badge_name_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = vm.description, + onValueChange = { vm.description = it }, + label = { Text(stringRes(R.string.badge_description_label)) }, + placeholder = { Text(stringRes(R.string.badge_description_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 6, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = vm.imageUrl, + onValueChange = { vm.imageUrl = it }, + label = { Text(stringRes(R.string.badge_image_label)) }, + placeholder = { Text(stringRes(R.string.badge_image_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = vm.thumbUrl, + onValueChange = { vm.thumbUrl = it }, + label = { Text(stringRes(R.string.badge_thumb_label)) }, + placeholder = { Text(stringRes(R.string.badge_thumb_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt new file mode 100644 index 000000000..1470fdca1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt @@ -0,0 +1,107 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.post + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent + +@Stable +class NewBadgeViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + var badgeId by mutableStateOf(TextFieldValue("")) + var name by mutableStateOf(TextFieldValue("")) + var description by mutableStateOf(TextFieldValue("")) + var imageUrl by mutableStateOf(TextFieldValue("")) + var thumbUrl by mutableStateOf(TextFieldValue("")) + + var isEdit by mutableStateOf(false) + + fun init( + accountVM: AccountViewModel, + editDTag: String?, + ) { + this.accountViewModel = accountVM + this.account = accountVM.account + + if (editDTag.isNullOrBlank()) return + + val existing = + LocalCache + .getAddressableNoteIfExists( + Address(BadgeDefinitionEvent.KIND, account.signer.pubKey, editDTag), + )?.event as? BadgeDefinitionEvent ?: return + + isEdit = true + badgeId = TextFieldValue(existing.dTag()) + name = TextFieldValue(existing.name() ?: "") + description = TextFieldValue(existing.description() ?: "") + imageUrl = TextFieldValue(existing.image() ?: "") + thumbUrl = TextFieldValue(existing.thumb() ?: "") + } + + fun canPost(): Boolean = badgeId.text.isNotBlank() && name.text.isNotBlank() + + fun cancel() { + badgeId = TextFieldValue("") + name = TextFieldValue("") + description = TextFieldValue("") + imageUrl = TextFieldValue("") + thumbUrl = TextFieldValue("") + isEdit = false + } + + suspend fun sendPost() { + if (!canPost()) return + + val thumb = thumbUrl.text.ifBlank { null } + val thumbs = + if (thumb != null) { + listOf( + com.vitorpamplona.quartz.nip58Badges.definition.tags + .ThumbTag(thumb), + ) + } else { + emptyList() + } + + account.sendBadgeDefinition( + badgeId = badgeId.text.trim(), + name = name.text.trim(), + imageUrl = imageUrl.text.ifBlank { null }, + imageDim = null, + description = description.text.ifBlank { null }, + thumbs = thumbs, + ) + + cancel() + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e2ffc91ef..b961ce5b9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -419,6 +419,31 @@ Polls Open Closed + Badges + Received + Mine + Awarded + Discover + New Badge + Edit Badge + Award Badge + Accept + Reject + Remove from profile + Badge ID (d-tag) + bravery, contributor-2025… + Name + Human-readable badge name + Description + Why is this badge awarded? + Image URL (1024×1024) + https://example.com/badge.png + Thumbnail URL (optional) + https://example.com/badge-thumb.png + Loading badge… + Recipients (npub or hex, one per line) + npub1…\nnpub1… + %1$d recipient(s) will receive this badge Pictures Shorts Videos From f00b7c9b5fc4ef49a176e0293203115cbcf0bf5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:40:50 +0000 Subject: [PATCH 02/18] fix(badges): use default TopAppBar title font to match other drawer screens --- .../amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt index ea22abc1b..562f84516 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R @@ -35,9 +34,6 @@ fun BadgesTopBar( nav: INav, ) { UserDrawerSearchTopBar(accountViewModel, nav) { - Text( - text = stringRes(R.string.badges), - style = MaterialTheme.typography.titleMedium, - ) + Text(text = stringRes(R.string.badges)) } } From 7440f28405bdab457c54190a60b9878a290678b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:49:02 +0000 Subject: [PATCH 03/18] feat(badges): redesign badge composables with Material3 card layout - Replace the centered, full-width-image RenderBadge with a consistent OutlinedCard (12dp corners, 16dp padding) containing a 72dp rounded thumbnail, titleMedium name, and a bodyMedium description on onSurfaceVariant (max 4 lines). - BadgeDisplay surfaces an Award button as a FilledTonalButton inside the card's action row when the definition is mine. - RenderBadgeAward now reuses the same card and shows: - the badge definition (image + name + description), - a compact "Awarded to N" FlowRow of 30dp user pics (capped at 24, with an overflow label), - a single Accept / Reject action row (TextButton + tonal Accept) or an OutlinedButton "Remove from profile" when already accepted. - Falls back to a robohash thumbnail when no image or thumb is set. --- .../amethyst/ui/note/types/Badge.kt | 307 +++++++++++------- amethyst/src/main/res/values/strings.xml | 3 + 2 files changed, 195 insertions(+), 115 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt index c130b1fa0..fdb777a1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -20,20 +20,27 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.MilitaryTech +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -41,29 +48,38 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User 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.Route import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size30dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent +private val BadgeCardShape = RoundedCornerShape(12.dp) +private val BadgeThumbSize = 72.dp + @Composable fun BadgeDisplay( baseNote: Note, @@ -71,32 +87,34 @@ fun BadgeDisplay( nav: INav? = null, ) { val badgeData by observeNoteEvent(baseNote, accountViewModel) + val definition = badgeData ?: return - badgeData?.let { - RenderBadge( - it.image(), - it.name(), - MaterialTheme.colorScheme.background, - MaterialTheme.colorScheme.onBackground, - it.description(), - ) + val isMine = definition.pubKey == accountViewModel.userProfile().pubkeyHex - if (nav != null && it.pubKey == accountViewModel.userProfile().pubkeyHex) { - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.End, - ) { - Button( + BadgeCard( + imageUrl = definition.thumb()?.ifBlank { null } ?: definition.image(), + name = definition.name(), + description = definition.description(), + ) { + if (isMine && nav != null) { + BadgeActionRow { + FilledTonalButton( onClick = { nav.nav( - com.vitorpamplona.amethyst.ui.navigation.routes.Route.AwardBadge( - kind = it.kind, - pubKeyHex = it.pubKey, - dTag = it.dTag(), + Route.AwardBadge( + kind = definition.kind, + pubKeyHex = definition.pubKey, + dTag = definition.dTag(), ), ) }, ) { + Icon( + imageVector = Icons.Outlined.MilitaryTech, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(6.dp)) Text(stringRes(R.string.award_badge)) } } @@ -104,76 +122,6 @@ fun BadgeDisplay( } } -@Preview -@Composable -private fun RenderBadgePreview() { - val background = MaterialTheme.colorScheme.background - - ThemeComparisonRow { - RenderBadge( - image = "http://test.com", - name = "Name", - backgroundForRow = background, - textColor = Color.LightGray, - description = "This badge is awarded to the dedicated individuals who actively contributed by writing events to the relay during the crucial testing phase leading up to the first beta release of Grain.", - ) - } -} - -@Composable -private fun RenderBadge( - image: String?, - name: String?, - backgroundForRow: Color, - textColor: Color, - description: String?, -) { - Row( - modifier = Modifier.padding(vertical = 10.dp), - ) { - Column { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringRes( - R.string.badge_award_image_for, - name ?: "", - ), - modifier = Modifier.fillMaxWidth().background(backgroundForRow), - contentScale = ContentScale.FillWidth, - ) - } - - name?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp), - color = textColor, - ) - } - - description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp), - color = textColor, - ) - } - } - } -} - @OptIn(ExperimentalLayoutApi::class) @Composable fun RenderBadgeAward( @@ -185,32 +133,154 @@ fun RenderBadgeAward( if (note.replyTo.isNullOrEmpty()) return val noteEvent = note.event as? BadgeAwardEvent ?: return + + val definitionNote = note.replyTo?.firstOrNull() + val definition by + if (definitionNote != null) { + observeNoteEvent(definitionNote, accountViewModel) + } else { + remember { mutableStateOf(null) } + } + var awardees by remember { mutableStateOf>(listOf()) } - Text(text = stringRes(R.string.award_granted_to)) + LaunchedEffect(note) { accountViewModel.loadUsers(noteEvent.awardeeIds()) { awardees = it } } - LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardeeIds()) { awardees = it } } + BadgeCard( + imageUrl = definition?.thumb()?.ifBlank { null } ?: definition?.image(), + name = definition?.name() ?: stringRes(R.string.award_granted_to), + description = definition?.description(), + ) { + if (awardees.isNotEmpty()) { + BadgeAwardeesRow(awardees, accountViewModel, nav) + } + AcceptBadgeControls(noteEvent, accountViewModel) + } +} - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - awardees.take(100).forEach { user -> +@Composable +private fun BadgeCard( + imageUrl: String?, + name: String?, + description: String?, + actions: @Composable () -> Unit = {}, +) { + OutlinedCard( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + shape = BadgeCardShape, + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + BadgeThumbnail(imageUrl, name) + + Spacer(modifier = Modifier.size(14.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = name?.ifBlank { null } ?: stringRes(R.string.badge_untitled), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + + if (!description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + + actions() + } + } +} + +@Composable +private fun BadgeThumbnail( + imageUrl: String?, + name: String?, +) { + val description = + if (name != null) { + stringRes(R.string.badge_award_image_for, name) + } else { + stringRes(R.string.badge_award_image) + } + + Box( + modifier = Modifier.size(BadgeThumbSize).clip(RoundedCornerShape(10.dp)), + ) { + if (imageUrl.isNullOrBlank()) { + RobohashAsyncImage( + robot = "badgenotfound", + contentDescription = description, + modifier = Modifier.size(BadgeThumbSize), + loadRobohash = true, + ) + } else { + AsyncImage( + model = imageUrl, + contentDescription = description, + modifier = Modifier.size(BadgeThumbSize), + contentScale = ContentScale.Crop, + ) + } + } +} + +@Composable +private fun BadgeActionRow(content: @Composable () -> Unit) { + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + content() + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun BadgeAwardeesRow( + awardees: List, + accountViewModel: AccountViewModel, + nav: INav, +) { + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = stringRes(R.string.badge_awardees_label, awardees.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(6.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + awardees.take(24).forEach { user -> UserPicture( user = user, - size = Size35dp, + size = Size30dp, accountViewModel = accountViewModel, nav = nav, ) } - - if (awardees.size > 100) { - Text(stringRes(R.string.badge_and_n_others, awardees.size - 100), maxLines = 1) + if (awardees.size > 24) { + Text( + text = stringRes(R.string.badge_and_n_others, awardees.size - 24), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 2.dp), + ) } } - - note.replyTo?.firstOrNull()?.let { - BadgeDisplay(baseNote = it, accountViewModel) - } - - AcceptBadgeControls(noteEvent, accountViewModel) } @Composable @@ -245,10 +315,7 @@ private fun AcceptBadgeControls( awardIds.contains(award.id) } - Row( - modifier = Modifier.fillMaxWidth().padding(top = 10.dp), - horizontalArrangement = Arrangement.End, - ) { + BadgeActionRow { if (isAccepted) { OutlinedButton( onClick = { @@ -260,7 +327,7 @@ private fun AcceptBadgeControls( Text(stringRes(R.string.unaccept_badge)) } } else { - OutlinedButton( + TextButton( onClick = { accountViewModel.launchSigner { accountViewModel.account.removeAcceptedBadge(award) @@ -270,13 +337,11 @@ private fun AcceptBadgeControls( Text(stringRes(R.string.reject_badge)) } Spacer(modifier = Modifier.size(8.dp)) - Button( + FilledTonalButton( onClick = { accountViewModel.launchSigner { val defAddr = award.awardDefinition().firstOrNull() ?: return@launchSigner - val defNote = - com.vitorpamplona.amethyst.model.LocalCache - .getAddressableNoteIfExists(defAddr) + val defNote = LocalCache.getAddressableNoteIfExists(defAddr) val defEvent = defNote?.event as? BadgeDefinitionEvent ?: return@launchSigner accountViewModel.account.addAcceptedBadge(award, defEvent) } @@ -287,3 +352,15 @@ private fun AcceptBadgeControls( } } } + +@Preview +@Composable +private fun RenderBadgePreview() { + ThemeComparisonRow { + BadgeCard( + imageUrl = null, + name = "Relay Beta Tester", + description = "Awarded to the dedicated individuals who actively contributed by writing events to the relay during the beta phase.", + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b961ce5b9..0ca6aa869 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -444,6 +444,9 @@ Recipients (npub or hex, one per line) npub1…\nnpub1… %1$d recipient(s) will receive this badge + Untitled badge + Awarded to %1$d + You received a badge Pictures Shorts Videos From 7a8dc0239462acee42ac16afa08929eccc63f7e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 23:45:26 +0000 Subject: [PATCH 04/18] refactor(badges): single feed + top-nav filter, profile badges to settings Restructure the Badges screen to match Polls and other feeds: - Drop the 4-tab pager in favor of a single feed of BadgeDefinitionEvent (kind 30009), with a FeedFilterSpinner in the top bar. - Introduce TopFilter.Mine as a selectable option so the same spinner switches between follow-list semantics and "only badges I authored". Defaults to AllFollows. - New unified BadgesFeedFilter reading defaultBadgesFollowList. - BadgesSubAssembler now uses PerUserAndFollowListEoseManager (limit 100). Mine subscribes to outbox with authors=me; everything else dispatches makeBadgesFilter across follow-list/global/authors/muted per-relay filter sets, identical in shape to the Polls pipeline. - Feed states: replace badgesReceived / badgesMine / badgesAwarded / badgesDiscover with a single badgesFeed. Navigation into a badge definition now surfaces its full award history: BadgeAwardEvent.KIND is added to RepliesAndReactionsToAddressesKinds1, so the existing thread view of a kind 30009 note pulls in every kind 8 referencing it via the `a` tag. Received-badge management moves to a dedicated settings page: - New Route.ProfileBadges + ProfileBadgesScreen listing every BadgeAwardEvent where I'm a `p` recipient with a Switch per row that toggles it into the ProfileBadgesEvent (10008). - Linked from AllSettingsScreen via a MilitaryTech row. --- .../vitorpamplona/amethyst/model/Account.kt | 3 + .../amethyst/model/AccountSettings.kt | 15 ++ .../topNavFeeds/FeedTopNavFilterState.kt | 4 + .../FilterRepliesAndReactionsToAddresses.kt | 2 + .../ui/feeds/RememberForeverStates.kt | 6 +- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../amethyst/ui/screen/TopNavFilterState.kt | 23 ++ .../loggedIn/AccountFeedContentStates.kt | 20 +- .../ui/screen/loggedIn/badges/BadgesScreen.kt | 164 +++---------- .../ui/screen/loggedIn/badges/BadgesTopBar.kt | 35 ++- .../badges/dal/BadgesAwardedFeedFilter.kt | 60 ----- .../badges/dal/BadgesDiscoverFeedFilter.kt | 59 ----- ...sMineFeedFilter.kt => BadgesFeedFilter.kt} | 53 ++++- .../badges/dal/BadgesReceivedFeedFilter.kt | 59 ----- .../datasource/BadgesFilterAssembler.kt | 4 + .../BadgesFilterAssemblerSubscription.kt | 3 +- .../badges/datasource/BadgesSubAssembler.kt | 71 +++++- .../badges/datasource/FilterBadges.kt | 131 +++++++++-- .../badges/profile/ProfileBadgesScreen.kt | 219 ++++++++++++++++++ .../loggedIn/settings/AllSettingsScreen.kt | 8 + amethyst/src/main/res/values/strings.xml | 4 + 22 files changed, 585 insertions(+), 362 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/{BadgesMineFeedFilter.kt => BadgesFeedFilter.kt} (53%) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 47263bd67..428dbdb1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -459,6 +459,9 @@ class Account( val liveArticlesFollowLists: StateFlow = topNavFilterFlow(settings.defaultArticlesFollowList) val liveArticlesFollowListsPerRelay = OutboxLoaderState(liveArticlesFollowLists, cache, scope).flow + val liveBadgesFollowLists: StateFlow = topNavFilterFlow(settings.defaultBadgesFollowList) + val liveBadgesFollowListsPerRelay = OutboxLoaderState(liveBadgesFollowLists, cache, scope).flow + override fun isWriteable(): Boolean = settings.isWriteable() suspend fun updateWarnReports(warnReports: Boolean): Boolean { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index d091bf5c3..bb1476e36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -125,6 +125,9 @@ sealed class TopFilter( @Serializable object Chess : TopFilter(" Chess ") + @Serializable + object Mine : TopFilter(" Mine ") + @Serializable class PeopleList( val address: Address, @@ -174,6 +177,7 @@ class AccountSettings( val defaultShortsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultLongsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultArticlesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), + val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, @@ -503,6 +507,17 @@ class AccountSettings( } } + fun changeDefaultBadgesFollowList(name: FeedDefinition) { + changeDefaultBadgesFollowList(name.code) + } + + fun changeDefaultBadgesFollowList(name: TopFilter) { + if (defaultBadgesFollowList.value != name) { + defaultBadgesFollowList.tryEmit(name) + saveAccountSettings() + } + } + // --- // language services // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index e9ae1dbc7..29a169e61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -89,6 +89,10 @@ class FeedTopNavFilterState( ChessFeedFlow(followsRelays, proxyRelays) } + TopFilter.Mine -> { + AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays) + } + is TopFilter.Community -> { NoteFeedFlow( LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt index dc2c08557..9015be8e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.utils.mapOfSet @@ -49,6 +50,7 @@ val RepliesAndReactionsToAddressesKinds1 = ZapPollEvent.KIND, CommentEvent.KIND, AttestationEvent.KIND, + BadgeAwardEvent.KIND, ) val PostsAndChatMessagesToAddresses = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index ddffb0e62..41de5c811 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -58,10 +58,7 @@ object ScrollStateKeys { const val POLLS_SCREEN = "PollsFeed" const val POLLS_OPEN = "PollsOpenFeed" const val POLLS_CLOSED = "PollsClosedFeed" - const val BADGES_RECEIVED = "BadgesReceivedFeed" - const val BADGES_MINE = "BadgesMineFeed" - const val BADGES_AWARDED = "BadgesAwardedFeed" - const val BADGES_DISCOVER = "BadgesDiscoverFeed" + const val BADGES_SCREEN = "BadgesFeed" const val PICTURES_SCREEN = "PicturesFeed" const val PRODUCTS_SCREEN = "ProductsFeed" const val SHORTS_SCREEN = "ShortsFeed" @@ -77,7 +74,6 @@ object PagerStateKeys { const val HOME_SCREEN = "PagerHome" const val DISCOVER_SCREEN = "PagerDiscover" const val POLLS_SCREEN = "PagerPolls" - const val BADGES_SCREEN = "PagerBadges" } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index c199acadd..efbf4541d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.ProfileBadgesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen @@ -218,6 +219,7 @@ fun BuildNavigation( composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } composableFromEnd { PollsScreen(accountViewModel, nav) } composableFromEnd { BadgesScreen(accountViewModel, nav) } + composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } composableFromBottomArgs { NewBadgeScreen(it.editDTag, accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 6f50d2d80..6a870f2bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -47,6 +47,8 @@ sealed class Route { @Serializable object Badges : Route() + @Serializable object ProfileBadges : Route() + @Serializable data class NewBadge( val editDTag: String? = null, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 50763b54d..d8a0c7cfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -96,6 +96,12 @@ class TopNavFilterState( name = ResourceName(R.string.follow_list_chess), ) + val mineFollow = + FeedDefinition( + code = TopFilter.Mine, + name = ResourceName(R.string.follow_list_mine), + ) + val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) fun mergePeopleLists( @@ -211,6 +217,18 @@ class TopNavFilterState( ) } + private val _badgeRoutes = + livePeopleListsFlow.transform { peopleLists -> + checkNotInMainThread() + emit( + listOf( + listOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow), + peopleLists, + listOf(muteListFollow), + ).flatten().toImmutableList(), + ) + } + private val _kind3GlobalPeople = livePeopleListsFlow.transform { peopleLists -> checkNotInMainThread() @@ -233,6 +251,11 @@ class TopNavFilterState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, defaultLists) + val badgeRoutes = + _badgeRoutes + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow)) + fun destroy() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index ee72b6faa..ac6a5e30a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -28,10 +28,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFilter -import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesAwardedFeedFilter -import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesDiscoverFeedFilter -import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesMineFeedFilter -import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesReceivedFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter @@ -85,10 +82,7 @@ class AccountFeedContentStates( val openPollsFeed = FeedContentState(OpenPollsFeedFilter(account), scope, LocalCache) val closedPollsFeed = FeedContentState(ClosedPollsFeedFilter(account), scope, LocalCache) - val badgesReceived = FeedContentState(BadgesReceivedFeedFilter(account), scope, LocalCache) - val badgesMine = FeedContentState(BadgesMineFeedFilter(account), scope, LocalCache) - val badgesAwarded = FeedContentState(BadgesAwardedFeedFilter(account), scope, LocalCache) - val badgesDiscover = FeedContentState(BadgesDiscoverFeedFilter(account), scope, LocalCache) + val badgesFeed = FeedContentState(BadgesFeedFilter(account), scope, LocalCache) val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache) val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache) @@ -134,10 +128,7 @@ class AccountFeedContentStates( openPollsFeed.updateFeedWith(newNotes) closedPollsFeed.updateFeedWith(newNotes) - badgesReceived.updateFeedWith(newNotes) - badgesMine.updateFeedWith(newNotes) - badgesAwarded.updateFeedWith(newNotes) - badgesDiscover.updateFeedWith(newNotes) + badgesFeed.updateFeedWith(newNotes) picturesFeed.updateFeedWith(newNotes) productsFeed.updateFeedWith(newNotes) @@ -177,10 +168,7 @@ class AccountFeedContentStates( openPollsFeed.deleteFromFeed(newNotes) closedPollsFeed.deleteFromFeed(newNotes) - badgesReceived.deleteFromFeed(newNotes) - badgesMine.deleteFromFeed(newNotes) - badgesAwarded.deleteFromFeed(newNotes) - badgesDiscover.deleteFromFeed(newNotes) + badgesFeed.deleteFromFeed(newNotes) picturesFeed.deleteFromFeed(newNotes) productsFeed.deleteFromFeed(newNotes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt index 35a0d3cf4..2551d3f3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt @@ -20,143 +20,54 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerState -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SecondaryTabRow -import androidx.compose.material3.Tab -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.graphics.Color -import com.vitorpamplona.amethyst.R +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel -import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.TabRowHeight -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.launch @Composable fun BadgesScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val feedStates = accountViewModel.feedStates - - WatchLifecycleAndUpdateModel(feedStates.badgesReceived) - WatchLifecycleAndUpdateModel(feedStates.badgesMine) - WatchLifecycleAndUpdateModel(feedStates.badgesAwarded) - WatchLifecycleAndUpdateModel(feedStates.badgesDiscover) - - BadgesFilterAssemblerSubscription(accountViewModel) - - AssembleBadgesTabs( - received = feedStates.badgesReceived, - mine = feedStates.badgesMine, - awarded = feedStates.badgesAwarded, - discover = feedStates.badgesDiscover, - ) { pagerState, tabs -> - BadgesPages(pagerState, tabs, accountViewModel, nav) - } + BadgesScreen( + feedContentState = accountViewModel.feedStates.badgesFeed, + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable -private fun AssembleBadgesTabs( - received: FeedContentState, - mine: FeedContentState, - awarded: FeedContentState, - discover: FeedContentState, - inner: @Composable (PagerState, ImmutableList) -> Unit, -) { - val pagerState = rememberForeverPagerState(key = PagerStateKeys.BADGES_SCREEN) { 4 } - - val tabs by - remember(received, mine, awarded, discover) { - mutableStateOf( - listOf( - BadgesTabItem( - resource = R.string.received_badges, - feedState = received, - routeForLastRead = "BadgesReceivedFeed", - scrollStateKey = ScrollStateKeys.BADGES_RECEIVED, - ), - BadgesTabItem( - resource = R.string.my_badges, - feedState = mine, - routeForLastRead = "BadgesMineFeed", - scrollStateKey = ScrollStateKeys.BADGES_MINE, - ), - BadgesTabItem( - resource = R.string.awarded_badges, - feedState = awarded, - routeForLastRead = "BadgesAwardedFeed", - scrollStateKey = ScrollStateKeys.BADGES_AWARDED, - ), - BadgesTabItem( - resource = R.string.discover_badges, - feedState = discover, - routeForLastRead = "BadgesDiscoverFeed", - scrollStateKey = ScrollStateKeys.BADGES_DISCOVER, - ), - ).toImmutableList(), - ) - } - - inner(pagerState, tabs) -} - -@Composable -private fun BadgesPages( - pagerState: PagerState, - tabs: ImmutableList, +fun BadgesScreen( + feedContentState: FeedContentState, accountViewModel: AccountViewModel, nav: INav, ) { + WatchLifecycleAndUpdateModel(feedContentState) + WatchAccountForBadgesScreen(feedContentState, accountViewModel) + BadgesFilterAssemblerSubscription(accountViewModel) + DisappearingScaffold( isInvertedLayout = false, topBar = { - Column { - BadgesTopBar(accountViewModel, nav) - SecondaryTabRow( - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onBackground, - modifier = TabRowHeight, - selectedTabIndex = pagerState.currentPage, - ) { - val coroutineScope = rememberCoroutineScope() - tabs.forEachIndexed { index, tab -> - Tab( - selected = pagerState.currentPage == index, - text = { Text(text = stringRes(tab.resource)) }, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, - ) - } - } - } + BadgesTopBar(accountViewModel, nav) }, bottomBar = { AppBottomBar(Route.Badges, accountViewModel) { route -> if (route == Route.Badges) { - tabs[pagerState.currentPage].feedState.sendToTop() + feedContentState.sendToTop() } else { nav.newStack(route) } @@ -167,30 +78,31 @@ private fun BadgesPages( }, accountViewModel = accountViewModel, ) { - HorizontalPager( - contentPadding = it, - state = pagerState, - userScrollEnabled = true, - ) { page -> - RefresheableBox(tabs[page].feedState, true) { - SaveableFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState -> - RenderFeedContentState( - feedContentState = tabs[page].feedState, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = tabs[page].routeForLastRead, - ) - } + RefresheableBox(feedContentState, true) { + SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BADGES_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = feedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "BadgesFeed", + ) } } } } -@Immutable -class BadgesTabItem( - val resource: Int, - val feedState: FeedContentState, - val routeForLastRead: String, - val scrollStateKey: String, -) +@Composable +fun WatchAccountForBadgesScreen( + feedContentState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.liveBadgesFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + feedContentState.checkKeysInvalidateDataAndSendToTop() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt index 562f84516..522a6d765 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt @@ -20,11 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges -import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -34,6 +39,32 @@ fun BadgesTopBar( nav: INav, ) { UserDrawerSearchTopBar(accountViewModel, nav) { - Text(text = stringRes(R.string.badges)) + val list by accountViewModel.account.settings.defaultBadgesFollowList + .collectAsStateWithLifecycle() + + BadgesTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultBadgesFollowList, + ) } } + +@Composable +private fun BadgesTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.badgeRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt deleted file mode 100644 index 76bf689e8..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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.amethyst.ui.screen.loggedIn.badges.dal - -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent - -class BadgesAwardedFeedFilter( - val account: Account, -) : AdditiveFeedFilter() { - override fun feedKey(): String = "badges-awarded-" + account.userProfile().pubkeyHex - - override fun limit() = 200 - - override fun showHiddenKey(): Boolean = false - - private fun myPubkey(): String = account.userProfile().pubkeyHex - - override fun feed(): List { - val me = myPubkey() - val notes = - LocalCache.notes.filterIntoSet { _, it -> - val noteEvent = it.event - noteEvent is BadgeAwardEvent && noteEvent.pubKey == me - } - return sort(notes) - } - - override fun applyFilter(newItems: Set): Set { - val me = myPubkey() - return newItems.filterTo(HashSet()) { - val noteEvent = it.event - noteEvent is BadgeAwardEvent && noteEvent.pubKey == me - } - } - - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt deleted file mode 100644 index 3732e680b..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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.amethyst.ui.screen.loggedIn.badges.dal - -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent - -class BadgesDiscoverFeedFilter( - val account: Account, -) : AdditiveFeedFilter() { - override fun feedKey(): String = "badges-discover-" + account.userProfile().pubkeyHex - - override fun limit() = 200 - - override fun showHiddenKey(): Boolean = false - - private fun isHidden(pubKey: String): Boolean = - account.hiddenUsers.flow.value.hiddenUsers - .contains(pubKey) - - override fun feed(): List { - val notes = - LocalCache.addressables.filterIntoSet { _, it -> - val noteEvent = it.event - noteEvent is BadgeDefinitionEvent && !isHidden(noteEvent.pubKey) - } - return sort(notes) - } - - override fun applyFilter(newItems: Set): Set = - newItems.filterTo(HashSet()) { - val noteEvent = it.event - noteEvent is BadgeDefinitionEvent && !isHidden(noteEvent.pubKey) - } - - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesMineFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt similarity index 53% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesMineFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt index 94d1a2191..049940997 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesMineFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt @@ -23,38 +23,71 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent -class BadgesMineFeedFilter( +class BadgesFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = "badges-mine-" + account.userProfile().pubkeyHex + override fun feedKey(): String = account.userProfile().pubkeyHex + "-badges-" + followList().code - override fun limit() = 200 + override fun limit() = 100 - override fun showHiddenKey(): Boolean = false + fun followList(): TopFilter = account.settings.defaultBadgesFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() private fun myPubkey(): String = account.userProfile().pubkeyHex override fun feed(): List { - val me = myPubkey() val notes = - LocalCache.addressables.filterIntoSet { _, it -> - val noteEvent = it.event - noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me + if (followList() == TopFilter.Mine) { + val me = myPubkey() + LocalCache.addressables.filterIntoSet(BadgeDefinitionEvent.KIND) { _, it -> + val noteEvent = it.event + noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me + } + } else { + val params = buildFilterParams(account) + LocalCache.addressables.filterIntoSet(BadgeDefinitionEvent.KIND) { _, it -> + val noteEvent = it.event + noteEvent is BadgeDefinitionEvent && params.match(noteEvent, it.relays) + } } return sort(notes) } override fun applyFilter(newItems: Set): Set { - val me = myPubkey() + if (followList() == TopFilter.Mine) { + val me = myPubkey() + return newItems.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me + } + } + + val params = buildFilterParams(account) return newItems.filterTo(HashSet()) { val noteEvent = it.event - noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me + noteEvent is BadgeDefinitionEvent && params.match(noteEvent, it.relays) } } + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.liveBadgesFollowLists.value, + account.hiddenUsers.flow.value, + ) + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt deleted file mode 100644 index af4c0dcc4..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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.amethyst.ui.screen.loggedIn.badges.dal - -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent - -class BadgesReceivedFeedFilter( - val account: Account, -) : AdditiveFeedFilter() { - override fun feedKey(): String = "badges-received-" + account.userProfile().pubkeyHex - - override fun limit() = 200 - - override fun showHiddenKey(): Boolean = false - - private fun myPubkey(): String = account.userProfile().pubkeyHex - - private fun awardsMe(noteEvent: BadgeAwardEvent): Boolean = noteEvent.awardeeIds().contains(myPubkey()) - - override fun feed(): List { - val notes = - LocalCache.notes.filterIntoSet { _, it -> - val noteEvent = it.event - noteEvent is BadgeAwardEvent && awardsMe(noteEvent) - } - return sort(notes) - } - - override fun applyFilter(newItems: Set): Set = - newItems.filterTo(HashSet()) { - val noteEvent = it.event - noteEvent is BadgeAwardEvent && awardsMe(noteEvent) - } - - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt index 774518ced..74d95bc09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt @@ -23,10 +23,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope class BadgesQueryState( val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, ) @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssemblerSubscription.kt index 6490033a4..3d4b818ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssemblerSubscription.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -40,7 +41,7 @@ fun BadgesFilterAssemblerSubscription( ) { val state = remember(accountViewModel.account) { - BadgesQueryState(accountViewModel.account) + BadgesQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) } KeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt index de15650f8..5971b8b40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt @@ -20,19 +20,84 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch class BadgesSubAssembler( client: INostrClient, allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { +) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: BadgesQueryState, since: SincePerRelayMap?, - ): List = filterMyBadges(user(key), since) + ): List { + val listName = key.listName() + val defaultSince = key.feedStates.badgesFeed.lastNoteCreatedAtIfFilled() + + return if (listName == TopFilter.Mine) { + val outbox = key.account.outboxRelays.flow.value + filterBadgesMine(key.account.userProfile().pubkeyHex, outbox, since) + } else { + makeBadgesFilter(key.followsPerRelay(), since, defaultSince) + } + } override fun user(key: BadgesQueryState) = key.account.userProfile() + + override fun list(key: BadgesQueryState) = key.listName() + + fun BadgesQueryState.listNameFlow() = account.settings.defaultBadgesFollowList + + fun BadgesQueryState.listName() = listNameFlow().value + + fun BadgesQueryState.followsPerRelayFlow() = account.liveBadgesFollowListsPerRelay + + fun BadgesQueryState.followsPerRelay() = followsPerRelayFlow().value + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: BadgesQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.badgesFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt index af71f1dba..ba08ae632 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt @@ -20,41 +20,130 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource -import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils -/** - * Subscribes to: - * - Badge definitions (kind 30009) authored by me — covers "Mine" tab. - * - Badge awards (kind 8) authored by me — covers "Awarded" tab. - * - * Received badges (kind 8 with `#p`=me) are already pulled via the standard - * notifications subscription (FilterNotificationsToPubkey), so we do not - * duplicate that here. - */ -fun filterMyBadges( - user: User, +private const val BADGE_FEED_LIMIT = 100 + +fun makeBadgesFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllFollowsTopNavPerRelayFilterSet -> filterBadgesByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterBadgesByAuthors(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterBadgesByMutedAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterBadgesGlobal(feedSettings, since, defaultSince) + else -> emptyList() + } + +fun filterBadgesMine( + pubkey: HexKey, + relays: Set, since: SincePerRelayMap?, ): List { - val relays = - user.outboxRelays()?.ifEmpty { null } - ?: user.allUsedRelaysOrNull() - ?: return emptyList() - + if (relays.isEmpty() || pubkey.isEmpty()) return emptyList() + val authors = listOf(pubkey) return relays.map { relay -> RelayBasedFilter( relay = relay, filter = Filter( - kinds = listOf(BadgeDefinitionEvent.KIND, BadgeAwardEvent.KIND), - authors = listOf(user.pubkeyHex), - limit = 500, + kinds = listOf(BadgeDefinitionEvent.KIND), + authors = authors, + limit = BADGE_FEED_LIMIT, since = since?.get(relay)?.time, ), ) } } + +private fun filterBadgesByAuthorsOnRelay( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + if (authors.isEmpty()) return emptyList() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(BadgeDefinitionEvent.KIND), + authors = authors.sorted(), + limit = BADGE_FEED_LIMIT, + since = since, + ), + ), + ) +} + +private fun filterBadgesByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + return followsSet.set.flatMap { + val sinceValue = since?.get(it.key)?.time ?: defaultSince + val authors = it.value.authors + if (authors == null || authors.isEmpty()) { + emptyList() + } else { + filterBadgesByAuthorsOnRelay(it.key, authors, sinceValue) + } + } +} + +private fun filterBadgesByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + return authorSet.set.flatMap { + filterBadgesByAuthorsOnRelay(it.key, it.value.authors, since?.get(it.key)?.time ?: defaultSince) + } +} + +private fun filterBadgesByMutedAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + return authorSet.set.flatMap { + filterBadgesByAuthorsOnRelay(it.key, it.value.authors, since?.get(it.key)?.time ?: defaultSince) + } +} + +private fun filterBadgesGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + return relays.set.map { + val sinceValue = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneMonthAgo() + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(BadgeDefinitionEvent.KIND), + limit = BADGE_FEED_LIMIT, + since = sinceValue, + ), + ) + } +} 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 new file mode 100644 index 000000000..d5c02bc07 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt @@ -0,0 +1,219 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.profile + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent + +@Composable +fun ProfileBadgesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val myPubkey = accountViewModel.userProfile().pubkeyHex + + val newNote = accountViewModel.getOrCreateAddressableNote(ProfileBadgesEvent.createAddress(myPubkey)) + val oldNote = accountViewModel.getOrCreateAddressableNote(AcceptedBadgeSetEvent.createAddress(myPubkey)) + + val newState by newNote + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val oldState by oldNote + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + + val acceptedAwardIds = + remember(newState, oldState) { + val newEvent = newState.note.event as? ProfileBadgesEvent + val oldEvent = oldState.note.event as? AcceptedBadgeSetEvent + (newEvent?.badgeAwardEvents()?.map { it.eventId } ?: oldEvent?.badgeAwardEvents()?.map { it.eventId } ?: emptyList()) + .toSet() + } + + val receivedAwards = + remember(myPubkey, newState, oldState) { + LocalCache.notes + .filterIntoSet { _, it -> + val event = it.event + event is BadgeAwardEvent && event.awardeeIds().contains(myPubkey) + }.mapNotNull { it.event as? BadgeAwardEvent } + .sortedByDescending { it.createdAt } + } + + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(id = R.string.profile_badges_title), nav::popBack) + }, + ) { pad -> + Column(Modifier.padding(pad).fillMaxSize()) { + Text( + text = stringRes(R.string.profile_badges_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + HorizontalDivider() + + if (receivedAwards.isEmpty()) { + Text( + text = stringRes(R.string.profile_badges_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(20.dp), + ) + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items( + items = receivedAwards, + key = { it.id }, + ) { award -> + AwardRow( + award = award, + isAccepted = acceptedAwardIds.contains(award.id), + accountViewModel = accountViewModel, + ) + HorizontalDivider() + } + } + } + } + } +} + +@Composable +private fun AwardRow( + award: BadgeAwardEvent, + isAccepted: Boolean, + accountViewModel: AccountViewModel, +) { + val defAddr = award.awardDefinition().firstOrNull() + val definition = + remember(award.id) { + defAddr?.let { LocalCache.getAddressableNoteIfExists(it)?.event as? BadgeDefinitionEvent } + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BadgeThumb(definition) + + Spacer(modifier = Modifier.size(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = definition?.name()?.ifBlank { null } ?: stringRes(R.string.badge_untitled), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + definition?.description()?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Spacer(modifier = Modifier.size(12.dp)) + + Switch( + checked = isAccepted, + onCheckedChange = { checked -> + accountViewModel.launchSigner { + if (checked) { + val defEvent = definition ?: return@launchSigner + accountViewModel.account.addAcceptedBadge(award, defEvent) + } else { + accountViewModel.account.removeAcceptedBadge(award) + } + } + }, + ) + } +} + +@Composable +private fun BadgeThumb(definition: BadgeDefinitionEvent?) { + val imageUrl = definition?.thumb()?.ifBlank { null } ?: definition?.image()?.ifBlank { null } + val thumbModifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)) + + if (imageUrl.isNullOrBlank()) { + RobohashAsyncImage( + robot = definition?.id ?: "badgenotfound", + contentDescription = null, + modifier = thumbModifier, + loadRobohash = true, + ) + } else { + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = thumbModifier, + contentScale = ContentScale.Crop, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 7d0796c8e..47104756d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.GroupAdd import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.MilitaryTech import androidx.compose.material.icons.outlined.Phone import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Security @@ -122,6 +123,13 @@ fun AllSettingsScreen( onClick = { nav.nav(Route.EditMediaServers) }, ) HorizontalDivider() + SettingsNavigationRow( + title = R.string.profile_badges_title, + icon = Icons.Outlined.MilitaryTech, + tint = tint, + onClick = { nav.nav(Route.ProfileBadges) }, + ) + HorizontalDivider() SettingsNavigationRow( title = R.string.reactions, icon = Icons.Outlined.FavoriteBorder, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0ca6aa869..99d6613fe 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -447,6 +447,9 @@ Untitled badge Awarded to %1$d You received a badge + Profile badges + Choose which of the badges you\'ve received appear on your profile. + You haven\'t received any badges yet. Pictures Shorts Videos @@ -652,6 +655,7 @@ Around Me Global Chess + Mine Mute List Follow Lists From 95b49879f4f974fa8474baeec4fb661f5dcd55ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 00:47:35 +0000 Subject: [PATCH 05/18] refactor(profile): refine badge strip on profile header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the bare octagonal FlowRow of 35dp thumbs. Replace with a labeled strip ("Badges · N") of 44dp rounded-square thumbs matching the BadgeCard language used elsewhere. - Cap the visible row at 8 badges and surface overflow as a "+N" pill that opens a ModalBottomSheet listing every accepted badge with its thumbnail, name, and description. Tapping a row closes the sheet and navigates to that badge's thread. - When viewing your own profile, add a settings gear trailing the header that jumps to Route.ProfileBadges to manage which badges appear. - Skip the entire strip (no empty header, no padding) until at least one badge is present. --- .../profile/header/badges/DisplayBadges.kt | 290 ++++++++++++++---- amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 239 insertions(+), 52 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt index 9992202f9..f1f12a75d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt @@ -20,17 +20,44 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +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.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R @@ -43,12 +70,11 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.BadgePictureModifier -import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent @@ -60,8 +86,12 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch + +private val ProfileBadgeSize = 44.dp +private val ProfileBadgeShape = RoundedCornerShape(8.dp) +private const val VISIBLE_BADGE_LIMIT = 8 @Composable fun DisplayBadges( @@ -75,91 +105,242 @@ fun DisplayBadges( val oldNote = accountViewModel.getOrCreateAddressableNote(oldDesign) val newNote = accountViewModel.getOrCreateAddressableNote(newDesign) - WatchAndRenderBadgeList(oldNote, newNote, accountViewModel, nav) + WatchAndRenderBadgeList(baseUser, oldNote, newNote, accountViewModel, nav) } @Composable private fun WatchAndRenderBadgeList( + baseUser: User, oldNote: AddressableNote, newNote: AddressableNote, accountViewModel: AccountViewModel, nav: INav, ) { - // Subscribe in the relay for changes in this note. EventFinderFilterAssemblerSubscription(oldNote, accountViewModel) EventFinderFilterAssemblerSubscription(newNote, accountViewModel) - // Subscribe in the LocalCache for changes that arrive in the device val flow = remember(oldNote, newNote) { combine( oldNote.flow().metadata.stateFlow, newNote.flow().metadata.stateFlow, - ) { oldNote, newNote -> - val oldProfileBadgeEvent = oldNote.note.event as? AcceptedBadgeSetEvent - val newProfileBadgeEvent = newNote.note.event as? ProfileBadgesEvent + ) { oldNoteState, newNoteState -> + val oldEvent = oldNoteState.note.event as? AcceptedBadgeSetEvent + val newEvent = newNoteState.note.event as? ProfileBadgesEvent - newProfileBadgeEvent?.badgeAwardEvents()?.toImmutableList() - ?: oldProfileBadgeEvent?.badgeAwardEvents()?.toImmutableList() + newEvent?.badgeAwardEvents()?.toImmutableList() + ?: oldEvent?.badgeAwardEvents()?.toImmutableList() + ?: persistentListOf() }.distinctUntilChanged() .flowOn(Dispatchers.IO) } - // Subscribe in the LocalCache for changes that arrive in the device val badgeList by flow.collectAsStateWithLifecycle(persistentListOf()) - badgeList?.let { list -> RenderBadgeList(list, accountViewModel, nav) } + if (badgeList.isEmpty()) return + + val isMe = baseUser.pubkeyHex == accountViewModel.userProfile().pubkeyHex + RenderProfileBadgeStrip(badgeList, isMe, accountViewModel, nav) } -@Composable @OptIn(ExperimentalLayoutApi::class) -private fun RenderBadgeList( +@Composable +private fun RenderProfileBadgeStrip( list: ImmutableList, + isMe: Boolean, accountViewModel: AccountViewModel, nav: INav, ) { - FlowRow( - verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(vertical = 5.dp), - ) { - list.forEach { badgeAwardEvent -> LoadAndRenderBadge(badgeAwardEvent, accountViewModel, nav) } + var showAllSheet by rememberSaveable { mutableStateOf(false) } + val visible = remember(list) { list.take(VISIBLE_BADGE_LIMIT) } + val overflow = (list.size - VISIBLE_BADGE_LIMIT).coerceAtLeast(0) + + Column(modifier = Modifier.padding(vertical = 6.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringRes(R.string.profile_badges_header, list.size), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (isMe) { + IconButton( + onClick = { nav.nav(Route.ProfileBadges) }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringRes(R.string.profile_badges_title), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + visible.forEach { eTag -> + LoadDefinitionForAward(eTag, accountViewModel) { defNote -> + BadgeThumb(defNote, accountViewModel, nav) + } + } + if (overflow > 0) { + OverflowChip(overflow) { showAllSheet = true } + } + } + } + + if (showAllSheet) { + AllBadgesSheet( + awards = list, + accountViewModel = accountViewModel, + nav = nav, + onDismiss = { showAllSheet = false }, + ) } } @Composable -private fun LoadAndRenderBadge( - badgeAwardEvent: ETag, +private fun OverflowChip( + count: Int, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .size(ProfileBadgeSize) + .clip(ProfileBadgeShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + text = "+$count", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AllBadgesSheet( + awards: ImmutableList, accountViewModel: AccountViewModel, nav: INav, + onDismiss: () -> Unit, ) { - val baseNote = + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Text( + text = stringRes(R.string.profile_badges_header, awards.size), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(items = awards, key = { it.eventId }) { eTag -> + LoadDefinitionForAward(eTag, accountViewModel) { defNote -> + BadgeSheetRow( + defNote = defNote, + accountViewModel = accountViewModel, + onClick = { + val route = routeFor(defNote, accountViewModel.account) + scope.launch { + sheetState.hide() + onDismiss() + route?.let { nav.nav(it) } + } + }, + ) + } + } + } + } +} + +@Composable +private fun BadgeSheetRow( + defNote: Note, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + val event by observeNoteEvent(defNote, accountViewModel) + val definition = event ?: return + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RenderBadgeImage( + id = definition.id, + name = definition.name(), + image = + definition.thumb()?.ifBlank { null } + ?: definition.image()?.ifBlank { null }, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.size(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = definition.name()?.ifBlank { null } ?: stringRes(R.string.badge_untitled), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + definition.description()?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun LoadDefinitionForAward( + eTag: ETag, + accountViewModel: AccountViewModel, + content: @Composable (Note) -> Unit, +) { + val awardNote = produceState( - LocalCache.getNoteIfExists(badgeAwardEvent), - badgeAwardEvent, + LocalCache.getNoteIfExists(eTag), + eTag, ) { - val newValue = LocalCache.checkGetOrCreateNote(badgeAwardEvent) + val newValue = LocalCache.checkGetOrCreateNote(eTag) if (newValue != value) { value = newValue } } - baseNote.value?.let { - ObserveAndRenderBadge(it, accountViewModel, nav) - } -} - -@Composable -private fun ObserveAndRenderBadge( - it: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - val badgeAwardState by observeNoteEvent(it, accountViewModel) - val badgeDefinitionId = badgeAwardState?.awardDefinition()?.firstOrNull() - if (badgeDefinitionId != null) { - LoadAddressableNote(badgeDefinitionId, accountViewModel) { badgeDefNote -> - badgeDefNote?.let { - BadgeThumb(it, accountViewModel, nav) + awardNote.value?.let { note -> + val awardEvent by observeNoteEvent(note, accountViewModel) + awardEvent?.awardDefinition()?.firstOrNull()?.let { defAddr -> + LoadAddressableNote(defAddr, accountViewModel) { defNote -> + defNote?.let { content(it) } } } } @@ -173,13 +354,16 @@ fun BadgeThumb( ) { Box( modifier = - Size35Modifier.clickable( - onClick = { - nav.nav { - routeFor(baseNote, accountViewModel.account) - } - }, - ), + Modifier + .size(ProfileBadgeSize) + .clip(ProfileBadgeShape) + .clickable( + onClick = { + nav.nav { + routeFor(baseNote, accountViewModel.account) + } + }, + ), ) { WatchAndRenderBadgeImage(baseNote, accountViewModel) } @@ -215,11 +399,13 @@ private fun RenderBadgeImage( stringRes(id = R.string.badge_award_image) } + val modifier = Modifier.size(ProfileBadgeSize).clip(ProfileBadgeShape) + if (image == null) { RobohashAsyncImage( robot = "badgenotfound", contentDescription = description, - modifier = BadgePictureModifier, + modifier = modifier, loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } else { @@ -227,7 +413,7 @@ private fun RenderBadgeImage( robot = id, model = image, contentDescription = description, - modifier = BadgePictureModifier, + modifier = modifier, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 99d6613fe..1615aeaa2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -448,6 +448,7 @@ Awarded to %1$d You received a badge Profile badges + Badges · %1$d Choose which of the badges you\'ve received appear on your profile. You haven\'t received any badges yet. Pictures From 55679b0a9e19274a6294940151ee8e3c2316a4f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 14:38:14 +0000 Subject: [PATCH 06/18] fix(badges): apply scaffold padding and make cards clickable BadgesScreen dropped the DisappearingScaffold's paddingValues on the floor, so the first list item hid behind the top bar and the last behind the bottom bar. Wrap the feed in Column(Modifier.padding(...)) like ArticlesScreen. BadgeCard was a bare OutlinedCard with no click target, so tapping a badge definition or award card did nothing. Thread a nullable onClick through BadgeCard; BadgeDisplay routes to the definition's thread and RenderBadgeAward routes to the award's thread. --- .../amethyst/ui/note/types/Badge.kt | 15 ++++++++++- .../ui/screen/loggedIn/badges/BadgesScreen.kt | 25 +++++++++++-------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt index fdb777a1b..4d3af6878 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -67,6 +68,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -95,6 +97,12 @@ fun BadgeDisplay( imageUrl = definition.thumb()?.ifBlank { null } ?: definition.image(), name = definition.name(), description = definition.description(), + onClick = + nav?.let { + { + routeFor(baseNote, accountViewModel.account)?.let { route -> nav.nav(route) } + } + }, ) { if (isMine && nav != null) { BadgeActionRow { @@ -150,6 +158,9 @@ fun RenderBadgeAward( imageUrl = definition?.thumb()?.ifBlank { null } ?: definition?.image(), name = definition?.name() ?: stringRes(R.string.award_granted_to), description = definition?.description(), + onClick = { + routeFor(note, accountViewModel.account)?.let { nav.nav(it) } + }, ) { if (awardees.isNotEmpty()) { BadgeAwardeesRow(awardees, accountViewModel, nav) @@ -163,10 +174,12 @@ private fun BadgeCard( imageUrl: String?, name: String?, description: String?, + onClick: (() -> Unit)? = null, actions: @Composable () -> Unit = {}, ) { + val baseModifier = Modifier.fillMaxWidth().padding(vertical = 6.dp) OutlinedCard( - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + modifier = if (onClick != null) baseModifier.clickable(onClick = onClick) else baseModifier, shape = BadgeCardShape, ) { Column(modifier = Modifier.padding(16.dp)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt index 2551d3f3c..3379f4e2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt @@ -20,9 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox @@ -77,16 +80,18 @@ fun BadgesScreen( NewBadgeButton(nav) }, accountViewModel = accountViewModel, - ) { - RefresheableBox(feedContentState, true) { - SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BADGES_SCREEN) { listState -> - RenderFeedContentState( - feedContentState = feedContentState, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = "BadgesFeed", - ) + ) { paddingValues -> + Column(Modifier.padding(paddingValues)) { + RefresheableBox(feedContentState, true) { + SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BADGES_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = feedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "BadgesFeed", + ) + } } } } From 64079d418803454565d3939e70700b841bb1b344 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:11:58 +0000 Subject: [PATCH 07/18] feat(badges/award): swap raw pubkey textarea for user search The award screen now collects awardees the same way other selection screens do (AddMemberScreen pattern): - A search OutlinedTextField wired into UserSuggestionState + ShowUserSuggestionList. Typing >2 chars triggers the existing user search pipeline. - Selecting a suggestion adds the user to a header list of selected recipients (avatar, display name, NIP-05 / pubkey), each with a Remove button. - Submit button disables until at least one recipient is picked. ViewModel reduced to definition + sendPost(awardees: List); parsedPubKeys / awardeesText state removed. --- .../loggedIn/badges/award/AwardBadgeScreen.kt | 220 +++++++++++++----- .../badges/award/AwardBadgeViewModel.kt | 25 +- amethyst/src/main/res/values/strings.xml | 6 +- 3 files changed, 175 insertions(+), 76 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt index 8f971fb70..f600bfd52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt @@ -22,34 +22,47 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.quartz.nip01Core.core.HexKey -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AwardBadgeScreen( kind: Int, @@ -64,8 +77,19 @@ fun AwardBadgeScreen( vm.init(accountViewModel, kind, pubKeyHex, dTag) } + var searchInput by remember { mutableStateOf("") } + val selectedUsers = remember { mutableStateListOf() } + + val userSuggestions = + remember { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + + DisposableEffect(Unit) { + onDispose { userSuggestions.reset() } + } + BackHandler { - vm.cancel() nav.popBack() } @@ -73,43 +97,85 @@ fun AwardBadgeScreen( topBar = { SavingTopBar( titleRes = R.string.award_badge, - isActive = vm::canPost, - onCancel = { - vm.cancel() - nav.popBack() - }, + isActive = { vm.definition != null && selectedUsers.isNotEmpty() }, + onCancel = { nav.popBack() }, onPost = { + val toAward = selectedUsers.toList() accountViewModel.launchSigner { - vm.sendPost() + vm.sendPost(toAward) nav.popBack() } }, ) }, - ) { pad -> - Surface( + ) { padding -> + Column( modifier = Modifier - .padding(pad) - .consumeWindowInsets(pad) + .padding(padding) + .consumeWindowInsets(padding) .imePadding(), ) { - AwardBadgeBody(vm) + BadgeSummary(vm) + + HorizontalDivider() + + if (selectedUsers.isNotEmpty()) { + selectedUsers.toList().forEachIndexed { index, user -> + SelectedUserRow( + user = user, + accountViewModel = accountViewModel, + nav = nav, + onClear = { selectedUsers.remove(user) }, + ) + if (index < selectedUsers.lastIndex) HorizontalDivider() + } + HorizontalDivider() + } + + OutlinedTextField( + value = searchInput, + onValueChange = { newValue -> + searchInput = newValue + if (newValue.length > 2) { + userSuggestions.processCurrentWord(newValue) + } else { + userSuggestions.reset() + } + }, + label = { Text(stringRes(R.string.award_badge_search_label)) }, + placeholder = { Text(stringRes(R.string.award_badge_search_placeholder)) }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + if (searchInput.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + if (selectedUsers.none { it.pubkeyHex == user.pubkeyHex }) { + selectedUsers.add(user) + } + searchInput = "" + userSuggestions.reset() + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightPage, + ) + } } } } @Composable -private fun AwardBadgeBody(vm: AwardBadgeViewModel) { - val scrollState = rememberScrollState() - - Column( - Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(16.dp), - ) { - val def = vm.definition +private fun BadgeSummary(vm: AwardBadgeViewModel) { + val def = vm.definition + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { if (def == null) { Text( text = stringRes(R.string.award_badge_loading), @@ -118,32 +184,82 @@ private fun AwardBadgeBody(vm: AwardBadgeViewModel) { } else { Text( text = def.name() ?: def.dTag(), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, ) - Spacer(modifier = Modifier.height(4.dp)) - def.description()?.let { - Text(it, style = MaterialTheme.typography.bodyMedium) + def.description()?.takeIf { it.isNotBlank() }?.let { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun SelectedUserRow( + user: User, + accountViewModel: AccountViewModel, + nav: INav, + onClear: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture( + userHex = user.pubkeyHex, + size = 40.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Column( + modifier = Modifier.weight(1f).padding(start = 12.dp), + ) { + Text( + text = user.toBestDisplayName(), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + UserSecondaryLine(user) + } + TextButton(onClick = onClear) { + Text(stringRes(R.string.award_badge_remove_recipient)) + } + } +} + +@Composable +private fun UserSecondaryLine(user: User) { + val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle() + + val text = + when (val state = nip05StateMetadata) { + is Nip05State.Exists -> { + val name = state.nip05.name + if (name == "_") state.nip05.domain else "$name@${state.nip05.domain}" + } + + else -> { + user.pubkeyDisplayHex() } } - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = vm.awardeesText, - onValueChange = { vm.awardeesText = it }, - label = { Text(stringRes(R.string.award_badge_recipients_label)) }, - placeholder = { Text(stringRes(R.string.award_badge_recipients_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - minLines = 4, - maxLines = 10, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - val parsed = vm.parsedPubKeys() - Text( - text = stringRes(R.string.award_badge_recipient_count, parsed.size), - style = MaterialTheme.typography.bodySmall, - ) - } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt index cdef93f67..3a2b2d5b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt @@ -24,15 +24,14 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent @Stable @@ -41,7 +40,6 @@ class AwardBadgeViewModel : ViewModel() { lateinit var account: Account var definition by mutableStateOf(null) - var awardeesText by mutableStateOf(TextFieldValue("")) fun init( accountVM: AccountViewModel, @@ -57,26 +55,11 @@ class AwardBadgeViewModel : ViewModel() { definition = ev } - fun parsedPubKeys(): List = - awardeesText.text - .split('\n', ',', ' ', ';') - .mapNotNull { raw -> - val trimmed = raw.trim() - if (trimmed.isEmpty()) null else decodePublicKeyAsHexOrNull(trimmed) - }.distinct() - - fun canPost(): Boolean = definition != null && parsedPubKeys().isNotEmpty() - - fun cancel() { - awardeesText = TextFieldValue("") - } - - suspend fun sendPost() { + suspend fun sendPost(awardees: List) { val def = definition ?: return - val awardees = parsedPubKeys().map { PTag(it) } if (awardees.isEmpty()) return - account.sendBadgeAward(def, awardees) - cancel() + val pTags = awardees.map { PTag(it.pubkeyHex) } + account.sendBadgeAward(def, pTags) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1615aeaa2..d50010c0e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -441,9 +441,9 @@ Thumbnail URL (optional) https://example.com/badge-thumb.png Loading badge… - Recipients (npub or hex, one per line) - npub1…\nnpub1… - %1$d recipient(s) will receive this badge + Search users + Name, npub, or NIP-05 + Remove Untitled badge Awarded to %1$d You received a badge From bc8edf95239ec65a44674e40e10c1dab674b6f89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:20:22 +0000 Subject: [PATCH 08/18] feat(badges/profile): live updates and dedicated relay subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfileBadgesScreen used to compute the received-awards list once via a plain remember and never refresh it; new awards landing in LocalCache were invisible until the user navigated away and back. There was also no relay subscription dedicated to back-filling award history — we relied on the always-on notifications subscription bounded by `since`, so older awards never arrived. - New ProfileBadgesFilterAssembler / SubAssembler / Subscription that, while the screen is mounted, queries kind 8 with `#p`=me on the user's notification relays (limit 500, no since) so the full history flows in. - Registered as `profileBadges` in RelaySubscriptionsCoordinator. - ProfileBadgesScreen now collects LocalCache.live.newEventBundles in a LaunchedEffect, bumping a tick whenever a bundle contains a BadgeAwardEvent for me. The receivedAwards remember keys on that tick, so newly arrived awards appear without leaving the screen. - AwardRow now resolves the badge definition via LoadAddressableNote + observeNoteEvent + EventFinderFilterAssemblerSubscription so each row re-renders when the linked kind 30009 lands in cache (and asks relays for it if missing). The Switch is disabled until the definition is available. --- .../RelaySubscriptionsCoordinator.kt | 3 + .../badges/profile/ProfileBadgesScreen.kt | 65 +++++++++++++++++-- .../datasource/FilterReceivedBadgeAwards.kt | 53 +++++++++++++++ .../ProfileBadgesFilterAssembler.kt | 46 +++++++++++++ ...rofileBadgesFilterAssemblerSubscription.kt | 36 ++++++++++ .../datasource/ProfileBadgesSubAssembler.kt | 42 ++++++++++++ 6 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/FilterReceivedBadgeAwards.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesSubAssembler.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index c315587fe..3d70480bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinder import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler @@ -99,6 +100,7 @@ class RelaySubscriptionsCoordinator( val longs = LongsFilterAssembler(client) val articles = ArticlesFilterAssembler(client) val badges = BadgesFilterAssembler(client) + val profileBadges = ProfileBadgesFilterAssembler(client) // active when sending zaps via NWC val nwc = NWCPaymentFilterAssembler(client) @@ -117,6 +119,7 @@ class RelaySubscriptionsCoordinator( longs, articles, badges, + profileBadges, channelFinder, eventFinder, userFinder, 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 d5c02bc07..ab219fe1d 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 @@ -36,8 +36,11 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -49,15 +52,21 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +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.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun ProfileBadgesScreen( @@ -66,6 +75,10 @@ fun ProfileBadgesScreen( ) { val myPubkey = accountViewModel.userProfile().pubkeyHex + // Pull every kind 8 award tagging me from the user's notification relays so + // historic awards land in cache while this screen is open. + ProfileBadgesFilterAssemblerSubscription(accountViewModel) + val newNote = accountViewModel.getOrCreateAddressableNote(ProfileBadgesEvent.createAddress(myPubkey)) val oldNote = accountViewModel.getOrCreateAddressableNote(AcceptedBadgeSetEvent.createAddress(myPubkey)) @@ -86,8 +99,24 @@ fun ProfileBadgesScreen( .toSet() } + // Tick whenever LocalCache emits a bundle that contains an award addressed + // to me, so the snapshot below recomputes and the new award appears. + var bundleTick by remember { mutableIntStateOf(0) } + LaunchedEffect(myPubkey) { + launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { bundle -> + val touchesMe = + bundle.any { note -> + val ev = note.event + ev is BadgeAwardEvent && ev.awardeeIds().contains(myPubkey) + } + if (touchesMe) bundleTick++ + } + } + } + val receivedAwards = - remember(myPubkey, newState, oldState) { + remember(myPubkey, bundleTick) { LocalCache.notes .filterIntoSet { _, it -> val event = it.event @@ -143,11 +172,36 @@ private fun AwardRow( accountViewModel: AccountViewModel, ) { val defAddr = award.awardDefinition().firstOrNull() - val definition = - remember(award.id) { - defAddr?.let { LocalCache.getAddressableNoteIfExists(it)?.event as? BadgeDefinitionEvent } - } + if (defAddr == null) { + StaticAwardRow(definition = null, isAccepted = isAccepted, accountViewModel = accountViewModel, award = award) + return + } + + LoadAddressableNote(defAddr, accountViewModel) { defNote -> + if (defNote == null) { + StaticAwardRow(definition = null, isAccepted = isAccepted, accountViewModel = accountViewModel, award = award) + } 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, + isAccepted = isAccepted, + accountViewModel = accountViewModel, + award = award, + ) + } + } +} + +@Composable +private fun StaticAwardRow( + definition: BadgeDefinitionEvent?, + isAccepted: Boolean, + accountViewModel: AccountViewModel, + award: BadgeAwardEvent, +) { Row( modifier = Modifier @@ -182,6 +236,7 @@ private fun AwardRow( Switch( checked = isAccepted, + enabled = definition != null, onCheckedChange = { checked -> accountViewModel.launchSigner { if (checked) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/FilterReceivedBadgeAwards.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/FilterReceivedBadgeAwards.kt new file mode 100644 index 000000000..4212caab4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/FilterReceivedBadgeAwards.kt @@ -0,0 +1,53 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.profile.datasource + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent + +/** + * Pulls every badge award (kind 8) tagging this pubkey, from the user's + * notification relays. Used by the "Profile badges" management screen to + * back-fill the full history of awards (the always-on notifications + * subscription is bounded by `since`, so older awards may not be in + * cache yet). + */ +fun filterReceivedBadgeAwards( + pubkey: HexKey, + relays: Set, +): List { + if (pubkey.isEmpty() || relays.isEmpty()) return emptyList() + val pTags = listOf(pubkey) + return relays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(BadgeAwardEvent.KIND), + tags = mapOf("p" to pTags), + limit = 500, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt new file mode 100644 index 000000000..02892b5a2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt @@ -0,0 +1,46 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.profile.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +class ProfileBadgesQueryState( + val account: Account, +) + +@Stable +class ProfileBadgesFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + ProfileBadgesSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssemblerSubscription.kt new file mode 100644 index 000000000..c0ecc05a1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssemblerSubscription.kt @@ -0,0 +1,36 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.profile.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun ProfileBadgesFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + val state = + remember(accountViewModel.account) { + ProfileBadgesQueryState(accountViewModel.account) + } + + KeyDataSourceSubscription(state, accountViewModel.dataSources().profileBadges) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesSubAssembler.kt new file mode 100644 index 000000000..b50260b1d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesSubAssembler.kt @@ -0,0 +1,42 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.profile.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class ProfileBadgesSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: ProfileBadgesQueryState) = key.account.userProfile() + + override fun updateFilter( + key: ProfileBadgesQueryState, + since: SincePerRelayMap?, + ): List = + filterReceivedBadgeAwards( + pubkey = user(key).pubkeyHex, + relays = key.account.notificationRelays.flow.value, + ) +} From 93cf9f1d6e12a6905d10f5be981e1eb6e69c6c10 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:40:30 +0000 Subject: [PATCH 09/18] refactor(badges): drop OutlinedCard chrome from feed items Each badge was wrapped in its own rounded OutlinedCard, which reads as an out-of-place boxed widget in the feed since every other feed item is a flat row separated by the standard HorizontalDivider drawn by FeedLoaded. Replace the Card with a plain Column + clickable + padding. The feed's own divider handles item separation and the UI now matches Notes, Articles, Pictures, etc. --- .../amethyst/ui/note/types/Badge.kt | 53 +++++++++---------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt index 4d3af6878..f73a0930e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -39,7 +39,6 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -79,7 +78,6 @@ import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent -private val BadgeCardShape = RoundedCornerShape(12.dp) private val BadgeThumbSize = 72.dp @Composable @@ -177,41 +175,38 @@ private fun BadgeCard( onClick: (() -> Unit)? = null, actions: @Composable () -> Unit = {}, ) { - val baseModifier = Modifier.fillMaxWidth().padding(vertical = 6.dp) - OutlinedCard( - modifier = if (onClick != null) baseModifier.clickable(onClick = onClick) else baseModifier, - shape = BadgeCardShape, - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - BadgeThumbnail(imageUrl, name) + val baseModifier = Modifier.fillMaxWidth() + val clickableModifier = if (onClick != null) baseModifier.clickable(onClick = onClick) else baseModifier - Spacer(modifier = Modifier.size(14.dp)) + Column(modifier = clickableModifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + BadgeThumbnail(imageUrl, name) - Column(modifier = Modifier.weight(1f)) { - Text( - text = name?.ifBlank { null } ?: stringRes(R.string.badge_untitled), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } + Spacer(modifier = Modifier.size(14.dp)) - if (!description.isNullOrBlank()) { - Spacer(modifier = Modifier.height(12.dp)) + Column(modifier = Modifier.weight(1f)) { Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 4, + text = name?.ifBlank { null } ?: stringRes(R.string.badge_untitled), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) } - - actions() } + + if (!description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + + actions() } } From ffa55a30f3c1135cc94937016638577c0ff99558 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:26:38 +0000 Subject: [PATCH 10/18] Revert "refactor(badges): drop OutlinedCard chrome from feed items" This reverts commit 93cf9f1d6e12a6905d10f5be981e1eb6e69c6c10. --- .../amethyst/ui/note/types/Badge.kt | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt index f73a0930e..4d3af6878 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -78,6 +79,7 @@ import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent +private val BadgeCardShape = RoundedCornerShape(12.dp) private val BadgeThumbSize = 72.dp @Composable @@ -175,38 +177,41 @@ private fun BadgeCard( onClick: (() -> Unit)? = null, actions: @Composable () -> Unit = {}, ) { - val baseModifier = Modifier.fillMaxWidth() - val clickableModifier = if (onClick != null) baseModifier.clickable(onClick = onClick) else baseModifier + val baseModifier = Modifier.fillMaxWidth().padding(vertical = 6.dp) + OutlinedCard( + modifier = if (onClick != null) baseModifier.clickable(onClick = onClick) else baseModifier, + shape = BadgeCardShape, + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + BadgeThumbnail(imageUrl, name) - Column(modifier = clickableModifier.padding(horizontal = 16.dp, vertical = 12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - BadgeThumbnail(imageUrl, name) + Spacer(modifier = Modifier.size(14.dp)) - Spacer(modifier = Modifier.size(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = name?.ifBlank { null } ?: stringRes(R.string.badge_untitled), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } - Column(modifier = Modifier.weight(1f)) { + if (!description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(12.dp)) Text( - text = name?.ifBlank { null } ?: stringRes(R.string.badge_untitled), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 2, + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 4, overflow = TextOverflow.Ellipsis, ) } - } - if (!description.isNullOrBlank()) { - Spacer(modifier = Modifier.height(10.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 4, - overflow = TextOverflow.Ellipsis, - ) + actions() } - - actions() } } From d1fc49dc515b57e8395a399adb13b250d386868d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:31:06 +0000 Subject: [PATCH 11/18] feat(badges): render feed items with author + reactions chrome Revert of 93cf9f1 restored the BadgeCard chrome that works well when embedded inside another NoteCompose (e.g. a BadgeAwardEvent's body). But the badge feed was still bypassing NoteCompose's author header and ReactionsRow because BadgeDefinitionEvent was hoisted out of CheckNewAndRenderNote up at the top of NoteCompose. Move BadgeDefinitionEvent into RenderNoteRow next to BadgeAwardEvent so it flows through the same chrome path. Feed items now get: - the author avatar / name / timestamp header - the existing BadgeDisplay card body - the standard ReactionsRow (reply / repost / zap / like) Other call sites of BadgeDisplay are untouched: - RenderBadgeAward still embeds BadgeDisplay as a child card - BadgeCompose notifications still embed BadgeDisplay - DisplayBadges profile strip uses BadgeThumb, not BadgeDisplay --- .../vitorpamplona/amethyst/ui/note/NoteCompose.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index b406352da..a869d042b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -396,10 +396,6 @@ fun AcceptableNote( } } - is BadgeDefinitionEvent -> { - BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav) - } - else -> { LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, nav) { showPopup -> CheckNewAndRenderNote( @@ -454,10 +450,6 @@ fun AcceptableNote( } } - is BadgeDefinitionEvent -> { - BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav) - } - else -> { LongPressToQuickAction(baseNote, accountViewModel, nav) { showPopup -> CheckNewAndRenderNote( @@ -938,6 +930,10 @@ private fun RenderNoteRow( RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav) } + is BadgeDefinitionEvent -> { + BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav) + } + is LnZapEvent -> { RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) } From d942a624ebeee44a0bf63c2b1884a67fbc6ff3de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:00:06 +0000 Subject: [PATCH 12/18] 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, "") } } } From 6dd373556ea4b704a3bca45024a6e9d095c5547a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:31:10 +0000 Subject: [PATCH 13/18] fix(badges): stop losing accepted-set entries on rapid toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two interacting bugs were still eating badges when the user toggled switches in the profile-badges settings page: 1. TimeUtils.now() returns seconds, and LocalCache.consumeBaseReplaceable only accepts an update whose createdAt is strictly greater than the one already stored. Two toggles within the same second produced equal-timestamp events, so the second was silently dropped from the cache — the UI reverted after a beat and the change looked like it had never happened. 2. launchSigner coroutines run on Dispatchers.IO so two concurrent toggles could both read the same pre-state, each write its own fragment, and whichever landed last clobbered the other. Wrap the read-modify-write with a Mutex and bump the outgoing createdAt to maxOf(now, latestCachedCreatedAt + 1) so every write is strictly newer than whatever sits in cache. The sign + local consume happen under the lock; network publish stays outside it. --- .../vitorpamplona/amethyst/model/Account.kt | 58 ++++++++++++++----- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 428dbdb1a..0ed223b28 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -239,6 +239,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.containsAny import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -251,6 +252,8 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.math.BigDecimal import kotlin.coroutines.cancellation.CancellationException @@ -1146,6 +1149,27 @@ class Account( return oldEvent?.acceptedBadges() ?: emptyList() } + /** + * Serializes read-modify-write of the accepted-badges replaceable event so two + * rapid toggles can't race each other into losing updates. + */ + private val profileBadgesMutex = Mutex() + + /** + * Returns a createdAt strictly greater than whatever ProfileBadgesEvent (or + * the legacy AcceptedBadgeSetEvent) currently sits in cache. Needed because + * LocalCache.consumeBaseReplaceable drops updates whose createdAt isn't + * strictly greater, and TimeUtils.now() has only second resolution. + */ + private fun nextProfileBadgesCreatedAt(): Long { + val latest = + maxOf( + (cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))?.event?.createdAt) ?: 0L, + (cache.getAddressableNoteIfExists(AcceptedBadgeSetEvent.createAddress(signer.pubKey))?.event?.createdAt) ?: 0L, + ) + return maxOf(TimeUtils.now(), latest + 1) + } + suspend fun addAcceptedBadge( award: BadgeAwardEvent, definition: BadgeDefinitionEvent, @@ -1155,30 +1179,36 @@ class Account( val aTag = ATag(definition.kind, definition.pubKey, definition.dTag(), null) val eTag = ETag(award.id) - val current = loadCurrentAcceptedBadges() - val alreadyAccepted = current.any { it.badgeAward.eventId == award.id } - if (alreadyAccepted) return + val signedEvent = + profileBadgesMutex.withLock { + val current = loadCurrentAcceptedBadges() + if (current.any { it.badgeAward.eventId == award.id }) return + val updated = current + AcceptedBadge(aTag, eTag) - val updated = current + AcceptedBadge(aTag, eTag) + val template = ProfileBadgesEvent.build(updated, createdAt = nextProfileBadgesCreatedAt()) + val signed = signer.sign(template) + cache.justConsumeMyOwnEvent(signed) + signed + } - val template = ProfileBadgesEvent.build(updated) - val signedEvent = signer.sign(template) - - cache.justConsumeMyOwnEvent(signedEvent) client.publish(signedEvent, outboxRelays.flow.value) } suspend fun removeAcceptedBadge(award: BadgeAwardEvent) { if (!isWriteable()) return - val current = loadCurrentAcceptedBadges() - val updated = current.filterNot { it.badgeAward.eventId == award.id } - if (updated.size == current.size) return + val signedEvent = + profileBadgesMutex.withLock { + val current = loadCurrentAcceptedBadges() + val updated = current.filterNot { it.badgeAward.eventId == award.id } + if (updated.size == current.size) return - val template = ProfileBadgesEvent.build(updated) - val signedEvent = signer.sign(template) + val template = ProfileBadgesEvent.build(updated, createdAt = nextProfileBadgesCreatedAt()) + val signed = signer.sign(template) + cache.justConsumeMyOwnEvent(signed) + signed + } - cache.justConsumeMyOwnEvent(signedEvent) client.publish(signedEvent, outboxRelays.flow.value) } From d2ddedc87159d3c08e4dfc33384722c3509d3ad9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:49:09 +0000 Subject: [PATCH 14/18] fix(badges/profile): freeze list order across Switch toggles The accepted-first sort was keyed on acceptedAwardIds, so every toggle changed that set and the list jumped around. Key the remember on a "loaded" flag (first time either the 10008 or the legacy 30008 event arrives) plus the existing bundle tick; acceptedAwardIds is read from the enclosing scope at the moment of recomputation but isn't part of the key. Result: the list loads with accepted badges on top, new awards flowing in during the session trigger a re-sort, but plain Switch toggles leave the visual order intact. --- .../badges/profile/ProfileBadgesScreen.kt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 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 c4492435b..516984277 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 @@ -117,16 +117,26 @@ fun ProfileBadgesScreen( } } + // "loaded" flips once when either profile-badges event arrives in cache, so + // we re-sort exactly once after the initial load. Toggling the Switch below + // doesn't change it, so the list stays in place while the user edits. + val acceptedDataLoaded = + newState.note.event != null || oldState.note.event != null + val receivedAwards = - remember(myPubkey, bundleTick, acceptedAwardIds) { + remember(myPubkey, bundleTick, acceptedDataLoaded) { + // acceptedAwardIds is captured from the enclosing scope at this + // recomputation point; it is NOT part of the remember key, so + // subsequent toggles don't re-run this sort. + val initialAccepted = acceptedAwardIds LocalCache.notes .filterIntoSet { _, it -> val event = it.event event is BadgeAwardEvent && event.awardeeIds().contains(myPubkey) }.mapNotNull { it.event as? BadgeAwardEvent } .sortedWith( - // Accepted badges on top, then most recent first. - compareByDescending { acceptedAwardIds.contains(it.id) } + // Accepted badges on top (at load time), then most recent first. + compareByDescending { initialAccepted.contains(it.id) } .thenByDescending { it.createdAt }, ) } From a346ae86de81896d46d08bed639f56967f0b0e59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 20:20:55 +0000 Subject: [PATCH 15/18] refactor(badges): default Badges feed filter to Mine --- .../java/com/vitorpamplona/amethyst/model/AccountSettings.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index bb1476e36..21224db39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -177,7 +177,7 @@ class AccountSettings( val defaultShortsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultLongsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultArticlesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), - val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), + val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, From 29518dbf1926e6a6ff8f02667de2b90716f9ed40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 20:53:24 +0000 Subject: [PATCH 16/18] feat(badges/new): image-first creation flow with upload + auto UUID Replace the form-first create screen with a picker-first media pipeline that matches the other upload screens in the app. - NewBadgeButton (FAB, AddPhotoAlternate icon) opens GallerySelect directly. - After the image is picked, NewBadgeDialog mirrors the NewMediaView / ImageVideoPost pattern: a thumbnail strip, name + description fields, server picker, compression-quality slider, and strip-metadata switch. - NewBadgeModel drives the upload through the shared MultiOrchestrator. Only on a successful upload does it reach into Account.sendBadgeDefinition with an auto-generated UUID d-tag, the uploaded URL + dimensions, and the uploaded URL reused as the NIP-58 thumb (a separate thumbnail upload can land as a follow-up). The Cancel / Post buttons use the CreatingTopBar so "Create" reads right on the primary action. Submit is disabled until the name is non-empty, an image is staged, and a server is selected. Drops the old route-based NewBadgeScreen / NewBadgeViewModel and the Route.NewBadge entry. --- .../amethyst/ui/navigation/AppNavigation.kt | 2 - .../amethyst/ui/navigation/routes/Routes.kt | 4 - .../ui/screen/loggedIn/badges/BadgesScreen.kt | 2 +- .../screen/loggedIn/badges/NewBadgeButton.kt | 46 ++- .../loggedIn/badges/post/NewBadgeDialog.kt | 287 ++++++++++++++++++ .../loggedIn/badges/post/NewBadgeModel.kt | 196 ++++++++++++ .../loggedIn/badges/post/NewBadgeScreen.kt | 163 ---------- .../loggedIn/badges/post/NewBadgeViewModel.kt | 107 ------- 8 files changed, 524 insertions(+), 283 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index efbf4541d..f7a069125 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -66,7 +66,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.ProfileBadgesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen @@ -220,7 +219,6 @@ fun BuildNavigation( composableFromEnd { PollsScreen(accountViewModel, nav) } composableFromEnd { BadgesScreen(accountViewModel, nav) } composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } - composableFromBottomArgs { NewBadgeScreen(it.editDTag, accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } composableFromEnd { ProductsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 6a870f2bf..b9d6ba509 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -49,10 +49,6 @@ sealed class Route { @Serializable object ProfileBadges : Route() - @Serializable data class NewBadge( - val editDTag: String? = null, - ) : Route() - @Serializable data class AwardBadge( val kind: Int, val pubKeyHex: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt index 3379f4e2c..19cf4ed9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt @@ -77,7 +77,7 @@ fun BadgesScreen( } }, floatingButton = { - NewBadgeButton(nav) + NewBadgeButton(accountViewModel) }, accountViewModel = accountViewModel, ) { paddingValues -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt index 64c858db7..55d365f7e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt @@ -22,29 +22,63 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.filled.AddPhotoAlternate import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Composable -fun NewBadgeButton(nav: INav) { +fun NewBadgeButton(accountViewModel: AccountViewModel) { + var wantsToPickImage by remember { mutableStateOf(false) } + var pickedMedia by remember { mutableStateOf>(persistentListOf()) } + + val postViewModel: NewBadgeModel = viewModel() + + if (wantsToPickImage) { + GallerySelect( + onImageUri = { uris -> + wantsToPickImage = false + // We only need the first picked image for a badge. + pickedMedia = if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf() + }, + ) + } + + if (pickedMedia.isNotEmpty()) { + NewBadgeDialog( + uris = pickedMedia, + onClose = { pickedMedia = persistentListOf() }, + postViewModel = postViewModel, + accountViewModel = accountViewModel, + ) + } + FloatingActionButton( - onClick = { nav.nav(Route.NewBadge()) }, + onClick = { wantsToPickImage = true }, modifier = Size55Modifier, shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, ) { Icon( - imageVector = Icons.Outlined.Add, + imageVector = Icons.Default.AddPhotoAlternate, contentDescription = stringRes(id = R.string.new_badge), modifier = Size26Modifier, tint = Color.White, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt new file mode 100644 index 000000000..ab2c0ff8c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt @@ -0,0 +1,287 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.post + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery +import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge +import com.vitorpamplona.amethyst.ui.components.TextSpinner +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewBadgeDialog( + uris: ImmutableList, + onClose: () -> Unit, + postViewModel: NewBadgeModel, + accountViewModel: AccountViewModel, +) { + val account = accountViewModel.account + val context = LocalContext.current + + val scrollState = rememberScrollState() + + LaunchedEffect(uris) { + postViewModel.load(account, uris) + } + + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) + + Dialog( + onDismissRequest = onClose, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), + ) { + SetDialogToEdgeToEdge() + Scaffold( + topBar = { + CreatingTopBar( + titleRes = R.string.new_badge, + isActive = postViewModel::canPost, + onCancel = { + postViewModel.cancelModel() + onClose() + }, + onPost = { + postViewModel.upload( + context, + onSuccess = onClose, + onError = accountViewModel.toastManager::toast, + ) + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = 10.dp, vertical = 10.dp), + ) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + ) { + BadgeImageForm(postViewModel, accountViewModel) + } + } + } + } + } +} + +@Composable +private fun BadgeImageForm( + postViewModel: NewBadgeModel, + accountViewModel: AccountViewModel, +) { + val fileServers by accountViewModel.account.blossomServers.hostNameFlow + .collectAsState() + + val fileServerOptions = + remember(fileServers) { + fileServers + .map { TitleExplainer(it.name, it.baseUrl) } + .toImmutableList() + } + + postViewModel.multiOrchestrator?.let { + ShowImageUploadGallery( + it, + // Only one item expected; removing via UI would orphan the dialog. + // Ignore deletes — Cancel clears state via cancelModel(). + onDelete = { }, + accountViewModel = accountViewModel, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = postViewModel.name, + onValueChange = { postViewModel.name = it }, + label = { Text(stringRes(R.string.badge_name_label)) }, + placeholder = { + Text( + text = stringRes(R.string.badge_name_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = postViewModel.description, + onValueChange = { postViewModel.description = it }, + label = { Text(stringRes(R.string.badge_description_label)) }, + placeholder = { + Text( + text = stringRes(R.string.badge_description_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .height(120.dp), + minLines = 2, + maxLines = 6, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + SettingsRow(R.string.file_server, R.string.file_server_description) { + TextSpinner( + label = "", + placeholder = + fileServers + .firstOrNull { it == accountViewModel.account.settings.defaultFileServer } + ?.name + ?: fileServers.firstOrNull()?.name + ?: DEFAULT_MEDIA_SERVERS[0].name, + options = fileServerOptions, + onSelect = { postViewModel.selectedServer = fileServers[it] }, + ) + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + Text( + text = stringRes(R.string.media_compression_quality_label), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringRes(R.string.media_compression_quality_explainer), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + ) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.fillMaxWidth()) { + Text( + text = + when (postViewModel.mediaQualitySlider) { + 0 -> stringRes(R.string.media_compression_quality_low) + 1 -> stringRes(R.string.media_compression_quality_medium) + 2 -> stringRes(R.string.media_compression_quality_high) + 3 -> stringRes(R.string.media_compression_quality_uncompressed) + else -> stringRes(R.string.media_compression_quality_medium) + }, + modifier = Modifier.align(Alignment.Center), + ) + } + + Slider( + value = postViewModel.mediaQualitySlider.toFloat(), + onValueChange = { postViewModel.mediaQualitySlider = it.toInt() }, + valueRange = 0f..3f, + steps = 2, + ) + } + + SettingSwitchItem( + title = R.string.strip_metadata_label, + description = R.string.strip_metadata_description, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + checked = postViewModel.stripMetadata, + onCheckedChange = { postViewModel.stripMetadata = it }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt new file mode 100644 index 000000000..f69654412 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt @@ -0,0 +1,196 @@ +/* + * 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.amethyst.ui.screen.loggedIn.badges.post + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.UUID + +/** + * Drives the "new badge" creation flow: uploads the user-picked image (at the + * chosen server / compression), then publishes a NIP-58 BadgeDefinitionEvent + * (kind 30009) with an auto-generated UUID d-tag, the uploaded URL as both + * `image` and `thumb`, and the user-provided name / description. + * + * Intentionally does NOT publish the event unless the image upload succeeds — + * otherwise we'd announce a badge that references a URL that doesn't exist. + */ +@Stable +class NewBadgeModel : ViewModel() { + var account: Account? = null + + var isUploading by mutableStateOf(false) + + var selectedServer by mutableStateOf(null) + var name by mutableStateOf("") + var description by mutableStateOf("") + + var multiOrchestrator by mutableStateOf(null) + + val strippingFailureConfirmation = SuspendableConfirmation() + + // 0 = Low, 1 = Medium, 2 = High, 3 = UNCOMPRESSED + var mediaQualitySlider by mutableIntStateOf(1) + + var stripMetadata by mutableStateOf(true) + + var onceUploaded: () -> Unit = {} + + fun load( + account: Account, + uris: ImmutableList, + ) { + this.account = account + this.multiOrchestrator = MultiOrchestrator(uris) + this.selectedServer = defaultServer() + this.stripMetadata = account.settings.stripLocationOnUpload + this.name = "" + this.description = "" + } + + fun canPost(): Boolean = + !isUploading && + multiOrchestrator != null && + selectedServer != null && + name.isNotBlank() + + fun upload( + context: Context, + onSuccess: () -> Unit, + onError: (String, String) -> Unit, + ) = try { + uploadUnsafe(context, onSuccess, onError) + } catch (e: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + private fun uploadUnsafe( + context: Context, + onSuccess: () -> Unit, + onError: (String, String) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val myAccount = account ?: return@launch + val serverToUse = selectedServer ?: return@launch + val orch = multiOrchestrator ?: return@launch + + isUploading = true + + val results = + orch.upload( + alt = name, + contentWarningReason = null, + mediaQuality = MediaCompressor.intToCompressorQuality(mediaQualitySlider), + server = serverToUse, + account = myAccount, + context = context, + useH265 = false, + stripMetadata = stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) + + if (!results.allGood) { + val messages = + results.errors + .map { stringRes(context, it.errorResource, *it.params) } + .distinct() + .joinToString(".\n") + onError(stringRes(context, R.string.failed_to_upload_media_no_details), messages) + isUploading = false + return@launch + } + + val uploaded = + results.successful.firstNotNullOfOrNull { + it.result as? UploadOrchestrator.OrchestratorResult.ServerResult + } + + if (uploaded == null) { + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + "Upload succeeded but no image URL was returned by the server.", + ) + isUploading = false + return@launch + } + + val imageUrl = uploaded.url + val dimensions = uploaded.fileHeader.dim + + myAccount.sendBadgeDefinition( + badgeId = UUID.randomUUID().toString(), + name = name.trim(), + imageUrl = imageUrl, + imageDim = dimensions, + description = description.trim().ifBlank { null }, + // NIP-58 thumb is optional; we emit it pointing at the same + // uploaded asset so clients that only honor the thumb tag also + // see the badge image. + thumbs = listOf(ThumbTag(imageUrl, dimensions)), + ) + + myAccount.settings.changeDefaultFileServer(serverToUse) + myAccount.settings.changeStripLocationOnUpload(stripMetadata) + + onSuccess() + onceUploaded() + cancelModel() + } + } + + fun cancelModel() { + multiOrchestrator = null + isUploading = false + name = "" + description = "" + selectedServer = defaultServer() + } + + fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0] + + fun onceUploaded(action: () -> Unit) { + this.onceUploaded = action + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt deleted file mode 100644 index ac8b6f4cb..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt +++ /dev/null @@ -1,163 +0,0 @@ -/* - * 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.amethyst.ui.screen.loggedIn.badges.post - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NewBadgeScreen( - editDTag: String?, - accountViewModel: AccountViewModel, - nav: INav, -) { - val vm: NewBadgeViewModel = viewModel() - - LaunchedEffect(accountViewModel, editDTag) { - vm.init(accountViewModel, editDTag) - } - - BackHandler { - vm.cancel() - nav.popBack() - } - - Scaffold( - topBar = { - PostingTopBar( - titleRes = if (vm.isEdit) R.string.edit_badge else R.string.new_badge, - isActive = vm::canPost, - onCancel = { - vm.cancel() - nav.popBack() - }, - onPost = { - accountViewModel.launchSigner { - vm.sendPost() - nav.popBack() - } - }, - ) - }, - ) { pad -> - Surface( - modifier = - Modifier - .padding(pad) - .consumeWindowInsets(pad) - .imePadding(), - ) { - NewBadgeBody(vm) - } - } -} - -@Composable -private fun NewBadgeBody(vm: NewBadgeViewModel) { - val scrollState = rememberScrollState() - - Column( - Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(16.dp), - ) { - OutlinedTextField( - value = vm.badgeId, - onValueChange = { vm.badgeId = it }, - label = { Text(stringRes(R.string.badge_id_label)) }, - placeholder = { Text(stringRes(R.string.badge_id_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - enabled = !vm.isEdit, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = vm.name, - onValueChange = { vm.name = it }, - label = { Text(stringRes(R.string.badge_name_label)) }, - placeholder = { Text(stringRes(R.string.badge_name_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = vm.description, - onValueChange = { vm.description = it }, - label = { Text(stringRes(R.string.badge_description_label)) }, - placeholder = { Text(stringRes(R.string.badge_description_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - minLines = 2, - maxLines = 6, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = vm.imageUrl, - onValueChange = { vm.imageUrl = it }, - label = { Text(stringRes(R.string.badge_image_label)) }, - placeholder = { Text(stringRes(R.string.badge_image_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = vm.thumbUrl, - onValueChange = { vm.thumbUrl = it }, - label = { Text(stringRes(R.string.badge_thumb_label)) }, - placeholder = { Text(stringRes(R.string.badge_thumb_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt deleted file mode 100644 index 1470fdca1..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * 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.amethyst.ui.screen.loggedIn.badges.post - -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue -import androidx.lifecycle.ViewModel -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent - -@Stable -class NewBadgeViewModel : ViewModel() { - lateinit var accountViewModel: AccountViewModel - lateinit var account: Account - - var badgeId by mutableStateOf(TextFieldValue("")) - var name by mutableStateOf(TextFieldValue("")) - var description by mutableStateOf(TextFieldValue("")) - var imageUrl by mutableStateOf(TextFieldValue("")) - var thumbUrl by mutableStateOf(TextFieldValue("")) - - var isEdit by mutableStateOf(false) - - fun init( - accountVM: AccountViewModel, - editDTag: String?, - ) { - this.accountViewModel = accountVM - this.account = accountVM.account - - if (editDTag.isNullOrBlank()) return - - val existing = - LocalCache - .getAddressableNoteIfExists( - Address(BadgeDefinitionEvent.KIND, account.signer.pubKey, editDTag), - )?.event as? BadgeDefinitionEvent ?: return - - isEdit = true - badgeId = TextFieldValue(existing.dTag()) - name = TextFieldValue(existing.name() ?: "") - description = TextFieldValue(existing.description() ?: "") - imageUrl = TextFieldValue(existing.image() ?: "") - thumbUrl = TextFieldValue(existing.thumb() ?: "") - } - - fun canPost(): Boolean = badgeId.text.isNotBlank() && name.text.isNotBlank() - - fun cancel() { - badgeId = TextFieldValue("") - name = TextFieldValue("") - description = TextFieldValue("") - imageUrl = TextFieldValue("") - thumbUrl = TextFieldValue("") - isEdit = false - } - - suspend fun sendPost() { - if (!canPost()) return - - val thumb = thumbUrl.text.ifBlank { null } - val thumbs = - if (thumb != null) { - listOf( - com.vitorpamplona.quartz.nip58Badges.definition.tags - .ThumbTag(thumb), - ) - } else { - emptyList() - } - - account.sendBadgeDefinition( - badgeId = badgeId.text.trim(), - name = name.text.trim(), - imageUrl = imageUrl.text.ifBlank { null }, - imageDim = null, - description = description.text.ifBlank { null }, - thumbs = thumbs, - ) - - cancel() - } -} From 8eeeae9601e599f009f5712822ef40cb762f174f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 21:20:38 +0000 Subject: [PATCH 17/18] refactor(badges/new): show form first with an upload placeholder The FAB now opens the new-badge dialog directly. The dialog renders a big bordered "Upload an image" placeholder where the picture will go; tapping it opens the gallery. Once the user picks an image, the placeholder is replaced by the existing ShowImageUploadGallery preview and tapping the preview lets them pick a different image. Lets the user see the whole form (name, description, server, quality, strip-metadata) immediately instead of being thrown into the picker the moment they hit the FAB. --- .../screen/loggedIn/badges/NewBadgeButton.kt | 30 +---- .../loggedIn/badges/post/NewBadgeDialog.kt | 120 +++++++++++++++--- .../loggedIn/badges/post/NewBadgeModel.kt | 13 ++ amethyst/src/main/res/values/strings.xml | 2 + 4 files changed, 122 insertions(+), 43 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt index 55d365f7e..209be6fe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AddPhotoAlternate import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -34,51 +32,35 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf @Composable fun NewBadgeButton(accountViewModel: AccountViewModel) { - var wantsToPickImage by remember { mutableStateOf(false) } - var pickedMedia by remember { mutableStateOf>(persistentListOf()) } - + var showDialog by remember { mutableStateOf(false) } val postViewModel: NewBadgeModel = viewModel() - if (wantsToPickImage) { - GallerySelect( - onImageUri = { uris -> - wantsToPickImage = false - // We only need the first picked image for a badge. - pickedMedia = if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf() - }, - ) - } - - if (pickedMedia.isNotEmpty()) { + if (showDialog) { NewBadgeDialog( - uris = pickedMedia, - onClose = { pickedMedia = persistentListOf() }, + onClose = { showDialog = false }, postViewModel = postViewModel, accountViewModel = accountViewModel, ) } FloatingActionButton( - onClick = { wantsToPickImage = true }, + onClick = { showDialog = true }, modifier = Size55Modifier, shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, ) { Icon( - imageVector = Icons.Default.AddPhotoAlternate, + painter = painterRes(R.drawable.ic_compose, 5), contentDescription = stringRes(id = R.string.new_badge), modifier = Size26Modifier, tint = Color.White, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt index ab2c0ff8c..6f3a40f90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt @@ -20,20 +20,28 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddPhotoAlternate import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -44,12 +52,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -57,7 +69,7 @@ import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.components.TextSpinner @@ -69,13 +81,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalMaterial3Api::class) @Composable fun NewBadgeDialog( - uris: ImmutableList, onClose: () -> Unit, postViewModel: NewBadgeModel, accountViewModel: AccountViewModel, @@ -85,12 +96,25 @@ fun NewBadgeDialog( val scrollState = rememberScrollState() - LaunchedEffect(uris) { - postViewModel.load(account, uris) + LaunchedEffect(account) { + postViewModel.init(account) } StrippingFailureDialog(postViewModel.strippingFailureConfirmation) + var wantsToPickImage by remember { mutableStateOf(false) } + + if (wantsToPickImage) { + GallerySelect( + onImageUri = { uris -> + wantsToPickImage = false + postViewModel.setPickedMedia( + if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf(), + ) + }, + ) + } + Dialog( onDismissRequest = onClose, properties = @@ -137,7 +161,15 @@ fun NewBadgeDialog( .fillMaxWidth() .verticalScroll(scrollState), ) { - BadgeImageForm(postViewModel, accountViewModel) + BadgeImagePicker( + postViewModel = postViewModel, + accountViewModel = accountViewModel, + onPickImage = { wantsToPickImage = true }, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + BadgeFormFields(postViewModel, accountViewModel) } } } @@ -146,7 +178,69 @@ fun NewBadgeDialog( } @Composable -private fun BadgeImageForm( +private fun BadgeImagePicker( + postViewModel: NewBadgeModel, + accountViewModel: AccountViewModel, + onPickImage: () -> Unit, +) { + if (postViewModel.hasPickedImage()) { + postViewModel.multiOrchestrator?.let { + // Tap the preview to swap to a different image. + Box(modifier = Modifier.clickable(onClick = onPickImage)) { + ShowImageUploadGallery( + list = it, + onDelete = { postViewModel.setPickedMedia(persistentListOf()) }, + accountViewModel = accountViewModel, + ) + } + } + } else { + UploadPlaceholder(onClick = onPickImage) + } +} + +@Composable +private fun UploadPlaceholder(onClick: () -> Unit) { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(12.dp), + ).clickable(onClick = onClick) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.AddPhotoAlternate, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringRes(R.string.badge_upload_image_cta), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringRes(R.string.badge_upload_image_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun BadgeFormFields( postViewModel: NewBadgeModel, accountViewModel: AccountViewModel, ) { @@ -160,18 +254,6 @@ private fun BadgeImageForm( .toImmutableList() } - postViewModel.multiOrchestrator?.let { - ShowImageUploadGallery( - it, - // Only one item expected; removing via UI would orphan the dialog. - // Ignore deletes — Cancel clears state via cancelModel(). - onDelete = { }, - accountViewModel = accountViewModel, - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( value = postViewModel.name, onValueChange = { postViewModel.name = it }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt index f69654412..1c33781fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt @@ -75,6 +75,13 @@ class NewBadgeModel : ViewModel() { var onceUploaded: () -> Unit = {} + fun init(account: Account) { + if (this.account == account) return + this.account = account + this.selectedServer = defaultServer() + this.stripMetadata = account.settings.stripLocationOnUpload + } + fun load( account: Account, uris: ImmutableList, @@ -87,6 +94,12 @@ class NewBadgeModel : ViewModel() { this.description = "" } + fun setPickedMedia(uris: ImmutableList) { + this.multiOrchestrator = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null + } + + fun hasPickedImage(): Boolean = multiOrchestrator != null + fun canPost(): Boolean = !isUploading && multiOrchestrator != null && diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d50010c0e..43f49356d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -440,6 +440,8 @@ https://example.com/badge.png Thumbnail URL (optional) https://example.com/badge-thumb.png + Upload an image + Pick a square image to be the face of your badge. Loading badge… Search users Name, npub, or NIP-05 From 35cab28543fd5d139be30c7bc38bd6de0c4e6e7e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 21:35:04 +0000 Subject: [PATCH 18/18] feat(badges): add accept controls to notification card The notification card BadgeCompose rendered the awarded badge via BadgeDisplay (definition-only) and stopped there, so a recipient viewing a fresh badge in their inbox had no way to add it to their profile without navigating to the award's thread first. Expose AcceptBadgeControls (was private inside Badge.kt) and call it from BadgeCompose under the BadgeDisplay row when the underlying note is a BadgeAwardEvent. Other surfaces are unchanged: - RenderBadgeAward (Badge feed / threads) keeps its own call to the same composable; behaviour is identical because the controls were already package-private. - BadgeCompose has only one caller (CardFeedView, the notifications feed), so the new row only appears there. --- .../java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt | 6 ++++++ .../java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index 338eb71b6..01a3c83e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -46,11 +46,13 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton +import com.vitorpamplona.amethyst.ui.note.types.AcceptBadgeControls import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.BadgeCard import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent @OptIn(ExperimentalFoundationApi::class) @Composable @@ -128,6 +130,10 @@ fun BadgeCompose( note.replyTo?.firstOrNull()?.let { BadgeDisplay(baseNote = it, accountViewModel) } + + (note.event as? BadgeAwardEvent)?.let { award -> + AcceptBadgeControls(award, accountViewModel) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt index 4d3af6878..509c82511 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -297,7 +297,7 @@ private fun BadgeAwardeesRow( } @Composable -private fun AcceptBadgeControls( +fun AcceptBadgeControls( award: BadgeAwardEvent, accountViewModel: AccountViewModel, ) {