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.
This commit is contained in:
Claude
2026-04-18 23:45:26 +00:00
parent 7440f28405
commit 7a8dc02394
22 changed files with 585 additions and 362 deletions
@@ -459,6 +459,9 @@ class Account(
val liveArticlesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultArticlesFollowList) val liveArticlesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultArticlesFollowList)
val liveArticlesFollowListsPerRelay = OutboxLoaderState(liveArticlesFollowLists, cache, scope).flow val liveArticlesFollowListsPerRelay = OutboxLoaderState(liveArticlesFollowLists, cache, scope).flow
val liveBadgesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultBadgesFollowList)
val liveBadgesFollowListsPerRelay = OutboxLoaderState(liveBadgesFollowLists, cache, scope).flow
override fun isWriteable(): Boolean = settings.isWriteable() override fun isWriteable(): Boolean = settings.isWriteable()
suspend fun updateWarnReports(warnReports: Boolean): Boolean { suspend fun updateWarnReports(warnReports: Boolean): Boolean {
@@ -125,6 +125,9 @@ sealed class TopFilter(
@Serializable @Serializable
object Chess : TopFilter(" Chess ") object Chess : TopFilter(" Chess ")
@Serializable
object Mine : TopFilter(" Mine ")
@Serializable @Serializable
class PeopleList( class PeopleList(
val address: Address, val address: Address,
@@ -174,6 +177,7 @@ class AccountSettings(
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global), val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global), val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows), val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()), val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null), val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
var hideDeleteRequestDialog: Boolean = false, 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 // language services
// --- // ---
@@ -89,6 +89,10 @@ class FeedTopNavFilterState(
ChessFeedFlow(followsRelays, proxyRelays) ChessFeedFlow(followsRelays, proxyRelays)
} }
TopFilter.Mine -> {
AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays)
}
is TopFilter.Community -> { is TopFilter.Community -> {
NoteFeedFlow( NoteFeedFlow(
LocalCache LocalCache
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.utils.mapOfSet import com.vitorpamplona.quartz.utils.mapOfSet
@@ -49,6 +50,7 @@ val RepliesAndReactionsToAddressesKinds1 =
ZapPollEvent.KIND, ZapPollEvent.KIND,
CommentEvent.KIND, CommentEvent.KIND,
AttestationEvent.KIND, AttestationEvent.KIND,
BadgeAwardEvent.KIND,
) )
val PostsAndChatMessagesToAddresses = val PostsAndChatMessagesToAddresses =
@@ -58,10 +58,7 @@ object ScrollStateKeys {
const val POLLS_SCREEN = "PollsFeed" const val POLLS_SCREEN = "PollsFeed"
const val POLLS_OPEN = "PollsOpenFeed" const val POLLS_OPEN = "PollsOpenFeed"
const val POLLS_CLOSED = "PollsClosedFeed" const val POLLS_CLOSED = "PollsClosedFeed"
const val BADGES_RECEIVED = "BadgesReceivedFeed" const val BADGES_SCREEN = "BadgesFeed"
const val BADGES_MINE = "BadgesMineFeed"
const val BADGES_AWARDED = "BadgesAwardedFeed"
const val BADGES_DISCOVER = "BadgesDiscoverFeed"
const val PICTURES_SCREEN = "PicturesFeed" const val PICTURES_SCREEN = "PicturesFeed"
const val PRODUCTS_SCREEN = "ProductsFeed" const val PRODUCTS_SCREEN = "ProductsFeed"
const val SHORTS_SCREEN = "ShortsFeed" const val SHORTS_SCREEN = "ShortsFeed"
@@ -77,7 +74,6 @@ object PagerStateKeys {
const val HOME_SCREEN = "PagerHome" const val HOME_SCREEN = "PagerHome"
const val DISCOVER_SCREEN = "PagerDiscover" const val DISCOVER_SCREEN = "PagerDiscover"
const val POLLS_SCREEN = "PagerPolls" const val POLLS_SCREEN = "PagerPolls"
const val BADGES_SCREEN = "PagerBadges"
} }
@Composable @Composable
@@ -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.BadgesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen 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.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.default.BookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen
@@ -218,6 +219,7 @@ fun BuildNavigation(
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) } composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) } composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.NewBadge> { NewBadgeScreen(it.editDTag, accountViewModel, nav) } composableFromBottomArgs<Route.NewBadge> { NewBadgeScreen(it.editDTag, accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) } composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
@@ -47,6 +47,8 @@ sealed class Route {
@Serializable object Badges : Route() @Serializable object Badges : Route()
@Serializable object ProfileBadges : Route()
@Serializable data class NewBadge( @Serializable data class NewBadge(
val editDTag: String? = null, val editDTag: String? = null,
) : Route() ) : Route()
@@ -96,6 +96,12 @@ class TopNavFilterState(
name = ResourceName(R.string.follow_list_chess), 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) val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)
fun mergePeopleLists( 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 = private val _kind3GlobalPeople =
livePeopleListsFlow.transform { peopleLists -> livePeopleListsFlow.transform { peopleLists ->
checkNotInMainThread() checkNotInMainThread()
@@ -233,6 +251,11 @@ class TopNavFilterState(
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, defaultLists) .stateIn(scope, SharingStarted.Eagerly, defaultLists)
val badgeRoutes =
_badgeRoutes
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow))
fun destroy() { fun destroy() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
} }
@@ -28,10 +28,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFilter 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.BadgesFeedFilter
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.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter
@@ -85,10 +82,7 @@ class AccountFeedContentStates(
val openPollsFeed = FeedContentState(OpenPollsFeedFilter(account), scope, LocalCache) val openPollsFeed = FeedContentState(OpenPollsFeedFilter(account), scope, LocalCache)
val closedPollsFeed = FeedContentState(ClosedPollsFeedFilter(account), scope, LocalCache) val closedPollsFeed = FeedContentState(ClosedPollsFeedFilter(account), scope, LocalCache)
val badgesReceived = FeedContentState(BadgesReceivedFeedFilter(account), scope, LocalCache) val badgesFeed = FeedContentState(BadgesFeedFilter(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 picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache) val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
@@ -134,10 +128,7 @@ class AccountFeedContentStates(
openPollsFeed.updateFeedWith(newNotes) openPollsFeed.updateFeedWith(newNotes)
closedPollsFeed.updateFeedWith(newNotes) closedPollsFeed.updateFeedWith(newNotes)
badgesReceived.updateFeedWith(newNotes) badgesFeed.updateFeedWith(newNotes)
badgesMine.updateFeedWith(newNotes)
badgesAwarded.updateFeedWith(newNotes)
badgesDiscover.updateFeedWith(newNotes)
picturesFeed.updateFeedWith(newNotes) picturesFeed.updateFeedWith(newNotes)
productsFeed.updateFeedWith(newNotes) productsFeed.updateFeedWith(newNotes)
@@ -177,10 +168,7 @@ class AccountFeedContentStates(
openPollsFeed.deleteFromFeed(newNotes) openPollsFeed.deleteFromFeed(newNotes)
closedPollsFeed.deleteFromFeed(newNotes) closedPollsFeed.deleteFromFeed(newNotes)
badgesReceived.deleteFromFeed(newNotes) badgesFeed.deleteFromFeed(newNotes)
badgesMine.deleteFromFeed(newNotes)
badgesAwarded.deleteFromFeed(newNotes)
badgesDiscover.deleteFromFeed(newNotes)
picturesFeed.deleteFromFeed(newNotes) picturesFeed.deleteFromFeed(newNotes)
productsFeed.deleteFromFeed(newNotes) productsFeed.deleteFromFeed(newNotes)
@@ -20,143 +20,54 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges 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.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel 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.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription 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 @Composable
fun BadgesScreen( fun BadgesScreen(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val feedStates = accountViewModel.feedStates BadgesScreen(
feedContentState = accountViewModel.feedStates.badgesFeed,
WatchLifecycleAndUpdateModel(feedStates.badgesReceived) accountViewModel = accountViewModel,
WatchLifecycleAndUpdateModel(feedStates.badgesMine) nav = nav,
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 @Composable
private fun AssembleBadgesTabs( fun BadgesScreen(
received: FeedContentState, feedContentState: FeedContentState,
mine: FeedContentState,
awarded: FeedContentState,
discover: FeedContentState,
inner: @Composable (PagerState, ImmutableList<BadgesTabItem>) -> 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<BadgesTabItem>,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
WatchLifecycleAndUpdateModel(feedContentState)
WatchAccountForBadgesScreen(feedContentState, accountViewModel)
BadgesFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold( DisappearingScaffold(
isInvertedLayout = false, isInvertedLayout = false,
topBar = { topBar = {
Column { BadgesTopBar(accountViewModel, nav)
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 = { bottomBar = {
AppBottomBar(Route.Badges, accountViewModel) { route -> AppBottomBar(Route.Badges, accountViewModel) { route ->
if (route == Route.Badges) { if (route == Route.Badges) {
tabs[pagerState.currentPage].feedState.sendToTop() feedContentState.sendToTop()
} else { } else {
nav.newStack(route) nav.newStack(route)
} }
@@ -167,30 +78,31 @@ private fun BadgesPages(
}, },
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) { ) {
HorizontalPager( RefresheableBox(feedContentState, true) {
contentPadding = it, SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BADGES_SCREEN) { listState ->
state = pagerState, RenderFeedContentState(
userScrollEnabled = true, feedContentState = feedContentState,
) { page -> accountViewModel = accountViewModel,
RefresheableBox(tabs[page].feedState, true) { listState = listState,
SaveableFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState -> nav = nav,
RenderFeedContentState( routeForLastRead = "BadgesFeed",
feedContentState = tabs[page].feedState, )
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = tabs[page].routeForLastRead,
)
}
} }
} }
} }
} }
@Immutable @Composable
class BadgesTabItem( fun WatchAccountForBadgesScreen(
val resource: Int, feedContentState: FeedContentState,
val feedState: FeedContentState, accountViewModel: AccountViewModel,
val routeForLastRead: String, ) {
val scrollStateKey: String, val listState by accountViewModel.account.liveBadgesFollowLists.collectAsStateWithLifecycle()
) val hiddenUsers =
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
feedContentState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -20,11 +20,16 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R 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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar 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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -34,6 +39,32 @@ fun BadgesTopBar(
nav: INav, nav: INav,
) { ) {
UserDrawerSearchTopBar(accountViewModel, nav) { 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,
)
}
@@ -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<Note>() {
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<Note> {
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<Note>): Set<Note> {
val me = myPubkey()
return newItems.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is BadgeAwardEvent && noteEvent.pubKey == me
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -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<Note>() {
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<Note> {
val notes =
LocalCache.addressables.filterIntoSet { _, it ->
val noteEvent = it.event
noteEvent is BadgeDefinitionEvent && !isHidden(noteEvent.pubKey)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> =
newItems.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is BadgeDefinitionEvent && !isHidden(noteEvent.pubKey)
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -23,38 +23,71 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note 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.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
class BadgesMineFeedFilter( class BadgesFeedFilter(
val account: Account, val account: Account,
) : AdditiveFeedFilter<Note>() { ) : AdditiveFeedFilter<Note>() {
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 private fun myPubkey(): String = account.userProfile().pubkeyHex
override fun feed(): List<Note> { override fun feed(): List<Note> {
val me = myPubkey()
val notes = val notes =
LocalCache.addressables.filterIntoSet { _, it -> if (followList() == TopFilter.Mine) {
val noteEvent = it.event val me = myPubkey()
noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me 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) return sort(notes)
} }
override fun applyFilter(newItems: Set<Note>): Set<Note> { override fun applyFilter(newItems: Set<Note>): Set<Note> {
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()) { return newItems.filterTo(HashSet()) {
val noteEvent = it.event 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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder) override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
} }
@@ -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<Note>() {
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<Note> {
val notes =
LocalCache.notes.filterIntoSet { _, it ->
val noteEvent = it.event
noteEvent is BadgeAwardEvent && awardsMe(noteEvent)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> =
newItems.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is BadgeAwardEvent && awardsMe(noteEvent)
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -23,10 +23,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class BadgesQueryState( class BadgesQueryState(
val account: Account, val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
) )
@Stable @Stable
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -40,7 +41,7 @@ fun BadgesFilterAssemblerSubscription(
) { ) {
val state = val state =
remember(accountViewModel.account) { remember(accountViewModel.account) {
BadgesQueryState(accountViewModel.account) BadgesQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
} }
KeyDataSourceSubscription(state, dataSource) KeyDataSourceSubscription(state, dataSource)
@@ -20,19 +20,84 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource 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.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter 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( class BadgesSubAssembler(
client: INostrClient, client: INostrClient,
allKeys: () -> Set<BadgesQueryState>, allKeys: () -> Set<BadgesQueryState>,
) : PerUserEoseManager<BadgesQueryState>(client, allKeys) { ) : PerUserAndFollowListEoseManager<BadgesQueryState, TopFilter>(client, allKeys) {
override fun updateFilter( override fun updateFilter(
key: BadgesQueryState, key: BadgesQueryState,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter> = filterMyBadges(user(key), since) ): List<RelayBasedFilter> {
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 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<User, List<Job>>()
@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() }
}
} }
@@ -20,41 +20,130 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource 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.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.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/** private const val BADGE_FEED_LIMIT = 100
* Subscribes to:
* - Badge definitions (kind 30009) authored by me covers "Mine" tab. fun makeBadgesFilter(
* - Badge awards (kind 8) authored by me covers "Awarded" tab. feedSettings: IFeedTopNavPerRelayFilterSet,
* since: SincePerRelayMap?,
* Received badges (kind 8 with `#p`=me) are already pulled via the standard defaultSince: Long? = null,
* notifications subscription (FilterNotificationsToPubkey), so we do not ): List<RelayBasedFilter> =
* duplicate that here. when (feedSettings) {
*/ is AllFollowsTopNavPerRelayFilterSet -> filterBadgesByFollows(feedSettings, since, defaultSince)
fun filterMyBadges( is AuthorsTopNavPerRelayFilterSet -> filterBadgesByAuthors(feedSettings, since, defaultSince)
user: User, is MutedAuthorsTopNavPerRelayFilterSet -> filterBadgesByMutedAuthors(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterBadgesGlobal(feedSettings, since, defaultSince)
else -> emptyList()
}
fun filterBadgesMine(
pubkey: HexKey,
relays: Set<NormalizedRelayUrl>,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter> { ): List<RelayBasedFilter> {
val relays = if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
user.outboxRelays()?.ifEmpty { null } val authors = listOf(pubkey)
?: user.allUsedRelaysOrNull()
?: return emptyList()
return relays.map { relay -> return relays.map { relay ->
RelayBasedFilter( RelayBasedFilter(
relay = relay, relay = relay,
filter = filter =
Filter( Filter(
kinds = listOf(BadgeDefinitionEvent.KIND, BadgeAwardEvent.KIND), kinds = listOf(BadgeDefinitionEvent.KIND),
authors = listOf(user.pubkeyHex), authors = authors,
limit = 500, limit = BADGE_FEED_LIMIT,
since = since?.get(relay)?.time, since = since?.get(relay)?.time,
), ),
) )
} }
} }
private fun filterBadgesByAuthorsOnRelay(
relay: NormalizedRelayUrl,
authors: Set<HexKey>,
since: Long? = null,
): List<RelayBasedFilter> {
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<RelayBasedFilter> {
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<RelayBasedFilter> {
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<RelayBasedFilter> {
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<RelayBasedFilter> {
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,
),
)
}
}
@@ -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,
)
}
}
@@ -36,6 +36,7 @@ import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.GroupAdd import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Key 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.Phone
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Security import androidx.compose.material.icons.outlined.Security
@@ -122,6 +123,13 @@ fun AllSettingsScreen(
onClick = { nav.nav(Route.EditMediaServers) }, onClick = { nav.nav(Route.EditMediaServers) },
) )
HorizontalDivider() HorizontalDivider()
SettingsNavigationRow(
title = R.string.profile_badges_title,
icon = Icons.Outlined.MilitaryTech,
tint = tint,
onClick = { nav.nav(Route.ProfileBadges) },
)
HorizontalDivider()
SettingsNavigationRow( SettingsNavigationRow(
title = R.string.reactions, title = R.string.reactions,
icon = Icons.Outlined.FavoriteBorder, icon = Icons.Outlined.FavoriteBorder,
+4
View File
@@ -447,6 +447,9 @@
<string name="badge_untitled">Untitled badge</string> <string name="badge_untitled">Untitled badge</string>
<string name="badge_awardees_label">Awarded to %1$d</string> <string name="badge_awardees_label">Awarded to %1$d</string>
<string name="badge_award_received_title">You received a badge</string> <string name="badge_award_received_title">You received a badge</string>
<string name="profile_badges_title">Profile badges</string>
<string name="profile_badges_description">Choose which of the badges you\'ve received appear on your profile.</string>
<string name="profile_badges_empty">You haven\'t received any badges yet.</string>
<string name="pictures">Pictures</string> <string name="pictures">Pictures</string>
<string name="shorts">Shorts</string> <string name="shorts">Shorts</string>
<string name="longs">Videos</string> <string name="longs">Videos</string>
@@ -652,6 +655,7 @@
<string name="follow_list_aroundme">Around Me</string> <string name="follow_list_aroundme">Around Me</string>
<string name="follow_list_global">Global</string> <string name="follow_list_global">Global</string>
<string name="follow_list_chess">Chess</string> <string name="follow_list_chess">Chess</string>
<string name="follow_list_mine">Mine</string>
<string name="follow_list_mute_list">Mute List</string> <string name="follow_list_mute_list">Mute List</string>
<string name="follow_sets">Follow Lists</string> <string name="follow_sets">Follow Lists</string>