desktop: full relay-integrated Import Follow List dialog

Rewrites ImportFollowListDialog with complete relay integration:

- Resolves identifiers: npub, hex, NIP-05 HTTP (via OkHttp), Namecoin
- Fetches kind 3 (ContactListEvent) from relays via subscription
- Parses follow list (p-tags) into selectable entries
- Fetches kind 0 metadata for display names (best-effort)
- Shows preview with select all/deselect all + individual toggles
- Publishes new kind 3 via broadcastToAll with selected follows
- State machine: Idle → Resolving → Fetching → Loaded → Publishing → Done
- Proper cleanup: DisposableEffect unsubscribes on dismiss
- 15s timeout for kind 3, 20s timeout for metadata enrichment
- Updates Main.kt call site to pass relayManager, account, localCache
This commit is contained in:
M
2026-03-24 08:01:32 +11:00
committed by m
parent 21133c34c0
commit 0bc1c2838d
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
@@ -43,7 +44,10 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@@ -60,61 +64,290 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
/**
* State machine for the import follow list flow.
*/
private sealed class ImportState {
data object Idle : ImportState()
data object ResolvingIdentifier : ImportState()
data class IdentifierResolved(val pubkey: String) : ImportState()
data object FetchingFollowList : ImportState()
data class FollowListLoaded(val sourcePubkey: String) : ImportState()
data class Error(val message: String) : ImportState()
data object Publishing : ImportState()
data object Done : ImportState()
}
/**
* A follow entry with mutable selection state.
*/
data class FollowEntry(
val pubkey: String,
val displayName: String? = null,
val selected: Boolean = true,
)
/**
* Resolves a NIP-05 identifier (user@domain.com) to a hex pubkey via HTTP.
*/
private suspend fun resolveNip05Http(identifier: String): String? {
if (!identifier.contains("@") || identifier.endsWith(".bit")) return null
val parts = identifier.split("@", limit = 2)
if (parts.size != 2) return null
val (name, domain) = parts
if (name.isBlank() || domain.isBlank()) return null
val encodedName = URLEncoder.encode(name, "UTF-8")
val url = "https://$domain/.well-known/nostr.json?name=$encodedName"
return withContext(Dispatchers.IO) {
try {
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
response.use { resp ->
if (!resp.isSuccessful) return@withContext null
val body = resp.body.string()
val json = jacksonObjectMapper().readTree(body)
json.get("names")?.get(name)?.asText()
}
} catch (_: Exception) {
null
}
}
}
/**
* Import Follow List dialog for Desktop.
*
* Lets users enter an identifier (npub, hex, NIP-05, or Namecoin),
* resolves it to a pubkey. The actual follow list fetching from relays
* is a placeholder — full implementation requires relay subscription
* infrastructure integration.
* Lets users enter an identifier (npub, hex, NIP-05 HTTP, or Namecoin),
* resolves it to a pubkey, fetches their kind 3 (ContactListEvent) from
* relays, displays the follow list with select/deselect toggles, and
* publishes a new kind 3 with the selected follows.
*/
@Composable
fun ImportFollowListDialog(
onDismiss: () -> Unit,
onImport: (List<String>) -> Unit,
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
localCache: DesktopLocalCache,
) {
val namecoinService = LocalNamecoinService.current
val scope = rememberCoroutineScope()
var input by remember { mutableStateOf("") }
var resolvedPubkey by remember { mutableStateOf<String?>(null) }
var isResolving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var followList = remember { mutableStateListOf<FollowEntry>() }
var isFetching by remember { mutableStateOf(false) }
var importState by remember { mutableStateOf<ImportState>(ImportState.Idle) }
val followEntries = remember { mutableStateListOf<FollowEntry>() }
// Track active subscription IDs for cleanup
val activeSubscriptions = remember { mutableStateListOf<String>() }
// Clean up all subscriptions when the dialog is dismissed
DisposableEffect(Unit) {
onDispose {
activeSubscriptions.forEach { subId ->
try {
relayManager.unsubscribe(subId)
} catch (_: Exception) {
// Ignore cleanup errors
}
}
}
}
// When identifier is resolved, auto-start fetching the follow list
LaunchedEffect(importState) {
val state = importState
if (state is ImportState.IdentifierResolved) {
importState = ImportState.FetchingFollowList
val pubkey = state.pubkey
followEntries.clear()
val subId = "import-follows-${System.currentTimeMillis()}"
activeSubscriptions.add(subId)
var receivedContactList = false
relayManager.subscribe(
subId = subId,
filters = listOf(
Filter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(pubkey),
limit = 1,
),
),
listener = object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event.kind == ContactListEvent.KIND && !receivedContactList) {
receivedContactList = true
val contactList = ContactListEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
val follows = contactList.unverifiedFollowKeySet()
followEntries.clear()
followEntries.addAll(
follows.map { FollowEntry(pubkey = it, selected = true) },
)
importState = ImportState.FollowListLoaded(sourcePubkey = pubkey)
// Clean up the contact list subscription
try {
relayManager.unsubscribe(subId)
} catch (_: Exception) {}
activeSubscriptions.remove(subId)
// Start fetching metadata for display names
if (follows.isNotEmpty()) {
val metaSubId = "import-meta-${System.currentTimeMillis()}"
activeSubscriptions.add(metaSubId)
relayManager.subscribe(
subId = metaSubId,
filters = listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
authors = follows,
limit = follows.size,
),
),
listener = object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event.kind == MetadataEvent.KIND) {
val metadata = try {
JsonMapper.fromJson<UserMetadata>(event.content)
} catch (_: Exception) {
null
}
val bestName = metadata?.bestName()
if (bestName != null) {
val idx = followEntries.indexOfFirst { it.pubkey == event.pubKey }
if (idx >= 0) {
followEntries[idx] = followEntries[idx].copy(displayName = bestName)
}
}
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// Metadata is best-effort; don't close early
// as multiple relays may have different metadata
}
},
)
// Clean up metadata subscription after 20s
scope.launch {
delay(20_000)
if (metaSubId in activeSubscriptions) {
try {
relayManager.unsubscribe(metaSubId)
} catch (_: Exception) {}
activeSubscriptions.remove(metaSubId)
}
}
}
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// If we got EOSE from all relays without a contact list,
// the timeout below will handle showing the error
}
},
)
// Timeout: if no contact list received after 15s, show error or empty result
scope.launch {
delay(15_000)
if (importState is ImportState.FetchingFollowList) {
try {
relayManager.unsubscribe(subId)
} catch (_: Exception) {}
activeSubscriptions.remove(subId)
if (followEntries.isEmpty()) {
importState = ImportState.Error("No follow list found for this user. They may not have published a contact list.")
}
}
}
}
}
// Auto-dismiss after Done
LaunchedEffect(importState) {
if (importState is ImportState.Done) {
delay(1500)
onDismiss()
}
}
fun resolveIdentifier() {
val trimmed = input.trim()
if (trimmed.isBlank()) {
error = "Enter an identifier"
importState = ImportState.Error("Enter an identifier")
return
}
error = null
isResolving = true
resolvedPubkey = null
followList.clear()
importState = ImportState.ResolvingIdentifier
followEntries.clear()
scope.launch {
try {
// Try bech32 first
// Try bech32 (npub/nprofile) first
val bech32Result = decodePublicKeyAsHexOrNull(trimmed)
if (bech32Result != null) {
resolvedPubkey = bech32Result
isResolving = false
importState = ImportState.IdentifierResolved(bech32Result)
return@launch
}
// Try hex pubkey
// Try raw hex pubkey
if (trimmed.matches(Regex("^[0-9a-fA-F]{64}$"))) {
resolvedPubkey = trimmed.lowercase()
isResolving = false
importState = ImportState.IdentifierResolved(trimmed.lowercase())
return@launch
}
@@ -122,21 +355,63 @@ fun ImportFollowListDialog(
if (NamecoinNameResolver.isNamecoinIdentifier(trimmed) && namecoinService != null) {
val result = namecoinService.resolvePubkey(trimmed)
if (result != null) {
resolvedPubkey = result
isResolving = false
importState = ImportState.IdentifierResolved(result)
return@launch
}
}
error = "Could not resolve identifier"
isResolving = false
// Try NIP-05 HTTP (user@domain)
if (trimmed.contains("@")) {
val result = resolveNip05Http(trimmed)
if (result != null) {
importState = ImportState.IdentifierResolved(result)
return@launch
}
}
importState = ImportState.Error("Could not resolve identifier")
} catch (e: Exception) {
error = e.message ?: "Resolution failed"
isResolving = false
importState = ImportState.Error(e.message ?: "Resolution failed")
}
}
}
fun toggleAll(selected: Boolean) {
val updated = followEntries.map { it.copy(selected = selected) }
followEntries.clear()
followEntries.addAll(updated)
}
fun toggleEntry(index: Int) {
if (index in followEntries.indices) {
followEntries[index] = followEntries[index].copy(selected = !followEntries[index].selected)
}
}
fun publishFollows() {
val selected = followEntries.filter { it.selected }
if (selected.isEmpty()) return
importState = ImportState.Publishing
scope.launch {
try {
val contactTags = selected.map { ContactTag(it.pubkey) }
val newContactList = ContactListEvent.createFromScratch(
followUsers = contactTags,
relayUse = null,
signer = account.signer,
)
relayManager.broadcastToAll(newContactList)
importState = ImportState.Done
} catch (e: Exception) {
importState = ImportState.Error("Failed to publish: ${e.message}")
}
}
}
val selectedCount = followEntries.count { it.selected }
val totalCount = followEntries.size
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.large,
@@ -152,135 +427,216 @@ fun ImportFollowListDialog(
)
Spacer(Modifier.height(8.dp))
Text(
"Enter an npub, hex pubkey, NIP-05, or Namecoin identifier (.bit, d/, id/) " +
"to import their follow list.",
"Enter an npub, hex pubkey, NIP-05 (user@domain), or Namecoin identifier " +
"(.bit, d/, id/) to import their follow list.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
// Input field
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = input,
onValueChange = {
input = it
error = null
},
label = { Text("Identifier") },
placeholder = { Text("npub1..., alice@example.bit, d/example") },
singleLine = true,
isError = error != null,
supportingText = error?.let { err ->
{ Text(err, color = MaterialTheme.colorScheme.error) }
},
modifier = Modifier.weight(1f).onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
resolveIdentifier()
true
} else {
false
}
},
)
Button(
onClick = { resolveIdentifier() },
enabled = input.isNotBlank() && !isResolving,
// Input field + Resolve button (shown in Idle and Error states)
val showInput = importState is ImportState.Idle ||
importState is ImportState.Error ||
importState is ImportState.ResolvingIdentifier
if (showInput) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (isResolving) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
} else {
Text("Resolve")
OutlinedTextField(
value = input,
onValueChange = {
input = it
if (importState is ImportState.Error) {
importState = ImportState.Idle
}
},
label = { Text("Identifier") },
placeholder = { Text("npub1..., alice@example.com, d/alice") },
singleLine = true,
isError = importState is ImportState.Error,
supportingText = (importState as? ImportState.Error)?.let { err ->
{ Text(err.message, color = MaterialTheme.colorScheme.error) }
},
modifier = Modifier.weight(1f).onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
resolveIdentifier()
true
} else {
false
}
},
)
Button(
onClick = { resolveIdentifier() },
enabled = input.isNotBlank() && importState !is ImportState.ResolvingIdentifier,
) {
if (importState is ImportState.ResolvingIdentifier) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
} else {
Text("Resolve")
}
}
}
}
// Resolved pubkey display
if (resolvedPubkey != null) {
// Fetching follow list state
if (importState is ImportState.FetchingFollowList) {
Spacer(Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Text(
"Fetching follow list from relays…",
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
// Follow list loaded — show entries with checkboxes
if (importState is ImportState.FollowListLoaded && followEntries.isNotEmpty()) {
Spacer(Modifier.height(12.dp))
// Header with count and select/deselect controls
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"$totalCount follows found",
style = MaterialTheme.typography.labelLarge,
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = { toggleAll(true) }) {
Text("Select All", style = MaterialTheme.typography.labelSmall)
}
TextButton(onClick = { toggleAll(false) }) {
Text("Deselect All", style = MaterialTheme.typography.labelSmall)
}
}
}
Spacer(Modifier.height(4.dp))
// Scrollable follow list
LazyColumn(
modifier = Modifier.height(300.dp).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
items(followEntries.size) { index ->
val entry = followEntries[index]
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
Checkbox(
checked = entry.selected,
onCheckedChange = { toggleEntry(index) },
)
Spacer(Modifier.width(8.dp))
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(6.dp))
Column {
if (entry.displayName != null) {
Text(
entry.displayName,
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
"${entry.pubkey.take(12)}${entry.pubkey.takeLast(8)}",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = if (entry.displayName != null) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
}
}
}
}
// Publishing state
if (importState is ImportState.Publishing) {
Spacer(Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Text(
"Publishing contact list…",
style = MaterialTheme.typography.bodyMedium,
)
}
}
// Done state
if (importState is ImportState.Done) {
Spacer(Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.Person,
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(24.dp),
)
Text(
"Resolved: ${resolvedPubkey!!.take(16)}...${resolvedPubkey!!.takeLast(8)}",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
"Successfully published! Following $selectedCount accounts.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Spacer(Modifier.height(8.dp))
Text(
"Follow list fetching requires relay subscriptions. " +
"The resolved pubkey can be used to follow this user directly.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(16.dp))
// Follow list preview (placeholder for when relay fetching is implemented)
if (followList.isNotEmpty()) {
Text(
"Follow List (${followList.size} users)",
style = MaterialTheme.typography.labelMedium,
)
Spacer(Modifier.height(8.dp))
LazyColumn(
modifier = Modifier.height(300.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(followList) { entry ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(4.dp),
) {
Checkbox(
checked = entry.selected,
onCheckedChange = { entry.selected = it },
)
Spacer(Modifier.width(8.dp))
Text(
entry.pubkey.take(16) + "...",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
}
}
}
}
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onDismiss) {
Text("Cancel")
if (importState !is ImportState.Done) {
OutlinedButton(onClick = onDismiss) {
Text("Cancel")
}
}
if (resolvedPubkey != null) {
if (importState is ImportState.FollowListLoaded && selectedCount > 0) {
Spacer(Modifier.width(8.dp))
Button(
onClick = {
resolvedPubkey?.let { pk ->
onImport(listOf(pk))
}
},
onClick = { publishFollows() },
) {
Text("Follow User")
Text("Follow $selectedCount account${if (selectedCount != 1) "s" else ""}")
}
}
if (importState is ImportState.Error) {
Spacer(Modifier.width(8.dp))
Button(onClick = {
importState = ImportState.Idle
followEntries.clear()
}) {
Text("Try Again")
}
}
}
@@ -288,8 +644,3 @@ fun ImportFollowListDialog(
}
}
}
data class FollowEntry(
val pubkey: String,
var selected: Boolean = true,
)