Merge pull request #1728 from vitorpamplona/claude/add-relay-subscriptions-display-WHbsT

Add active subscriptions and outbox display to relay info screen
This commit is contained in:
Vitor Pamplona
2026-03-01 12:48:25 -05:00
committed by GitHub
2 changed files with 370 additions and 0 deletions
@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Article
@@ -58,6 +59,7 @@ import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Payment
import androidx.compose.material.icons.filled.PrivacyTip
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material.icons.filled.Topic
@@ -70,6 +72,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -79,6 +82,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
@@ -91,6 +95,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.amethyst.commons.util.timeDiffAgoShortish
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
@@ -112,6 +118,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size100dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.ErrorDebugMessage
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessage
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.NoticeDebugMessage
@@ -202,6 +209,11 @@ fun RelayInformationBody(
accountViewModel: AccountViewModel,
nav: INav,
) {
val nostrClient = remember { Amethyst.instance.client as? NostrClient }
val activeReqs = remember(relay) { nostrClient?.activeRequests(relay) ?: emptyMap() }
val activeCounts = remember(relay) { nostrClient?.activeCounts(relay) ?: emptyMap() }
val activeOutbox = remember(relay) { nostrClient?.activeOutboxCache(relay) ?: emptySet() }
LazyColumn(
modifier =
Modifier
@@ -264,6 +276,59 @@ fun RelayInformationBody(
item { SoftwareCard(relayInfo) }
}
// Active subscriptions and outbox section
val hasReqs = activeReqs.isNotEmpty()
val hasCounts = activeCounts.isNotEmpty()
val hasOutbox = activeOutbox.isNotEmpty()
if (hasReqs || hasCounts || hasOutbox) {
item { SectionHeader(stringRes(R.string.relay_active_subscriptions)) }
if (hasReqs) {
item {
Text(
text = stringRes(R.string.relay_req_subscriptions, activeReqs.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp),
)
}
items(activeReqs.entries.toList(), key = { "req_${it.key}" }) { (subId, filters) ->
SubscriptionCard(subId = subId, filters = filters)
Spacer(modifier = StdVertSpacer)
}
}
if (hasCounts) {
item {
Text(
text = stringRes(R.string.relay_count_subscriptions, activeCounts.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(bottom = 4.dp, top = if (hasReqs) 8.dp else 0.dp),
)
}
items(activeCounts.entries.toList(), key = { "count_${it.key}" }) { (subId, filters) ->
SubscriptionCard(subId = subId, filters = filters)
Spacer(modifier = StdVertSpacer)
}
}
if (hasOutbox) {
item {
Text(
text = stringRes(R.string.relay_outbox_events, activeOutbox.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.padding(bottom = 4.dp, top = if (hasReqs || hasCounts) 8.dp else 0.dp),
)
}
item {
OutboxEventsCard(eventIds = activeOutbox)
}
}
}
item {
SectionHeader(stringRes(R.string.relay_error_messages))
}
@@ -276,6 +341,300 @@ fun RelayInformationBody(
}
}
// ---------------------------------------------------------------------------
// Active subscriptions + outbox display
// ---------------------------------------------------------------------------
private fun kindDisplayName(kind: Int): String =
when (kind) {
0 -> "Profile"
1 -> "Note"
2 -> "Relay"
3 -> "Contacts"
4 -> "DM"
5 -> "Delete"
6 -> "Repost"
7 -> "Reaction"
8 -> "Badge"
9 -> "Chat"
16 -> "GenRepost"
40 -> "Channel"
41 -> "ChMeta"
42 -> "ChMsg"
1059 -> "GiftWrap"
1311 -> "LiveChat"
1984 -> "Report"
9041 -> "ZapGoal"
9734 -> "ZapReq"
9735 -> "Zap"
10000 -> "Mute"
10001 -> "Pins"
10002 -> "RelayList"
10003 -> "Bookmarks"
10004 -> "Communities"
10050 -> "DMRelays"
17375 -> "Wallet"
23194 -> "NWC Req"
23195 -> "NWC Resp"
30000 -> "FollowSets"
30023 -> "Article"
30311 -> "LiveEvent"
else -> "k$kind"
}
@Composable
private fun KindChip(kind: Int) {
val name = kindDisplayName(kind)
val (bg, fg) =
when {
kind in setOf(0, 1, 6, 7, 16, 30023) ->
MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
kind in setOf(3, 10002, 10000, 10001, 10003, 10004, 30000) ->
MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer
kind in setOf(4, 1059, 10050) ->
MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer
kind in setOf(9734, 9735, 9041, 17375, 23194, 23195) ->
MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
else ->
MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
shape = RoundedCornerShape(50),
color = bg,
) {
Text(
text = name,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
color = fg,
)
}
}
@Composable
private fun FilterAttributeChip(
text: String,
color: Color = MaterialTheme.colorScheme.surfaceVariant,
textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Surface(
shape = RoundedCornerShape(50),
color = color,
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
color = textColor,
)
}
}
@Composable
private fun FilterVisual(
filter: Filter,
index: Int,
) {
val context = LocalContext.current
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = "filter ${index + 1}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Kinds
filter.kinds?.forEach { kind ->
KindChip(kind)
}
// Authors
filter.authors?.let { authors ->
if (authors.isNotEmpty()) {
FilterAttributeChip(
text = "👤 ${stringRes(R.string.relay_filter_authors, authors.size)}",
color = MaterialTheme.colorScheme.secondaryContainer,
textColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
// IDs
filter.ids?.let { ids ->
if (ids.isNotEmpty()) {
FilterAttributeChip(
text = "🆔 ${stringRes(R.string.relay_filter_ids, ids.size)}",
)
}
}
// Tags
filter.tags?.forEach { (tagName, values) ->
if (values.isNotEmpty()) {
FilterAttributeChip(
text =
when {
values.size == 1 -> "#$tagName:${values[0].take(8)}"
else -> "#$tagName ×${values.size}"
},
color = MaterialTheme.colorScheme.tertiaryContainer,
textColor = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
// tagsAll
filter.tagsAll?.forEach { (tagName, values) ->
if (values.isNotEmpty()) {
FilterAttributeChip(
text =
when {
values.size == 1 -> "&$tagName:${values[0].take(8)}"
else -> "&$tagName ×${values.size}"
},
color = MaterialTheme.colorScheme.tertiaryContainer,
textColor = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
// Since
filter.since?.let { since ->
FilterAttributeChip(
text = stringRes(R.string.relay_filter_since, timeAgoNoDot(since, context)),
color = MaterialTheme.colorScheme.surfaceVariant,
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Until
filter.until?.let { until ->
FilterAttributeChip(
text = stringRes(R.string.relay_filter_until, timeAgoNoDot(until, context)),
)
}
// Limit
filter.limit?.let { limit ->
FilterAttributeChip(
text = stringRes(R.string.relay_filter_limit, limit),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
)
}
// Search
filter.search?.let { search ->
if (search.isNotEmpty()) {
FilterAttributeChip(
text = "🔍 ${search.take(20)}${if (search.length > 20) "…" else ""}",
color = MaterialTheme.colorScheme.primaryContainer,
textColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
}
}
}
@Composable
private fun SubscriptionCard(
subId: String,
filters: List<Filter>,
) {
val displayId =
if (subId.length > 28) {
subId.take(22) + "" + subId.takeLast(4)
} else {
subId
}
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.FilterAlt,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary,
)
SelectionContainer(modifier = Modifier.weight(1f)) {
Text(
text = displayId,
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = "${filters.size}f",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
filters.forEachIndexed { index, filter ->
FilterVisual(filter = filter, index = index)
}
}
}
}
@Composable
private fun OutboxEventsCard(eventIds: Set<HexKey>) {
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
FlowRow(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
eventIds.forEach { eventId ->
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.tertiaryContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
imageVector = Icons.Default.Send,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
text = eventId.take(8) + "" + eventId.takeLast(4),
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}
}
}
}
@Composable
private fun RenderDebugMessage(msg: IRelayDebugMessage) {
val context = LocalContext.current
+11
View File
@@ -792,6 +792,17 @@
<string name="terms_and_conditions">Terms &amp; Conditions</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Errors and Notices from this Relay</string>
<string name="relay_active_subscriptions">Active Subscriptions</string>
<string name="relay_active_outbox">Pending Outbox Events</string>
<string name="relay_req_subscriptions">REQ Subscriptions (%1$d)</string>
<string name="relay_count_subscriptions">COUNT Subscriptions (%1$d)</string>
<string name="relay_outbox_events">Outbox Events (%1$d)</string>
<string name="relay_no_active_subscriptions">No active subscriptions for this relay</string>
<string name="relay_filter_authors">%1$d authors</string>
<string name="relay_filter_ids">%1$d ids</string>
<string name="relay_filter_since">since %1$s</string>
<string name="relay_filter_until">until %1$s</string>
<string name="relay_filter_limit">limit %1$d</string>
<string name="message_length">Message length</string>
<string name="subscriptions">Subscriptions</string>
<string name="filters">Filters</string>