Merge pull request #2452 from vitorpamplona/claude/badge-system-amethyst-J07UM
Add comprehensive badge system with creation, awarding, and profile management
This commit is contained in:
@@ -154,8 +154,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
|
||||
@@ -199,6 +202,12 @@ import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
|
||||
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.accepted.tags.AcceptedBadge
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag
|
||||
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
|
||||
@@ -235,6 +244,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.containsAny
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
@@ -247,6 +257,8 @@ import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.math.BigDecimal
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@@ -461,6 +473,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 {
|
||||
@@ -1084,6 +1099,136 @@ class Account(
|
||||
client.publish(signedEvent, computeRelayListToBroadcast(signedEvent))
|
||||
}
|
||||
|
||||
suspend fun sendBadgeDefinition(
|
||||
badgeId: String,
|
||||
name: String?,
|
||||
imageUrl: String?,
|
||||
imageDim: DimensionTag?,
|
||||
description: String?,
|
||||
thumbs: List<ThumbTag> = emptyList(),
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val template =
|
||||
BadgeDefinitionEvent.build(
|
||||
badgeId = badgeId,
|
||||
name = name,
|
||||
imageUrl = imageUrl,
|
||||
imageDimensions = imageDim,
|
||||
description = description,
|
||||
thumbs = thumbs,
|
||||
)
|
||||
val signedEvent = signer.sign(template)
|
||||
|
||||
cache.justConsumeMyOwnEvent(signedEvent)
|
||||
client.publish(signedEvent, outboxRelays.flow.value)
|
||||
}
|
||||
|
||||
suspend fun deleteBadgeDefinition(event: BadgeDefinitionEvent) {
|
||||
if (!isWriteable()) return
|
||||
if (event.pubKey != signer.pubKey) return
|
||||
|
||||
val template = DeletionEvent.build(listOf(event))
|
||||
val signedEvent = signer.sign(template)
|
||||
|
||||
cache.justConsumeMyOwnEvent(signedEvent)
|
||||
client.publish(signedEvent, computeRelayListToBroadcast(signedEvent))
|
||||
}
|
||||
|
||||
suspend fun sendBadgeAward(
|
||||
definition: BadgeDefinitionEvent,
|
||||
awardees: List<PTag>,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
if (awardees.isEmpty()) return
|
||||
|
||||
val aTag = ATag(definition.kind, definition.pubKey, definition.dTag(), null)
|
||||
val template = BadgeAwardEvent.build(aTag, awardees)
|
||||
val signedEvent = signer.sign(template)
|
||||
|
||||
val relays =
|
||||
outboxRelays.flow.value +
|
||||
awardees
|
||||
.flatMap { cache.getOrCreateUser(it.pubKey).inboxRelays() ?: emptyList() }
|
||||
.toSet()
|
||||
|
||||
cache.justConsumeMyOwnEvent(signedEvent)
|
||||
client.publish(signedEvent, relays)
|
||||
}
|
||||
|
||||
private fun loadCurrentAcceptedBadges(): List<AcceptedBadge> {
|
||||
val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))
|
||||
val newEvent = newNote?.event as? ProfileBadgesEvent
|
||||
if (newEvent != null) return newEvent.acceptedBadges()
|
||||
|
||||
val oldNote = cache.getAddressableNoteIfExists(AcceptedBadgeSetEvent.createAddress(signer.pubKey))
|
||||
val oldEvent = oldNote?.event as? AcceptedBadgeSetEvent
|
||||
return oldEvent?.acceptedBadges() ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes read-modify-write of the accepted-badges replaceable event so two
|
||||
* rapid toggles can't race each other into losing updates.
|
||||
*/
|
||||
private val profileBadgesMutex = Mutex()
|
||||
|
||||
/**
|
||||
* Returns a createdAt strictly greater than whatever ProfileBadgesEvent (or
|
||||
* the legacy AcceptedBadgeSetEvent) currently sits in cache. Needed because
|
||||
* LocalCache.consumeBaseReplaceable drops updates whose createdAt isn't
|
||||
* strictly greater, and TimeUtils.now() has only second resolution.
|
||||
*/
|
||||
private fun nextProfileBadgesCreatedAt(): Long {
|
||||
val latest =
|
||||
maxOf(
|
||||
(cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))?.event?.createdAt) ?: 0L,
|
||||
(cache.getAddressableNoteIfExists(AcceptedBadgeSetEvent.createAddress(signer.pubKey))?.event?.createdAt) ?: 0L,
|
||||
)
|
||||
return maxOf(TimeUtils.now(), latest + 1)
|
||||
}
|
||||
|
||||
suspend fun addAcceptedBadge(
|
||||
award: BadgeAwardEvent,
|
||||
definition: BadgeDefinitionEvent,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val aTag = ATag(definition.kind, definition.pubKey, definition.dTag(), null)
|
||||
val eTag = ETag(award.id)
|
||||
|
||||
val signedEvent =
|
||||
profileBadgesMutex.withLock {
|
||||
val current = loadCurrentAcceptedBadges()
|
||||
if (current.any { it.badgeAward.eventId == award.id }) return
|
||||
val updated = current + AcceptedBadge(aTag, eTag)
|
||||
|
||||
val template = ProfileBadgesEvent.build(updated, createdAt = nextProfileBadgesCreatedAt())
|
||||
val signed = signer.sign(template)
|
||||
cache.justConsumeMyOwnEvent(signed)
|
||||
signed
|
||||
}
|
||||
|
||||
client.publish(signedEvent, outboxRelays.flow.value)
|
||||
}
|
||||
|
||||
suspend fun removeAcceptedBadge(award: BadgeAwardEvent) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val signedEvent =
|
||||
profileBadgesMutex.withLock {
|
||||
val current = loadCurrentAcceptedBadges()
|
||||
val updated = current.filterNot { it.badgeAward.eventId == award.id }
|
||||
if (updated.size == current.size) return
|
||||
|
||||
val template = ProfileBadgesEvent.build(updated, createdAt = nextProfileBadgesCreatedAt())
|
||||
val signed = signer.sign(template)
|
||||
cache.justConsumeMyOwnEvent(signed)
|
||||
signed
|
||||
}
|
||||
|
||||
client.publish(signedEvent, outboxRelays.flow.value)
|
||||
}
|
||||
|
||||
fun sendMyPublicAndPrivateOutbox(event: Event?) {
|
||||
if (event == null) return
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
|
||||
@@ -126,6 +126,9 @@ sealed class TopFilter(
|
||||
@Serializable
|
||||
object Chess : TopFilter(" Chess ")
|
||||
|
||||
@Serializable
|
||||
object Mine : TopFilter(" Mine ")
|
||||
|
||||
@Serializable
|
||||
class PeopleList(
|
||||
val address: Address,
|
||||
@@ -182,6 +185,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.Mine),
|
||||
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
||||
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
@@ -512,6 +516,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
@@ -95,6 +95,10 @@ class FeedTopNavFilterState(
|
||||
ChessFeedFlow(followsRelays, proxyRelays)
|
||||
}
|
||||
|
||||
TopFilter.Mine -> {
|
||||
AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays)
|
||||
}
|
||||
|
||||
is TopFilter.Community -> {
|
||||
NoteFeedFlow(
|
||||
LocalCache
|
||||
|
||||
+6
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
|
||||
@@ -97,6 +99,8 @@ class RelaySubscriptionsCoordinator(
|
||||
val shorts = ShortsFilterAssembler(client)
|
||||
val longs = LongsFilterAssembler(client)
|
||||
val articles = ArticlesFilterAssembler(client)
|
||||
val badges = BadgesFilterAssembler(client)
|
||||
val profileBadges = ProfileBadgesFilterAssembler(client)
|
||||
|
||||
// active when sending zaps via NWC
|
||||
val nwc = NWCPaymentFilterAssembler(client)
|
||||
@@ -114,6 +118,8 @@ class RelaySubscriptionsCoordinator(
|
||||
shorts,
|
||||
longs,
|
||||
articles,
|
||||
badges,
|
||||
profileBadges,
|
||||
channelFinder,
|
||||
eventFinder,
|
||||
userFinder,
|
||||
|
||||
+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,6 +58,7 @@ object ScrollStateKeys {
|
||||
const val POLLS_SCREEN = "PollsFeed"
|
||||
const val POLLS_OPEN = "PollsOpenFeed"
|
||||
const val POLLS_CLOSED = "PollsClosedFeed"
|
||||
const val BADGES_SCREEN = "BadgesFeed"
|
||||
const val PICTURES_SCREEN = "PicturesFeed"
|
||||
const val PRODUCTS_SCREEN = "ProductsFeed"
|
||||
const val SHORTS_SCREEN = "ShortsFeed"
|
||||
|
||||
@@ -64,6 +64,9 @@ import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
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.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
|
||||
@@ -215,6 +218,9 @@ fun BuildNavigation(
|
||||
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
|
||||
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.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
||||
|
||||
+9
@@ -59,6 +59,7 @@ import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||
import androidx.compose.material.icons.outlined.Drafts
|
||||
import androidx.compose.material.icons.outlined.GroupAdd
|
||||
import androidx.compose.material.icons.outlined.Language
|
||||
import androidx.compose.material.icons.outlined.MilitaryTech
|
||||
import androidx.compose.material.icons.outlined.Photo
|
||||
import androidx.compose.material.icons.outlined.PlayCircle
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
@@ -601,6 +602,14 @@ fun ListContent(
|
||||
route = Route.Polls,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.badges,
|
||||
icon = Icons.Outlined.MilitaryTech,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Badges,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.discover_marketplace,
|
||||
icon = Icons.Outlined.Storefront,
|
||||
|
||||
@@ -45,6 +45,22 @@ sealed class Route {
|
||||
|
||||
@Serializable object Polls : Route()
|
||||
|
||||
@Serializable object Badges : Route()
|
||||
|
||||
@Serializable object ProfileBadges : Route()
|
||||
|
||||
@Serializable data class AwardBadge(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
val dTag: String,
|
||||
) : Route() {
|
||||
constructor(address: Address) : this(
|
||||
kind = address.kind,
|
||||
pubKeyHex = address.pubKeyHex,
|
||||
dTag = address.dTag,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable object Pictures : Route()
|
||||
|
||||
@Serializable object Products : Route()
|
||||
|
||||
@@ -46,11 +46,13 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
|
||||
import com.vitorpamplona.amethyst.ui.note.types.AcceptBadgeControls
|
||||
import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.BadgeCard
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -128,6 +130,10 @@ fun BadgeCompose(
|
||||
note.replyTo?.firstOrNull()?.let {
|
||||
BadgeDisplay(baseNote = it, accountViewModel)
|
||||
}
|
||||
|
||||
(note.event as? BadgeAwardEvent)?.let { award ->
|
||||
AcceptBadgeControls(award, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,10 +396,6 @@ fun AcceptableNote(
|
||||
}
|
||||
}
|
||||
|
||||
is BadgeDefinitionEvent -> {
|
||||
BadgeDisplay(baseNote = baseNote, accountViewModel)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, nav) { showPopup ->
|
||||
CheckNewAndRenderNote(
|
||||
@@ -454,10 +450,6 @@ fun AcceptableNote(
|
||||
}
|
||||
}
|
||||
|
||||
is BadgeDefinitionEvent -> {
|
||||
BadgeDisplay(baseNote, accountViewModel)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LongPressToQuickAction(baseNote, accountViewModel, nav) { showPopup ->
|
||||
CheckNewAndRenderNote(
|
||||
@@ -938,6 +930,10 @@ private fun RenderNoteRow(
|
||||
RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is BadgeDefinitionEvent -> {
|
||||
BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@@ -20,15 +20,28 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.MilitaryTech
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -36,109 +49,82 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
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
|
||||
|
||||
private val BadgeCardShape = RoundedCornerShape(12.dp)
|
||||
private val BadgeThumbSize = 72.dp
|
||||
|
||||
@Composable
|
||||
fun BadgeDisplay(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav? = null,
|
||||
) {
|
||||
val badgeData by observeNoteEvent<BadgeDefinitionEvent>(baseNote, accountViewModel)
|
||||
val definition = badgeData ?: return
|
||||
|
||||
badgeData?.let {
|
||||
RenderBadge(
|
||||
it.image(),
|
||||
it.name(),
|
||||
MaterialTheme.colorScheme.background,
|
||||
MaterialTheme.colorScheme.onBackground,
|
||||
it.description(),
|
||||
)
|
||||
}
|
||||
}
|
||||
val isMine = definition.pubKey == accountViewModel.userProfile().pubkeyHex
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RenderBadgePreview() {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
|
||||
ThemeComparisonRow {
|
||||
RenderBadge(
|
||||
image = "http://test.com",
|
||||
name = "Name",
|
||||
backgroundForRow = background,
|
||||
textColor = Color.LightGray,
|
||||
description = "This badge is awarded to the dedicated individuals who actively contributed by writing events to the relay during the crucial testing phase leading up to the first beta release of Grain.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderBadge(
|
||||
image: String?,
|
||||
name: String?,
|
||||
backgroundForRow: Color,
|
||||
textColor: Color,
|
||||
description: String?,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
BadgeCard(
|
||||
imageUrl = definition.thumb()?.ifBlank { null } ?: definition.image(),
|
||||
name = definition.name(),
|
||||
description = definition.description(),
|
||||
onClick =
|
||||
nav?.let {
|
||||
{
|
||||
routeFor(baseNote, accountViewModel.account)?.let { route -> nav.nav(route) }
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
image?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription =
|
||||
stringRes(
|
||||
R.string.badge_award_image_for,
|
||||
name ?: "",
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().background(backgroundForRow),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
|
||||
name?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp),
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
|
||||
description?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 20.dp, end = 20.dp),
|
||||
color = textColor,
|
||||
)
|
||||
if (isMine && nav != null) {
|
||||
BadgeActionRow {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
nav.nav(
|
||||
Route.AwardBadge(
|
||||
kind = definition.kind,
|
||||
pubKeyHex = definition.pubKey,
|
||||
dTag = definition.dTag(),
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.MilitaryTech,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(6.dp))
|
||||
Text(stringRes(R.string.award_badge))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,28 +141,239 @@ fun RenderBadgeAward(
|
||||
if (note.replyTo.isNullOrEmpty()) return
|
||||
|
||||
val noteEvent = note.event as? BadgeAwardEvent ?: return
|
||||
|
||||
val definitionNote = note.replyTo?.firstOrNull()
|
||||
val definition by
|
||||
if (definitionNote != null) {
|
||||
observeNoteEvent<BadgeDefinitionEvent>(definitionNote, accountViewModel)
|
||||
} else {
|
||||
remember { mutableStateOf<BadgeDefinitionEvent?>(null) }
|
||||
}
|
||||
|
||||
var awardees by remember { mutableStateOf<List<User>>(listOf()) }
|
||||
|
||||
Text(text = stringRes(R.string.award_granted_to))
|
||||
LaunchedEffect(note) { accountViewModel.loadUsers(noteEvent.awardeeIds()) { awardees = it } }
|
||||
|
||||
LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardeeIds()) { awardees = it } }
|
||||
BadgeCard(
|
||||
imageUrl = definition?.thumb()?.ifBlank { null } ?: definition?.image(),
|
||||
name = definition?.name() ?: stringRes(R.string.award_granted_to),
|
||||
description = definition?.description(),
|
||||
onClick = {
|
||||
routeFor(note, accountViewModel.account)?.let { nav.nav(it) }
|
||||
},
|
||||
) {
|
||||
if (awardees.isNotEmpty()) {
|
||||
BadgeAwardeesRow(awardees, accountViewModel, nav)
|
||||
}
|
||||
AcceptBadgeControls(noteEvent, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
|
||||
awardees.take(100).forEach { user ->
|
||||
@Composable
|
||||
private fun BadgeCard(
|
||||
imageUrl: String?,
|
||||
name: String?,
|
||||
description: String?,
|
||||
onClick: (() -> Unit)? = null,
|
||||
actions: @Composable () -> Unit = {},
|
||||
) {
|
||||
val baseModifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)
|
||||
OutlinedCard(
|
||||
modifier = if (onClick != null) baseModifier.clickable(onClick = onClick) else baseModifier,
|
||||
shape = BadgeCardShape,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
BadgeThumbnail(imageUrl, name)
|
||||
|
||||
Spacer(modifier = Modifier.size(14.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = name?.ifBlank { null } ?: stringRes(R.string.badge_untitled),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!description.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
actions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeThumbnail(
|
||||
imageUrl: String?,
|
||||
name: String?,
|
||||
) {
|
||||
val description =
|
||||
if (name != null) {
|
||||
stringRes(R.string.badge_award_image_for, name)
|
||||
} else {
|
||||
stringRes(R.string.badge_award_image)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(BadgeThumbSize).clip(RoundedCornerShape(10.dp)),
|
||||
) {
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
RobohashAsyncImage(
|
||||
robot = "badgenotfound",
|
||||
contentDescription = description,
|
||||
modifier = Modifier.size(BadgeThumbSize),
|
||||
loadRobohash = true,
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = description,
|
||||
modifier = Modifier.size(BadgeThumbSize),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeActionRow(content: @Composable () -> Unit) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun BadgeAwardeesRow(
|
||||
awardees: List<User>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.badge_awardees_label, awardees.size),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
awardees.take(24).forEach { user ->
|
||||
UserPicture(
|
||||
user = user,
|
||||
size = Size35dp,
|
||||
size = Size30dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
if (awardees.size > 100) {
|
||||
Text(stringRes(R.string.badge_and_n_others, awardees.size - 100), maxLines = 1)
|
||||
if (awardees.size > 24) {
|
||||
Text(
|
||||
text = stringRes(R.string.badge_and_n_others, awardees.size - 24),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
note.replyTo?.firstOrNull()?.let {
|
||||
BadgeDisplay(baseNote = it, accountViewModel)
|
||||
@Composable
|
||||
fun AcceptBadgeControls(
|
||||
award: BadgeAwardEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val myPubkey = accountViewModel.userProfile().pubkeyHex
|
||||
val amAwardee = remember(award, myPubkey) { award.awardeeIds().contains(myPubkey) }
|
||||
if (!amAwardee) return
|
||||
|
||||
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 isAccepted =
|
||||
remember(newState, oldState, award.id) {
|
||||
val newEvent = newState.note.event as? ProfileBadgesEvent
|
||||
val oldEvent = oldState.note.event as? AcceptedBadgeSetEvent
|
||||
val awardIds =
|
||||
newEvent?.badgeAwardEvents()?.map { it.eventId }
|
||||
?: oldEvent?.badgeAwardEvents()?.map { it.eventId }
|
||||
?: emptyList()
|
||||
awardIds.contains(award.id)
|
||||
}
|
||||
|
||||
BadgeActionRow {
|
||||
if (isAccepted) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.removeAcceptedBadge(award)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.unaccept_badge))
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = {
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.removeAcceptedBadge(award)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.reject_badge))
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
accountViewModel.launchSigner {
|
||||
val defAddr = award.awardDefinition().firstOrNull() ?: return@launchSigner
|
||||
val defNote = LocalCache.getAddressableNoteIfExists(defAddr)
|
||||
val defEvent = defNote?.event as? BadgeDefinitionEvent ?: return@launchSigner
|
||||
accountViewModel.account.addAcceptedBadge(award, defEvent)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.accept_badge))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RenderBadgePreview() {
|
||||
ThemeComparisonRow {
|
||||
BadgeCard(
|
||||
imageUrl = null,
|
||||
name = "Relay Beta Tester",
|
||||
description = "Awarded to the dedicated individuals who actively contributed by writing events to the relay during the beta phase.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,12 @@ class TopNavFilterState(
|
||||
name = ResourceName(R.string.follow_list_chess),
|
||||
)
|
||||
|
||||
val mineFollow =
|
||||
FeedDefinition(
|
||||
code = TopFilter.Mine,
|
||||
name = ResourceName(R.string.follow_list_mine),
|
||||
)
|
||||
|
||||
val allFavoriteAlgoFeedsFollow =
|
||||
FeedDefinition(
|
||||
code = TopFilter.AllFavoriteAlgoFeeds,
|
||||
@@ -240,6 +246,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()
|
||||
@@ -262,6 +280,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}" }
|
||||
}
|
||||
|
||||
+7
@@ -28,6 +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.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
|
||||
@@ -81,6 +82,8 @@ class AccountFeedContentStates(
|
||||
val openPollsFeed = FeedContentState(OpenPollsFeedFilter(account), scope, LocalCache)
|
||||
val closedPollsFeed = FeedContentState(ClosedPollsFeedFilter(account), scope, LocalCache)
|
||||
|
||||
val badgesFeed = FeedContentState(BadgesFeedFilter(account), scope, LocalCache)
|
||||
|
||||
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
|
||||
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
|
||||
val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache)
|
||||
@@ -125,6 +128,8 @@ class AccountFeedContentStates(
|
||||
openPollsFeed.updateFeedWith(newNotes)
|
||||
closedPollsFeed.updateFeedWith(newNotes)
|
||||
|
||||
badgesFeed.updateFeedWith(newNotes)
|
||||
|
||||
picturesFeed.updateFeedWith(newNotes)
|
||||
productsFeed.updateFeedWith(newNotes)
|
||||
shortsFeed.updateFeedWith(newNotes)
|
||||
@@ -163,6 +168,8 @@ class AccountFeedContentStates(
|
||||
openPollsFeed.deleteFromFeed(newNotes)
|
||||
closedPollsFeed.deleteFromFeed(newNotes)
|
||||
|
||||
badgesFeed.deleteFromFeed(newNotes)
|
||||
|
||||
picturesFeed.deleteFromFeed(newNotes)
|
||||
productsFeed.deleteFromFeed(newNotes)
|
||||
shortsFeed.deleteFromFeed(newNotes)
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
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.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
|
||||
|
||||
@Composable
|
||||
fun BadgesScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
BadgesScreen(
|
||||
feedContentState = accountViewModel.feedStates.badgesFeed,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgesScreen(
|
||||
feedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(feedContentState)
|
||||
WatchAccountForBadgesScreen(feedContentState, accountViewModel)
|
||||
BadgesFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
BadgesTopBar(accountViewModel, nav)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.Badges, accountViewModel) { route ->
|
||||
if (route == Route.Badges) {
|
||||
feedContentState.sendToTop()
|
||||
} else {
|
||||
nav.newStack(route)
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingButton = {
|
||||
NewBadgeButton(accountViewModel)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) { paddingValues ->
|
||||
Column(Modifier.padding(paddingValues)) {
|
||||
RefresheableBox(feedContentState, true) {
|
||||
SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BADGES_SCREEN) { listState ->
|
||||
RenderFeedContentState(
|
||||
feedContentState = feedContentState,
|
||||
accountViewModel = accountViewModel,
|
||||
listState = listState,
|
||||
nav = nav,
|
||||
routeForLastRead = "BadgesFeed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun BadgesTopBar(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
UserDrawerSearchTopBar(accountViewModel, nav) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
|
||||
@Composable
|
||||
fun NewBadgeButton(accountViewModel: AccountViewModel) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
val postViewModel: NewBadgeModel = viewModel()
|
||||
|
||||
if (showDialog) {
|
||||
NewBadgeDialog(
|
||||
onClose = { showDialog = false },
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = { showDialog = true },
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterRes(R.drawable.ic_compose, 5),
|
||||
contentDescription = stringRes(id = R.string.new_badge),
|
||||
modifier = Size26Modifier,
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* 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.award
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@Composable
|
||||
fun AwardBadgeScreen(
|
||||
kind: Int,
|
||||
pubKeyHex: HexKey,
|
||||
dTag: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val vm: AwardBadgeViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(accountViewModel, kind, pubKeyHex, dTag) {
|
||||
vm.init(accountViewModel, kind, pubKeyHex, dTag)
|
||||
}
|
||||
|
||||
var searchInput by remember { mutableStateOf("") }
|
||||
val selectedUsers = remember { mutableStateListOf<User>() }
|
||||
|
||||
val userSuggestions =
|
||||
remember {
|
||||
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { userSuggestions.reset() }
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
nav.popBack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SavingTopBar(
|
||||
titleRes = R.string.award_badge,
|
||||
isActive = { vm.definition != null && selectedUsers.isNotEmpty() },
|
||||
onCancel = { nav.popBack() },
|
||||
onPost = {
|
||||
val toAward = selectedUsers.toList()
|
||||
accountViewModel.launchSigner {
|
||||
vm.sendPost(toAward)
|
||||
nav.popBack()
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.imePadding(),
|
||||
) {
|
||||
BadgeSummary(vm)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (selectedUsers.isNotEmpty()) {
|
||||
selectedUsers.toList().forEachIndexed { index, user ->
|
||||
SelectedUserRow(
|
||||
user = user,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onClear = { selectedUsers.remove(user) },
|
||||
)
|
||||
if (index < selectedUsers.lastIndex) HorizontalDivider()
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = searchInput,
|
||||
onValueChange = { newValue ->
|
||||
searchInput = newValue
|
||||
if (newValue.length > 2) {
|
||||
userSuggestions.processCurrentWord(newValue)
|
||||
} else {
|
||||
userSuggestions.reset()
|
||||
}
|
||||
},
|
||||
label = { Text(stringRes(R.string.award_badge_search_label)) },
|
||||
placeholder = { Text(stringRes(R.string.award_badge_search_placeholder)) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
if (searchInput.length > 2) {
|
||||
ShowUserSuggestionList(
|
||||
userSuggestions = userSuggestions,
|
||||
onSelect = { user ->
|
||||
if (selectedUsers.none { it.pubkeyHex == user.pubkeyHex }) {
|
||||
selectedUsers.add(user)
|
||||
}
|
||||
searchInput = ""
|
||||
userSuggestions.reset()
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = SuggestionListDefaultHeightPage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeSummary(vm: AwardBadgeViewModel) {
|
||||
val def = vm.definition
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
if (def == null) {
|
||||
Text(
|
||||
text = stringRes(R.string.award_badge_loading),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = def.name() ?: def.dTag(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
def.description()?.takeIf { it.isNotBlank() }?.let {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedUserRow(
|
||||
user: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onClear: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
UserPicture(
|
||||
userHex = user.pubkeyHex,
|
||||
size = 40.dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = user.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
UserSecondaryLine(user)
|
||||
}
|
||||
TextButton(onClick = onClear) {
|
||||
Text(stringRes(R.string.award_badge_remove_recipient))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserSecondaryLine(user: User) {
|
||||
val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle()
|
||||
|
||||
val text =
|
||||
when (val state = nip05StateMetadata) {
|
||||
is Nip05State.Exists -> {
|
||||
val name = state.nip05.name
|
||||
if (name == "_") state.nip05.domain else "$name@${state.nip05.domain}"
|
||||
}
|
||||
|
||||
else -> {
|
||||
user.pubkeyDisplayHex()
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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.award
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
|
||||
@Stable
|
||||
class AwardBadgeViewModel : ViewModel() {
|
||||
lateinit var accountViewModel: AccountViewModel
|
||||
lateinit var account: Account
|
||||
|
||||
var definition by mutableStateOf<BadgeDefinitionEvent?>(null)
|
||||
|
||||
fun init(
|
||||
accountVM: AccountViewModel,
|
||||
kind: Int,
|
||||
pubKeyHex: HexKey,
|
||||
dTag: String,
|
||||
) {
|
||||
this.accountViewModel = accountVM
|
||||
this.account = accountVM.account
|
||||
|
||||
val ev =
|
||||
LocalCache.getAddressableNoteIfExists(Address(kind, pubKeyHex, dTag))?.event as? BadgeDefinitionEvent
|
||||
definition = ev
|
||||
}
|
||||
|
||||
suspend fun sendPost(awardees: List<User>) {
|
||||
val def = definition ?: return
|
||||
if (awardees.isEmpty()) return
|
||||
|
||||
val pTags = awardees.map { PTag(it.pubkeyHex) }
|
||||
account.sendBadgeAward(def, pTags)
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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.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 BadgesFeedFilter(
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-badges-" + followList().code
|
||||
|
||||
override fun limit() = 100
|
||||
|
||||
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 notes =
|
||||
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> {
|
||||
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 && 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)
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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.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
|
||||
class BadgesFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<BadgesQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
BadgesSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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.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
|
||||
|
||||
@Composable
|
||||
fun BadgesFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
|
||||
BadgesFilterAssemblerSubscription(
|
||||
accountViewModel.dataSources().badges,
|
||||
accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgesFilterAssemblerSubscription(
|
||||
dataSource: BadgesFilterAssembler,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val state =
|
||||
remember(accountViewModel.account) {
|
||||
BadgesQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
|
||||
}
|
||||
|
||||
KeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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.datasource
|
||||
|
||||
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>,
|
||||
) : PerUserAndFollowListEoseManager<BadgesQueryState, TopFilter>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: BadgesQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): 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() }
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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.datasource
|
||||
|
||||
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.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
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> {
|
||||
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
|
||||
val authors = listOf(pubkey)
|
||||
return relays.map { relay ->
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
* 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.post
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddPhotoAlternate
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
|
||||
import com.vitorpamplona.amethyst.ui.components.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NewBadgeDialog(
|
||||
onClose: () -> Unit,
|
||||
postViewModel: NewBadgeModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
val context = LocalContext.current
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(account) {
|
||||
postViewModel.init(account)
|
||||
}
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
var wantsToPickImage by remember { mutableStateOf(false) }
|
||||
|
||||
if (wantsToPickImage) {
|
||||
GallerySelect(
|
||||
onImageUri = { uris ->
|
||||
wantsToPickImage = false
|
||||
postViewModel.setPickedMedia(
|
||||
if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false,
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
SetDialogToEdgeToEdge()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CreatingTopBar(
|
||||
titleRes = R.string.new_badge,
|
||||
isActive = postViewModel::canPost,
|
||||
onCancel = {
|
||||
postViewModel.cancelModel()
|
||||
onClose()
|
||||
},
|
||||
onPost = {
|
||||
postViewModel.upload(
|
||||
context,
|
||||
onSuccess = onClose,
|
||||
onError = accountViewModel.toastManager::toast,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(pad)
|
||||
.consumeWindowInsets(pad)
|
||||
.imePadding(),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
BadgeImagePicker(
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
onPickImage = { wantsToPickImage = true },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
BadgeFormFields(postViewModel, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeImagePicker(
|
||||
postViewModel: NewBadgeModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onPickImage: () -> Unit,
|
||||
) {
|
||||
if (postViewModel.hasPickedImage()) {
|
||||
postViewModel.multiOrchestrator?.let {
|
||||
// Tap the preview to swap to a different image.
|
||||
Box(modifier = Modifier.clickable(onClick = onPickImage)) {
|
||||
ShowImageUploadGallery(
|
||||
list = it,
|
||||
onDelete = { postViewModel.setPickedMedia(persistentListOf()) },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
UploadPlaceholder(onClick = onPickImage)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UploadPlaceholder(onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
).clickable(onClick = onClick)
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AddPhotoAlternate,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(56.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.badge_upload_image_cta),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.badge_upload_image_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeFormFields(
|
||||
postViewModel: NewBadgeModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val fileServers by accountViewModel.account.blossomServers.hostNameFlow
|
||||
.collectAsState()
|
||||
|
||||
val fileServerOptions =
|
||||
remember(fileServers) {
|
||||
fileServers
|
||||
.map { TitleExplainer(it.name, it.baseUrl) }
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = postViewModel.name,
|
||||
onValueChange = { postViewModel.name = it },
|
||||
label = { Text(stringRes(R.string.badge_name_label)) },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.badge_name_placeholder),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = postViewModel.description,
|
||||
onValueChange = { postViewModel.description = it },
|
||||
label = { Text(stringRes(R.string.badge_description_label)) },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.badge_description_placeholder),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp),
|
||||
minLines = 2,
|
||||
maxLines = 6,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
SettingsRow(R.string.file_server, R.string.file_server_description) {
|
||||
TextSpinner(
|
||||
label = "",
|
||||
placeholder =
|
||||
fileServers
|
||||
.firstOrNull { it == accountViewModel.account.settings.defaultFileServer }
|
||||
?.name
|
||||
?: fileServers.firstOrNull()?.name
|
||||
?: DEFAULT_MEDIA_SERVERS[0].name,
|
||||
options = fileServerOptions,
|
||||
onSelect = { postViewModel.selectedServer = fileServers[it] },
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.media_compression_quality_label),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.media_compression_quality_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text =
|
||||
when (postViewModel.mediaQualitySlider) {
|
||||
0 -> stringRes(R.string.media_compression_quality_low)
|
||||
1 -> stringRes(R.string.media_compression_quality_medium)
|
||||
2 -> stringRes(R.string.media_compression_quality_high)
|
||||
3 -> stringRes(R.string.media_compression_quality_uncompressed)
|
||||
else -> stringRes(R.string.media_compression_quality_medium)
|
||||
},
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
|
||||
Slider(
|
||||
value = postViewModel.mediaQualitySlider.toFloat(),
|
||||
onValueChange = { postViewModel.mediaQualitySlider = it.toInt() },
|
||||
valueRange = 0f..3f,
|
||||
steps = 2,
|
||||
)
|
||||
}
|
||||
|
||||
SettingSwitchItem(
|
||||
title = R.string.strip_metadata_label,
|
||||
description = R.string.strip_metadata_description,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
checked = postViewModel.stripMetadata,
|
||||
onCheckedChange = { postViewModel.stripMetadata = it },
|
||||
)
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* 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.post
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Drives the "new badge" creation flow: uploads the user-picked image (at the
|
||||
* chosen server / compression), then publishes a NIP-58 BadgeDefinitionEvent
|
||||
* (kind 30009) with an auto-generated UUID d-tag, the uploaded URL as both
|
||||
* `image` and `thumb`, and the user-provided name / description.
|
||||
*
|
||||
* Intentionally does NOT publish the event unless the image upload succeeds —
|
||||
* otherwise we'd announce a badge that references a URL that doesn't exist.
|
||||
*/
|
||||
@Stable
|
||||
class NewBadgeModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
|
||||
var isUploading by mutableStateOf(false)
|
||||
|
||||
var selectedServer by mutableStateOf<ServerName?>(null)
|
||||
var name by mutableStateOf("")
|
||||
var description by mutableStateOf("")
|
||||
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// 0 = Low, 1 = Medium, 2 = High, 3 = UNCOMPRESSED
|
||||
var mediaQualitySlider by mutableIntStateOf(1)
|
||||
|
||||
var stripMetadata by mutableStateOf(true)
|
||||
|
||||
var onceUploaded: () -> Unit = {}
|
||||
|
||||
fun init(account: Account) {
|
||||
if (this.account == account) return
|
||||
this.account = account
|
||||
this.selectedServer = defaultServer()
|
||||
this.stripMetadata = account.settings.stripLocationOnUpload
|
||||
}
|
||||
|
||||
fun load(
|
||||
account: Account,
|
||||
uris: ImmutableList<SelectedMedia>,
|
||||
) {
|
||||
this.account = account
|
||||
this.multiOrchestrator = MultiOrchestrator(uris)
|
||||
this.selectedServer = defaultServer()
|
||||
this.stripMetadata = account.settings.stripLocationOnUpload
|
||||
this.name = ""
|
||||
this.description = ""
|
||||
}
|
||||
|
||||
fun setPickedMedia(uris: ImmutableList<SelectedMedia>) {
|
||||
this.multiOrchestrator = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null
|
||||
}
|
||||
|
||||
fun hasPickedImage(): Boolean = multiOrchestrator != null
|
||||
|
||||
fun canPost(): Boolean =
|
||||
!isUploading &&
|
||||
multiOrchestrator != null &&
|
||||
selectedServer != null &&
|
||||
name.isNotBlank()
|
||||
|
||||
fun upload(
|
||||
context: Context,
|
||||
onSuccess: () -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) = try {
|
||||
uploadUnsafe(context, onSuccess, onError)
|
||||
} catch (e: SignerExceptions.ReadOnlyException) {
|
||||
onError(
|
||||
stringRes(context, R.string.read_only_user),
|
||||
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
|
||||
)
|
||||
}
|
||||
|
||||
private fun uploadUnsafe(
|
||||
context: Context,
|
||||
onSuccess: () -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val myAccount = account ?: return@launch
|
||||
val serverToUse = selectedServer ?: return@launch
|
||||
val orch = multiOrchestrator ?: return@launch
|
||||
|
||||
isUploading = true
|
||||
|
||||
val results =
|
||||
orch.upload(
|
||||
alt = name,
|
||||
contentWarningReason = null,
|
||||
mediaQuality = MediaCompressor.intToCompressorQuality(mediaQualitySlider),
|
||||
server = serverToUse,
|
||||
account = myAccount,
|
||||
context = context,
|
||||
useH265 = false,
|
||||
stripMetadata = stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (!results.allGood) {
|
||||
val messages =
|
||||
results.errors
|
||||
.map { stringRes(context, it.errorResource, *it.params) }
|
||||
.distinct()
|
||||
.joinToString(".\n")
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), messages)
|
||||
isUploading = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
val uploaded =
|
||||
results.successful.firstNotNullOfOrNull {
|
||||
it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
|
||||
}
|
||||
|
||||
if (uploaded == null) {
|
||||
onError(
|
||||
stringRes(context, R.string.failed_to_upload_media_no_details),
|
||||
"Upload succeeded but no image URL was returned by the server.",
|
||||
)
|
||||
isUploading = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
val imageUrl = uploaded.url
|
||||
val dimensions = uploaded.fileHeader.dim
|
||||
|
||||
myAccount.sendBadgeDefinition(
|
||||
badgeId = UUID.randomUUID().toString(),
|
||||
name = name.trim(),
|
||||
imageUrl = imageUrl,
|
||||
imageDim = dimensions,
|
||||
description = description.trim().ifBlank { null },
|
||||
// NIP-58 thumb is optional; we emit it pointing at the same
|
||||
// uploaded asset so clients that only honor the thumb tag also
|
||||
// see the badge image.
|
||||
thumbs = listOf(ThumbTag(imageUrl, dimensions)),
|
||||
)
|
||||
|
||||
myAccount.settings.changeDefaultFileServer(serverToUse)
|
||||
myAccount.settings.changeStripLocationOnUpload(stripMetadata)
|
||||
|
||||
onSuccess()
|
||||
onceUploaded()
|
||||
cancelModel()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelModel() {
|
||||
multiOrchestrator = null
|
||||
isUploading = false
|
||||
name = ""
|
||||
description = ""
|
||||
selectedServer = defaultServer()
|
||||
}
|
||||
|
||||
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
|
||||
|
||||
fun onceUploaded(action: () -> Unit) {
|
||||
this.onceUploaded = action
|
||||
}
|
||||
}
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* 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.clickable
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssemblerSubscription
|
||||
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
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ProfileBadgesScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val myPubkey = accountViewModel.userProfile().pubkeyHex
|
||||
|
||||
// Pull every kind 8 award tagging me from the user's notification relays so
|
||||
// historic awards land in cache while this screen is open.
|
||||
ProfileBadgesFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// Tick whenever LocalCache emits a bundle that contains an award addressed
|
||||
// to me, so the snapshot below recomputes and the new award appears.
|
||||
var bundleTick by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(myPubkey) {
|
||||
launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { bundle ->
|
||||
val touchesMe =
|
||||
bundle.any { note ->
|
||||
val ev = note.event
|
||||
ev is BadgeAwardEvent && ev.awardeeIds().contains(myPubkey)
|
||||
}
|
||||
if (touchesMe) bundleTick++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "loaded" flips once when either profile-badges event arrives in cache, so
|
||||
// we re-sort exactly once after the initial load. Toggling the Switch below
|
||||
// doesn't change it, so the list stays in place while the user edits.
|
||||
val acceptedDataLoaded =
|
||||
newState.note.event != null || oldState.note.event != null
|
||||
|
||||
val receivedAwards =
|
||||
remember(myPubkey, bundleTick, acceptedDataLoaded) {
|
||||
// acceptedAwardIds is captured from the enclosing scope at this
|
||||
// recomputation point; it is NOT part of the remember key, so
|
||||
// subsequent toggles don't re-run this sort.
|
||||
val initialAccepted = acceptedAwardIds
|
||||
LocalCache.notes
|
||||
.filterIntoSet { _, it ->
|
||||
val event = it.event
|
||||
event is BadgeAwardEvent && event.awardeeIds().contains(myPubkey)
|
||||
}.mapNotNull { it.event as? BadgeAwardEvent }
|
||||
.sortedWith(
|
||||
// Accepted badges on top (at load time), then most recent first.
|
||||
compareByDescending<BadgeAwardEvent> { initialAccepted.contains(it.id) }
|
||||
.thenByDescending { 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,
|
||||
nav = nav,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AwardRow(
|
||||
award: BadgeAwardEvent,
|
||||
isAccepted: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val defAddr = award.awardDefinition().firstOrNull()
|
||||
|
||||
if (defAddr == null) {
|
||||
StaticAwardRow(
|
||||
definition = null,
|
||||
defNote = null,
|
||||
isAccepted = isAccepted,
|
||||
accountViewModel = accountViewModel,
|
||||
award = award,
|
||||
nav = nav,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LoadAddressableNote(defAddr, accountViewModel) { defNote ->
|
||||
if (defNote == null) {
|
||||
StaticAwardRow(
|
||||
definition = null,
|
||||
defNote = null,
|
||||
isAccepted = isAccepted,
|
||||
accountViewModel = accountViewModel,
|
||||
award = award,
|
||||
nav = nav,
|
||||
)
|
||||
} else {
|
||||
// Ask relays for the definition if we don't have it yet, then watch.
|
||||
EventFinderFilterAssemblerSubscription(defNote, accountViewModel)
|
||||
val definition by observeNoteEvent<BadgeDefinitionEvent>(defNote, accountViewModel)
|
||||
StaticAwardRow(
|
||||
definition = definition,
|
||||
defNote = defNote,
|
||||
isAccepted = isAccepted,
|
||||
accountViewModel = accountViewModel,
|
||||
award = award,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StaticAwardRow(
|
||||
definition: BadgeDefinitionEvent?,
|
||||
defNote: com.vitorpamplona.amethyst.model.AddressableNote?,
|
||||
isAccepted: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
award: BadgeAwardEvent,
|
||||
nav: INav,
|
||||
) {
|
||||
val rowModifier =
|
||||
if (defNote != null) {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
routeFor(defNote, accountViewModel.account)?.let { nav.nav(it) }
|
||||
}.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = rowModifier,
|
||||
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,
|
||||
enabled = definition != null,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.datasource
|
||||
|
||||
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.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
|
||||
/**
|
||||
* Pulls every badge award (kind 8) tagging this pubkey, from the user's
|
||||
* notification relays. Used by the "Profile badges" management screen to
|
||||
* back-fill the full history of awards (the always-on notifications
|
||||
* subscription is bounded by `since`, so older awards may not be in
|
||||
* cache yet).
|
||||
*/
|
||||
fun filterReceivedBadgeAwards(
|
||||
pubkey: HexKey,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
): List<RelayBasedFilter> {
|
||||
if (pubkey.isEmpty() || relays.isEmpty()) return emptyList()
|
||||
val pTags = listOf(pubkey)
|
||||
return relays.map { relay ->
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(BadgeAwardEvent.KIND),
|
||||
tags = mapOf("p" to pTags),
|
||||
limit = 500,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.datasource
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
class ProfileBadgesQueryState(
|
||||
val account: Account,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class ProfileBadgesFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<ProfileBadgesQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
ProfileBadgesSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ProfileBadgesFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
|
||||
val state =
|
||||
remember(accountViewModel.account) {
|
||||
ProfileBadgesQueryState(accountViewModel.account)
|
||||
}
|
||||
|
||||
KeyDataSourceSubscription(state, accountViewModel.dataSources().profileBadges)
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
class ProfileBadgesSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<ProfileBadgesQueryState>,
|
||||
) : PerUserEoseManager<ProfileBadgesQueryState>(client, allKeys) {
|
||||
override fun user(key: ProfileBadgesQueryState) = key.account.userProfile()
|
||||
|
||||
override fun updateFilter(
|
||||
key: ProfileBadgesQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> =
|
||||
filterReceivedBadgeAwards(
|
||||
pubkey = user(key).pubkeyHex,
|
||||
relays = key.account.notificationRelays.flow.value,
|
||||
)
|
||||
}
|
||||
+238
-52
@@ -20,17 +20,44 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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 com.vitorpamplona.amethyst.R
|
||||
@@ -43,12 +70,11 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BadgePictureModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
@@ -60,8 +86,12 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val ProfileBadgeSize = 44.dp
|
||||
private val ProfileBadgeShape = RoundedCornerShape(8.dp)
|
||||
private const val VISIBLE_BADGE_LIMIT = 8
|
||||
|
||||
@Composable
|
||||
fun DisplayBadges(
|
||||
@@ -75,91 +105,242 @@ fun DisplayBadges(
|
||||
val oldNote = accountViewModel.getOrCreateAddressableNote(oldDesign)
|
||||
val newNote = accountViewModel.getOrCreateAddressableNote(newDesign)
|
||||
|
||||
WatchAndRenderBadgeList(oldNote, newNote, accountViewModel, nav)
|
||||
WatchAndRenderBadgeList(baseUser, oldNote, newNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchAndRenderBadgeList(
|
||||
baseUser: User,
|
||||
oldNote: AddressableNote,
|
||||
newNote: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// Subscribe in the relay for changes in this note.
|
||||
EventFinderFilterAssemblerSubscription(oldNote, accountViewModel)
|
||||
EventFinderFilterAssemblerSubscription(newNote, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
val flow =
|
||||
remember(oldNote, newNote) {
|
||||
combine(
|
||||
oldNote.flow().metadata.stateFlow,
|
||||
newNote.flow().metadata.stateFlow,
|
||||
) { oldNote, newNote ->
|
||||
val oldProfileBadgeEvent = oldNote.note.event as? AcceptedBadgeSetEvent
|
||||
val newProfileBadgeEvent = newNote.note.event as? ProfileBadgesEvent
|
||||
) { oldNoteState, newNoteState ->
|
||||
val oldEvent = oldNoteState.note.event as? AcceptedBadgeSetEvent
|
||||
val newEvent = newNoteState.note.event as? ProfileBadgesEvent
|
||||
|
||||
newProfileBadgeEvent?.badgeAwardEvents()?.toImmutableList()
|
||||
?: oldProfileBadgeEvent?.badgeAwardEvents()?.toImmutableList()
|
||||
newEvent?.badgeAwardEvents()?.toImmutableList()
|
||||
?: oldEvent?.badgeAwardEvents()?.toImmutableList()
|
||||
?: persistentListOf()
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
val badgeList by flow.collectAsStateWithLifecycle(persistentListOf())
|
||||
|
||||
badgeList?.let { list -> RenderBadgeList(list, accountViewModel, nav) }
|
||||
if (badgeList.isEmpty()) return
|
||||
|
||||
val isMe = baseUser.pubkeyHex == accountViewModel.userProfile().pubkeyHex
|
||||
RenderProfileBadgeStrip(badgeList, isMe, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun RenderBadgeList(
|
||||
@Composable
|
||||
private fun RenderProfileBadgeStrip(
|
||||
list: ImmutableList<ETag>,
|
||||
isMe: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(vertical = 5.dp),
|
||||
) {
|
||||
list.forEach { badgeAwardEvent -> LoadAndRenderBadge(badgeAwardEvent, accountViewModel, nav) }
|
||||
var showAllSheet by rememberSaveable { mutableStateOf(false) }
|
||||
val visible = remember(list) { list.take(VISIBLE_BADGE_LIMIT) }
|
||||
val overflow = (list.size - VISIBLE_BADGE_LIMIT).coerceAtLeast(0)
|
||||
|
||||
Column(modifier = Modifier.padding(vertical = 6.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.profile_badges_header, list.size),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (isMe) {
|
||||
IconButton(
|
||||
onClick = { nav.nav(Route.ProfileBadges) },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Settings,
|
||||
contentDescription = stringRes(R.string.profile_badges_title),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
visible.forEach { eTag ->
|
||||
LoadDefinitionForAward(eTag, accountViewModel) { defNote ->
|
||||
BadgeThumb(defNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
if (overflow > 0) {
|
||||
OverflowChip(overflow) { showAllSheet = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAllSheet) {
|
||||
AllBadgesSheet(
|
||||
awards = list,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onDismiss = { showAllSheet = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadAndRenderBadge(
|
||||
badgeAwardEvent: ETag,
|
||||
private fun OverflowChip(
|
||||
count: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(ProfileBadgeSize)
|
||||
.clip(ProfileBadgeShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "+$count",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AllBadgesSheet(
|
||||
awards: ImmutableList<ETag>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val baseNote =
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.profile_badges_header, awards.size),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
)
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
items(items = awards, key = { it.eventId }) { eTag ->
|
||||
LoadDefinitionForAward(eTag, accountViewModel) { defNote ->
|
||||
BadgeSheetRow(
|
||||
defNote = defNote,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
val route = routeFor(defNote, accountViewModel.account)
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
onDismiss()
|
||||
route?.let { nav.nav(it) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeSheetRow(
|
||||
defNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val event by observeNoteEvent<BadgeDefinitionEvent>(defNote, accountViewModel)
|
||||
val definition = event ?: return
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RenderBadgeImage(
|
||||
id = definition.id,
|
||||
name = definition.name(),
|
||||
image =
|
||||
definition.thumb()?.ifBlank { null }
|
||||
?: definition.image()?.ifBlank { null },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadDefinitionForAward(
|
||||
eTag: ETag,
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable (Note) -> Unit,
|
||||
) {
|
||||
val awardNote =
|
||||
produceState(
|
||||
LocalCache.getNoteIfExists(badgeAwardEvent),
|
||||
badgeAwardEvent,
|
||||
LocalCache.getNoteIfExists(eTag),
|
||||
eTag,
|
||||
) {
|
||||
val newValue = LocalCache.checkGetOrCreateNote(badgeAwardEvent)
|
||||
val newValue = LocalCache.checkGetOrCreateNote(eTag)
|
||||
if (newValue != value) {
|
||||
value = newValue
|
||||
}
|
||||
}
|
||||
|
||||
baseNote.value?.let {
|
||||
ObserveAndRenderBadge(it, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserveAndRenderBadge(
|
||||
it: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val badgeAwardState by observeNoteEvent<BadgeAwardEvent>(it, accountViewModel)
|
||||
val badgeDefinitionId = badgeAwardState?.awardDefinition()?.firstOrNull()
|
||||
if (badgeDefinitionId != null) {
|
||||
LoadAddressableNote(badgeDefinitionId, accountViewModel) { badgeDefNote ->
|
||||
badgeDefNote?.let {
|
||||
BadgeThumb(it, accountViewModel, nav)
|
||||
awardNote.value?.let { note ->
|
||||
val awardEvent by observeNoteEvent<BadgeAwardEvent>(note, accountViewModel)
|
||||
awardEvent?.awardDefinition()?.firstOrNull()?.let { defAddr ->
|
||||
LoadAddressableNote(defAddr, accountViewModel) { defNote ->
|
||||
defNote?.let { content(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,13 +354,16 @@ fun BadgeThumb(
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Size35Modifier.clickable(
|
||||
onClick = {
|
||||
nav.nav {
|
||||
routeFor(baseNote, accountViewModel.account)
|
||||
}
|
||||
},
|
||||
),
|
||||
Modifier
|
||||
.size(ProfileBadgeSize)
|
||||
.clip(ProfileBadgeShape)
|
||||
.clickable(
|
||||
onClick = {
|
||||
nav.nav {
|
||||
routeFor(baseNote, accountViewModel.account)
|
||||
}
|
||||
},
|
||||
),
|
||||
) {
|
||||
WatchAndRenderBadgeImage(baseNote, accountViewModel)
|
||||
}
|
||||
@@ -215,11 +399,13 @@ private fun RenderBadgeImage(
|
||||
stringRes(id = R.string.badge_award_image)
|
||||
}
|
||||
|
||||
val modifier = Modifier.size(ProfileBadgeSize).clip(ProfileBadgeShape)
|
||||
|
||||
if (image == null) {
|
||||
RobohashAsyncImage(
|
||||
robot = "badgenotfound",
|
||||
contentDescription = description,
|
||||
modifier = BadgePictureModifier,
|
||||
modifier = modifier,
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
)
|
||||
} else {
|
||||
@@ -227,7 +413,7 @@ private fun RenderBadgeImage(
|
||||
robot = id,
|
||||
model = image,
|
||||
contentDescription = description,
|
||||
modifier = BadgePictureModifier,
|
||||
modifier = modifier,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
)
|
||||
|
||||
+8
@@ -37,6 +37,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
|
||||
@@ -123,6 +124,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.favorite_dvms_title,
|
||||
icon = Icons.Outlined.AutoAwesome,
|
||||
|
||||
@@ -419,6 +419,40 @@
|
||||
<string name="polls">Polls</string>
|
||||
<string name="open_polls">Open</string>
|
||||
<string name="closed_polls">Closed</string>
|
||||
<string name="badges">Badges</string>
|
||||
<string name="received_badges">Received</string>
|
||||
<string name="my_badges">Mine</string>
|
||||
<string name="awarded_badges">Awarded</string>
|
||||
<string name="discover_badges">Discover</string>
|
||||
<string name="new_badge">New Badge</string>
|
||||
<string name="edit_badge">Edit Badge</string>
|
||||
<string name="award_badge">Award Badge</string>
|
||||
<string name="accept_badge">Accept</string>
|
||||
<string name="reject_badge">Reject</string>
|
||||
<string name="unaccept_badge">Remove from profile</string>
|
||||
<string name="badge_id_label">Badge ID (d-tag)</string>
|
||||
<string name="badge_id_placeholder">bravery, contributor-2025…</string>
|
||||
<string name="badge_name_label">Name</string>
|
||||
<string name="badge_name_placeholder">Human-readable badge name</string>
|
||||
<string name="badge_description_label">Description</string>
|
||||
<string name="badge_description_placeholder">Why is this badge awarded?</string>
|
||||
<string name="badge_image_label">Image URL (1024×1024)</string>
|
||||
<string name="badge_image_placeholder">https://example.com/badge.png</string>
|
||||
<string name="badge_thumb_label">Thumbnail URL (optional)</string>
|
||||
<string name="badge_thumb_placeholder">https://example.com/badge-thumb.png</string>
|
||||
<string name="badge_upload_image_cta">Upload an image</string>
|
||||
<string name="badge_upload_image_hint">Pick a square image to be the face of your badge.</string>
|
||||
<string name="award_badge_loading">Loading badge…</string>
|
||||
<string name="award_badge_search_label">Search users</string>
|
||||
<string name="award_badge_search_placeholder">Name, npub, or NIP-05</string>
|
||||
<string name="award_badge_remove_recipient">Remove</string>
|
||||
<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_header">Badges · %1$d</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>
|
||||
@@ -628,6 +662,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>
|
||||
|
||||
+14
-8
@@ -25,10 +25,11 @@ import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.tagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
@@ -80,13 +81,18 @@ class AcceptedBadgeSetEvent(
|
||||
acceptedBadges: List<AcceptedBadge> = emptyList(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<AcceptedBadgeSetEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
dTag(STANDARD_D_TAG)
|
||||
alt(ALT_DESCRIPTION)
|
||||
if (acceptedBadges.isNotEmpty()) {
|
||||
acceptedBadges(acceptedBadges)
|
||||
}
|
||||
initializer()
|
||||
): EventTemplate<AcceptedBadgeSetEvent> {
|
||||
// TagArrayBuilder groups tags by name, which would scramble the
|
||||
// alternating a/e pairs NIP-58 requires. Build the prefix tags via
|
||||
// the builder, then append the ordered pair tags verbatim.
|
||||
val prefix =
|
||||
tagArray<AcceptedBadgeSetEvent> {
|
||||
dTag(STANDARD_D_TAG)
|
||||
alt(ALT_DESCRIPTION)
|
||||
initializer()
|
||||
}
|
||||
val pairs = AcceptedBadge.assemble(acceptedBadges).toTypedArray()
|
||||
return EventTemplate(createdAt, KIND, prefix + pairs, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-7
@@ -25,10 +25,11 @@ import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.tagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
@@ -78,12 +79,17 @@ class ProfileBadgesEvent(
|
||||
acceptedBadges: List<AcceptedBadge> = emptyList(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<ProfileBadgesEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
alt(ALT_DESCRIPTION)
|
||||
if (acceptedBadges.isNotEmpty()) {
|
||||
acceptedBadges(acceptedBadges)
|
||||
}
|
||||
initializer()
|
||||
): EventTemplate<ProfileBadgesEvent> {
|
||||
// TagArrayBuilder groups tags by name, which would scramble the
|
||||
// alternating a/e pairs NIP-58 requires. Build the prefix tags via
|
||||
// the builder, then append the ordered pair tags verbatim.
|
||||
val prefix =
|
||||
tagArray<ProfileBadgesEvent> {
|
||||
alt(ALT_DESCRIPTION)
|
||||
initializer()
|
||||
}
|
||||
val pairs = AcceptedBadge.assemble(acceptedBadges).toTypedArray()
|
||||
return EventTemplate(createdAt, KIND, prefix + pairs, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user