feat(cordn): offer the coordinators that announce, instead of a hex field

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
Claude
2026-09-24 02:10:28 +00:00
parent a7a39fd4ec
commit fd522877d9
3 changed files with 174 additions and 0 deletions
@@ -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<NormalizedRelayUrl>): CordnCoordinatorDiscovery.Result = CordnCoordinatorDiscovery(client).discover(relays)
/**
* Publishes a handoff and stands this device down.
*
@@ -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<String>,
) {
val scope = rememberCoroutineScope()
var result by remember { mutableStateOf<CordnCoordinatorDiscovery.Result?>(null) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(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()
+8
View File
@@ -365,6 +365,14 @@
<string name="cordn_invitations_accept">Join</string>
<string name="cordn_invitations_decline">Decline</string>
<string name="cordn_invitations_decline_warning">Declining is permanent. Getting back in means being invited again.</string>
<string name="cordn_coordinators_discover">Coordinators announcing themselves</string>
<string name="cordn_coordinators_discover_explainer">Reads announcements your relays already carry. No coordinator is contacted, so none of them learns you looked.</string>
<string name="cordn_coordinators_discover_action">Look for coordinators</string>
<string name="cordn_coordinators_discover_add">Add</string>
<string name="cordn_coordinators_discover_failed">Could not read announcements</string>
<string name="cordn_coordinators_discover_none">Nobody is announcing on your relays.</string>
<string name="cordn_coordinators_discover_unheard">Nothing found, and %1$d of your relays did not answer.</string>
<string name="cordn_coordinators_discover_seen">Last announced %1$s ago</string>
<string name="cordn_info_admin_badge">Admin</string>
<string name="cordn_invitations_members_more">+%1$d more</string>
<string name="cordn_invitations_via">Through %1$s</string>