From 994363602d4ab10385b85a5914d21c56116f04b3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 8 Jul 2025 12:00:17 -0400 Subject: [PATCH] Turns the relay information dialog into a route --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/Routes.kt | 6 + .../amethyst/ui/note/RelayListRow.kt | 43 +- .../feed/types/RenderCreateChannelNote.kt | 45 +- .../loggedIn/relays/RelayInformationDialog.kt | 439 ----------------- .../loggedIn/relays/RelayInformationScreen.kt | 450 ++++++++++++++++++ .../common/BasicRelaySetupInfoDialog.kt | 73 +-- amethyst/src/main/res/values/strings.xml | 1 + 8 files changed, 466 insertions(+), 593 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 181bcfd7c..da00bc896 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScree import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen @@ -135,6 +136,7 @@ fun AppNavigation( composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } + composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { ChatroomScreen(it.id.toString(), it.message, it.replyId, it.draftId, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 14caf81b9..cd27911ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -89,6 +89,10 @@ sealed class Route { val id: String, ) : Route() + @Serializable data class RelayInfo( + val url: String, + ) : Route() + @Serializable data class EphemeralChat( val id: String, val relayUrl: String, @@ -197,6 +201,8 @@ fun getRouteWithArguments(navController: NavHostController): Route? { dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index 024faaaf7..110aacf9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -53,13 +53,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter @@ -130,18 +129,6 @@ fun RenderRelay( ) { val relayInfo by loadRelayInfo(relay, accountViewModel) - var openRelayDialog by remember { mutableStateOf(false) } - - if (openRelayDialog) { - RelayInformationDialog( - onClose = { openRelayDialog = false }, - relayInfo = relayInfo, - relay = relay, - accountViewModel = accountViewModel, - nav = nav, - ) - } - val clipboardManager = LocalClipboardManager.current val clickableModifier = remember(relay) { @@ -153,33 +140,7 @@ fun RenderRelay( onLongClick = { clipboardManager.setText(AnnotatedString(relay.url)) }, - onClick = { - openRelayDialog = true - accountViewModel.retrieveRelayDocument( - relay, - onInfo = { }, - onError = { url, errorCode, exceptionMessage -> - accountViewModel.toastManager.toast( - R.string.unable_to_download_relay_document, - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - R.string.relay_information_document_error_failed_to_assemble_url - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - R.string.relay_information_document_error_failed_to_reach_server - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - R.string.relay_information_document_error_failed_to_parse_response - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - R.string.relay_information_document_error_failed_with_http - }, - url.url, - exceptionMessage ?: errorCode.toString(), - ) - }, - ) - }, + onClick = { nav.nav(Route.RelayInfo(relay.url)) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt index 289021747..fbfcdcbcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt @@ -52,16 +52,15 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter import com.vitorpamplona.amethyst.ui.theme.Size20dp @@ -241,18 +240,6 @@ fun RenderRelayLinePublicChat( @Suppress("ProduceStateDoesNotAssignValue") val relayInfo by loadRelayInfo(relay, accountViewModel) - var openRelayDialog by remember { mutableStateOf(false) } - - if (openRelayDialog) { - RelayInformationDialog( - onClose = { openRelayDialog = false }, - relayInfo = relayInfo, - relay = relay, - accountViewModel = accountViewModel, - nav = nav, - ) - } - val clipboardManager = LocalClipboardManager.current val clickableModifier = remember(relay) { @@ -260,39 +247,13 @@ fun RenderRelayLinePublicChat( onLongClick = { clipboardManager.setText(AnnotatedString(relay.url)) }, - onClick = { - openRelayDialog = true - accountViewModel.retrieveRelayDocument( - relay = relay, - onInfo = {}, - onError = { relay, errorCode, exceptionMessage -> - accountViewModel.toastManager.toast( - R.string.unable_to_download_relay_document, - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - R.string.relay_information_document_error_failed_to_assemble_url - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - R.string.relay_information_document_error_failed_to_reach_server - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - R.string.relay_information_document_error_failed_to_parse_response - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - R.string.relay_information_document_error_failed_with_http - }, - relay.url, - exceptionMessage ?: errorCode.toString(), - ) - }, - ) - }, + onClick = { nav.nav(Route.RelayInfo(relay.url)) }, ) } RenderRelayLine( relay.displayUrl(), - relayInfo?.icon, + relayInfo.icon, clickableModifier, showPicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationDialog.kt deleted file mode 100644 index 84b450de9..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationDialog.kt +++ /dev/null @@ -1,439 +0,0 @@ -/** - * 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 - -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.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.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -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.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.FeatureSetType -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.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav -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.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser -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.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdPadding -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayDebugMessage -import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList -import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import kotlinx.collections.immutable.toImmutableList - -@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) -@Composable -fun RelayInformationDialog( - onClose: () -> Unit, - relay: NormalizedRelayUrl, - relayInfo: Nip11RelayInformation, - accountViewModel: AccountViewModel, - nav: INav, -) { - val newNav = rememberExtendedNav(nav, onClose) - - val messages = - remember(relay) { - RelayStats - .get(url = relay) - .messages - .snapshot() - .values - .sortedByDescending { it.time } - .toImmutableList() - } - - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - ), - ) { - SetDialogToEdgeToEdge() - - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - TopAppBar( - actions = {}, - title = { - Text(relay.displayUrl()) - }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - BackButton( - onPress = { onClose() }, - ) - } - }, - ) - }, - ) { pad -> - 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.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - 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() ?: "") - } - item { - Section(stringRes(R.string.owner)) - - relayInfo.pubkey?.let { - DisplayOwnerInformation(it, accountViewModel, newNav) - } - } - item { - Section(stringRes(R.string.contact)) - - Box(modifier = Modifier.padding(start = 10.dp)) { - relayInfo.contact?.let { - if (it.startsWith("https:")) { - ClickableUrl(urlText = it, url = it) - } else if (it.startsWith("mailto:") || it.contains('@')) { - ClickableEmail(it) - } else { - SectionContent(it) - } - } - } - } - item { - Section(stringRes(R.string.software)) - - DisplaySoftwareInformation(relayInfo) - - Section(stringRes(R.string.version)) - - SectionContent(relayInfo.version ?: "") - } - item { - Section(stringRes(R.string.supports)) - - DisplaySupportedNips(relayInfo) - } - item { - relayInfo.fees?.admission?.let { - if (it.isNotEmpty()) { - Section(stringRes(R.string.admission_fees)) - - it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") } - } - } - - relayInfo.payments_url?.let { - Section(stringRes(R.string.payments_url)) - - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = it, - url = it, - ) - } - } - } - item { - relayInfo.limitation?.let { - 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") - } - } - } - item { - relayInfo.relay_countries?.let { - Section(stringRes(R.string.countries)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - item { - relayInfo.language_tags?.let { - Section(stringRes(R.string.languages)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - item { - relayInfo.tags?.let { - Section(stringRes(R.string.tags)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - item { - relayInfo.posting_policy?.let { - 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, newNav) - } - - Spacer(modifier = StdVertSpacer) - } - } - } - } -} - -@Composable -private fun RenderDebugMessage( - msg: RelayDebugMessage, - accountViewModel: AccountViewModel, - newNav: 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, - ) - } -} - -@Composable -@OptIn(ExperimentalLayoutApi::class) -private fun DisplaySupportedNips(relayInfo: Nip11RelayInformation) { - FlowRow { - relayInfo.supported_nips?.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", - ) - } - } - - relayInfo.supported_nip_extensions?.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", - ) - } - } - } -} - -@Composable -private fun DisplaySoftwareInformation(relayInfo: Nip11RelayInformation) { - val url = (relayInfo.software ?: "").replace("git+", "") - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = url, - url = url, - ) - } -} - -@Composable -private fun DisplayOwnerInformation( - userHex: String, - accountViewModel: AccountViewModel, - nav: INav, -) { - LoadUser(baseUserHex = userHex, accountViewModel) { - CrossfadeIfEnabled(it, accountViewModel = accountViewModel) { - if (it != null) { - UserCompose( - baseUser = it, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } -} - -@Composable -fun Title(text: String) { - Text( - text = text, - fontWeight = FontWeight.Bold, - fontSize = 24.sp, - ) -} - -@Composable -fun SubtitleContent(text: String) { - Text( - text = text, - ) -} - -@Composable -fun Section(text: String) { - Spacer(modifier = DoubleVertSpacer) - Text( - text = text, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) - Spacer(modifier = DoubleVertSpacer) -} - -@Composable -fun SectionContent(text: String) { - Text( - modifier = Modifier.padding(start = 10.dp), - text = text, - ) -} 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 new file mode 100644 index 000000000..d5578ac6d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -0,0 +1,450 @@ +/** + * 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 + +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.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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.FeatureSetType +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.navigation.INav +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.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +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.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +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.nip02FollowList.EmptyTagList +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun RelayInformationScreen( + relayUrl: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + RelayUrlNormalizer.normalizeOrNull(relayUrl)?.let { + RelayInformationScreen( + relay = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RelayInformationScreen( + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + actions = {}, + title = { + Text(relay.displayUrl()) + }, + navigationIcon = { + Row { + Spacer(modifier = StdHorzSpacer) + BackButton( + onPress = nav::popBack, + ) + } + }, + ) + }, + ) { pad -> + val relayInfo by loadRelayInfo(relay, accountViewModel) + + val messages = + remember(relay) { + RelayStats + .get(url = relay) + .messages + .snapshot() + .values + .sortedByDescending { it.time } + .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.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + 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) + } + } + } +} + +@Composable +private fun RenderDebugMessage( + msg: RelayDebugMessage, + accountViewModel: AccountViewModel, + newNav: 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, + ) + } +} + +@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", + ) + } + } + + 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", + ) + } + } + } +} + +@Composable +private fun DisplaySoftwareInformation(software: String) { + val url = software.replace("git+", "") + Box(modifier = Modifier.padding(start = 10.dp)) { + ClickableUrl( + urlText = url, + url = url, + ) + } +} + +@Composable +private fun DisplayOwnerInformation( + userHex: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadUser(baseUserHex = userHex, accountViewModel) { + CrossfadeIfEnabled(it, accountViewModel = accountViewModel) { + if (it != null) { + UserCompose( + baseUser = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +fun Title(text: String) { + Text( + text = text, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + ) +} + +@Composable +fun SubtitleContent(text: String) { + Text( + text = text, + ) +} + +@Composable +fun Section(text: String) { + Spacer(modifier = DoubleVertSpacer) + Text( + text = text, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Spacer(modifier = DoubleVertSpacer) +} + +@Composable +fun SectionContent(text: String) { + Text( + modifier = Modifier.padding(start = 10.dp), + text = text, + ) +} 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 64c7fb125..8da93ea6c 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 @@ -22,19 +22,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.service.Nip11Retriever -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.nav import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog -import com.vitorpamplona.amethyst.ui.stringRes @Composable fun BasicRelaySetupInfoDialog( @@ -43,75 +35,14 @@ fun BasicRelaySetupInfoDialog( accountViewModel: AccountViewModel, nav: INav, ) { - val context = LocalContext.current - val relayInfo by loadRelayInfo(item.relay, accountViewModel) - var openRelayDialog by remember { mutableStateOf(false) } - - if (openRelayDialog) { - RelayInformationDialog( - onClose = { openRelayDialog = false }, - relayInfo = relayInfo, - relay = item.relay, - accountViewModel = accountViewModel, - nav = nav, - ) - } - BasicRelaySetupInfoClickableRow( item = item, loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, onDelete = onDelete, accountViewModel = accountViewModel, - onClick = { - openRelayDialog = true - accountViewModel.retrieveRelayDocument( - relay = item.relay, - onInfo = { }, - onError = { relay, errorCode, exceptionMessage -> - val msg = - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - relay.url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - relay.url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - relay.url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - relay.url, - exceptionMessage, - ) - } - - accountViewModel.toastManager.toast( - stringRes(context, R.string.unable_to_download_relay_document), - msg, - ) - }, - ) - }, + onClick = { nav.nav(Route.RelayInfo(item.relay.url)) }, ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e7083237c..82a56524e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -107,6 +107,7 @@ My Awesome Group Picture Url Description + Description not found "About us.. " What\'s on your mind? Write a message…