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:
Vitor Pamplona
2026-04-19 17:41:10 -04:00
committed by GitHub
36 changed files with 2936 additions and 162 deletions
@@ -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
// ---
@@ -95,6 +95,10 @@ class FeedTopNavFilterState(
ChessFeedFlow(followsRelays, proxyRelays)
}
TopFilter.Mine -> {
AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays)
}
is TopFilter.Community -> {
NoteFeedFlow(
LocalCache
@@ -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,
@@ -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) }
@@ -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}" }
}
@@ -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)
@@ -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()
}
}
@@ -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,
)
}
@@ -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,
)
}
}
@@ -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,
)
}
@@ -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)
}
}
@@ -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)
}
@@ -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() }
}
@@ -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)
}
@@ -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() }
}
}
@@ -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,
),
)
}
}
@@ -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 },
)
}
@@ -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
}
}
@@ -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,
)
}
}
@@ -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,
),
)
}
}
@@ -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() }
}
@@ -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)
}
@@ -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,
)
}
@@ -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(),
)
@@ -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,
+35
View File
@@ -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>
@@ -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, "")
}
}
}
@@ -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, "")
}
}
}