From fd522877d9d5db65025045e70faa074eede9ba6d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 02:10:28 +0000 Subject: [PATCH] feat(cordn): offer the coordinators that announce, instead of a hex field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CordnCoordinatorDiscovery was built when the picker was asked for and then never connected to it — referenced only by its own file and its own test, unreachable from any screen, any ViewModel, and from amy. Adding a coordinator meant pasting a 64-character public key and a list of relay URLs, which nobody can do without being handed the answer out of band. The picker now reads CEP-6 announcements off the account's outbox relays and lists what it finds: the coordinator's own name and description, the relays that carried it, and how long ago it last announced. That last line earns its place — the live survey behind this feature found 41 of 42 announcing coordinators were months-dead browser demos. Deliberate choices: - Not automatic on entry. The query touches no coordinator, but it is still the user's relays being asked something on their behalf, and a screen that reaches out the moment it opens is what this feature is supposed to be careful about. - Already-added coordinators are dropped from the list rather than shown disabled: it answers "what could I add", and a dead row is one more thing to read. - "Nobody is announcing" and "nothing found, and N relays did not answer" are different sentences, because only one of them is final. - The announced name never becomes the label. A coordinator cannot prove a name; a label is the user's own word. toConfig() already refused to launder one into the other and this respects that. Uses the app's CrossfadeIfEnabled, so performance mode still turns the animation off. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n --- .../amethyst/model/cordn/CordnRuntime.kt | 11 ++ .../settings/cordn/CordnCoordinatorsScreen.kt | 155 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 8 + 3 files changed, 174 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnRuntime.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnRuntime.kt index a72ecb4c85..d9f0abba3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnRuntime.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnRuntime.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth import com.vitorpamplona.amethyst.commons.cordn.CordnBackup import com.vitorpamplona.amethyst.commons.cordn.CordnBlobCipher +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorDiscovery import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorLinkFactory import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorRegistry import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager @@ -565,6 +566,16 @@ class CordnRuntime( CordnMigrationStores.read(filesDir, accountSigner.pubKey, cipher, coordinatorStore.load()) } + /** + * Coordinators announcing themselves on [relays], newest first. + * + * Touches no coordinator: it reads the CEP-6 announcements they already + * published, so nothing discovered here learns this account exists. That + * is the whole reason discovery can be offered before the user has + * committed to anything — see [CordnCoordinatorDiscovery]. + */ + suspend fun discover(relays: Set): CordnCoordinatorDiscovery.Result = CordnCoordinatorDiscovery(client).discover(relays) + /** * Publishes a handoff and stands this device down. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt index a966474835..bc5eba5c09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt @@ -23,15 +23,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize 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.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -52,12 +56,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorDiscovery +import com.vitorpamplona.amethyst.commons.cordn.DiscoveredCoordinator import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_title import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo @@ -137,6 +145,10 @@ fun CordnCoordinatorsScreen( HorizontalDivider(Modifier.padding(vertical = 8.dp)) + DiscoverCoordinators(runtime, accountViewModel, coordinators.map { it.pubKey }.toSet()) + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + AddCoordinator(runtime) } } @@ -265,6 +277,149 @@ private fun HealthLine(state: CoordinatorHealth.State) { ) } +/** + * Coordinators that announced themselves, offered instead of a hex field. + * + * Adding one used to mean pasting a 64-character public key and a list of + * relay URLs, which is not something anyone can do without being told the + * answer out of band. Coordinators publish CEP-6 announcements; reading them + * is a relay query that touches no coordinator, so nothing here tells anyone + * that this account exists. + * + * Deliberately not automatic on entry. The query is cheap but it is still the + * user's relays being asked a question on their behalf, and a screen that + * reaches out the moment it opens is the kind of thing this feature is + * supposed to be careful about. + */ +@Composable +private fun DiscoverCoordinators( + runtime: CordnRuntime, + accountViewModel: AccountViewModel, + known: Set, +) { + val scope = rememberCoroutineScope() + var result by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val failed = stringRes(R.string.cordn_coordinators_discover_failed) + + Text(stringRes(R.string.cordn_coordinators_discover), style = MaterialTheme.typography.titleSmall) + Text( + text = stringRes(R.string.cordn_coordinators_discover_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedButton( + onClick = { + busy = true + error = null + scope.launch { + try { + result = runtime.discover(accountViewModel.account.outboxRelays.flow.value) + } catch (e: Exception) { + error = e.message ?: failed + } finally { + busy = false + } + } + }, + enabled = !busy, + ) { + if (busy) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + } + Text(stringRes(R.string.cordn_coordinators_discover_action)) + } + + error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } + + // The app's own crossfade, so performance mode still turns it off. + CrossfadeIfEnabled( + targetState = result, + label = "cordn-discovery", + accountViewModel = accountViewModel, + ) { found -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (found == null) return@Column + + // Already-added ones are dropped rather than shown disabled: this + // list is "what you could add", and a row that does nothing is + // just something else to read. + val offers = found.coordinators.filter { it.pubKey !in known } + + if (offers.isEmpty()) { + Text( + text = + if (found.unreachable.isEmpty()) { + stringRes(R.string.cordn_coordinators_discover_none) + } else { + // "Nobody is announcing" and "we were not told" are + // different answers and only one of them is final. + stringRes(R.string.cordn_coordinators_discover_unheard, found.unreachable.size) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + offers.forEach { offer -> DiscoveredCard(offer, runtime) } + } + } +} + +@Composable +private fun DiscoveredCard( + offer: DiscoveredCoordinator, + runtime: CordnRuntime, +) { + val scope = rememberCoroutineScope() + var busy by remember { mutableStateOf(false) } + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + // Its own word for itself, and said so: a coordinator cannot + // prove a name, which is why this never becomes the label. + text = offer.surface.name?.takeIf { it.isNotBlank() } ?: offer.pubKey.take(16), + style = MaterialTheme.typography.titleSmall, + ) + offer.surface.about?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text( + text = offer.relays.joinToString { it.url }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + // Staleness matters more here than anywhere: the last live + // survey found most announcements were months-dead demos. + text = stringRes(R.string.cordn_coordinators_discover_seen, timeAgoNoDot(offer.announcedAt).trim()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { + busy = true + scope.launch { + try { + runtime.session(offer.toConfig()) + } finally { + busy = false + } + } + }, + enabled = !busy, + modifier = Modifier.padding(top = 4.dp), + ) { + Text(stringRes(R.string.cordn_coordinators_discover_add)) + } + } + } +} + @Composable private fun AddCoordinator(runtime: CordnRuntime) { val scope = rememberCoroutineScope() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 32c659ee06..feb427cdef 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -365,6 +365,14 @@ Join Decline Declining is permanent. Getting back in means being invited again. + Coordinators announcing themselves + Reads announcements your relays already carry. No coordinator is contacted, so none of them learns you looked. + Look for coordinators + Add + Could not read announcements + Nobody is announcing on your relays. + Nothing found, and %1$d of your relays did not answer. + Last announced %1$s ago Admin +%1$d more Through %1$s