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 liveArticlesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultArticlesFollowList)
|
||||||
val liveArticlesFollowListsPerRelay = OutboxLoaderState(liveArticlesFollowLists, cache, scope).flow
|
val liveArticlesFollowListsPerRelay = OutboxLoaderState(liveArticlesFollowLists, cache, scope).flow
|
||||||
|
|
||||||
|
val liveBadgesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultBadgesFollowList)
|
||||||
|
val liveBadgesFollowListsPerRelay = OutboxLoaderState(liveBadgesFollowLists, cache, scope).flow
|
||||||
|
|
||||||
override fun isWriteable(): Boolean = settings.isWriteable()
|
override fun isWriteable(): Boolean = settings.isWriteable()
|
||||||
|
|
||||||
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
|
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ sealed class TopFilter(
|
|||||||
@Serializable
|
@Serializable
|
||||||
object Chess : TopFilter(" Chess ")
|
object Chess : TopFilter(" Chess ")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
object Mine : TopFilter(" Mine ")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class PeopleList(
|
class PeopleList(
|
||||||
val address: Address,
|
val address: Address,
|
||||||
@@ -174,6 +177,7 @@ class AccountSettings(
|
|||||||
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||||
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||||
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||||
|
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||||
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
||||||
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
|
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
|
||||||
var hideDeleteRequestDialog: Boolean = false,
|
var hideDeleteRequestDialog: Boolean = false,
|
||||||
@@ -503,6 +507,17 @@ class AccountSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun changeDefaultBadgesFollowList(name: FeedDefinition) {
|
||||||
|
changeDefaultBadgesFollowList(name.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeDefaultBadgesFollowList(name: TopFilter) {
|
||||||
|
if (defaultBadgesFollowList.value != name) {
|
||||||
|
defaultBadgesFollowList.tryEmit(name)
|
||||||
|
saveAccountSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---
|
// ---
|
||||||
// language services
|
// language services
|
||||||
// ---
|
// ---
|
||||||
|
|||||||
+4
@@ -89,6 +89,10 @@ class FeedTopNavFilterState(
|
|||||||
ChessFeedFlow(followsRelays, proxyRelays)
|
ChessFeedFlow(followsRelays, proxyRelays)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TopFilter.Mine -> {
|
||||||
|
AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays)
|
||||||
|
}
|
||||||
|
|
||||||
is TopFilter.Community -> {
|
is TopFilter.Community -> {
|
||||||
NoteFeedFlow(
|
NoteFeedFlow(
|
||||||
LocalCache
|
LocalCache
|
||||||
|
|||||||
+2
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
|||||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
|
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ val RepliesAndReactionsToAddressesKinds1 =
|
|||||||
ZapPollEvent.KIND,
|
ZapPollEvent.KIND,
|
||||||
CommentEvent.KIND,
|
CommentEvent.KIND,
|
||||||
AttestationEvent.KIND,
|
AttestationEvent.KIND,
|
||||||
|
BadgeAwardEvent.KIND,
|
||||||
)
|
)
|
||||||
|
|
||||||
val PostsAndChatMessagesToAddresses =
|
val PostsAndChatMessagesToAddresses =
|
||||||
|
|||||||
@@ -58,10 +58,7 @@ object ScrollStateKeys {
|
|||||||
const val POLLS_SCREEN = "PollsFeed"
|
const val POLLS_SCREEN = "PollsFeed"
|
||||||
const val POLLS_OPEN = "PollsOpenFeed"
|
const val POLLS_OPEN = "PollsOpenFeed"
|
||||||
const val POLLS_CLOSED = "PollsClosedFeed"
|
const val POLLS_CLOSED = "PollsClosedFeed"
|
||||||
const val BADGES_RECEIVED = "BadgesReceivedFeed"
|
const val BADGES_SCREEN = "BadgesFeed"
|
||||||
const val BADGES_MINE = "BadgesMineFeed"
|
|
||||||
const val BADGES_AWARDED = "BadgesAwardedFeed"
|
|
||||||
const val BADGES_DISCOVER = "BadgesDiscoverFeed"
|
|
||||||
const val PICTURES_SCREEN = "PicturesFeed"
|
const val PICTURES_SCREEN = "PicturesFeed"
|
||||||
const val PRODUCTS_SCREEN = "ProductsFeed"
|
const val PRODUCTS_SCREEN = "ProductsFeed"
|
||||||
const val SHORTS_SCREEN = "ShortsFeed"
|
const val SHORTS_SCREEN = "ShortsFeed"
|
||||||
@@ -77,7 +74,6 @@ object PagerStateKeys {
|
|||||||
const val HOME_SCREEN = "PagerHome"
|
const val HOME_SCREEN = "PagerHome"
|
||||||
const val DISCOVER_SCREEN = "PagerDiscover"
|
const val DISCOVER_SCREEN = "PagerDiscover"
|
||||||
const val POLLS_SCREEN = "PagerPolls"
|
const val POLLS_SCREEN = "PagerPolls"
|
||||||
const val BADGES_SCREEN = "PagerBadges"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.ProfileBadgesScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.BookmarkListScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.BookmarkListScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen
|
||||||
@@ -218,6 +219,7 @@ fun BuildNavigation(
|
|||||||
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
|
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) }
|
||||||
|
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
|
||||||
composableFromBottomArgs<Route.NewBadge> { NewBadgeScreen(it.editDTag, accountViewModel, nav) }
|
composableFromBottomArgs<Route.NewBadge> { NewBadgeScreen(it.editDTag, accountViewModel, nav) }
|
||||||
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
|
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object Badges : Route()
|
@Serializable object Badges : Route()
|
||||||
|
|
||||||
|
@Serializable object ProfileBadges : Route()
|
||||||
|
|
||||||
@Serializable data class NewBadge(
|
@Serializable data class NewBadge(
|
||||||
val editDTag: String? = null,
|
val editDTag: String? = null,
|
||||||
) : Route()
|
) : Route()
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ class TopNavFilterState(
|
|||||||
name = ResourceName(R.string.follow_list_chess),
|
name = ResourceName(R.string.follow_list_chess),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val mineFollow =
|
||||||
|
FeedDefinition(
|
||||||
|
code = TopFilter.Mine,
|
||||||
|
name = ResourceName(R.string.follow_list_mine),
|
||||||
|
)
|
||||||
|
|
||||||
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)
|
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)
|
||||||
|
|
||||||
fun mergePeopleLists(
|
fun mergePeopleLists(
|
||||||
@@ -211,6 +217,18 @@ class TopNavFilterState(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val _badgeRoutes =
|
||||||
|
livePeopleListsFlow.transform { peopleLists ->
|
||||||
|
checkNotInMainThread()
|
||||||
|
emit(
|
||||||
|
listOf(
|
||||||
|
listOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow),
|
||||||
|
peopleLists,
|
||||||
|
listOf(muteListFollow),
|
||||||
|
).flatten().toImmutableList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private val _kind3GlobalPeople =
|
private val _kind3GlobalPeople =
|
||||||
livePeopleListsFlow.transform { peopleLists ->
|
livePeopleListsFlow.transform { peopleLists ->
|
||||||
checkNotInMainThread()
|
checkNotInMainThread()
|
||||||
@@ -233,6 +251,11 @@ class TopNavFilterState(
|
|||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
.stateIn(scope, SharingStarted.Eagerly, defaultLists)
|
.stateIn(scope, SharingStarted.Eagerly, defaultLists)
|
||||||
|
|
||||||
|
val badgeRoutes =
|
||||||
|
_badgeRoutes
|
||||||
|
.flowOn(Dispatchers.IO)
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow))
|
||||||
|
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-16
@@ -28,10 +28,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
|||||||
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
|
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
|
||||||
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
|
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFilter
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesAwardedFeedFilter
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesDiscoverFeedFilter
|
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesMineFeedFilter
|
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesReceivedFeedFilter
|
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter
|
||||||
@@ -85,10 +82,7 @@ class AccountFeedContentStates(
|
|||||||
val openPollsFeed = FeedContentState(OpenPollsFeedFilter(account), scope, LocalCache)
|
val openPollsFeed = FeedContentState(OpenPollsFeedFilter(account), scope, LocalCache)
|
||||||
val closedPollsFeed = FeedContentState(ClosedPollsFeedFilter(account), scope, LocalCache)
|
val closedPollsFeed = FeedContentState(ClosedPollsFeedFilter(account), scope, LocalCache)
|
||||||
|
|
||||||
val badgesReceived = FeedContentState(BadgesReceivedFeedFilter(account), scope, LocalCache)
|
val badgesFeed = FeedContentState(BadgesFeedFilter(account), scope, LocalCache)
|
||||||
val badgesMine = FeedContentState(BadgesMineFeedFilter(account), scope, LocalCache)
|
|
||||||
val badgesAwarded = FeedContentState(BadgesAwardedFeedFilter(account), scope, LocalCache)
|
|
||||||
val badgesDiscover = FeedContentState(BadgesDiscoverFeedFilter(account), scope, LocalCache)
|
|
||||||
|
|
||||||
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
|
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
|
||||||
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
|
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
|
||||||
@@ -134,10 +128,7 @@ class AccountFeedContentStates(
|
|||||||
openPollsFeed.updateFeedWith(newNotes)
|
openPollsFeed.updateFeedWith(newNotes)
|
||||||
closedPollsFeed.updateFeedWith(newNotes)
|
closedPollsFeed.updateFeedWith(newNotes)
|
||||||
|
|
||||||
badgesReceived.updateFeedWith(newNotes)
|
badgesFeed.updateFeedWith(newNotes)
|
||||||
badgesMine.updateFeedWith(newNotes)
|
|
||||||
badgesAwarded.updateFeedWith(newNotes)
|
|
||||||
badgesDiscover.updateFeedWith(newNotes)
|
|
||||||
|
|
||||||
picturesFeed.updateFeedWith(newNotes)
|
picturesFeed.updateFeedWith(newNotes)
|
||||||
productsFeed.updateFeedWith(newNotes)
|
productsFeed.updateFeedWith(newNotes)
|
||||||
@@ -177,10 +168,7 @@ class AccountFeedContentStates(
|
|||||||
openPollsFeed.deleteFromFeed(newNotes)
|
openPollsFeed.deleteFromFeed(newNotes)
|
||||||
closedPollsFeed.deleteFromFeed(newNotes)
|
closedPollsFeed.deleteFromFeed(newNotes)
|
||||||
|
|
||||||
badgesReceived.deleteFromFeed(newNotes)
|
badgesFeed.deleteFromFeed(newNotes)
|
||||||
badgesMine.deleteFromFeed(newNotes)
|
|
||||||
badgesAwarded.deleteFromFeed(newNotes)
|
|
||||||
badgesDiscover.deleteFromFeed(newNotes)
|
|
||||||
|
|
||||||
picturesFeed.deleteFromFeed(newNotes)
|
picturesFeed.deleteFromFeed(newNotes)
|
||||||
productsFeed.deleteFromFeed(newNotes)
|
productsFeed.deleteFromFeed(newNotes)
|
||||||
|
|||||||
+38
-126
@@ -20,143 +20,54 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
|
||||||
import androidx.compose.foundation.pager.PagerState
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.SecondaryTabRow
|
|
||||||
import androidx.compose.material3.Tab
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import com.vitorpamplona.amethyst.R
|
|
||||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||||
import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys
|
|
||||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||||
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||||
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
|
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
|
||||||
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
|
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
|
||||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||||
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState
|
|
||||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
|
||||||
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BadgesScreen(
|
fun BadgesScreen(
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
val feedStates = accountViewModel.feedStates
|
BadgesScreen(
|
||||||
|
feedContentState = accountViewModel.feedStates.badgesFeed,
|
||||||
WatchLifecycleAndUpdateModel(feedStates.badgesReceived)
|
accountViewModel = accountViewModel,
|
||||||
WatchLifecycleAndUpdateModel(feedStates.badgesMine)
|
nav = nav,
|
||||||
WatchLifecycleAndUpdateModel(feedStates.badgesAwarded)
|
)
|
||||||
WatchLifecycleAndUpdateModel(feedStates.badgesDiscover)
|
|
||||||
|
|
||||||
BadgesFilterAssemblerSubscription(accountViewModel)
|
|
||||||
|
|
||||||
AssembleBadgesTabs(
|
|
||||||
received = feedStates.badgesReceived,
|
|
||||||
mine = feedStates.badgesMine,
|
|
||||||
awarded = feedStates.badgesAwarded,
|
|
||||||
discover = feedStates.badgesDiscover,
|
|
||||||
) { pagerState, tabs ->
|
|
||||||
BadgesPages(pagerState, tabs, accountViewModel, nav)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AssembleBadgesTabs(
|
fun BadgesScreen(
|
||||||
received: FeedContentState,
|
feedContentState: FeedContentState,
|
||||||
mine: FeedContentState,
|
|
||||||
awarded: FeedContentState,
|
|
||||||
discover: FeedContentState,
|
|
||||||
inner: @Composable (PagerState, ImmutableList<BadgesTabItem>) -> Unit,
|
|
||||||
) {
|
|
||||||
val pagerState = rememberForeverPagerState(key = PagerStateKeys.BADGES_SCREEN) { 4 }
|
|
||||||
|
|
||||||
val tabs by
|
|
||||||
remember(received, mine, awarded, discover) {
|
|
||||||
mutableStateOf(
|
|
||||||
listOf(
|
|
||||||
BadgesTabItem(
|
|
||||||
resource = R.string.received_badges,
|
|
||||||
feedState = received,
|
|
||||||
routeForLastRead = "BadgesReceivedFeed",
|
|
||||||
scrollStateKey = ScrollStateKeys.BADGES_RECEIVED,
|
|
||||||
),
|
|
||||||
BadgesTabItem(
|
|
||||||
resource = R.string.my_badges,
|
|
||||||
feedState = mine,
|
|
||||||
routeForLastRead = "BadgesMineFeed",
|
|
||||||
scrollStateKey = ScrollStateKeys.BADGES_MINE,
|
|
||||||
),
|
|
||||||
BadgesTabItem(
|
|
||||||
resource = R.string.awarded_badges,
|
|
||||||
feedState = awarded,
|
|
||||||
routeForLastRead = "BadgesAwardedFeed",
|
|
||||||
scrollStateKey = ScrollStateKeys.BADGES_AWARDED,
|
|
||||||
),
|
|
||||||
BadgesTabItem(
|
|
||||||
resource = R.string.discover_badges,
|
|
||||||
feedState = discover,
|
|
||||||
routeForLastRead = "BadgesDiscoverFeed",
|
|
||||||
scrollStateKey = ScrollStateKeys.BADGES_DISCOVER,
|
|
||||||
),
|
|
||||||
).toImmutableList(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
inner(pagerState, tabs)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BadgesPages(
|
|
||||||
pagerState: PagerState,
|
|
||||||
tabs: ImmutableList<BadgesTabItem>,
|
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
|
WatchLifecycleAndUpdateModel(feedContentState)
|
||||||
|
WatchAccountForBadgesScreen(feedContentState, accountViewModel)
|
||||||
|
BadgesFilterAssemblerSubscription(accountViewModel)
|
||||||
|
|
||||||
DisappearingScaffold(
|
DisappearingScaffold(
|
||||||
isInvertedLayout = false,
|
isInvertedLayout = false,
|
||||||
topBar = {
|
topBar = {
|
||||||
Column {
|
BadgesTopBar(accountViewModel, nav)
|
||||||
BadgesTopBar(accountViewModel, nav)
|
|
||||||
SecondaryTabRow(
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
|
||||||
modifier = TabRowHeight,
|
|
||||||
selectedTabIndex = pagerState.currentPage,
|
|
||||||
) {
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
tabs.forEachIndexed { index, tab ->
|
|
||||||
Tab(
|
|
||||||
selected = pagerState.currentPage == index,
|
|
||||||
text = { Text(text = stringRes(tab.resource)) },
|
|
||||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
AppBottomBar(Route.Badges, accountViewModel) { route ->
|
AppBottomBar(Route.Badges, accountViewModel) { route ->
|
||||||
if (route == Route.Badges) {
|
if (route == Route.Badges) {
|
||||||
tabs[pagerState.currentPage].feedState.sendToTop()
|
feedContentState.sendToTop()
|
||||||
} else {
|
} else {
|
||||||
nav.newStack(route)
|
nav.newStack(route)
|
||||||
}
|
}
|
||||||
@@ -167,30 +78,31 @@ private fun BadgesPages(
|
|||||||
},
|
},
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
) {
|
) {
|
||||||
HorizontalPager(
|
RefresheableBox(feedContentState, true) {
|
||||||
contentPadding = it,
|
SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BADGES_SCREEN) { listState ->
|
||||||
state = pagerState,
|
RenderFeedContentState(
|
||||||
userScrollEnabled = true,
|
feedContentState = feedContentState,
|
||||||
) { page ->
|
accountViewModel = accountViewModel,
|
||||||
RefresheableBox(tabs[page].feedState, true) {
|
listState = listState,
|
||||||
SaveableFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState ->
|
nav = nav,
|
||||||
RenderFeedContentState(
|
routeForLastRead = "BadgesFeed",
|
||||||
feedContentState = tabs[page].feedState,
|
)
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
listState = listState,
|
|
||||||
nav = nav,
|
|
||||||
routeForLastRead = tabs[page].routeForLastRead,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Composable
|
||||||
class BadgesTabItem(
|
fun WatchAccountForBadgesScreen(
|
||||||
val resource: Int,
|
feedContentState: FeedContentState,
|
||||||
val feedState: FeedContentState,
|
accountViewModel: AccountViewModel,
|
||||||
val routeForLastRead: String,
|
) {
|
||||||
val scrollStateKey: String,
|
val listState by accountViewModel.account.liveBadgesFollowLists.collectAsStateWithLifecycle()
|
||||||
)
|
val hiddenUsers =
|
||||||
|
accountViewModel.account.hiddenUsers.flow
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
|
||||||
|
feedContentState.checkKeysInvalidateDataAndSendToTop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+33
-2
@@ -20,11 +20,16 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges
|
||||||
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.TopFilter
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
|
||||||
@@ -34,6 +39,32 @@ fun BadgesTopBar(
|
|||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
UserDrawerSearchTopBar(accountViewModel, nav) {
|
UserDrawerSearchTopBar(accountViewModel, nav) {
|
||||||
Text(text = stringRes(R.string.badges))
|
val list by accountViewModel.account.settings.defaultBadgesFollowList
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
BadgesTopNavFilterBar(
|
||||||
|
followListsModel = accountViewModel.feedStates.feedListOptions,
|
||||||
|
listName = list,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onChange = accountViewModel.account.settings::changeDefaultBadgesFollowList,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BadgesTopNavFilterBar(
|
||||||
|
followListsModel: TopNavFilterState,
|
||||||
|
listName: TopFilter,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
onChange: (FeedDefinition) -> Unit,
|
||||||
|
) {
|
||||||
|
val allLists by followListsModel.badgeRoutes.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
FeedFilterSpinner(
|
||||||
|
placeholderCode = listName,
|
||||||
|
explainer = stringRes(R.string.select_list_to_filter),
|
||||||
|
options = allLists,
|
||||||
|
onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) },
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
-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.Account
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.model.TopFilter
|
||||||
|
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||||
|
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||||
|
|
||||||
class BadgesMineFeedFilter(
|
class BadgesFeedFilter(
|
||||||
val account: Account,
|
val account: Account,
|
||||||
) : AdditiveFeedFilter<Note>() {
|
) : AdditiveFeedFilter<Note>() {
|
||||||
override fun feedKey(): String = "badges-mine-" + account.userProfile().pubkeyHex
|
override fun feedKey(): String = account.userProfile().pubkeyHex + "-badges-" + followList().code
|
||||||
|
|
||||||
override fun limit() = 200
|
override fun limit() = 100
|
||||||
|
|
||||||
override fun showHiddenKey(): Boolean = false
|
fun followList(): TopFilter = account.settings.defaultBadgesFollowList.value
|
||||||
|
|
||||||
|
fun TopFilter.isMuteList() = this is TopFilter.MuteList
|
||||||
|
|
||||||
|
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
|
||||||
|
|
||||||
|
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
|
||||||
|
|
||||||
|
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
|
||||||
|
|
||||||
private fun myPubkey(): String = account.userProfile().pubkeyHex
|
private fun myPubkey(): String = account.userProfile().pubkeyHex
|
||||||
|
|
||||||
override fun feed(): List<Note> {
|
override fun feed(): List<Note> {
|
||||||
val me = myPubkey()
|
|
||||||
val notes =
|
val notes =
|
||||||
LocalCache.addressables.filterIntoSet { _, it ->
|
if (followList() == TopFilter.Mine) {
|
||||||
val noteEvent = it.event
|
val me = myPubkey()
|
||||||
noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me
|
LocalCache.addressables.filterIntoSet(BadgeDefinitionEvent.KIND) { _, it ->
|
||||||
|
val noteEvent = it.event
|
||||||
|
noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val params = buildFilterParams(account)
|
||||||
|
LocalCache.addressables.filterIntoSet(BadgeDefinitionEvent.KIND) { _, it ->
|
||||||
|
val noteEvent = it.event
|
||||||
|
noteEvent is BadgeDefinitionEvent && params.match(noteEvent, it.relays)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return sort(notes)
|
return sort(notes)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun applyFilter(newItems: Set<Note>): Set<Note> {
|
override fun applyFilter(newItems: Set<Note>): Set<Note> {
|
||||||
val me = myPubkey()
|
if (followList() == TopFilter.Mine) {
|
||||||
|
val me = myPubkey()
|
||||||
|
return newItems.filterTo(HashSet()) {
|
||||||
|
val noteEvent = it.event
|
||||||
|
noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val params = buildFilterParams(account)
|
||||||
return newItems.filterTo(HashSet()) {
|
return newItems.filterTo(HashSet()) {
|
||||||
val noteEvent = it.event
|
val noteEvent = it.event
|
||||||
noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me
|
noteEvent is BadgeDefinitionEvent && params.match(noteEvent, it.relays)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun buildFilterParams(account: Account): FilterByListParams =
|
||||||
|
FilterByListParams.create(
|
||||||
|
account.liveBadgesFollowLists.value,
|
||||||
|
account.hiddenUsers.flow.value,
|
||||||
|
)
|
||||||
|
|
||||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||||
}
|
}
|
||||||
-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 androidx.compose.runtime.Stable
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
||||||
class BadgesQueryState(
|
class BadgesQueryState(
|
||||||
val account: Account,
|
val account: Account,
|
||||||
|
val feedStates: AccountFeedContentStates,
|
||||||
|
val scope: CoroutineScope,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
|
|||||||
+2
-1
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ fun BadgesFilterAssemblerSubscription(
|
|||||||
) {
|
) {
|
||||||
val state =
|
val state =
|
||||||
remember(accountViewModel.account) {
|
remember(accountViewModel.account) {
|
||||||
BadgesQueryState(accountViewModel.account)
|
BadgesQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyDataSourceSubscription(state, dataSource)
|
KeyDataSourceSubscription(state, dataSource)
|
||||||
|
|||||||
+68
-3
@@ -20,19 +20,84 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource
|
||||||
|
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
import com.vitorpamplona.amethyst.model.TopFilter
|
||||||
|
import com.vitorpamplona.amethyst.model.User
|
||||||
|
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
|
||||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.sample
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class BadgesSubAssembler(
|
class BadgesSubAssembler(
|
||||||
client: INostrClient,
|
client: INostrClient,
|
||||||
allKeys: () -> Set<BadgesQueryState>,
|
allKeys: () -> Set<BadgesQueryState>,
|
||||||
) : PerUserEoseManager<BadgesQueryState>(client, allKeys) {
|
) : PerUserAndFollowListEoseManager<BadgesQueryState, TopFilter>(client, allKeys) {
|
||||||
override fun updateFilter(
|
override fun updateFilter(
|
||||||
key: BadgesQueryState,
|
key: BadgesQueryState,
|
||||||
since: SincePerRelayMap?,
|
since: SincePerRelayMap?,
|
||||||
): List<RelayBasedFilter> = filterMyBadges(user(key), since)
|
): List<RelayBasedFilter> {
|
||||||
|
val listName = key.listName()
|
||||||
|
val defaultSince = key.feedStates.badgesFeed.lastNoteCreatedAtIfFilled()
|
||||||
|
|
||||||
|
return if (listName == TopFilter.Mine) {
|
||||||
|
val outbox = key.account.outboxRelays.flow.value
|
||||||
|
filterBadgesMine(key.account.userProfile().pubkeyHex, outbox, since)
|
||||||
|
} else {
|
||||||
|
makeBadgesFilter(key.followsPerRelay(), since, defaultSince)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun user(key: BadgesQueryState) = key.account.userProfile()
|
override fun user(key: BadgesQueryState) = key.account.userProfile()
|
||||||
|
|
||||||
|
override fun list(key: BadgesQueryState) = key.listName()
|
||||||
|
|
||||||
|
fun BadgesQueryState.listNameFlow() = account.settings.defaultBadgesFollowList
|
||||||
|
|
||||||
|
fun BadgesQueryState.listName() = listNameFlow().value
|
||||||
|
|
||||||
|
fun BadgesQueryState.followsPerRelayFlow() = account.liveBadgesFollowListsPerRelay
|
||||||
|
|
||||||
|
fun BadgesQueryState.followsPerRelay() = followsPerRelayFlow().value
|
||||||
|
|
||||||
|
val userJobMap = mutableMapOf<User, List<Job>>()
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
override fun newSub(key: BadgesQueryState): Subscription {
|
||||||
|
val user = user(key)
|
||||||
|
userJobMap[user]?.forEach { it.cancel() }
|
||||||
|
userJobMap[user] =
|
||||||
|
listOf(
|
||||||
|
key.scope.launch(Dispatchers.IO) {
|
||||||
|
key.listNameFlow().collectLatest {
|
||||||
|
invalidateFilters()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
key.scope.launch(Dispatchers.IO) {
|
||||||
|
key.followsPerRelayFlow().sample(500).collectLatest {
|
||||||
|
invalidateFilters()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
key.account.scope.launch(Dispatchers.IO) {
|
||||||
|
key.feedStates.badgesFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
|
||||||
|
invalidateFilters()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return super.newSub(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun endSub(
|
||||||
|
key: User,
|
||||||
|
subId: String,
|
||||||
|
) {
|
||||||
|
super.endSub(key, subId)
|
||||||
|
userJobMap[key]?.forEach { it.cancel() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-21
@@ -20,41 +20,130 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource
|
||||||
|
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||||
|
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||||
|
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||||
|
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||||
|
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
|
||||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
/**
|
private const val BADGE_FEED_LIMIT = 100
|
||||||
* Subscribes to:
|
|
||||||
* - Badge definitions (kind 30009) authored by me — covers "Mine" tab.
|
fun makeBadgesFilter(
|
||||||
* - Badge awards (kind 8) authored by me — covers "Awarded" tab.
|
feedSettings: IFeedTopNavPerRelayFilterSet,
|
||||||
*
|
since: SincePerRelayMap?,
|
||||||
* Received badges (kind 8 with `#p`=me) are already pulled via the standard
|
defaultSince: Long? = null,
|
||||||
* notifications subscription (FilterNotificationsToPubkey), so we do not
|
): List<RelayBasedFilter> =
|
||||||
* duplicate that here.
|
when (feedSettings) {
|
||||||
*/
|
is AllFollowsTopNavPerRelayFilterSet -> filterBadgesByFollows(feedSettings, since, defaultSince)
|
||||||
fun filterMyBadges(
|
is AuthorsTopNavPerRelayFilterSet -> filterBadgesByAuthors(feedSettings, since, defaultSince)
|
||||||
user: User,
|
is MutedAuthorsTopNavPerRelayFilterSet -> filterBadgesByMutedAuthors(feedSettings, since, defaultSince)
|
||||||
|
is GlobalTopNavPerRelayFilterSet -> filterBadgesGlobal(feedSettings, since, defaultSince)
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun filterBadgesMine(
|
||||||
|
pubkey: HexKey,
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
since: SincePerRelayMap?,
|
since: SincePerRelayMap?,
|
||||||
): List<RelayBasedFilter> {
|
): List<RelayBasedFilter> {
|
||||||
val relays =
|
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
|
||||||
user.outboxRelays()?.ifEmpty { null }
|
val authors = listOf(pubkey)
|
||||||
?: user.allUsedRelaysOrNull()
|
|
||||||
?: return emptyList()
|
|
||||||
|
|
||||||
return relays.map { relay ->
|
return relays.map { relay ->
|
||||||
RelayBasedFilter(
|
RelayBasedFilter(
|
||||||
relay = relay,
|
relay = relay,
|
||||||
filter =
|
filter =
|
||||||
Filter(
|
Filter(
|
||||||
kinds = listOf(BadgeDefinitionEvent.KIND, BadgeAwardEvent.KIND),
|
kinds = listOf(BadgeDefinitionEvent.KIND),
|
||||||
authors = listOf(user.pubkeyHex),
|
authors = authors,
|
||||||
limit = 500,
|
limit = BADGE_FEED_LIMIT,
|
||||||
since = since?.get(relay)?.time,
|
since = since?.get(relay)?.time,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun filterBadgesByAuthorsOnRelay(
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
authors: Set<HexKey>,
|
||||||
|
since: Long? = null,
|
||||||
|
): List<RelayBasedFilter> {
|
||||||
|
if (authors.isEmpty()) return emptyList()
|
||||||
|
return listOf(
|
||||||
|
RelayBasedFilter(
|
||||||
|
relay = relay,
|
||||||
|
filter =
|
||||||
|
Filter(
|
||||||
|
kinds = listOf(BadgeDefinitionEvent.KIND),
|
||||||
|
authors = authors.sorted(),
|
||||||
|
limit = BADGE_FEED_LIMIT,
|
||||||
|
since = since,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterBadgesByFollows(
|
||||||
|
followsSet: AllFollowsTopNavPerRelayFilterSet,
|
||||||
|
since: SincePerRelayMap?,
|
||||||
|
defaultSince: Long? = null,
|
||||||
|
): List<RelayBasedFilter> {
|
||||||
|
if (followsSet.set.isEmpty()) return emptyList()
|
||||||
|
return followsSet.set.flatMap {
|
||||||
|
val sinceValue = since?.get(it.key)?.time ?: defaultSince
|
||||||
|
val authors = it.value.authors
|
||||||
|
if (authors == null || authors.isEmpty()) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
filterBadgesByAuthorsOnRelay(it.key, authors, sinceValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterBadgesByAuthors(
|
||||||
|
authorSet: AuthorsTopNavPerRelayFilterSet,
|
||||||
|
since: SincePerRelayMap?,
|
||||||
|
defaultSince: Long? = null,
|
||||||
|
): List<RelayBasedFilter> {
|
||||||
|
if (authorSet.set.isEmpty()) return emptyList()
|
||||||
|
return authorSet.set.flatMap {
|
||||||
|
filterBadgesByAuthorsOnRelay(it.key, it.value.authors, since?.get(it.key)?.time ?: defaultSince)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterBadgesByMutedAuthors(
|
||||||
|
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
|
||||||
|
since: SincePerRelayMap?,
|
||||||
|
defaultSince: Long? = null,
|
||||||
|
): List<RelayBasedFilter> {
|
||||||
|
if (authorSet.set.isEmpty()) return emptyList()
|
||||||
|
return authorSet.set.flatMap {
|
||||||
|
filterBadgesByAuthorsOnRelay(it.key, it.value.authors, since?.get(it.key)?.time ?: defaultSince)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterBadgesGlobal(
|
||||||
|
relays: GlobalTopNavPerRelayFilterSet,
|
||||||
|
since: SincePerRelayMap?,
|
||||||
|
defaultSince: Long? = null,
|
||||||
|
): List<RelayBasedFilter> {
|
||||||
|
if (relays.set.isEmpty()) return emptyList()
|
||||||
|
return relays.set.map {
|
||||||
|
val sinceValue = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneMonthAgo()
|
||||||
|
RelayBasedFilter(
|
||||||
|
relay = it.key,
|
||||||
|
filter =
|
||||||
|
Filter(
|
||||||
|
kinds = listOf(BadgeDefinitionEvent.KIND),
|
||||||
|
limit = BADGE_FEED_LIMIT,
|
||||||
|
since = sinceValue,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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.GroupAdd
|
||||||
import androidx.compose.material.icons.outlined.History
|
import androidx.compose.material.icons.outlined.History
|
||||||
import androidx.compose.material.icons.outlined.Key
|
import androidx.compose.material.icons.outlined.Key
|
||||||
|
import androidx.compose.material.icons.outlined.MilitaryTech
|
||||||
import androidx.compose.material.icons.outlined.Phone
|
import androidx.compose.material.icons.outlined.Phone
|
||||||
import androidx.compose.material.icons.outlined.Search
|
import androidx.compose.material.icons.outlined.Search
|
||||||
import androidx.compose.material.icons.outlined.Security
|
import androidx.compose.material.icons.outlined.Security
|
||||||
@@ -122,6 +123,13 @@ fun AllSettingsScreen(
|
|||||||
onClick = { nav.nav(Route.EditMediaServers) },
|
onClick = { nav.nav(Route.EditMediaServers) },
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
SettingsNavigationRow(
|
||||||
|
title = R.string.profile_badges_title,
|
||||||
|
icon = Icons.Outlined.MilitaryTech,
|
||||||
|
tint = tint,
|
||||||
|
onClick = { nav.nav(Route.ProfileBadges) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
title = R.string.reactions,
|
title = R.string.reactions,
|
||||||
icon = Icons.Outlined.FavoriteBorder,
|
icon = Icons.Outlined.FavoriteBorder,
|
||||||
|
|||||||
@@ -447,6 +447,9 @@
|
|||||||
<string name="badge_untitled">Untitled badge</string>
|
<string name="badge_untitled">Untitled badge</string>
|
||||||
<string name="badge_awardees_label">Awarded to %1$d</string>
|
<string name="badge_awardees_label">Awarded to %1$d</string>
|
||||||
<string name="badge_award_received_title">You received a badge</string>
|
<string name="badge_award_received_title">You received a badge</string>
|
||||||
|
<string name="profile_badges_title">Profile badges</string>
|
||||||
|
<string name="profile_badges_description">Choose which of the badges you\'ve received appear on your profile.</string>
|
||||||
|
<string name="profile_badges_empty">You haven\'t received any badges yet.</string>
|
||||||
<string name="pictures">Pictures</string>
|
<string name="pictures">Pictures</string>
|
||||||
<string name="shorts">Shorts</string>
|
<string name="shorts">Shorts</string>
|
||||||
<string name="longs">Videos</string>
|
<string name="longs">Videos</string>
|
||||||
@@ -652,6 +655,7 @@
|
|||||||
<string name="follow_list_aroundme">Around Me</string>
|
<string name="follow_list_aroundme">Around Me</string>
|
||||||
<string name="follow_list_global">Global</string>
|
<string name="follow_list_global">Global</string>
|
||||||
<string name="follow_list_chess">Chess</string>
|
<string name="follow_list_chess">Chess</string>
|
||||||
|
<string name="follow_list_mine">Mine</string>
|
||||||
<string name="follow_list_mute_list">Mute List</string>
|
<string name="follow_list_mute_list">Mute List</string>
|
||||||
|
|
||||||
<string name="follow_sets">Follow Lists</string>
|
<string name="follow_sets">Follow Lists</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user