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:
@@ -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
|
||||
// ---
|
||||
|
||||
+4
@@ -89,6 +89,10 @@ class FeedTopNavFilterState(
|
||||
ChessFeedFlow(followsRelays, proxyRelays)
|
||||
}
|
||||
|
||||
TopFilter.Mine -> {
|
||||
AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays)
|
||||
}
|
||||
|
||||
is TopFilter.Community -> {
|
||||
NoteFeedFlow(
|
||||
LocalCache
|
||||
|
||||
+2
@@ -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}" }
|
||||
}
|
||||
|
||||
+4
-16
@@ -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)
|
||||
|
||||
+38
-126
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+33
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
-60
@@ -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)
|
||||
}
|
||||
-59
@@ -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)
|
||||
}
|
||||
+43
-10
@@ -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)
|
||||
}
|
||||
-59
@@ -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)
|
||||
}
|
||||
+4
@@ -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
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+68
-3
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
+110
-21
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+219
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user