mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
@@ -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.
|
||||
*
|
||||
|
||||
+155
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user