From e26fb949331c38e8af5a61e2653a3f091bb60124 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 6 Jan 2026 17:38:47 -0500 Subject: [PATCH] Improvements on NIP-11 - Support for self, privacy_policy, terms_of_service, grasps - New UI for the Relay Information Screen - Improvements to the Debug Message - Compose-stable objects - Clickable elements for NIPs, external links, grasps, etc --- .../amethyst/model/AntiSpamFilter.kt | 4 +- .../amethyst/ui/note/NoteQuickActionMenu.kt | 8 + .../ui/note/ZapFormatterNoDecimals.kt | 6 + .../loggedIn/relays/RelayInformationScreen.kt | 1102 ++++++++++++----- amethyst/src/main/res/values/strings.xml | 45 +- .../amethyst/commons/util/TimeAgoFormatter.kt | 45 + .../nip01Core/relay/client/stats/RelayStat.kt | 50 +- .../relay/normalizer/NormalizedRelayUrl.kt | 3 + .../FlexibleIntListSerializer.kt | 14 +- .../nip11RelayInfo/Nip11RelayInformation.kt | 96 +- 10 files changed, 1008 insertions(+), 365 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index 902a9015b..67b8afb51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -95,7 +95,7 @@ class AntiSpamFilter { if (spammer.shouldHide() && relay != null) { Amethyst.instance.relayStats .get(relay) - .newSpam("$link1 $link2") + .newSpam(link1, link2) } flowSpam.tryEmit(AntiSpamState(this)) @@ -123,7 +123,7 @@ class AntiSpamFilter { if (spammer.shouldHide() && relay != null) { Amethyst.instance.relayStats .get(relay) - .newSpam("$link1 $link2") + .newSpam(link1, link2) } flowSpam.tryEmit(AntiSpamState(this)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 070403e87..c1bc1c0a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -116,6 +116,14 @@ val njumpLink = { nip19BechAddress: String -> "https://njump.to/$nip19BechAddress" } +val nipLink = { nipNumber: String -> + "https://nostrhub.io/$nipNumber" +} + +val graspLink = { graspNumber: String -> + "https://gitworkshop.dev/danconwaydev.com/grasp/tree/master/$graspNumber.md" +} + val externalLinkForNote = { note: Note -> if (note is AddressableNote) { if (note.event?.bountyBaseReward() != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt index 2b33c9b50..e6b09d2ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt @@ -56,6 +56,12 @@ fun showAmountInteger(amount: BigDecimal?): String { } } +fun showAmountInteger(amount: Int?): String { + if (amount == null) return "0" + + return showAmountIntegerWithZero(BigDecimal.valueOf(amount.toLong())) +} + fun showAmountIntegerWithZero(amount: BigDecimal?): String { if (amount == null) return "0" if (amount.abs() < BigDecimal(0.01)) return "0" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index ce9cc9466..5e4fc7b20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -20,65 +20,113 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.automirrored.filled.ContactSupport +import androidx.compose.material.icons.automirrored.filled.Label +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.automirrored.filled.Message +import androidx.compose.material.icons.filled.AttachMoney +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material.icons.filled.EditOff +import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material.icons.filled.Gavel +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Key +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.Storage +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material.icons.filled.Topic +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf 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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily 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.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.util.timeDiffAgoShortish import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.components.ClickableEmail -import com.vitorpamplona.amethyst.ui.components.ClickableUrl -import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.appendLink +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.note.UserCompose -import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.note.graspLink +import com.vitorpamplona.amethyst.ui.note.nipLink +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer -import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier -import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size100dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList -import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayDebugMessage +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +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 +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.SpamDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract @Composable fun RelayInformationScreen( @@ -138,267 +186,177 @@ fun RelayInformationScreen( .toImmutableList() } - LazyColumn( - modifier = - Modifier - .padding(pad) - .consumeWindowInsets(pad) - .padding(bottom = Size10dp, start = Size10dp, end = Size10dp) - .fillMaxSize(), - ) { - item { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = StdPadding.fillMaxWidth(), - ) { - Column { - RenderRelayIcon( - displayUrl = relay.displayUrl(), - iconUrl = relayInfo.icon, - loadProfilePicture = accountViewModel.settings.showProfilePictures(), - loadRobohash = accountViewModel.settings.isNotPerformanceMode(), - pingInMs = - Amethyst.instance.relayStats - .get(relay) - .pingInMs, - iconModifier = LargeRelayIconModifier, - ) - } - - Spacer(modifier = DoubleHorzSpacer) - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Title(relayInfo.name?.trim() ?: "") - Spacer(modifier = HalfVertSpacer) - SubtitleContent(relay.url) - } - } - } - item { - Section(stringRes(R.string.description)) - - SectionContent(relayInfo.description?.trim() ?: stringRes(R.string.no_description)) - } - relayInfo.pubkey?.let { - item { - Section(stringRes(R.string.owner)) - DisplayOwnerInformation(it, accountViewModel, nav) - } - } - relayInfo.contact?.let { - item { - Section(stringRes(R.string.contact)) - - Box(modifier = Modifier.padding(start = 10.dp)) { - if (it.startsWith("https:")) { - ClickableUrl(urlText = it, url = it) - } else if (it.startsWith("mailto:") || it.contains('@')) { - ClickableEmail(it) - } else { - SectionContent(it) - } - } - } - } - relayInfo.software?.let { - item { - Section(stringRes(R.string.software)) - - DisplaySoftwareInformation(it) - - Section(stringRes(R.string.version)) - - SectionContent(relayInfo.version ?: "") - } - } - relayInfo.supported_nips?.let { - if (it.isNotEmpty()) { - item { - Section(stringRes(R.string.supports)) - - DisplaySupportedNips(it, relayInfo.supported_nip_extensions) - } - } - } - relayInfo.fees?.admission?.let { - item { - if (it.isNotEmpty()) { - Section(stringRes(R.string.admission_fees)) - - it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") } - } - } - } - - relayInfo.payments_url?.let { - item { - Section(stringRes(R.string.payments_url)) - - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = it, - url = it, - ) - } - } - } - - relayInfo.limitation?.let { - item { - Section(stringRes(R.string.limitations)) - val authRequiredText = - if (it.auth_required ?: false) stringRes(R.string.yes) else stringRes(R.string.no) - - val paymentRequiredText = - if (it.payment_required ?: false) stringRes(R.string.yes) else stringRes(R.string.no) - - val restrictedWritesText = - if (it.restricted_writes ?: false) stringRes(R.string.yes) else stringRes(R.string.no) - - Column { - SectionContent( - "${stringRes(R.string.message_length)}: ${it.max_message_length ?: 0}", - ) - SectionContent( - "${stringRes(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}", - ) - SectionContent("${stringRes(R.string.filters)}: ${it.max_filters ?: 0}") - SectionContent( - "${stringRes(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}", - ) - SectionContent("${stringRes(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}") - SectionContent( - "${stringRes(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}", - ) - SectionContent( - "${stringRes(R.string.content_length)}: ${it.max_content_length ?: 0}", - ) - SectionContent( - "${stringRes(R.string.max_limit)}: ${it.max_limit ?: 0}", - ) - SectionContent("${stringRes(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}") - SectionContent("${stringRes(R.string.auth)}: $authRequiredText") - SectionContent("${stringRes(R.string.payment)}: $paymentRequiredText") - SectionContent("${stringRes(R.string.restricted_writes)}: $restrictedWritesText") - } - } - } - relayInfo.relay_countries?.let { - item { - Section(stringRes(R.string.countries)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - relayInfo.language_tags?.let { - item { - Section(stringRes(R.string.languages)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - relayInfo.tags?.let { - item { - Section(stringRes(R.string.tags)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - relayInfo.posting_policy?.let { - item { - Section(stringRes(R.string.posting_policy)) - - Box(Modifier.padding(10.dp)) { - ClickableUrl( - it, - it, - ) - } - } - } - - item { - Section(stringRes(R.string.relay_error_messages)) - } - - items(messages) { msg -> - Row { - RenderDebugMessage(msg, accountViewModel, nav) - } - - Spacer(modifier = StdVertSpacer) - } - } + RelayInformationBody(relay, relayInfo, Amethyst.instance.relayStats.get(relay), messages, pad, accountViewModel, nav) } } @Composable -private fun RenderDebugMessage( - msg: RelayDebugMessage, +fun RelayInformationBody( + relay: NormalizedRelayUrl, + relayInfo: Nip11RelayInformation, + relayStats: RelayStat, + messages: ImmutableList, + pad: PaddingValues, accountViewModel: AccountViewModel, - newNav: INav, + nav: INav, ) { - SelectionContainer { - val context = LocalContext.current - val color = - remember { - mutableStateOf(Color.Transparent) - } - TranslatableRichTextViewer( - content = - remember { - "${timeAgo(msg.time, context)}, ${msg.type.name}: ${msg.message}" - }, - canPreview = false, - quotesLeft = 0, - modifier = Modifier.fillMaxWidth(), - tags = EmptyTagList, - backgroundColor = color, - id = msg.hashCode().toString(), - accountViewModel = accountViewModel, - nav = newNav, - ) - } -} + LazyColumn( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .fillMaxSize(), + contentPadding = PaddingValues(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // 1. Header Section + item { + RelayHeader(relay, relayStats, relayInfo, accountViewModel) + } -@Composable -@OptIn(ExperimentalLayoutApi::class) -private fun DisplaySupportedNips( - supportedNips: List, - supportedNipExtensions: List?, -) { - FlowRow { - supportedNips.forEach { item -> - val text = item.toString().padStart(2, '0') - Box(Modifier.padding(10.dp)) { - ClickableUrl( - urlText = text, - url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", - ) + val targetAudience = + relayInfo.tags != null || + relayInfo.language_tags != null || + relayInfo.relay_countries != null + + if (targetAudience) { + item { SectionHeader(stringRes(R.string.target_audience)) } + item { TargetAudienceCard(relayInfo, nav) } + } + + relayInfo.pubkey?.let { + item { + SectionHeader(stringRes(R.string.owner)) + DisplayOwnerInformation(it, accountViewModel, nav) } } - supportedNipExtensions?.forEach { item -> - val text = item.padStart(2, '0') - Box(Modifier.padding(10.dp)) { - ClickableUrl( - urlText = text, - url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", - ) + relayInfo.self?.let { + item { + SectionHeader(stringRes(R.string.self)) + DisplayOwnerInformation(it, accountViewModel, nav) } } + + item { SectionHeader(stringRes(R.string.policies_and_links)) } + item { PoliciesCard(relayInfo) } + + relayInfo.fees?.let { fees -> + item { SectionHeader(stringRes(R.string.fees_and_payments)) } + item { FeesCard(fees, relayInfo.payments_url) } + } + + relayInfo.limitation?.let { + item { SectionHeader(stringRes(R.string.limitations)) } + item { LimitationsCard(it) } + } + + val atLeastOneSoftware = + relayInfo.software != null || + relayInfo.version != null || + relayInfo.supported_grasps != null || + relayInfo.supported_nips != null + + if (atLeastOneSoftware) { + item { SectionHeader(stringRes(R.string.software)) } + item { SoftwareCard(relayInfo) } + } + + item { + SectionHeader(stringRes(R.string.relay_error_messages)) + } + + items(messages) { msg -> + RenderDebugMessage(msg) + + Spacer(modifier = StdVertSpacer) + } } } @Composable -private fun DisplaySoftwareInformation(software: String) { - val url = software.replace("git+", "") - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = url, - url = url, - ) +private fun RenderDebugMessage(msg: IRelayDebugMessage) { + val context = LocalContext.current + + Column(Modifier.padding(horizontal = 12.dp)) { + Row( + modifier = Modifier.padding(vertical = 6.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Timestamp with Monospace font for alignment + Text( + text = timeAgoNoDot(msg.time, context), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + + // Type Tag + Text( + text = + when (msg) { + is ErrorDebugMessage -> stringRes(R.string.errors) + is NoticeDebugMessage -> stringRes(R.string.relay_notice) + is SpamDebugMessage -> stringRes(R.string.spam) + }, + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + ), + color = + when (msg) { + is ErrorDebugMessage -> MaterialTheme.colorScheme.error + is NoticeDebugMessage -> MaterialTheme.colorScheme.primary + is SpamDebugMessage -> MaterialTheme.colorScheme.outline + }, + ) + } + + when (msg) { + is ErrorDebugMessage -> + SelectionContainer { + Text( + text = msg.message, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + ) + } + is NoticeDebugMessage -> + SelectionContainer { + Text( + text = msg.message, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + ) + } + is SpamDebugMessage -> + SelectionContainer { + val uri = LocalUriHandler.current + val start = stringRes(R.string.duplicated_post) + Text( + text = + remember { + buildAnnotatedString { + append(start) + append(" ") + appendLink(msg.link1) { + runCatching { + uri.openUri(msg.link1) + } + } + appendLink(msg.link2) { + runCatching { + uri.openUri(msg.link2) + } + } + } + }, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } } } @@ -408,50 +366,616 @@ private fun DisplayOwnerInformation( accountViewModel: AccountViewModel, nav: INav, ) { - LoadUser(baseUserHex = userHex, accountViewModel) { - CrossfadeIfEnabled(it, accountViewModel = accountViewModel) { + LoadUser(baseUserHex = userHex, accountViewModel) { loadedUser -> + CrossfadeIfEnabled(loadedUser, accountViewModel = accountViewModel) { if (it != null) { - UserCompose( - baseUser = it, - accountViewModel = accountViewModel, - nav = nav, - ) + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + UserCompose( + baseUser = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } } } @Composable -fun Title(text: String) { +private fun RelayHeader( + relay: NormalizedRelayUrl, + relayStats: RelayStat, + relayInfo: Nip11RelayInformation, + accountViewModel: AccountViewModel, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + RenderRelayIcon( + displayUrl = relay.displayUrl(), + iconUrl = relayInfo.icon, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + pingInMs = relayStats.pingInMs, + iconModifier = + Modifier + .size(Size100dp) + .clip(shape = CircleShape), + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = relayInfo.description ?: relay.displayUrl(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +fun FeesCard( + fees: Nip11RelayInformation.RelayInformationFees, + payUrl: String?, +) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + fees.admission?.forEach { + FeeRow(stringRes(R.string.admission), it) + } + fees.subscription?.forEach { + FeeRow(stringRes(R.string.subscription), it) + } + fees.publication?.forEach { + FeeRow(stringRes(R.string.publication), it) + } + payUrl?.let { + val uri = LocalUriHandler.current + ClickableInfoRow(Icons.Default.Payment, stringRes(R.string.payments_url), it.removePrefix("https://")) { + runCatching { + uri.openUri(it) + } + } + } + } + } +} + +@Composable +fun LimitationsCard(lim: Nip11RelayInformation.RelayInformationLimitation) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + val atLeastOneAccessControl = + lim.auth_required != null || + lim.payment_required != null || + lim.restricted_writes != null || + lim.min_pow_difficulty != null || + lim.min_prefix != null + + if (atLeastOneAccessControl) { + Column { + Text(stringRes(R.string.access_control), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + val yes = stringRes(R.string.yes) + val no = stringRes(R.string.no) + + lim.auth_required?.let { + InfoRow(Icons.Default.History, stringRes(R.string.auth_required), if (it) yes else no) + } + lim.payment_required?.let { + InfoRow(Icons.Default.Lock, stringRes(R.string.payment_required), if (it) yes else no) + } + lim.restricted_writes?.let { + InfoRow(Icons.Default.EditOff, stringRes(R.string.restricted_writes), if (it) yes else no) + } + + val minPoW = lim.min_pow_difficulty + + if (minPoW != null && minPoW > 0) { + InfoRow(Icons.Default.Bolt, stringRes(R.string.minimum_pow), stringRes(R.string.amount_in_bits, minPoW)) + } else { + lim.min_prefix?.let { + if (it > 0) { + InfoRow(Icons.Default.Key, stringRes(R.string.minimum_prefix), stringRes(R.string.amount_in_bits, it * 8)) + } + } + } + } + } + + val atLeastOneConnectivity = + lim.max_message_length.isNotNullAndNotZero() || + lim.max_subscriptions.isNotNullAndNotZero() || + lim.max_filters.isNotNullAndNotZero() || + lim.max_limit.isNotNullAndNotZero() || + lim.default_limit.isNotNullAndNotZero() || + lim.max_subid_length.isNotNullAndNotZero() + + if (atLeastOneConnectivity) { + Column { + Text(stringRes(R.string.connectivity), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + lim.max_message_length?.let { + InfoRow(Icons.AutoMirrored.Default.Message, stringRes(R.string.max_message_length), "${it / 1024} kb") + } + lim.max_subscriptions?.let { + InfoRow(Icons.Default.Dns, stringRes(R.string.max_subs), it.toString()) + } + lim.max_filters?.let { + InfoRow(Icons.Default.FilterAlt, stringRes(R.string.max_filters_per_sub), it.toString()) + } + lim.max_limit?.let { + InfoRow(Icons.AutoMirrored.Default.List, stringRes(R.string.max_limit_events_returning), it.toString()) + } + lim.default_limit?.let { + InfoRow(Icons.AutoMirrored.Default.List, stringRes(R.string.max_limit_events_returning), it.toString()) + } + lim.max_subid_length?.let { + InfoRow(Icons.AutoMirrored.Default.Label, stringRes(R.string.max_subid_length), it.toString()) + } + } + } + + val atLeastOneContentSize = + lim.max_event_tags != null || + lim.max_content_length != null + + if (atLeastOneContentSize) { + Column { + Text(stringRes(R.string.content_size), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + lim.max_event_tags?.let { + InfoRow(Icons.Default.Tag, stringRes(R.string.maximum_event_tags), it.toString()) + } + + lim.max_content_length?.let { + InfoRow(Icons.AutoMirrored.Default.Article, stringRes(R.string.max_content_length), "${it / 1024} kb") + } + } + } + + val atLeastOneRestriction = + lim.created_at_lower_limit.isNotNullAndNotZero() || + lim.created_at_upper_limit.isNotNullAndNotZero() + + if (atLeastOneRestriction) { + Column { + Text(stringRes(R.string.event_retention), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + lim.created_at_lower_limit?.let { + if (it > 0) { + InfoRow(Icons.Default.History, stringRes(R.string.discards_older_than), stringRes(R.string.time_in_the_past, timeDiffAgoShortish(it))) + } + } + lim.created_at_upper_limit?.let { + if (it > 0) { + InfoRow(Icons.Default.History, stringRes(R.string.accepts_up_to), stringRes(R.string.time_in_the_future, timeDiffAgoShortish(it))) + } + } + } + } + } + } +} + +@OptIn(ExperimentalContracts::class) +fun Int?.isNotNullAndNotZero(): Boolean { + contract { + returns(true) implies (this@isNotNullAndNotZero != null) + } + + return this != null && this != 0 +} + +@Composable +fun SoftwareCard(relayInfo: Nip11RelayInformation) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + val uri = LocalUriHandler.current + relayInfo.software?.let { + if (it.contains("https://")) { + ClickableInfoRow(Icons.Default.Code, stringRes(R.string.software), it.removePrefix("git+https://").removePrefix("https://")) { + runCatching { + uri.openUri(it.removePrefix("git+")) + } + } + } else { + InfoRow(Icons.Default.Code, stringRes(R.string.software), it) + } + } + + relayInfo.version?.let { + InfoRow(Icons.Default.Storage, stringRes(R.string.version), it) + } + + relayInfo.supported_nips?.let { nips -> + Text( + stringRes(R.string.supports), + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + val uri = LocalUriHandler.current + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + nips.forEach { nip -> + val nipStr = nip.padStart(2, '0') + SuggestionChip( + onClick = { + runCatching { + uri.openUri(nipLink(nipStr)) + } + }, + label = { + Text(nipStr) + }, + ) + } + } + } + + relayInfo.supported_grasps?.let { grasps -> + Text( + stringRes(R.string.supported_grasps), + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + val uri = LocalUriHandler.current + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + grasps.forEach { grasp -> + val graspStr = grasp.padStart(2, '0') + SuggestionChip( + onClick = { + runCatching { + uri.openUri(graspLink(graspStr)) + } + }, + label = { + Text(graspStr) + }, + ) + } + } + } + } + } +} + +@Composable +fun TargetAudienceCard( + relay: Nip11RelayInformation, + nav: INav, +) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + relay.tags?.let { tags -> + if (tags.size > 2) { + Column { + Text(stringRes(R.string.topics), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + tags.forEach { tag -> + SuggestionChip( + onClick = { nav.nav(Route.Hashtag(tag)) }, + label = { + Text(tag) + }, + ) + } + } + } + } else if (tags.isNotEmpty()) { + InfoRow(Icons.Default.Topic, stringRes(R.string.topics), tags.joinToString()) + } + } + relay.relay_countries?.let { countries -> + val allCountries = stringRes(R.string.all_countries) + if (countries.size > 2) { + Column { + Text(stringRes(R.string.countries), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + countries.forEach { country -> + if (country == "*") { + SuggestionChip( + onClick = { }, + label = { + Text(allCountries) + }, + ) + } else { + SuggestionChip( + onClick = { }, + label = { + Text(country) + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } else if (countries.isNotEmpty()) { + InfoRow( + Icons.Default.Language, + stringRes(R.string.countries), + countries.joinToString { + if (it == "*") allCountries else it + }, + ) + } + } + relay.language_tags?.let { languages -> + val allLang = stringRes(R.string.all_languages) + if (languages.size > 2) { + Column { + Text(stringRes(R.string.languages), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + languages.forEach { lang -> + if (lang == "*") { + SuggestionChip( + onClick = { }, + label = { + Text(allLang) + }, + ) + } else { + SuggestionChip( + onClick = { }, + label = { + Text(lang) + }, + ) + } + } + } + } + } else if (languages.isNotEmpty()) { + InfoRow( + Icons.Default.Translate, + stringRes(R.string.languages), + languages.joinToString { + if (it == "*") allLang else it + }, + ) + } + } + } + } +} + +@Composable +fun PoliciesCard(relay: Nip11RelayInformation) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + val uri = LocalUriHandler.current + relay.contact?.let { + if (it.contains("@")) { + ClickableInfoRow(Icons.AutoMirrored.Default.ContactSupport, stringRes(R.string.contact), it) { + runCatching { + uri.openUri("mailto:$it") + } + } + } else { + InfoRow(Icons.AutoMirrored.Default.ContactSupport, stringRes(R.string.contact), it) + } + } + + relay.posting_policy?.let { + ClickableInfoRow(Icons.Default.EditNote, stringRes(R.string.posting_policy), it) { + runCatching { + uri.openUri(it) + } + } + } + + val pp = relay.privacy_policy + + if (pp != null) { + ClickableInfoRow(Icons.Default.PrivacyTip, stringRes(R.string.privacy_policy), pp.removePrefix("https://")) { + runCatching { + uri.openUri(pp) + } + } + } else { + InfoRow(Icons.Default.PrivacyTip, stringRes(R.string.privacy_policy), stringRes(R.string.not_available_acronym)) + } + + val ts = relay.terms_of_service + if (ts != null) { + ClickableInfoRow(Icons.Default.Gavel, stringRes(R.string.terms_and_conditions), ts.removePrefix("https://")) { + runCatching { + uri.openUri(ts) + } + } + } else { + InfoRow(Icons.Default.Gavel, stringRes(R.string.terms_and_conditions), stringRes(R.string.not_available_acronym)) + } + } + } +} + +@Composable +fun SectionHeader(title: String) { Text( - text = text, + text = title, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - fontSize = 24.sp, + modifier = Modifier.padding(top = 5.dp), + color = MaterialTheme.colorScheme.primary, ) } @Composable -fun SubtitleContent(text: String) { - Text( - text = text, - ) +private fun InfoRow( + icon: ImageVector, + label: String, + value: String, +) { + Row( + modifier = Modifier.padding(vertical = 4.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) + } } @Composable -fun Section(text: String) { - Spacer(modifier = DoubleVertSpacer) - Text( - text = text, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) - Spacer(modifier = DoubleVertSpacer) +private fun ClickableInfoRow( + icon: ImageVector, + label: String, + value: String, + onClickValue: () -> Unit, +) { + Row( + modifier = Modifier.padding(vertical = 4.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.clickable(onClick = onClickValue).weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1) + } } @Composable -fun SectionContent(text: String) { - Text( - modifier = Modifier.padding(start = 10.dp), - text = text, - ) +fun FeeRow( + label: String, + fee: Nip11RelayInformation.RelayInformationFee, +) { + fee.amount?.let { + val period = fee.period + val combinedLabel = + if (period != null) { + label + " (${timeDiffAgoShortish(period)})" + } else { + label + } + + if (fee.unit == "msats") { + InfoRow(Icons.Default.AttachMoney, combinedLabel, "${it / 1000} sats") + } else { + InfoRow(Icons.Default.AttachMoney, combinedLabel, "$it ${fee.unit}") + } + } +} + +@Composable +@Preview(showBackground = true, name = "Nost.wine Relay Info", device = "spec:width=2160px,height=5640px,dpi=440") +fun RelayHeaderPreview() { + ThemeComparisonRow { + RelayInformationBody( + relay = NormalizedRelayUrl("wss://nostr.wine/"), + relayInfo = + Nip11RelayInformation( + name = "Nostr.wine", + icon = "https://image.nostr.build/30acdce4a81926f386622a07343228ae99fa68d012d54c538c0b2129dffe400c.png", + description = "A paid nostr relay for wine enthusiasts and everyone else", + software = "https://nostr.wine", + contact = "wino@nostr.wine", + version = "0.3.3", + self = "4918eb332a41b71ba9a74b1dc64276cfff592e55107b93baae38af3520e55975", + pubkey = "4918eb332a41b71ba9a74b1dc64276cfff592e55107b93baae38af3520e55975", + payments_url = "https://nostr.wine/invoices", + privacy_policy = "https://nostr.wine/terms", + terms_of_service = "https://nostr.wine/terms", + tags = listOf("Bitcoin", "Amethyst"), + supported_nips = listOf("1", "2", "4", "9", "11", "40", "42", "50", "70", "77"), + relay_countries = listOf("*"), + language_tags = listOf("*"), + limitation = + Nip11RelayInformation.RelayInformationLimitation( + auth_required = false, + created_at_lower_limit = 94608000, + created_at_upper_limit = 300, + max_event_tags = 4000, + max_limit = 1000, + default_limit = 20, + max_message_length = 524288, + max_subid_length = 71, + max_subscriptions = 50, + min_pow_difficulty = 0, + payment_required = true, + restricted_writes = true, + ), + fees = + Nip11RelayInformation.RelayInformationFees( + admission = + listOf( + Nip11RelayInformation.RelayInformationFee( + amount = 3000, + unit = "msats", + period = 2628003, + ), + Nip11RelayInformation.RelayInformationFee( + amount = 8000, + unit = "sats", + period = 7884009, + ), + ), + ), + supported_grasps = listOf("GRASP-01"), + ), + pad = PaddingValues(0.dp), + relayStats = RelayStat(), + messages = + persistentListOf( + NoticeDebugMessage( + time = TimeUtils.now(), + message = "Subscription closed: AccountNotificationsEoseFromRandomRelaysManagerugZU9o auth-required: At least one matching event requires AUTH", + ), + ErrorDebugMessage( + time = TimeUtils.now() - 24000, + message = "No such subscription", + ), + SpamDebugMessage( + time = TimeUtils.now() - 24000, + link1 = "http://test1.com", + link2 = "http://test2.com", + ), + ), + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9542f59a9..212407e46 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -17,6 +17,8 @@ Could not decrypt the message Group Picture Explicit Content + Relay Notice + Duplicated Post Spam The number of spamming events coming from this relay Impersonation @@ -133,6 +135,7 @@ Relay Address Posts Bytes + Error Errors The number of connection errors in this session Home Feed @@ -747,28 +750,64 @@ The amount in bytes that was received from this relay, including filters and events An error occurred trying to get relay information from %1$s Owner + Service Key + Running %1$s + Running %1$s (%2$s) Version Software Contact Supported NIPs + Supported Grasps Admission Fees + Admission + Subscription + Publication + Payments %1$s Payments url + Target Audience + Policies & Links + Fees & Payments Limitations Countries Languages Tags + Topics + All Countries + All Languages Posting policy + Privacy Policy + Terms & Conditions + N/A Errors and Notices from this Relay Message length Subscriptions Filters Subscription id length - Minimum prefix - Maximum event tags + Min Prefix + Max Event Tags Content length - Minimum PoW + Max Content Length + Discards older than + Accepts up to + %1$s in the future + %1$s ago + %1$s zeros + %1$s bits + Event Retention + Content Size + Connectivity + Access Control + Min PoW Difficulty Auth + Auth Required Payment + Payment Required + Max Message Length + Max Subscriptions + Max Filters per Sub + Max Limit (Events Returning) + Default Limit (Events Returning) + Max SubID Length Cashu Token Redeem Send to Zap Wallet diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt index 88ef514b9..12307ecd2 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.util +import com.sun.org.apache.xalan.internal.lib.ExsltDatetime.time import com.vitorpamplona.quartz.utils.TimeUtils import java.text.SimpleDateFormat import java.util.Locale @@ -81,6 +82,50 @@ fun timeAgo( } } +fun timeDiffAgoLong(timeDifference: Int): String = + when { + timeDifference > TimeUtils.ONE_YEAR -> { + (timeDifference / TimeUtils.ONE_YEAR).toString() + " years" + } + timeDifference > TimeUtils.ONE_MONTH -> { + (timeDifference / TimeUtils.ONE_MONTH).toString() + " months" + } + timeDifference > TimeUtils.ONE_DAY -> { + (timeDifference / TimeUtils.ONE_DAY).toString() + " days" + } + timeDifference > TimeUtils.ONE_HOUR -> { + (timeDifference / TimeUtils.ONE_HOUR).toString() + " hours" + } + timeDifference > TimeUtils.ONE_MINUTE -> { + (timeDifference / TimeUtils.ONE_MINUTE).toString() + " minutes" + } + else -> { + "now" + } + } + +fun timeDiffAgoShortish(timeDifference: Int): String = + when { + timeDifference > TimeUtils.ONE_YEAR -> { + (timeDifference / TimeUtils.ONE_YEAR).toString() + " yrs" + } + timeDifference > TimeUtils.ONE_MONTH -> { + (timeDifference / TimeUtils.ONE_MONTH).toString() + " mos" + } + timeDifference > TimeUtils.ONE_DAY -> { + (timeDifference / TimeUtils.ONE_DAY).toString() + " days" + } + timeDifference > TimeUtils.ONE_HOUR -> { + (timeDifference / TimeUtils.ONE_HOUR).toString() + " hrs" + } + timeDifference > TimeUtils.ONE_MINUTE -> { + (timeDifference / TimeUtils.ONE_MINUTE).toString() + " mins" + } + else -> { + "now" + } + } + /** * Formats a Unix timestamp as a date string. * For recent dates (< 1 day), returns the provided today string. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt index c7576094f..b51b8428c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.stats import androidx.collection.LruCache +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.utils.TimeUtils +@Stable class RelayStat( var receivedBytes: Int = 0, var sentBytes: Int = 0, @@ -31,12 +33,11 @@ class RelayStat( var pingInMs: Int = 0, var compression: Boolean = false, ) { - val messages = LruCache(100) + val messages = LruCache(100) fun newNotice(notice: String?) { val debugMessage = - RelayDebugMessage( - type = RelayDebugMessageType.NOTICE, + NoticeDebugMessage( message = notice ?: "No error message provided", ) @@ -47,8 +48,7 @@ class RelayStat( errorCounter++ val debugMessage = - RelayDebugMessage( - type = RelayDebugMessageType.ERROR, + ErrorDebugMessage( message = error ?: "No error message provided", ) @@ -63,27 +63,35 @@ class RelayStat( sentBytes += bytesUsedInMemory } - fun newSpam(spamDescriptor: String) { + fun newSpam( + link1: String, + link2: String, + ) { spamCounter++ - val debugMessage = - RelayDebugMessage( - type = RelayDebugMessageType.SPAM, - message = spamDescriptor, - ) + val debugMessage = SpamDebugMessage(link1, link2) messages.put(debugMessage, debugMessage) } } -class RelayDebugMessage( - val type: RelayDebugMessageType, - val message: String, - val time: Long = TimeUtils.now(), -) - -enum class RelayDebugMessageType { - SPAM, - NOTICE, - ERROR, +@Stable +sealed interface IRelayDebugMessage { + val time: Long } + +class SpamDebugMessage( + val link1: String, + val link2: String, + override val time: Long = TimeUtils.now(), +) : IRelayDebugMessage + +class NoticeDebugMessage( + val message: String, + override val time: Long = TimeUtils.now(), +) : IRelayDebugMessage + +class ErrorDebugMessage( + val message: String, + override val time: Long = TimeUtils.now(), +) : IRelayDebugMessage diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt index dd9ec6f7c..1fb5ef244 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.normalizer +import androidx.compose.runtime.Stable + +@Stable data class NormalizedRelayUrl( val url: String, ) : Comparable { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt index f29779ceb..eaf719fbc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip11RelayInfo import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.text import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.ListSerializer @@ -32,15 +33,14 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonPrimitive -object FlexibleIntListSerializer : KSerializer?> { - private val listSerializer = ListSerializer(Int.serializer()) +object FlexibleIntListSerializer : KSerializer?> { + private val listSerializer = ListSerializer(String.serializer()) override val descriptor: SerialDescriptor = listSerializer.descriptor - override fun deserialize(decoder: Decoder): List? { + override fun deserialize(decoder: Decoder): List? { require(decoder is JsonDecoder) { "This serializer can only be used with Json format" } return when (val element = decoder.decodeJsonElement()) { @@ -51,7 +51,7 @@ object FlexibleIntListSerializer : KSerializer?> { is JsonArray -> { element.mapNotNull { arrayElement -> try { - arrayElement.jsonPrimitive.int + arrayElement.jsonPrimitive.text } catch (e: Exception) { // Skip elements that aren't valid integers (strings, booleans, floats, etc.) Log.w("FlexibleIntListSerializer", "Invalid element in array: $arrayElement", e) @@ -63,7 +63,7 @@ object FlexibleIntListSerializer : KSerializer?> { // Handle single integer format (malformed but found in the wild): 1 is JsonPrimitive if !element.isString -> { try { - listOf(element.int) + listOf(element.text) } catch (e: Exception) { // Can't parse as integer (e.g., float, boolean), treat as missing data Log.w("FlexibleIntListSerializer", "Invalid primitive: $element", e) @@ -79,7 +79,7 @@ object FlexibleIntListSerializer : KSerializer?> { @OptIn(ExperimentalSerializationApi::class) override fun serialize( encoder: Encoder, - value: List?, + value: List?, ) { if (value == null) { encoder.encodeNull() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt index 593f20494..cdcaad58d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt @@ -21,19 +21,22 @@ package com.vitorpamplona.quartz.nip11RelayInfo import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import kotlinx.serialization.Serializable +@Stable @Serializable class Nip11RelayInformation( val id: String? = null, val name: String? = null, val description: String? = null, val icon: String? = null, - val pubkey: String? = null, + val pubkey: HexKey? = null, + val self: HexKey? = null, val contact: String? = null, @Serializable(with = FlexibleIntListSerializer::class) - val supported_nips: List? = null, + val supported_nips: List? = null, val supported_nip_extensions: List? = null, val software: String? = null, val version: String? = null, @@ -42,53 +45,60 @@ class Nip11RelayInformation( val language_tags: List? = null, val tags: List? = null, val posting_policy: String? = null, + val privacy_policy: String? = null, + val terms_of_service: String? = null, val payments_url: String? = null, val retention: List? = null, val fees: RelayInformationFees? = null, val nip50: List? = null, + val supported_grasps: List? = null, ) { companion object { fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson(json) } + + @Stable + @Serializable + class RelayInformationFee( + val amount: Int? = null, + val unit: String? = null, + val period: Int? = null, + val kinds: List? = null, + ) + + @Stable + @Serializable + class RelayInformationFees( + val admission: List? = null, + val subscription: List? = null, + val publication: List? = null, + ) + + @Stable + @Serializable + class RelayInformationLimitation( + val max_message_length: Int? = null, + val max_subscriptions: Int? = null, + val max_filters: Int? = null, + val max_limit: Int? = null, + val default_limit: Int? = null, + val max_subid_length: Int? = null, + val min_prefix: Int? = null, + val max_event_tags: Int? = null, + val max_content_length: Int? = null, + val min_pow_difficulty: Int? = null, + val auth_required: Boolean? = null, + val payment_required: Boolean? = null, + val restricted_writes: Boolean? = null, + val created_at_lower_limit: Int? = null, + val created_at_upper_limit: Int? = null, + ) + + @Stable + @Serializable + class RelayInformationRetentionData( + val kinds: ArrayList? = null, + val time: Int? = null, + val count: Int? = null, + ) } - -@Stable -@Serializable -class RelayInformationFee( - val amount: Int? = null, - val unit: String? = null, - val period: Int? = null, - val kinds: List? = null, -) - -@Serializable -class RelayInformationFees( - val admission: List? = null, - val subscription: List? = null, - val publication: List? = null, -) - -@Serializable -class RelayInformationLimitation( - val max_message_length: Int? = null, - val max_subscriptions: Int? = null, - val max_filters: Int? = null, - val max_limit: Int? = null, - val max_subid_length: Int? = null, - val min_prefix: Int? = null, - val max_event_tags: Int? = null, - val max_content_length: Int? = null, - val min_pow_difficulty: Int? = null, - val auth_required: Boolean? = null, - val payment_required: Boolean? = null, - val restricted_writes: Boolean? = null, - val created_at_lower_limit: Int? = null, - val created_at_upper_limit: Int? = null, -) - -@Serializable -class RelayInformationRetentionData( - val kinds: ArrayList? = null, - val time: Int? = null, - val count: Int? = null, -)