From 7a8dc0239462acee42ac16afa08929eccc63f7e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 23:45:26 +0000 Subject: [PATCH] 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