Turns the relay information dialog into a route

This commit is contained in:
Vitor Pamplona
2025-07-08 12:00:17 -04:00
parent 296fa201f7
commit 994363602d
8 changed files with 466 additions and 593 deletions

View File

@@ -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<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.Room> { ChatroomScreen(it.id.toString(), it.message, it.replyId, it.draftId, accountViewModel, nav) }
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }

View File

@@ -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<Route.Geohash>() -> entry.toRoute<Route.Geohash>()
dest.hasRoute<Route.Community>() -> entry.toRoute<Route.Community>()
dest.hasRoute<Route.RelayInfo>() -> entry.toRoute<Route.RelayInfo>()
dest.hasRoute<Route.RoomByAuthor>() -> entry.toRoute<Route.RoomByAuthor>()
dest.hasRoute<Route.Channel>() -> entry.toRoute<Route.Channel>()
dest.hasRoute<Route.ChannelMetadataEdit>() -> entry.toRoute<Route.ChannelMetadataEdit>()

View File

@@ -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)) },
)
}

View File

@@ -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,

View File

@@ -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,
)
}

View File

@@ -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<Int>,
supportedNipExtensions: List<String>?,
) {
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,
)
}

View File

@@ -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)) },
)
}

View File

@@ -107,6 +107,7 @@
<string name="my_awesome_group">My Awesome Group</string>
<string name="picture_url">Picture Url</string>
<string name="description">Description</string>
<string name="no_description">Description not found</string>
<string name="about_us">"About us.. "</string>
<string name="what_s_on_your_mind">What\'s on your mind?</string>
<string name="write_a_message">Write a message…</string>