feat(badges/profile): live updates and dedicated relay subscription

ProfileBadgesScreen used to compute the received-awards list once via a
plain remember and never refresh it; new awards landing in LocalCache
were invisible until the user navigated away and back. There was also
no relay subscription dedicated to back-filling award history — we
relied on the always-on notifications subscription bounded by `since`,
so older awards never arrived.

- New ProfileBadgesFilterAssembler / SubAssembler / Subscription that,
  while the screen is mounted, queries kind 8 with `#p`=me on the
  user's notification relays (limit 500, no since) so the full history
  flows in.
- Registered as `profileBadges` in RelaySubscriptionsCoordinator.
- ProfileBadgesScreen now collects LocalCache.live.newEventBundles in
  a LaunchedEffect, bumping a tick whenever a bundle contains a
  BadgeAwardEvent for me. The receivedAwards remember keys on that
  tick, so newly arrived awards appear without leaving the screen.
- AwardRow now resolves the badge definition via LoadAddressableNote +
  observeNoteEvent + EventFinderFilterAssemblerSubscription so each
  row re-renders when the linked kind 30009 lands in cache (and asks
  relays for it if missing). The Switch is disabled until the
  definition is available.
This commit is contained in:
Claude
2026-04-19 15:20:22 +00:00
parent 64079d4188
commit bc8edf9523
6 changed files with 240 additions and 5 deletions
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinder
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilterAssembler 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.articles.datasource.ArticlesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssembler 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.privateDM.datasource.ChatroomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
@@ -99,6 +100,7 @@ class RelaySubscriptionsCoordinator(
val longs = LongsFilterAssembler(client) val longs = LongsFilterAssembler(client)
val articles = ArticlesFilterAssembler(client) val articles = ArticlesFilterAssembler(client)
val badges = BadgesFilterAssembler(client) val badges = BadgesFilterAssembler(client)
val profileBadges = ProfileBadgesFilterAssembler(client)
// active when sending zaps via NWC // active when sending zaps via NWC
val nwc = NWCPaymentFilterAssembler(client) val nwc = NWCPaymentFilterAssembler(client)
@@ -117,6 +119,7 @@ class RelaySubscriptionsCoordinator(
longs, longs,
articles, articles,
badges, badges,
profileBadges,
channelFinder, channelFinder,
eventFinder, eventFinder,
userFinder, userFinder,
@@ -36,8 +36,11 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -49,15 +52,21 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache 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.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable @Composable
fun ProfileBadgesScreen( fun ProfileBadgesScreen(
@@ -66,6 +75,10 @@ fun ProfileBadgesScreen(
) { ) {
val myPubkey = accountViewModel.userProfile().pubkeyHex 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 newNote = accountViewModel.getOrCreateAddressableNote(ProfileBadgesEvent.createAddress(myPubkey))
val oldNote = accountViewModel.getOrCreateAddressableNote(AcceptedBadgeSetEvent.createAddress(myPubkey)) val oldNote = accountViewModel.getOrCreateAddressableNote(AcceptedBadgeSetEvent.createAddress(myPubkey))
@@ -86,8 +99,24 @@ fun ProfileBadgesScreen(
.toSet() .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++
}
}
}
val receivedAwards = val receivedAwards =
remember(myPubkey, newState, oldState) { remember(myPubkey, bundleTick) {
LocalCache.notes LocalCache.notes
.filterIntoSet { _, it -> .filterIntoSet { _, it ->
val event = it.event val event = it.event
@@ -143,11 +172,36 @@ private fun AwardRow(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val defAddr = award.awardDefinition().firstOrNull() val defAddr = award.awardDefinition().firstOrNull()
val definition =
remember(award.id) {
defAddr?.let { LocalCache.getAddressableNoteIfExists(it)?.event as? BadgeDefinitionEvent }
}
if (defAddr == null) {
StaticAwardRow(definition = null, isAccepted = isAccepted, accountViewModel = accountViewModel, award = award)
return
}
LoadAddressableNote(defAddr, accountViewModel) { defNote ->
if (defNote == null) {
StaticAwardRow(definition = null, isAccepted = isAccepted, accountViewModel = accountViewModel, award = award)
} 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,
isAccepted = isAccepted,
accountViewModel = accountViewModel,
award = award,
)
}
}
}
@Composable
private fun StaticAwardRow(
definition: BadgeDefinitionEvent?,
isAccepted: Boolean,
accountViewModel: AccountViewModel,
award: BadgeAwardEvent,
) {
Row( Row(
modifier = modifier =
Modifier Modifier
@@ -182,6 +236,7 @@ private fun AwardRow(
Switch( Switch(
checked = isAccepted, checked = isAccepted,
enabled = definition != null,
onCheckedChange = { checked -> onCheckedChange = { checked ->
accountViewModel.launchSigner { accountViewModel.launchSigner {
if (checked) { if (checked) {
@@ -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,
)
}