From 0adea4f40ab7ef09f548323dda022f867106eb64 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 22:36:42 +0000 Subject: [PATCH] feat: add RelayDiscoveryEvent renderer for feed display Add a composable to render NIP-66 RelayDiscoveryEvent (kind 30166) in the note feed, displaying relay URL, RTT metrics with color-coded chips, network type, relay type, requirements, supported NIPs, accepted kinds, topics, and geohash location. https://claude.ai/code/session_01EsPmtRFQgknkDV6zmKkeS2 --- .../amethyst/ui/note/NoteCompose.kt | 6 + .../amethyst/ui/note/types/RelayDiscovery.kt | 297 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + 3 files changed, 305 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayDiscovery.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index bb3335959..11833bcab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -144,6 +144,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage import com.vitorpamplona.amethyst.ui.note.types.RenderReaction +import com.vitorpamplona.amethyst.ui.note.types.RenderRelayDiscovery import com.vitorpamplona.amethyst.ui.note.types.RenderReport import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent @@ -244,6 +245,7 @@ import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChalleng import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent @@ -911,6 +913,10 @@ private fun RenderNoteRow( DisplayBroadcastRelayList(baseNote, backgroundColor, accountViewModel, nav) } + is RelayDiscoveryEvent -> { + RenderRelayDiscovery(baseNote, accountViewModel, nav) + } + is PinListEvent -> { RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayDiscovery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayDiscovery.kt new file mode 100644 index 000000000..e1b24815a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayDiscovery.kt @@ -0,0 +1,297 @@ +/* + * 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.note.types + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.Numbers +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material.icons.filled.TravelExplore +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderRelayDiscovery( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = baseNote.event as? RelayDiscoveryEvent ?: return + val context = LocalContext.current + + val relayUrl = remember(noteEvent) { noteEvent.relay() } + val rttOpen = remember(noteEvent) { noteEvent.rttOpen() } + val rttRead = remember(noteEvent) { noteEvent.rttRead() } + val rttWrite = remember(noteEvent) { noteEvent.rttWrite() } + val networkTypes = remember(noteEvent) { noteEvent.networkTypes() } + val relayTypes = remember(noteEvent) { noteEvent.relayTypes() } + val supportedNips = remember(noteEvent) { noteEvent.supportedNips() } + val requirements = remember(noteEvent) { noteEvent.requirements() } + val acceptedKinds = remember(noteEvent) { noteEvent.acceptedKinds() } + val topics = remember(noteEvent) { noteEvent.topics() } + val geohashes = remember(noteEvent) { noteEvent.geohashes() } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Relay URL header + if (relayUrl != null) { + Text( + text = relayUrl.displayUrl(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + // RTT metrics + if (rttOpen != null || rttRead != null || rttWrite != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + rttOpen?.let { + RttMetricChip(stringRes(R.string.relay_monitor_rtt_open), it) + } + rttRead?.let { + RttMetricChip(stringRes(R.string.relay_monitor_rtt_read), it) + } + rttWrite?.let { + RttMetricChip(stringRes(R.string.relay_monitor_rtt_write), it) + } + } + } + + // Network type + if (networkTypes.isNotEmpty()) { + DiscoveryInfoRow( + icon = Icons.Default.Language, + label = stringRes(R.string.relay_monitor_network), + value = networkTypes.joinToString { it.code }, + ) + } + + // Relay type + if (relayTypes.isNotEmpty()) { + DiscoveryInfoRow( + icon = Icons.Default.Dns, + label = stringRes(R.string.relay_monitor_relay_type), + value = relayTypes.joinToString(), + ) + } + + // Requirements + if (requirements.isNotEmpty()) { + DiscoveryInfoRow( + icon = if (requirements.any { !it.negated }) Icons.Default.Lock else Icons.Default.LockOpen, + label = stringRes(R.string.relay_monitor_requirements), + value = + requirements.joinToString { req -> + if (req.negated) "!${req.value}" else req.value + }, + ) + } + + // Supported NIPs + if (supportedNips.isNotEmpty()) { + DiscoveryInfoRow( + icon = Icons.Default.Numbers, + label = stringRes(R.string.relay_monitor_supported_nips), + value = supportedNips.sorted().joinToString(), + ) + } + + // Accepted kinds + if (acceptedKinds.isNotEmpty()) { + DiscoveryInfoRow( + icon = Icons.Default.Dns, + label = stringRes(R.string.relay_discovery_accepted_kinds), + value = + acceptedKinds.joinToString { kind -> + if (kind.negated) "!${kind.kind}" else "${kind.kind}" + }, + ) + } + + // Topics + if (topics.isNotEmpty()) { + Row( + modifier = Modifier.padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Tag, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.secondary, + ) + Spacer(Modifier.width(12.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + topics.forEach { topic -> + TopicChip(topic) + } + } + } + } + + // Geohashes + if (geohashes.isNotEmpty()) { + DiscoveryInfoRow( + icon = Icons.Default.TravelExplore, + label = stringRes(R.string.relay_discovery_geohash), + value = geohashes.joinToString(), + ) + } + + // Content description + if (noteEvent.content.isNotBlank()) { + Text( + text = noteEvent.content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun RttMetricChip( + label: String, + ms: Long, +) { + val color = + when { + ms < 200 -> Color(0xFF4CAF50) + ms < 500 -> Color(0xFFFFC107) + else -> MaterialTheme.colorScheme.error + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Surface( + shape = RoundedCornerShape(50), + color = color.copy(alpha = 0.15f), + ) { + Text( + text = stringRes(R.string.relay_monitor_ms, ms.toInt()), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = color, + ) + } + } +} + +@Composable +private fun TopicChip(topic: String) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = "#$topic", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +@Composable +private fun DiscoveryInfoRow( + icon: ImageVector, + label: String, + value: String, +) { + Row( + modifier = Modifier.padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.secondary, + ) + Spacer(Modifier.width(12.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + ) + Text( + text = value, + textAlign = TextAlign.End, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 139f6ca4c..e62ea06c2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -839,6 +839,8 @@ Requirements Last check %1$d ms + Accepted Kinds + Location Active Subscriptions Pending Outbox Events REQ Subscriptions (%1$d)