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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
@@ -43,7 +44,10 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf 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.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog 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.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.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull 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.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. * Import Follow List dialog for Desktop.
* *
* Lets users enter an identifier (npub, hex, NIP-05, or Namecoin), * Lets users enter an identifier (npub, hex, NIP-05 HTTP, or Namecoin),
* resolves it to a pubkey. The actual follow list fetching from relays * resolves it to a pubkey, fetches their kind 3 (ContactListEvent) from
* is a placeholder — full implementation requires relay subscription * relays, displays the follow list with select/deselect toggles, and
* infrastructure integration. * publishes a new kind 3 with the selected follows.
*/ */
@Composable @Composable
fun ImportFollowListDialog( fun ImportFollowListDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onImport: (List<String>) -> Unit, relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
localCache: DesktopLocalCache,
) { ) {
val namecoinService = LocalNamecoinService.current val namecoinService = LocalNamecoinService.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var input by remember { mutableStateOf("") } var input by remember { mutableStateOf("") }
var resolvedPubkey by remember { mutableStateOf<String?>(null) } var importState by remember { mutableStateOf<ImportState>(ImportState.Idle) }
var isResolving by remember { mutableStateOf(false) } val followEntries = remember { mutableStateListOf<FollowEntry>() }
var error by remember { mutableStateOf<String?>(null) }
var followList = remember { mutableStateListOf<FollowEntry>() } // Track active subscription IDs for cleanup
var isFetching by remember { mutableStateOf(false) } 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() { fun resolveIdentifier() {
val trimmed = input.trim() val trimmed = input.trim()
if (trimmed.isBlank()) { if (trimmed.isBlank()) {
error = "Enter an identifier" importState = ImportState.Error("Enter an identifier")
return return
} }
error = null importState = ImportState.ResolvingIdentifier
isResolving = true followEntries.clear()
resolvedPubkey = null
followList.clear()
scope.launch { scope.launch {
try { try {
// Try bech32 first // Try bech32 (npub/nprofile) first
val bech32Result = decodePublicKeyAsHexOrNull(trimmed) val bech32Result = decodePublicKeyAsHexOrNull(trimmed)
if (bech32Result != null) { if (bech32Result != null) {
resolvedPubkey = bech32Result importState = ImportState.IdentifierResolved(bech32Result)
isResolving = false
return@launch return@launch
} }
// Try hex pubkey // Try raw hex pubkey
if (trimmed.matches(Regex("^[0-9a-fA-F]{64}$"))) { if (trimmed.matches(Regex("^[0-9a-fA-F]{64}$"))) {
resolvedPubkey = trimmed.lowercase() importState = ImportState.IdentifierResolved(trimmed.lowercase())
isResolving = false
return@launch return@launch
} }
@@ -122,21 +355,63 @@ fun ImportFollowListDialog(
if (NamecoinNameResolver.isNamecoinIdentifier(trimmed) && namecoinService != null) { if (NamecoinNameResolver.isNamecoinIdentifier(trimmed) && namecoinService != null) {
val result = namecoinService.resolvePubkey(trimmed) val result = namecoinService.resolvePubkey(trimmed)
if (result != null) { if (result != null) {
resolvedPubkey = result importState = ImportState.IdentifierResolved(result)
isResolving = false
return@launch return@launch
} }
} }
error = "Could not resolve identifier" // Try NIP-05 HTTP (user@domain)
isResolving = false 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) { } catch (e: Exception) {
error = e.message ?: "Resolution failed" importState = ImportState.Error(e.message ?: "Resolution failed")
isResolving = false
} }
} }
} }
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) { Dialog(onDismissRequest = onDismiss) {
Surface( Surface(
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
@@ -152,14 +427,18 @@ fun ImportFollowListDialog(
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
"Enter an npub, hex pubkey, NIP-05, or Namecoin identifier (.bit, d/, id/) " + "Enter an npub, hex pubkey, NIP-05 (user@domain), or Namecoin identifier " +
"to import their follow list.", "(.bit, d/, id/) to import their follow list.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Input field // 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -169,14 +448,16 @@ fun ImportFollowListDialog(
value = input, value = input,
onValueChange = { onValueChange = {
input = it input = it
error = null if (importState is ImportState.Error) {
importState = ImportState.Idle
}
}, },
label = { Text("Identifier") }, label = { Text("Identifier") },
placeholder = { Text("npub1..., alice@example.bit, d/example") }, placeholder = { Text("npub1..., alice@example.com, d/alice") },
singleLine = true, singleLine = true,
isError = error != null, isError = importState is ImportState.Error,
supportingText = error?.let { err -> supportingText = (importState as? ImportState.Error)?.let { err ->
{ Text(err, color = MaterialTheme.colorScheme.error) } { Text(err.message, color = MaterialTheme.colorScheme.error) }
}, },
modifier = Modifier.weight(1f).onPreviewKeyEvent { event -> modifier = Modifier.weight(1f).onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) { if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
@@ -189,98 +470,173 @@ fun ImportFollowListDialog(
) )
Button( Button(
onClick = { resolveIdentifier() }, onClick = { resolveIdentifier() },
enabled = input.isNotBlank() && !isResolving, enabled = input.isNotBlank() && importState !is ImportState.ResolvingIdentifier,
) { ) {
if (isResolving) { if (importState is ImportState.ResolvingIdentifier) {
CircularProgressIndicator(modifier = Modifier.size(16.dp)) CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
} else { } else {
Text("Resolve") Text("Resolve")
} }
} }
} }
}
// Resolved pubkey display // Fetching follow list state
if (resolvedPubkey != null) { 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)) 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Icon( Icon(
Icons.Default.Person, Icons.Default.CheckCircle,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp), modifier = Modifier.size(24.dp),
) )
Text( Text(
"Resolved: ${resolvedPubkey!!.take(16)}...${resolvedPubkey!!.takeLast(8)}", "Successfully published! Following $selectedCount accounts.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary, 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)) 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 // Action buttons
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (importState !is ImportState.Done) {
OutlinedButton(onClick = onDismiss) { OutlinedButton(onClick = onDismiss) {
Text("Cancel") Text("Cancel")
} }
if (resolvedPubkey != null) { }
if (importState is ImportState.FollowListLoaded && selectedCount > 0) {
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Button( Button(
onClick = { onClick = { publishFollows() },
resolvedPubkey?.let { pk ->
onImport(listOf(pk))
}
},
) { ) {
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,
)