feat(concord): rich, clickable relay rows in the community create/edit form

The Concord create/edit screens showed each community relay as a bare URL string
with a remove button — far more basic than every other relay list in the app.
Extract a shared ConcordRelayListEditor that renders each relay the way the Relay
Settings / Marmot screens do: the relay's NIP-11 favicon, its advertised name, and
its host, with the row tapping through to the full relay-info page
(Route.RelayInfo) and long-press copying the URL. Both screens now call the one
editor (also removes the duplicated relay block).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-15 01:54:32 +00:00
parent d0a817a607
commit a06cdd82fe
3 changed files with 97 additions and 30 deletions
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -42,7 +41,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -50,11 +48,9 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
@@ -109,17 +105,10 @@ fun ConcordCreateScreen(
title = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays),
description = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays_desc),
)
relays.forEach { relay ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(relay.displayUrl(), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
IconButton(onClick = { relays.remove(relay) }) {
SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.remove))
}
}
}
RelayUrlEditField(
onNewRelay = { if (it !in relays) relays.add(it) },
modifier = Modifier.fillMaxWidth(),
ConcordRelayListEditor(
relays = relays,
onRemove = { relays.remove(it) },
onAdd = { if (it !in relays) relays.add(it) },
accountViewModel = accountViewModel,
nav = nav,
)
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -33,7 +32,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -54,12 +52,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
@@ -152,17 +148,10 @@ fun ConcordEditScreen(
title = stringRes(R.string.concord_create_relays),
description = stringRes(R.string.concord_edit_relays_desc),
)
relays.forEach { relay ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(relay.displayUrl(), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
IconButton(onClick = { relays.remove(relay) }) {
SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.remove))
}
}
}
RelayUrlEditField(
onNewRelay = { if (it !in relays) relays.add(it) },
modifier = Modifier.fillMaxWidth(),
ConcordRelayListEditor(
relays = relays,
onRemove = { relays.remove(it) },
onAdd = { if (it !in relays) relays.add(it) },
accountViewModel = accountViewModel,
nav = nav,
)
@@ -24,16 +24,20 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
@@ -52,18 +56,29 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.util.setText
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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
@@ -268,3 +283,77 @@ private fun ConcordBannerHero(
}
}
}
/**
* The community's bootstrap-relay list editor, shared by the create and edit screens. Each relay is
* shown the same way the Relay Settings screens show them — the relay's NIP-11 favicon, its
* advertised name, and its host — and tapping the row opens the full relay-info page ([Route.RelayInfo]),
* so a community relay is a first-class, inspectable relay rather than a bare URL string. A trailing
* ✕ removes it; the [RelayUrlEditField] below adds one. State is owned by the caller.
*/
@Composable
fun ConcordRelayListEditor(
relays: List<NormalizedRelayUrl>,
onRemove: (NormalizedRelayUrl) -> Unit,
onAdd: (NormalizedRelayUrl) -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
relays.forEach { relay ->
ConcordRelayRow(relay, { onRemove(relay) }, accountViewModel, nav)
}
RelayUrlEditField(
onNewRelay = onAdd,
modifier = Modifier.fillMaxWidth(),
accountViewModel = accountViewModel,
nav = nav,
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ConcordRelayRow(
relay: NormalizedRelayUrl,
onRemove: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
// The NIP-11 relay-info doc (icon + display name), fetched + cached exactly like the settings rows.
val relayInfo by loadRelayInfo(relay)
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
RenderRelayIcon(
displayUrl = relayInfo.id ?: relay.displayUrl(),
iconUrl = relayInfo.icon,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
pingInMs = 0,
iconModifier = MediumRelayIconModifier,
)
Spacer(Modifier.width(10.dp))
Column(
Modifier
.weight(1f)
.combinedClickable(
onClick = { nav.nav(Route.RelayInfo(relay.url)) },
onLongClick = { scope.launch { clipboard.setText(relay.url) } },
),
) {
relayInfo.name?.takeIf { it.isNotBlank() }?.let { name ->
Text(name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text(
text = relay.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
IconButton(onClick = onRemove) {
SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.remove))
}
}
}