From b1723a5dfb68c6994dc7105f27e9192ba87deebf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 14:41:19 +0000 Subject: [PATCH 1/5] feat: add relay event count stats to AllRelay settings screen Use NostrClient's NIP-45 COUNT queries to show how many events each relay stores, with filters specific to each relay role: - Outbox: events authored by the user - Inbox: events where the user is p-tagged - DM Inbox: DM events (kind 4, 1059) tagging the user - Private Home: events authored by the user - Proxy: total event count - Search: total event count - Indexer: kind 0 and kind 10002 counts separately - Broadcast: no count (as specified) https://claude.ai/code/session_016158D5mq5BygS1uBbLNbsA --- .../loggedIn/relays/AllRelayListScreen.kt | 102 ++++++++- .../common/BasicRelaySetupInfoClickableRow.kt | 6 + .../common/BasicRelaySetupInfoDialog.kt | 2 + .../relays/common/RelayEventCountRow.kt | 85 +++++++ .../relays/common/RelayEventCountViewModel.kt | 214 ++++++++++++++++++ .../loggedIn/relays/dm/DMRelayListView.kt | 4 + .../relays/indexer/IndexerRelayListView.kt | 4 + .../nip37/PrivateOutboxRelayListView.kt | 4 + .../relays/nip65/Nip65RelayListView.kt | 6 + .../relays/proxy/ProxyRelayListView.kt | 4 + .../relays/search/SearchRelayListView.kt | 4 + amethyst/src/main/res/values/strings.xml | 1 + 12 files changed, 429 insertions(+), 7 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index e2b79d62cb..a9b79bb717 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.BlockedRelay import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.renderBlockedItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast.BroadcastRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast.renderBroadcastItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayEventCountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayExporter import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayListCollection import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayZipExporter @@ -109,6 +110,13 @@ fun AllRelayListScreen( val indexerViewModel: IndexerRelayListViewModel = viewModel() val proxyViewModel: ProxyRelayListViewModel = viewModel() val relayFeedsViewModel: RelayFeedsListViewModel = viewModel() + val outboxCountViewModel: RelayEventCountViewModel = viewModel(key = "outboxCount") + val inboxCountViewModel: RelayEventCountViewModel = viewModel(key = "inboxCount") + val dmCountViewModel: RelayEventCountViewModel = viewModel(key = "dmCount") + val privateHomeCountViewModel: RelayEventCountViewModel = viewModel(key = "privateHomeCount") + val proxyCountViewModel: RelayEventCountViewModel = viewModel(key = "proxyCount") + val indexerCountViewModel: RelayEventCountViewModel = viewModel(key = "indexerCount") + val searchCountViewModel: RelayEventCountViewModel = viewModel(key = "searchCount") dmViewModel.init(accountViewModel) nip65ViewModel.init(accountViewModel) @@ -151,6 +159,13 @@ fun AllRelayListScreen( indexerViewModel, proxyViewModel, relayFeedsViewModel, + outboxCountViewModel, + inboxCountViewModel, + dmCountViewModel, + privateHomeCountViewModel, + proxyCountViewModel, + indexerCountViewModel, + searchCountViewModel, accountViewModel, nav, ) @@ -171,6 +186,13 @@ fun MappedAllRelayListView( indexerViewModel: IndexerRelayListViewModel, proxyViewModel: ProxyRelayListViewModel, relayFeedsViewModel: RelayFeedsListViewModel, + outboxCountViewModel: RelayEventCountViewModel, + inboxCountViewModel: RelayEventCountViewModel, + dmCountViewModel: RelayEventCountViewModel, + privateHomeCountViewModel: RelayEventCountViewModel, + proxyCountViewModel: RelayEventCountViewModel, + indexerCountViewModel: RelayEventCountViewModel, + searchCountViewModel: RelayEventCountViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -188,6 +210,72 @@ fun MappedAllRelayListView( val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle() val relayFeedsFeedState by relayFeedsViewModel.relays.collectAsStateWithLifecycle() + val outboxCounts by outboxCountViewModel.counts.collectAsStateWithLifecycle() + val inboxCounts by inboxCountViewModel.counts.collectAsStateWithLifecycle() + val dmCounts by dmCountViewModel.counts.collectAsStateWithLifecycle() + val privateHomeCounts by privateHomeCountViewModel.counts.collectAsStateWithLifecycle() + val proxyCounts by proxyCountViewModel.counts.collectAsStateWithLifecycle() + val indexerCounts by indexerCountViewModel.counts.collectAsStateWithLifecycle() + val searchCounts by searchCountViewModel.counts.collectAsStateWithLifecycle() + + val userPubKey = accountViewModel.account.pubKey + + LaunchedEffect(homeFeedState) { + if (homeFeedState.isNotEmpty()) { + outboxCountViewModel.queryCountsForRelays( + RelayEventCountViewModel.authorCountFilters(userPubKey, homeFeedState.map { it.relay }), + ) + } + } + + LaunchedEffect(notifFeedState) { + if (notifFeedState.isNotEmpty()) { + inboxCountViewModel.queryCountsForRelays( + RelayEventCountViewModel.pTagCountFilters(userPubKey, notifFeedState.map { it.relay }), + ) + } + } + + LaunchedEffect(dmFeedState) { + if (dmFeedState.isNotEmpty()) { + dmCountViewModel.queryCountsForRelays( + RelayEventCountViewModel.dmCountFilters(userPubKey, dmFeedState.map { it.relay }), + ) + } + } + + LaunchedEffect(privateOutboxFeedState) { + if (privateOutboxFeedState.isNotEmpty()) { + privateHomeCountViewModel.queryCountsForRelays( + RelayEventCountViewModel.authorCountFilters(userPubKey, privateOutboxFeedState.map { it.relay }), + ) + } + } + + LaunchedEffect(proxyRelays) { + if (proxyRelays.isNotEmpty()) { + proxyCountViewModel.queryCountsForRelays( + RelayEventCountViewModel.totalCountFilters(proxyRelays.map { it.relay }), + ) + } + } + + LaunchedEffect(indexerRelays) { + if (indexerRelays.isNotEmpty()) { + indexerCountViewModel.queryCountsForRelays( + RelayEventCountViewModel.indexerCountFilters(indexerRelays.map { it.relay }), + ) + } + } + + LaunchedEffect(searchFeedState) { + if (searchFeedState.isNotEmpty()) { + searchCountViewModel.queryCountsForRelays( + RelayEventCountViewModel.totalCountFilters(searchFeedState.map { it.relay }), + ) + } + } + Scaffold( topBar = { SavingTopBar( @@ -261,7 +349,7 @@ fun MappedAllRelayListView( SettingsCategoryFirstModifier, ) } - renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, nav) + renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, nav, outboxCounts) item { SettingsCategory( @@ -270,7 +358,7 @@ fun MappedAllRelayListView( SettingsCategorySpacingModifier, ) } - renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, nav) + renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, nav, inboxCounts) item { SettingsCategoryWithButton( @@ -282,7 +370,7 @@ fun MappedAllRelayListView( }, ) } - renderDMItems(dmFeedState, dmViewModel, accountViewModel, nav) + renderDMItems(dmFeedState, dmViewModel, accountViewModel, nav, dmCounts) item { SettingsCategory( @@ -291,7 +379,7 @@ fun MappedAllRelayListView( SettingsCategorySpacingModifier, ) } - renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, nav) + renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, nav, privateHomeCounts) item { SettingsCategory( @@ -300,7 +388,7 @@ fun MappedAllRelayListView( SettingsCategorySpacingModifier, ) } - renderProxyItems(proxyRelays, proxyViewModel, accountViewModel, nav) + renderProxyItems(proxyRelays, proxyViewModel, accountViewModel, nav, proxyCounts) item { SettingsCategory( @@ -320,7 +408,7 @@ fun MappedAllRelayListView( ResetIndexerRelays(indexerViewModel) } } - renderIndexerItems(indexerRelays, indexerViewModel, accountViewModel, nav) + renderIndexerItems(indexerRelays, indexerViewModel, accountViewModel, nav, indexerCounts) item { SettingsCategoryWithButton( @@ -331,7 +419,7 @@ fun MappedAllRelayListView( ResetSearchRelays(searchViewModel) } } - renderSearchItems(searchFeedState, searchViewModel, accountViewModel, nav) + renderSearchItems(searchFeedState, searchViewModel, accountViewModel, nav, searchCounts) item { SettingsCategory( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index 73fad951f1..625fde7c29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -63,6 +63,7 @@ fun BasicRelaySetupInfoClickableRow( onClick: () -> Unit, nip11CachedRetriever: Nip11CachedRetriever, modifier: Modifier = Modifier, + countResult: RelayCountResult? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -104,6 +105,11 @@ fun BasicRelaySetupInfoClickableRow( UsedBy(item, accountViewModel, nav) + RelayEventCountRow( + countResult = countResult, + modifier = ReactionRowHeightChatMaxWidth, + ) + RelayStatusRow( item = item, onClick = onClick, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt index 1a85e2aec2..3196a11747 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt @@ -32,6 +32,7 @@ fun BasicRelaySetupInfoDialog( item: BasicRelaySetupInfo, nip11CachedRetriever: Nip11CachedRetriever, onDelete: ((BasicRelaySetupInfo) -> Unit)?, + countResult: RelayCountResult? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -43,6 +44,7 @@ fun BasicRelaySetupInfoDialog( onClick = { nav.nav(Route.RelayInfo(item.relay.url)) }, nip11CachedRetriever = nip11CachedRetriever, modifier = HalfVertPadding, + countResult = countResult, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt new file mode 100644 index 0000000000..c1858eb9d7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt @@ -0,0 +1,85 @@ +/* + * 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.relays.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.countToHumanReadable +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun RelayEventCountRow( + countResult: RelayCountResult?, + modifier: Modifier, +) { + if (countResult == null || countResult.counts.isEmpty()) return + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = modifier, + ) { + Icon( + imageVector = Icons.Default.Storage, + contentDescription = stringRes(R.string.relay_event_count), + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.allGoodColor, + ) + + countResult.counts.forEachIndexed { index, entry -> + if (index > 0) { + Spacer(modifier = Modifier.width(8.dp)) + } + + val text = + if (entry.approximate) { + "~${countToHumanReadable(entry.count, entry.label)}" + } else { + countToHumanReadable(entry.count, entry.label) + } + + Text( + text = text, + maxLines = 1, + fontSize = Font12SP, + modifier = HalfStartPadding, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt new file mode 100644 index 0000000000..5529c2d920 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt @@ -0,0 +1,214 @@ +/* + * 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.relays.common + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +@Immutable +data class RelayCountResult( + val counts: List = emptyList(), +) { + @Immutable + data class CountEntry( + val label: String, + val count: Int, + val approximate: Boolean = false, + ) +} + +class RelayEventCountViewModel : ViewModel(), IRelayClientListener { + private val client: INostrClient get() = Amethyst.instance.client + + private val _counts = MutableStateFlow>(emptyMap()) + val counts = _counts.asStateFlow() + + // Maps subId -> (relay, filterIndex) so we can route count results + private val subIdToRelay = mutableMapOf>() + // Maps relay -> list of (subId, label) to track active queries + private val relayQueries = mutableMapOf>() + + private data class QueryInfo( + val subId: String, + val label: String, + val filterIndex: Int, + ) + + fun queryCountsForRelays(queries: Map>) { + cleanup() + client.subscribe(this) + + queries.forEach { (relay, filters) -> + val queryInfos = mutableListOf() + + filters.forEachIndexed { index, countFilter -> + val subId = newSubId() + subIdToRelay[subId] = Pair(relay, index) + queryInfos.add(QueryInfo(subId, countFilter.label, index)) + + client.queryCount( + subId = subId, + filters = mapOf(relay to listOf(countFilter.filter)), + ) + } + + relayQueries[relay] = queryInfos + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is CountMessage) { + val (relayUrl, filterIndex) = subIdToRelay[msg.queryId] ?: return + val queryInfos = relayQueries[relayUrl] ?: return + val queryInfo = queryInfos.find { it.filterIndex == filterIndex } ?: return + + _counts.update { currentMap -> + val currentResult = currentMap[relayUrl] ?: RelayCountResult() + val updatedEntries = currentResult.counts.toMutableList() + + // Replace or add the entry for this filter + val existingIndex = updatedEntries.indexOfFirst { it.label == queryInfo.label } + val newEntry = + RelayCountResult.CountEntry( + label = queryInfo.label, + count = msg.result.count, + approximate = msg.result.approximate, + ) + + if (existingIndex >= 0) { + updatedEntries[existingIndex] = newEntry + } else { + updatedEntries.add(newEntry) + } + + currentMap + (relayUrl to RelayCountResult(updatedEntries)) + } + } + } + + private fun cleanup() { + // Close existing queries + subIdToRelay.keys.forEach { subId -> + client.close(subId) + } + subIdToRelay.clear() + relayQueries.clear() + _counts.value = emptyMap() + client.unsubscribe(this) + } + + override fun onCleared() { + cleanup() + super.onCleared() + } + + companion object { + fun authorCountFilters( + userPubKey: HexKey, + relays: List, + ): Map> = + relays.associateWith { + listOf( + CountFilter( + label = "events", + filter = Filter(authors = listOf(userPubKey)), + ), + ) + } + + fun pTagCountFilters( + userPubKey: HexKey, + relays: List, + ): Map> = + relays.associateWith { + listOf( + CountFilter( + label = "events", + filter = Filter(tags = mapOf("p" to listOf(userPubKey))), + ), + ) + } + + fun dmCountFilters( + userPubKey: HexKey, + relays: List, + ): Map> = + relays.associateWith { + listOf( + CountFilter( + label = "events", + filter = + Filter( + kinds = listOf(GiftWrapEvent.KIND, PrivateDmEvent.KIND), + tags = mapOf("p" to listOf(userPubKey)), + ), + ), + ) + } + + fun totalCountFilters(relays: List): Map> = + relays.associateWith { + listOf( + CountFilter( + label = "events", + filter = Filter(kinds = null), + ), + ) + } + + fun indexerCountFilters(relays: List): Map> = + relays.associateWith { + listOf( + CountFilter( + label = "kind 0", + filter = Filter(kinds = listOf(0)), + ), + CountFilter( + label = "kind 10002", + filter = Filter(kinds = listOf(10002)), + ), + ) + } + } +} + +data class CountFilter( + val label: String, + val filter: Filter, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt index 37e02b99be..f6e9395714 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun DMRelayList( @@ -64,12 +66,14 @@ fun LazyListScope.renderDMItems( postViewModel: DMRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "DM" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt index 1af2490cdf..56dbf8018c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun IndexerRelayList( @@ -65,12 +67,14 @@ fun LazyListScope.renderIndexerItems( postViewModel: IndexerRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Indexer" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt index 212e1b3b50..a372d7286b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun PrivateOutboxRelayList( @@ -64,12 +66,14 @@ fun LazyListScope.renderPrivateOutboxItems( postViewModel: PrivateOutboxRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Outbox" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt index 7ac2497f0c..73c201b6bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun Nip65RelayList( @@ -107,12 +109,14 @@ fun LazyListScope.renderNip65HomeItems( postViewModel: Nip65RelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteHomeRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) @@ -133,12 +137,14 @@ fun LazyListScope.renderNip65NotifItems( postViewModel: Nip65RelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteNotifRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt index 2997a5c398..a737641f2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun ProxyRelayList( @@ -65,12 +67,14 @@ fun LazyListScope.renderProxyItems( postViewModel: ProxyRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Proxy" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt index 15f6975960..1077d42e2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun SearchRelayList( @@ -65,12 +67,14 @@ fun LazyListScope.renderSearchItems( postViewModel: SearchRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Search" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 36ce301422..e322c05e7e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -776,6 +776,7 @@ Write to Relay The amount in bytes that was sent to this relay, including filters and events The amount in bytes that was received from this relay, including filters and events + Events stored An error occurred trying to get relay information from %1$s Owner Used By From 764c2241cae17c65939de0395f064fbe1a422153 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 15:02:09 +0000 Subject: [PATCH 2/5] refactor: integrate relay event counts into each ViewModel Move count query logic from separate RelayEventCountViewModel into each relay list's own ViewModel. BasicRelaySetupInfoModel now manages counts via countFilters() override. Nip65RelayListViewModel gets its own count infrastructure for home (outbox) and notification (inbox) relay lists. Simplify AllRelayListScreen by removing 7 extra count ViewModels and their LaunchedEffects. https://claude.ai/code/session_016158D5mq5BygS1uBbLNbsA --- .../loggedIn/relays/AllRelayListScreen.kt | 94 +--------- .../relays/common/BasicRelaySetupInfoModel.kt | 104 ++++++++++- .../relays/common/RelayEventCountViewModel.kt | 173 ------------------ .../relays/dm/DMRelayListViewModel.kt | 16 ++ .../indexer/IndexerRelayListViewModel.kt | 14 ++ .../nip37/PrivateOutboxRelayListViewModel.kt | 10 + .../relays/nip65/Nip65RelayListViewModel.kt | 92 +++++++++- .../relays/proxy/ProxyRelayListViewModel.kt | 10 + .../relays/search/SearchRelayListViewModel.kt | 10 + 9 files changed, 261 insertions(+), 262 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index a9b79bb717..e45562fe9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -60,7 +60,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.BlockedRelay import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.renderBlockedItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast.BroadcastRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast.renderBroadcastItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayEventCountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayExporter import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayListCollection import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayZipExporter @@ -110,13 +109,6 @@ fun AllRelayListScreen( val indexerViewModel: IndexerRelayListViewModel = viewModel() val proxyViewModel: ProxyRelayListViewModel = viewModel() val relayFeedsViewModel: RelayFeedsListViewModel = viewModel() - val outboxCountViewModel: RelayEventCountViewModel = viewModel(key = "outboxCount") - val inboxCountViewModel: RelayEventCountViewModel = viewModel(key = "inboxCount") - val dmCountViewModel: RelayEventCountViewModel = viewModel(key = "dmCount") - val privateHomeCountViewModel: RelayEventCountViewModel = viewModel(key = "privateHomeCount") - val proxyCountViewModel: RelayEventCountViewModel = viewModel(key = "proxyCount") - val indexerCountViewModel: RelayEventCountViewModel = viewModel(key = "indexerCount") - val searchCountViewModel: RelayEventCountViewModel = viewModel(key = "searchCount") dmViewModel.init(accountViewModel) nip65ViewModel.init(accountViewModel) @@ -159,13 +151,6 @@ fun AllRelayListScreen( indexerViewModel, proxyViewModel, relayFeedsViewModel, - outboxCountViewModel, - inboxCountViewModel, - dmCountViewModel, - privateHomeCountViewModel, - proxyCountViewModel, - indexerCountViewModel, - searchCountViewModel, accountViewModel, nav, ) @@ -186,13 +171,6 @@ fun MappedAllRelayListView( indexerViewModel: IndexerRelayListViewModel, proxyViewModel: ProxyRelayListViewModel, relayFeedsViewModel: RelayFeedsListViewModel, - outboxCountViewModel: RelayEventCountViewModel, - inboxCountViewModel: RelayEventCountViewModel, - dmCountViewModel: RelayEventCountViewModel, - privateHomeCountViewModel: RelayEventCountViewModel, - proxyCountViewModel: RelayEventCountViewModel, - indexerCountViewModel: RelayEventCountViewModel, - searchCountViewModel: RelayEventCountViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -210,71 +188,13 @@ fun MappedAllRelayListView( val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle() val relayFeedsFeedState by relayFeedsViewModel.relays.collectAsStateWithLifecycle() - val outboxCounts by outboxCountViewModel.counts.collectAsStateWithLifecycle() - val inboxCounts by inboxCountViewModel.counts.collectAsStateWithLifecycle() - val dmCounts by dmCountViewModel.counts.collectAsStateWithLifecycle() - val privateHomeCounts by privateHomeCountViewModel.counts.collectAsStateWithLifecycle() - val proxyCounts by proxyCountViewModel.counts.collectAsStateWithLifecycle() - val indexerCounts by indexerCountViewModel.counts.collectAsStateWithLifecycle() - val searchCounts by searchCountViewModel.counts.collectAsStateWithLifecycle() - - val userPubKey = accountViewModel.account.pubKey - - LaunchedEffect(homeFeedState) { - if (homeFeedState.isNotEmpty()) { - outboxCountViewModel.queryCountsForRelays( - RelayEventCountViewModel.authorCountFilters(userPubKey, homeFeedState.map { it.relay }), - ) - } - } - - LaunchedEffect(notifFeedState) { - if (notifFeedState.isNotEmpty()) { - inboxCountViewModel.queryCountsForRelays( - RelayEventCountViewModel.pTagCountFilters(userPubKey, notifFeedState.map { it.relay }), - ) - } - } - - LaunchedEffect(dmFeedState) { - if (dmFeedState.isNotEmpty()) { - dmCountViewModel.queryCountsForRelays( - RelayEventCountViewModel.dmCountFilters(userPubKey, dmFeedState.map { it.relay }), - ) - } - } - - LaunchedEffect(privateOutboxFeedState) { - if (privateOutboxFeedState.isNotEmpty()) { - privateHomeCountViewModel.queryCountsForRelays( - RelayEventCountViewModel.authorCountFilters(userPubKey, privateOutboxFeedState.map { it.relay }), - ) - } - } - - LaunchedEffect(proxyRelays) { - if (proxyRelays.isNotEmpty()) { - proxyCountViewModel.queryCountsForRelays( - RelayEventCountViewModel.totalCountFilters(proxyRelays.map { it.relay }), - ) - } - } - - LaunchedEffect(indexerRelays) { - if (indexerRelays.isNotEmpty()) { - indexerCountViewModel.queryCountsForRelays( - RelayEventCountViewModel.indexerCountFilters(indexerRelays.map { it.relay }), - ) - } - } - - LaunchedEffect(searchFeedState) { - if (searchFeedState.isNotEmpty()) { - searchCountViewModel.queryCountsForRelays( - RelayEventCountViewModel.totalCountFilters(searchFeedState.map { it.relay }), - ) - } - } + val outboxCounts by nip65ViewModel.homeCountResults.collectAsStateWithLifecycle() + val inboxCounts by nip65ViewModel.notifCountResults.collectAsStateWithLifecycle() + val dmCounts by dmViewModel.countResults.collectAsStateWithLifecycle() + val privateHomeCounts by privateOutboxViewModel.countResults.collectAsStateWithLifecycle() + val proxyCounts by proxyViewModel.countResults.collectAsStateWithLifecycle() + val indexerCounts by indexerViewModel.countResults.collectAsStateWithLifecycle() + val searchCounts by searchViewModel.countResults.collectAsStateWithLifecycle() Scaffold( topBar = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index 758c39646f..466cc16820 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -26,6 +26,11 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -33,13 +38,19 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -abstract class BasicRelaySetupInfoModel : ViewModel() { +abstract class BasicRelaySetupInfoModel : ViewModel(), IRelayClientListener { lateinit var accountViewModel: AccountViewModel lateinit var account: Account private val _relays = MutableStateFlow>(emptyList()) val relays = _relays.asStateFlow() + private val _countResults = MutableStateFlow>(emptyMap()) + val countResults = _countResults.asStateFlow() + + private val subIdToRelay = mutableMapOf>() + private val relayQueryInfos = mutableMapOf>() + var hasModified = false fun init(accountViewModel: AccountViewModel) { @@ -50,12 +61,15 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { fun load() { clear() loadRelayDocuments() + loadCounts() } abstract fun getRelayList(): List? abstract suspend fun saveRelayList(urlList: List) + open fun countFilters(relayUrl: NormalizedRelayUrl): List = emptyList() + fun create() { if (hasModified) { accountViewModel.launchSigner { @@ -79,6 +93,83 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { } } + private fun loadCounts() { + val client = Amethyst.instance.client + cleanupCounts() + + val relayList = _relays.value + if (relayList.isEmpty()) return + + val hasFilters = relayList.any { countFilters(it.relay).isNotEmpty() } + if (!hasFilters) return + + client.subscribe(this) + + relayList.forEach { item -> + val filters = countFilters(item.relay) + if (filters.isEmpty()) return@forEach + + val queryInfos = mutableListOf() + + filters.forEachIndexed { index, countFilter -> + val subId = newSubId() + subIdToRelay[subId] = Pair(item.relay, index) + queryInfos.add(CountQueryInfo(subId, countFilter.label, index)) + + client.queryCount( + subId = subId, + filters = mapOf(item.relay to listOf(countFilter.filter)), + ) + } + + relayQueryInfos[item.relay] = queryInfos + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is CountMessage) { + val (relayUrl, filterIndex) = subIdToRelay[msg.queryId] ?: return + val queryInfos = relayQueryInfos[relayUrl] ?: return + val queryInfo = queryInfos.find { it.filterIndex == filterIndex } ?: return + + _countResults.update { currentMap -> + val currentResult = currentMap[relayUrl] ?: RelayCountResult() + val updatedEntries = currentResult.counts.toMutableList() + + val existingIndex = updatedEntries.indexOfFirst { it.label == queryInfo.label } + val newEntry = + RelayCountResult.CountEntry( + label = queryInfo.label, + count = msg.result.count, + approximate = msg.result.approximate, + ) + + if (existingIndex >= 0) { + updatedEntries[existingIndex] = newEntry + } else { + updatedEntries.add(newEntry) + } + + currentMap + (relayUrl to RelayCountResult(updatedEntries)) + } + } + } + + private fun cleanupCounts() { + val client = Amethyst.instance.client + subIdToRelay.keys.forEach { subId -> + client.close(subId) + } + subIdToRelay.clear() + relayQueryInfos.clear() + _countResults.value = emptyMap() + client.unsubscribe(this) + } + open fun relayListBuilder(): List { val relayList = getRelayList() ?: emptyList() @@ -122,4 +213,15 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { ) { _relays.update { it.replace(relay, relay.copy(paidRelay = paid)) } } + + override fun onCleared() { + cleanupCounts() + super.onCleared() + } + + private data class CountQueryInfo( + val subId: String, + val label: String, + val filterIndex: Int, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt index 5529c2d920..6f77913fff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt @@ -21,21 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Immutable -import androidx.lifecycle.ViewModel -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update @Immutable data class RelayCountResult( @@ -49,165 +35,6 @@ data class RelayCountResult( ) } -class RelayEventCountViewModel : ViewModel(), IRelayClientListener { - private val client: INostrClient get() = Amethyst.instance.client - - private val _counts = MutableStateFlow>(emptyMap()) - val counts = _counts.asStateFlow() - - // Maps subId -> (relay, filterIndex) so we can route count results - private val subIdToRelay = mutableMapOf>() - // Maps relay -> list of (subId, label) to track active queries - private val relayQueries = mutableMapOf>() - - private data class QueryInfo( - val subId: String, - val label: String, - val filterIndex: Int, - ) - - fun queryCountsForRelays(queries: Map>) { - cleanup() - client.subscribe(this) - - queries.forEach { (relay, filters) -> - val queryInfos = mutableListOf() - - filters.forEachIndexed { index, countFilter -> - val subId = newSubId() - subIdToRelay[subId] = Pair(relay, index) - queryInfos.add(QueryInfo(subId, countFilter.label, index)) - - client.queryCount( - subId = subId, - filters = mapOf(relay to listOf(countFilter.filter)), - ) - } - - relayQueries[relay] = queryInfos - } - } - - override fun onIncomingMessage( - relay: IRelayClient, - msgStr: String, - msg: Message, - ) { - if (msg is CountMessage) { - val (relayUrl, filterIndex) = subIdToRelay[msg.queryId] ?: return - val queryInfos = relayQueries[relayUrl] ?: return - val queryInfo = queryInfos.find { it.filterIndex == filterIndex } ?: return - - _counts.update { currentMap -> - val currentResult = currentMap[relayUrl] ?: RelayCountResult() - val updatedEntries = currentResult.counts.toMutableList() - - // Replace or add the entry for this filter - val existingIndex = updatedEntries.indexOfFirst { it.label == queryInfo.label } - val newEntry = - RelayCountResult.CountEntry( - label = queryInfo.label, - count = msg.result.count, - approximate = msg.result.approximate, - ) - - if (existingIndex >= 0) { - updatedEntries[existingIndex] = newEntry - } else { - updatedEntries.add(newEntry) - } - - currentMap + (relayUrl to RelayCountResult(updatedEntries)) - } - } - } - - private fun cleanup() { - // Close existing queries - subIdToRelay.keys.forEach { subId -> - client.close(subId) - } - subIdToRelay.clear() - relayQueries.clear() - _counts.value = emptyMap() - client.unsubscribe(this) - } - - override fun onCleared() { - cleanup() - super.onCleared() - } - - companion object { - fun authorCountFilters( - userPubKey: HexKey, - relays: List, - ): Map> = - relays.associateWith { - listOf( - CountFilter( - label = "events", - filter = Filter(authors = listOf(userPubKey)), - ), - ) - } - - fun pTagCountFilters( - userPubKey: HexKey, - relays: List, - ): Map> = - relays.associateWith { - listOf( - CountFilter( - label = "events", - filter = Filter(tags = mapOf("p" to listOf(userPubKey))), - ), - ) - } - - fun dmCountFilters( - userPubKey: HexKey, - relays: List, - ): Map> = - relays.associateWith { - listOf( - CountFilter( - label = "events", - filter = - Filter( - kinds = listOf(GiftWrapEvent.KIND, PrivateDmEvent.KIND), - tags = mapOf("p" to listOf(userPubKey)), - ), - ), - ) - } - - fun totalCountFilters(relays: List): Map> = - relays.associateWith { - listOf( - CountFilter( - label = "events", - filter = Filter(kinds = null), - ), - ) - } - - fun indexerCountFilters(relays: List): Map> = - relays.associateWith { - listOf( - CountFilter( - label = "kind 0", - filter = Filter(kinds = listOf(0)), - ), - CountFilter( - label = "kind 10002", - filter = Filter(kinds = listOf(10002)), - ), - ) - } - } -} - data class CountFilter( val label: String, val filter: Filter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt index d08612a485..a78df4519d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt @@ -22,7 +22,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @Stable class DMRelayListViewModel : BasicRelaySetupInfoModel() { @@ -31,4 +35,16 @@ class DMRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.saveDMRelayList(urlList) } + + override fun countFilters(relayUrl: NormalizedRelayUrl): List = + listOf( + CountFilter( + label = "events", + filter = + Filter( + kinds = listOf(GiftWrapEvent.KIND, PrivateDmEvent.KIND), + tags = mapOf("p" to listOf(account.pubKey)), + ), + ), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt index 8379666245..c875c5a63f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable @@ -33,4 +35,16 @@ class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.saveIndexerRelayList(urlList) } + + override fun countFilters(relayUrl: NormalizedRelayUrl): List = + listOf( + CountFilter( + label = "kind 0", + filter = Filter(kinds = listOf(0)), + ), + CountFilter( + label = "kind 10002", + filter = Filter(kinds = listOf(10002)), + ), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt index a3e5f9f41f..5cd3e96ff9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37 import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable @@ -33,4 +35,12 @@ class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.savePrivateOutboxRelayList(urlList) } + + override fun countFilters(relayUrl: NormalizedRelayUrl): List = + listOf( + CountFilter( + label = "events", + filter = Filter(authors = listOf(account.pubKey)), + ), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index 134f7425c9..fa1427b892 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -28,7 +28,15 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import kotlinx.coroutines.Dispatchers @@ -38,7 +46,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @Stable -class Nip65RelayListViewModel : ViewModel() { +class Nip65RelayListViewModel : ViewModel(), IRelayClientListener { private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account @@ -48,6 +56,14 @@ class Nip65RelayListViewModel : ViewModel() { private val _notificationRelays = MutableStateFlow>(emptyList()) val notificationRelays = _notificationRelays.asStateFlow() + private val _homeCountResults = MutableStateFlow>(emptyMap()) + val homeCountResults = _homeCountResults.asStateFlow() + + private val _notifCountResults = MutableStateFlow>(emptyMap()) + val notifCountResults = _notifCountResults.asStateFlow() + + private val subIdToRelay = mutableMapOf>() + var hasModified = false fun init(accountViewModel: AccountViewModel) { @@ -58,6 +74,7 @@ class Nip65RelayListViewModel : ViewModel() { fun load() { clear() loadRelayDocuments() + loadCounts() } fun create() { @@ -111,6 +128,74 @@ class Nip65RelayListViewModel : ViewModel() { } } + private fun loadCounts() { + val client = Amethyst.instance.client + cleanupCounts() + + val homeList = _homeRelays.value + val notifList = _notificationRelays.value + + if (homeList.isEmpty() && notifList.isEmpty()) return + + client.subscribe(this) + + homeList.forEach { item -> + val subId = newSubId() + subIdToRelay[subId] = Pair(item.relay, true) + client.queryCount( + subId = subId, + filters = mapOf(item.relay to listOf(Filter(authors = listOf(account.pubKey)))), + ) + } + + notifList.forEach { item -> + val subId = newSubId() + subIdToRelay[subId] = Pair(item.relay, false) + client.queryCount( + subId = subId, + filters = mapOf(item.relay to listOf(Filter(tags = mapOf("p" to listOf(account.pubKey))))), + ) + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is CountMessage) { + val (relayUrl, isHome) = subIdToRelay[msg.queryId] ?: return + + val newResult = + RelayCountResult( + listOf( + RelayCountResult.CountEntry( + label = "events", + count = msg.result.count, + approximate = msg.result.approximate, + ), + ), + ) + + if (isHome) { + _homeCountResults.update { it + (relayUrl to newResult) } + } else { + _notifCountResults.update { it + (relayUrl to newResult) } + } + } + } + + private fun cleanupCounts() { + val client = Amethyst.instance.client + subIdToRelay.keys.forEach { subId -> + client.close(subId) + } + subIdToRelay.clear() + _homeCountResults.value = emptyMap() + _notifCountResults.value = emptyMap() + client.unsubscribe(this) + } + fun clear() { hasModified = false _homeRelays.update { @@ -181,4 +266,9 @@ class Nip65RelayListViewModel : ViewModel() { ) { _notificationRelays.update { it.replace(relay, relay.copy(paidRelay = paid)) } } + + override fun onCleared() { + cleanupCounts() + super.onCleared() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt index e2a0f13596..01d07c40b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable @@ -33,4 +35,12 @@ class ProxyRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.saveProxyRelayList(urlList) } + + override fun countFilters(relayUrl: NormalizedRelayUrl): List = + listOf( + CountFilter( + label = "events", + filter = Filter(), + ), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt index e2e3ba0aa0..aaf592a68b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable @@ -33,4 +35,12 @@ class SearchRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.saveSearchRelayList(urlList) } + + override fun countFilters(relayUrl: NormalizedRelayUrl): List = + listOf( + CountFilter( + label = "events", + filter = Filter(), + ), + ) } From 17c3611f503951c19612331b4d3eee63c65d236c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 15:46:56 +0000 Subject: [PATCH 3/5] feat: redesign RelayEventCountRow with pill chip style Replace the plain icon+text count display with rounded pill chips that have a subtle green border and tinted background. Each count entry gets its own pill with an icon and bold text, making the event counts more visually distinct and scannable. https://claude.ai/code/session_016158D5mq5BygS1uBbLNbsA --- .../relays/common/RelayEventCountRow.kt | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt index c1858eb9d7..b62432f4b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt @@ -20,10 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Storage import androidx.compose.material3.Icon @@ -32,15 +36,17 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.countToHumanReadable import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font12SP -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding -import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.Font10SP +import com.vitorpamplona.amethyst.ui.theme.Size10Modifier import com.vitorpamplona.amethyst.ui.theme.allGoodColor -import com.vitorpamplona.amethyst.ui.theme.placeholderText + +private val PillShape = RoundedCornerShape(12.dp) @Composable fun RelayEventCountRow( @@ -49,37 +55,51 @@ fun RelayEventCountRow( ) { if (countResult == null || countResult.counts.isEmpty()) return + val pillColor = MaterialTheme.colorScheme.allGoodColor + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, modifier = modifier, ) { - Icon( - imageVector = Icons.Default.Storage, - contentDescription = stringRes(R.string.relay_event_count), - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.allGoodColor, - ) - countResult.counts.forEachIndexed { index, entry -> if (index > 0) { - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(6.dp)) } - val text = + val countText = if (entry.approximate) { "~${countToHumanReadable(entry.count, entry.label)}" } else { countToHumanReadable(entry.count, entry.label) } - Text( - text = text, - maxLines = 1, - fontSize = Font12SP, - modifier = HalfStartPadding, - color = MaterialTheme.colorScheme.placeholderText, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .clip(PillShape) + .border(width = 1.dp, color = pillColor.copy(alpha = 0.4f), shape = PillShape) + .background(pillColor.copy(alpha = 0.1f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Icon( + imageVector = Icons.Default.Storage, + contentDescription = stringRes(R.string.relay_event_count), + modifier = Size10Modifier, + tint = pillColor, + ) + + Spacer(modifier = Modifier.width(3.dp)) + + Text( + text = countText, + maxLines = 1, + fontSize = Font10SP, + fontWeight = FontWeight.Medium, + color = pillColor, + ) + } } } } From 22945b7faa5ae8b418461eef2c3a79d029fcbaff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 16:14:24 +0000 Subject: [PATCH 4/5] feat: add queryCountSuspend utility to Quartz and simplify ViewModels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add INostrClient.queryCountSuspend() extension functions in Quartz that wrap NIP-45 COUNT queries as suspend functions, managing subscription lifecycle internally. Two overloads: single-relay and multi-relay. Simplify BasicRelaySetupInfoModel and Nip65RelayListViewModel to use the new utility — removes IRelayClientListener implementation, subId tracking maps, onIncomingMessage handlers, and cleanup logic from both ViewModels. https://claude.ai/code/session_016158D5mq5BygS1uBbLNbsA --- .../relays/common/BasicRelaySetupInfoModel.kt | 105 +++----------- .../relays/nip65/Nip65RelayListViewModel.kt | 118 ++++++--------- .../client/accessories/NostrClientCountExt.kt | 136 ++++++++++++++++++ 3 files changed, 200 insertions(+), 159 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index 466cc16820..e198edcc7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -26,11 +26,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.queryCountSuspend import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -38,7 +34,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -abstract class BasicRelaySetupInfoModel : ViewModel(), IRelayClientListener { +abstract class BasicRelaySetupInfoModel : ViewModel() { lateinit var accountViewModel: AccountViewModel lateinit var account: Account @@ -48,9 +44,6 @@ abstract class BasicRelaySetupInfoModel : ViewModel(), IRelayClientListener { private val _countResults = MutableStateFlow>(emptyMap()) val countResults = _countResults.asStateFlow() - private val subIdToRelay = mutableMapOf>() - private val relayQueryInfos = mutableMapOf>() - var hasModified = false fun init(accountViewModel: AccountViewModel) { @@ -94,82 +87,39 @@ abstract class BasicRelaySetupInfoModel : ViewModel(), IRelayClientListener { } private fun loadCounts() { - val client = Amethyst.instance.client - cleanupCounts() + _countResults.value = emptyMap() + val client = Amethyst.instance.client val relayList = _relays.value if (relayList.isEmpty()) return - val hasFilters = relayList.any { countFilters(it.relay).isNotEmpty() } - if (!hasFilters) return - - client.subscribe(this) - relayList.forEach { item -> val filters = countFilters(item.relay) if (filters.isEmpty()) return@forEach - val queryInfos = mutableListOf() - - filters.forEachIndexed { index, countFilter -> - val subId = newSubId() - subIdToRelay[subId] = Pair(item.relay, index) - queryInfos.add(CountQueryInfo(subId, countFilter.label, index)) - - client.queryCount( - subId = subId, - filters = mapOf(item.relay to listOf(countFilter.filter)), - ) - } - - relayQueryInfos[item.relay] = queryInfos - } - } - - override fun onIncomingMessage( - relay: IRelayClient, - msgStr: String, - msg: Message, - ) { - if (msg is CountMessage) { - val (relayUrl, filterIndex) = subIdToRelay[msg.queryId] ?: return - val queryInfos = relayQueryInfos[relayUrl] ?: return - val queryInfo = queryInfos.find { it.filterIndex == filterIndex } ?: return - - _countResults.update { currentMap -> - val currentResult = currentMap[relayUrl] ?: RelayCountResult() - val updatedEntries = currentResult.counts.toMutableList() - - val existingIndex = updatedEntries.indexOfFirst { it.label == queryInfo.label } - val newEntry = - RelayCountResult.CountEntry( - label = queryInfo.label, - count = msg.result.count, - approximate = msg.result.approximate, - ) - - if (existingIndex >= 0) { - updatedEntries[existingIndex] = newEntry - } else { - updatedEntries.add(newEntry) + filters.forEach { countFilter -> + viewModelScope.launch(Dispatchers.IO) { + val result = client.queryCountSuspend(item.relay, countFilter.filter) + if (result != null) { + _countResults.update { currentMap -> + val current = currentMap[item.relay] ?: RelayCountResult() + val entries = current.counts.toMutableList() + val newEntry = + RelayCountResult.CountEntry( + label = countFilter.label, + count = result.count, + approximate = result.approximate, + ) + val existing = entries.indexOfFirst { it.label == countFilter.label } + if (existing >= 0) entries[existing] = newEntry else entries.add(newEntry) + currentMap + (item.relay to RelayCountResult(entries)) + } + } } - - currentMap + (relayUrl to RelayCountResult(updatedEntries)) } } } - private fun cleanupCounts() { - val client = Amethyst.instance.client - subIdToRelay.keys.forEach { subId -> - client.close(subId) - } - subIdToRelay.clear() - relayQueryInfos.clear() - _countResults.value = emptyMap() - client.unsubscribe(this) - } - open fun relayListBuilder(): List { val relayList = getRelayList() ?: emptyList() @@ -213,15 +163,4 @@ abstract class BasicRelaySetupInfoModel : ViewModel(), IRelayClientListener { ) { _relays.update { it.replace(relay, relay.copy(paidRelay = paid)) } } - - override fun onCleared() { - cleanupCounts() - super.onCleared() - } - - private data class CountQueryInfo( - val subId: String, - val label: String, - val filterIndex: Int, - ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index fa1427b892..0894a0f047 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -30,11 +30,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.queryCountSuspend import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo @@ -46,7 +42,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @Stable -class Nip65RelayListViewModel : ViewModel(), IRelayClientListener { +class Nip65RelayListViewModel : ViewModel() { private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account @@ -62,8 +58,6 @@ class Nip65RelayListViewModel : ViewModel(), IRelayClientListener { private val _notifCountResults = MutableStateFlow>(emptyMap()) val notifCountResults = _notifCountResults.asStateFlow() - private val subIdToRelay = mutableMapOf>() - var hasModified = false fun init(accountViewModel: AccountViewModel) { @@ -129,71 +123,48 @@ class Nip65RelayListViewModel : ViewModel(), IRelayClientListener { } private fun loadCounts() { - val client = Amethyst.instance.client - cleanupCounts() - - val homeList = _homeRelays.value - val notifList = _notificationRelays.value - - if (homeList.isEmpty() && notifList.isEmpty()) return - - client.subscribe(this) - - homeList.forEach { item -> - val subId = newSubId() - subIdToRelay[subId] = Pair(item.relay, true) - client.queryCount( - subId = subId, - filters = mapOf(item.relay to listOf(Filter(authors = listOf(account.pubKey)))), - ) - } - - notifList.forEach { item -> - val subId = newSubId() - subIdToRelay[subId] = Pair(item.relay, false) - client.queryCount( - subId = subId, - filters = mapOf(item.relay to listOf(Filter(tags = mapOf("p" to listOf(account.pubKey))))), - ) - } - } - - override fun onIncomingMessage( - relay: IRelayClient, - msgStr: String, - msg: Message, - ) { - if (msg is CountMessage) { - val (relayUrl, isHome) = subIdToRelay[msg.queryId] ?: return - - val newResult = - RelayCountResult( - listOf( - RelayCountResult.CountEntry( - label = "events", - count = msg.result.count, - approximate = msg.result.approximate, - ), - ), - ) - - if (isHome) { - _homeCountResults.update { it + (relayUrl to newResult) } - } else { - _notifCountResults.update { it + (relayUrl to newResult) } - } - } - } - - private fun cleanupCounts() { - val client = Amethyst.instance.client - subIdToRelay.keys.forEach { subId -> - client.close(subId) - } - subIdToRelay.clear() _homeCountResults.value = emptyMap() _notifCountResults.value = emptyMap() - client.unsubscribe(this) + + val client = Amethyst.instance.client + + _homeRelays.value.forEach { item -> + viewModelScope.launch(Dispatchers.IO) { + val result = client.queryCountSuspend(item.relay, Filter(authors = listOf(account.pubKey))) + if (result != null) { + val countResult = + RelayCountResult( + listOf( + RelayCountResult.CountEntry( + label = "events", + count = result.count, + approximate = result.approximate, + ), + ), + ) + _homeCountResults.update { it + (item.relay to countResult) } + } + } + } + + _notificationRelays.value.forEach { item -> + viewModelScope.launch(Dispatchers.IO) { + val result = client.queryCountSuspend(item.relay, Filter(tags = mapOf("p" to listOf(account.pubKey)))) + if (result != null) { + val countResult = + RelayCountResult( + listOf( + RelayCountResult.CountEntry( + label = "events", + count = result.count, + approximate = result.approximate, + ), + ), + ) + _notifCountResults.update { it + (item.relay to countResult) } + } + } + } } fun clear() { @@ -266,9 +237,4 @@ class Nip65RelayListViewModel : ViewModel(), IRelayClientListener { ) { _notificationRelays.update { it.replace(relay, relay.copy(paidRelay = paid)) } } - - override fun onCleared() { - cleanupCounts() - super.onCleared() - } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt new file mode 100644 index 0000000000..b1ea3d05d7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt @@ -0,0 +1,136 @@ +/* + * 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.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Sends a NIP-45 COUNT query to a single relay and suspends until + * the result arrives or the timeout expires. + * + * @param relay Target relay to query. + * @param filter The filter to count against. + * @param timeoutMs How long to wait for a response (default 15 s). + * @return The [CountResult], or `null` on timeout. + */ +suspend fun INostrClient.queryCountSuspend( + relay: NormalizedRelayUrl, + filter: Filter, + timeoutMs: Long = 15_000, +): CountResult? { + val subId = newSubId() + val resultChannel = Channel(UNLIMITED) + + val listener = + object : IRelayClientListener { + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is CountMessage && msg.queryId == subId) { + resultChannel.trySend(msg.result) + } + } + } + + subscribe(listener) + + queryCount(subId = subId, filters = mapOf(relay to listOf(filter))) + + val result = + withTimeoutOrNull(timeoutMs) { + resultChannel.receive() + } + + close(subId) + unsubscribe(listener) + resultChannel.close() + + return result +} + +/** + * Sends NIP-45 COUNT queries to multiple relays in parallel + * (one filter per relay) and suspends until all results arrive + * or the timeout expires. + * + * @param filters Map of relay -> filter to count. + * @param timeoutMs How long to wait for all responses (default 15 s). + * @return Map of relay -> [CountResult] for every relay that responded in time. + */ +suspend fun INostrClient.queryCountSuspend( + filters: Map>, + timeoutMs: Long = 15_000, +): Map { + if (filters.isEmpty()) return emptyMap() + + val subIdToRelay = mutableMapOf() + val resultChannel = Channel>(UNLIMITED) + + val listener = + object : IRelayClientListener { + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is CountMessage) { + val relayUrl = subIdToRelay[msg.queryId] ?: return + resultChannel.trySend(relayUrl to msg.result) + } + } + } + + subscribe(listener) + + filters.forEach { (relay, filterList) -> + val subId = newSubId() + subIdToRelay[subId] = relay + queryCount(subId = subId, filters = mapOf(relay to filterList)) + } + + val results = mutableMapOf() + + withTimeoutOrNull(timeoutMs) { + while (results.size < filters.size) { + val (relay, result) = resultChannel.receive() + results[relay] = result + } + } + + subIdToRelay.keys.forEach { close(it) } + unsubscribe(listener) + resultChannel.close() + + return results +} From 0e476c898839dd412f75f7ff4da068fbd2723d07 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 19:51:39 -0400 Subject: [PATCH 5/5] Adds test and some refinements. --- .../relays/common/BasicRelaySetupInfoModel.kt | 2 +- .../relays/common/RelayEventCountRow.kt | 4 +- .../relays/common/RelayEventCountViewModel.kt | 4 +- .../relays/dm/DMRelayListViewModel.kt | 3 +- .../indexer/IndexerRelayListViewModel.kt | 11 ++- .../nip37/PrivateOutboxRelayListViewModel.kt | 3 +- .../relays/nip65/Nip65RelayListViewModel.kt | 5 +- .../relays/proxy/ProxyRelayListViewModel.kt | 10 -- .../relays/search/SearchRelayListViewModel.kt | 3 +- amethyst/src/main/res/values/strings.xml | 4 + .../kotlinSerialization/MessageKSerializer.kt | 4 + .../relay/client/accessories/RelayLogger.kt | 2 + .../toClient/CountResultDeserializer.kt | 4 +- .../commands/toClient/MessageSerializer.kt | 4 +- .../relay/NostrClientQueryCountTest.kt | 93 +++++++++++++++++++ 15 files changed, 128 insertions(+), 28 deletions(-) create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index e198edcc7b..91bc1f373b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -89,7 +89,7 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { private fun loadCounts() { _countResults.value = emptyMap() - val client = Amethyst.instance.client + val client = account.client val relayList = _relays.value if (relayList.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt index b62432f4b2..835eb764dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt @@ -69,9 +69,9 @@ fun RelayEventCountRow( val countText = if (entry.approximate) { - "~${countToHumanReadable(entry.count, entry.label)}" + "~${countToHumanReadable(entry.count, stringRes(entry.label))}" } else { - countToHumanReadable(entry.count, entry.label) + countToHumanReadable(entry.count, stringRes(entry.label)) } Row( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt index 6f77913fff..5aaa39b6ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt @@ -29,13 +29,13 @@ data class RelayCountResult( ) { @Immutable data class CountEntry( - val label: String, + val label: Int, val count: Int, val approximate: Boolean = false, ) } data class CountFilter( - val label: String, + val label: Int, val filter: Filter, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt index a78df4519d..53a6a5ed01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -39,7 +40,7 @@ class DMRelayListViewModel : BasicRelaySetupInfoModel() { override fun countFilters(relayUrl: NormalizedRelayUrl): List = listOf( CountFilter( - label = "events", + label = R.string.dms, filter = Filter( kinds = listOf(GiftWrapEvent.KIND, PrivateDmEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt index c875c5a63f..46da1f83e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt @@ -21,10 +21,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @Stable class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { @@ -39,12 +42,12 @@ class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { override fun countFilters(relayUrl: NormalizedRelayUrl): List = listOf( CountFilter( - label = "kind 0", - filter = Filter(kinds = listOf(0)), + label = R.string.profiles, + filter = Filter(kinds = listOf(MetadataEvent.KIND)), ), CountFilter( - label = "kind 10002", - filter = Filter(kinds = listOf(10002)), + label = R.string.relay_settings_lower, + filter = Filter(kinds = listOf(AdvertisedRelayListEvent.KIND)), ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt index 5cd3e96ff9..1003e95606 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37 import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -39,7 +40,7 @@ class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() { override fun countFilters(relayUrl: NormalizedRelayUrl): List = listOf( CountFilter( - label = "events", + label = R.string.events, filter = Filter(authors = listOf(account.pubKey)), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index 0894a0f047..e6fb7883a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -136,7 +137,7 @@ class Nip65RelayListViewModel : ViewModel() { RelayCountResult( listOf( RelayCountResult.CountEntry( - label = "events", + label = R.string.events, count = result.count, approximate = result.approximate, ), @@ -155,7 +156,7 @@ class Nip65RelayListViewModel : ViewModel() { RelayCountResult( listOf( RelayCountResult.CountEntry( - label = "events", + label = R.string.events, count = result.count, approximate = result.approximate, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt index 01d07c40b6..e2a0f13596 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt @@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable @@ -35,12 +33,4 @@ class ProxyRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.saveProxyRelayList(urlList) } - - override fun countFilters(relayUrl: NormalizedRelayUrl): List = - listOf( - CountFilter( - label = "events", - filter = Filter(), - ), - ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt index aaf592a68b..2f219c5347 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -39,7 +40,7 @@ class SearchRelayListViewModel : BasicRelaySetupInfoModel() { override fun countFilters(relayUrl: NormalizedRelayUrl): List = listOf( CountFilter( - label = "events", + label = R.string.events, filter = Filter(), ), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e322c05e7e..d2ae0d7247 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1802,4 +1802,8 @@ %1$d%% uptime Namecoin Settings Bitcoin Explorer (OTS) + events + DMs + profiles + relay settings diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index 964b77d35a..34ee1b491a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -90,6 +90,10 @@ object MessageKSerializer : KSerializer { is CountMessage -> { add(CountResultKSerializer.serializeToElement(value.result)) } + + is EoseMessage -> { + add(JsonPrimitive(value.subId)) + } } } jsonEncoder.encodeJsonElement(element) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt index eba39649b7..ceb4b36de0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientLis import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message @@ -62,6 +63,7 @@ class RelayLogger( is OkMessage -> if (debugReceiving) Log.d(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}") is AuthMessage -> if (debugReceiving) Log.d(logTag, "Auth: ${msg.challenge}") is NotifyMessage -> if (debugReceiving) Log.d(logTag, "Notify: ${msg.message}") + is CountMessage -> if (debugReceiving) Log.d(logTag, "Count: ${msg.result.count} approx: ${msg.result.approximate}") is ClosedMessage -> Log.w(logTag, "Closed: ${msg.subId} ${msg.message}") } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultDeserializer.kt index dd5cfa0bb0..3858f32368 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultDeserializer.kt @@ -26,8 +26,8 @@ class CountResultDeserializer { companion object { fun fromJson(jsonObject: JsonNode): CountResult = CountResult( - count = jsonObject.get("count").asInt(), - approximate = jsonObject.get("approximate").asBoolean(), + count = jsonObject.get("count")?.asInt() ?: 0, + approximate = jsonObject.get("approximate")?.asBoolean() ?: false, ) } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index 470360c800..345b9d9c46 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -72,8 +72,8 @@ class MessageSerializer : StdSerializer(Message::class.java) { countSerializer.serialize(msg.result, gen, provider) } - else -> { - null + is EoseMessage -> { + gen.writeString(msg.subId) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt new file mode 100644 index 0000000000..d58ed363fd --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt @@ -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.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.queryCountSuspend +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlin.test.Test + +class NostrClientQueryCountTest : BaseNostrClientTest() { + val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl() + val utxo = "wss://news.utxo.one".normalizeRelayUrl() + + val metadata = Filter(kinds = listOf(0)) + val outboxRelays = Filter(kinds = listOf(10002)) + + @Test + fun testQueryCountSuspend() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val result = client.queryCountSuspend(relay = fiatjaf, filter = metadata) + + assertTrue((result?.count ?: 0) > 1) + + client.disconnect() + appScope.cancel() + } + + @Test + fun testQueryCountSuspendAllEvents() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val result = client.queryCountSuspend(relay = fiatjaf, filter = Filter()) + + assertTrue((result?.count ?: 0) > 1) + + client.disconnect() + appScope.cancel() + } + + @Test + fun testQueryCountSuspendMultipleRelays() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val result = + client.queryCountSuspend( + filters = + mapOf( + fiatjaf to listOf(metadata, outboxRelays), + utxo to listOf(metadata, outboxRelays), + ), + ) + + result.forEach { url, result -> + println("${url.url}: ${result.count}") + assertTrue(result.count > 1) + } + + client.disconnect() + appScope.cancel() + } +}