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 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()
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
@@ -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<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val defaultNwcWalletId: MutableStateFlow<String?> = 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
// ---
@@ -89,6 +89,10 @@ class FeedTopNavFilterState(
ChessFeedFlow(followsRelays, proxyRelays)
}
TopFilter.Mine -> {
AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays)
}
is TopFilter.Community -> {
NoteFeedFlow(
LocalCache
@@ -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 =
@@ -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
@@ -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<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.NewBadge> { NewBadgeScreen(it.editDTag, accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
@@ -47,6 +47,8 @@ sealed class Route {
@Serializable object Badges : Route()
@Serializable object ProfileBadges : Route()
@Serializable data class NewBadge(
val editDTag: String? = null,
) : Route()
@@ -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}" }
}
@@ -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)
@@ -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<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>,
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()
}
}
@@ -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,
)
}
@@ -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.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<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
override fun feed(): List<Note> {
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<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()) {
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)
}
@@ -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 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
@@ -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)
@@ -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<BadgesQueryState>,
) : PerUserEoseManager<BadgesQueryState>(client, allKeys) {
) : PerUserAndFollowListEoseManager<BadgesQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: BadgesQueryState,
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 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
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<RelayBasedFilter> =
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<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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<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.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,
+4
View File
@@ -447,6 +447,9 @@
<string name="badge_untitled">Untitled badge</string>
<string name="badge_awardees_label">Awarded to %1$d</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="shorts">Shorts</string>
<string name="longs">Videos</string>
@@ -652,6 +655,7 @@
<string name="follow_list_aroundme">Around Me</string>
<string name="follow_list_global">Global</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_sets">Follow Lists</string>