From 8456b38deacf0982f0bd4427f2f4549cc7ba54f3 Mon Sep 17 00:00:00 2001 From: M Date: Sun, 8 Mar 2026 15:38:54 +1100 Subject: [PATCH] feat: import follow list with Namecoin resolution during signup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new signup step that lets users bootstrap their feed by importing another profile's follow list. Supports all identifier types: - npub1... (NIP-19 bech32) - 64-char hex pubkey - alice@example.com (NIP-05 HTTP) - alice@example.bit / d/name / id/name (Namecoin blockchain) Flow: Enter identifier → Resolve → Fetch kind 3 → Preview with select/deselect → Apply follows → Done. Users can search multiple profiles before continuing. Changes to existing files: - NamecoinNameResolver: add resolveDetailed() + NamecoinResolveOutcome - NamecoinNameService: add resolveDetailed(), resolvePubkey() - AccountSessionManager: add isNewAccount flag, finishNewAccountSetup() - AccountScreen: route new accounts to ImportFollowListSection New files: - FollowListImporter: identifier resolution + kind 3 fetch + parsing - ImportFollowListViewModel: state machine for the import flow - ImportFollowListScreen: UI with preview, select/deselect, search again - FollowListImporterTest: 14 unit tests --- .../followimport/FollowListImporter.kt | 257 +++++++++ .../service/namecoin/NamecoinNameService.kt | 12 + .../amethyst/ui/screen/AccountScreen.kt | 106 +++- .../ui/screen/AccountSessionManager.kt | 15 +- .../screen/signup/ImportFollowListScreen.kt | 545 ++++++++++++++++++ .../signup/ImportFollowListViewModel.kt | 174 ++++++ .../followimport/FollowListImporterTest.kt | 268 +++++++++ .../namecoin/NamecoinNameResolver.kt | 75 +++ 8 files changed, 1447 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListViewModel.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporterTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporter.kt new file mode 100644 index 000000000..361cea2ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporter.kt @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.followimport + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +/** + * A single entry from a follow list. + */ +data class FollowEntry( + val pubkeyHex: String, + val relayHint: String? = null, + val petname: String? = null, +) + +/** + * Result of fetching a user's follow list. + */ +sealed class FollowListResult { + data class Success( + val sourcePubkeyHex: String, + val follows: List, + val createdAt: Long, + /** If the identifier was resolved via Namecoin, this holds the .bit name */ + val resolvedViaNamecoin: String? = null, + ) : FollowListResult() + + data object NoFollowList : FollowListResult() + + data class InvalidIdentifier( + val reason: String, + ) : FollowListResult() + + data class Error( + val message: String, + ) : FollowListResult() +} + +/** + * Minimal representation of a kind 3 event received from a relay callback. + * Avoids coupling to any specific Event class. + */ +data class Kind3EventData( + val pTags: List>, + val createdAt: Long, +) + +/** + * Fetches another user's follow list from Nostr relays. + * + * Resolution order for identifiers: + * 1. Namecoin (.bit / d/ / id/) → ElectrumX blockchain query + * 2. Hex pubkey (64 hex chars) + * 3. npub1... (NIP-19 bech32) + * 4. NIP-05 (user@domain) → HTTP /.well-known/nostr.json + * + * @param resolveNamecoin Optional Namecoin resolver. When provided, .bit/d//id/ identifiers + * will be resolved via ElectrumX. Pass `namecoinNameResolver::resolveDetailed`. + */ +class FollowListImporter( + private val resolveNamecoin: (suspend (String) -> NamecoinResolveOutcome)? = null, +) { + companion object { + const val KIND_CONTACT_LIST = 3 + const val DEFAULT_TIMEOUT_MS = 15_000L + private val HEX_PUBKEY_REGEX = Regex("^[0-9a-fA-F]{64}$") + private const val NPUB_PREFIX = "npub1" + } + + /** + * Resolve an identifier to a hex pubkey. + * + * Tries Namecoin first, then npub, hex, and finally NIP-05 (HTTP). + */ + suspend fun resolveIdentifier( + identifier: String, + resolveNip05: (suspend (String) -> String?)? = null, + ): ResolvedIdentifier? { + val trimmed = identifier.trim() + + // ── 1. Namecoin ──────────────────────────────────────────────── + if (resolveNamecoin != null && NamecoinNameResolver.isNamecoinIdentifier(trimmed)) { + val outcome = resolveNamecoin.invoke(trimmed) + return when (outcome) { + is NamecoinResolveOutcome.Success -> { + ResolvedIdentifier(outcome.result.pubkey, namecoinSource = trimmed) + } + + else -> { + null + } + } + } + + // ── 2. Direct hex pubkey ─────────────────────────────────────── + if (HEX_PUBKEY_REGEX.matches(trimmed)) { + return ResolvedIdentifier(trimmed.lowercase()) + } + + // ── 3. NIP-19 npub ──────────────────────────────────────────── + if (trimmed.startsWith(NPUB_PREFIX, ignoreCase = true)) { + return try { + val bytes = trimmed.bechToBytes() + if (bytes.size == 32) { + ResolvedIdentifier(bytes.toHexKey()) + } else { + null + } + } catch (_: Exception) { + null + } + } + + // ── 4. NIP-05 (HTTP) ────────────────────────────────────────── + if (trimmed.contains("@") && resolveNip05 != null) { + val pk = resolveNip05(trimmed) + if (pk != null) return ResolvedIdentifier(pk) + } + + // ── 5. Bare string — try as NIP-05 ──────────────────────────── + if (resolveNip05 != null && !trimmed.startsWith("nsec")) { + val pk = resolveNip05(trimmed) + if (pk != null) return ResolvedIdentifier(pk) + } + + return null + } + + /** + * Fetch the follow list for a given identifier. + */ + suspend fun fetchFollowList( + identifier: String, + relayUrls: List, + fetchEvent: suspend (kind: Int, author: String, limit: Int, onEvent: (Kind3EventData) -> Unit) -> AutoCloseable?, + resolveNip05: (suspend (String) -> String?)? = null, + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + ): FollowListResult = + withContext(Dispatchers.IO) { + // 1. Resolve identifier + val resolved = resolveIdentifier(identifier, resolveNip05) + if (resolved == null) { + val msg = + if (resolveNamecoin != null && NamecoinNameResolver.isNamecoinIdentifier(identifier)) { + // Get detailed outcome for specific error message + val outcome = resolveNamecoin.invoke(identifier) + when (outcome) { + is NamecoinResolveOutcome.NameNotFound -> { + "Namecoin name \"$identifier\" does not exist on the blockchain. " + + "Check the spelling or register it with Electrum-NMC." + } + + is NamecoinResolveOutcome.NoNostrField -> { + "Namecoin name \"$identifier\" exists but has no \"nostr\" field. " + + "The owner needs to add a nostr pubkey to the name's value." + } + + is NamecoinResolveOutcome.ServersUnreachable -> { + "All Namecoin ElectrumX servers are unreachable. " + + "Check your internet connection and try again. (${outcome.message})" + } + + is NamecoinResolveOutcome.Timeout -> { + "Namecoin lookup for \"$identifier\" timed out. " + + "ElectrumX servers may be slow or unreachable — try again later." + } + + is NamecoinResolveOutcome.InvalidIdentifier -> { + "\"$identifier\" is not a valid Namecoin identifier. " + + "Use .bit domains, d/name, or id/name format." + } + + is NamecoinResolveOutcome.Success -> { + "Unexpected error resolving \"$identifier\"." + } + } + } else { + "Could not resolve \"$identifier\" to a public key. " + + "Enter an npub, hex pubkey, NIP-05, or Namecoin name (.bit / d/ / id/)." + } + return@withContext FollowListResult.InvalidIdentifier(msg) + } + + // 2. Fetch kind 3 + val deferred = CompletableDeferred() + val sub = + try { + fetchEvent(KIND_CONTACT_LIST, resolved.pubkeyHex, 1) { event -> + if (!deferred.isCompleted) deferred.complete(event) + } + } catch (e: Exception) { + return@withContext FollowListResult.Error("Failed to connect to relays: ${e.message}") + } + + val event = withTimeoutOrNull(timeoutMs) { deferred.await() } + try { + sub?.close() + } catch (_: Exception) { + } + + if (event == null) return@withContext FollowListResult.NoFollowList + + // 3. Parse p-tags + val follows = + event.pTags + .mapNotNull { tag -> + if (tag.isEmpty()) return@mapNotNull null + val pk = tag[0] + if (!HEX_PUBKEY_REGEX.matches(pk)) return@mapNotNull null + FollowEntry( + pubkeyHex = pk.lowercase(), + relayHint = tag.getOrNull(1)?.takeIf { it.isNotBlank() }, + petname = tag.getOrNull(2)?.takeIf { it.isNotBlank() }, + ) + }.distinctBy { it.pubkeyHex } + + FollowListResult.Success( + sourcePubkeyHex = resolved.pubkeyHex, + follows = follows, + createdAt = event.createdAt, + resolvedViaNamecoin = resolved.namecoinSource, + ) + } +} + +/** + * Internal: result of resolving an identifier, tracking whether Namecoin was used. + */ +data class ResolvedIdentifier( + val pubkeyHex: String, + val namecoinSource: String? = null, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt index d2240151b..b37a4832c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -77,6 +78,17 @@ class NamecoinNameService( return result } + /** + * Resolve and return just the hex pubkey, or null. + * Convenience for follow-import integration. + */ + suspend fun resolvePubkey(identifier: String): String? = resolve(identifier)?.pubkey + + /** + * Resolve with detailed outcome for error reporting. + */ + suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome = resolver.resolveDetailed(identifier) + /** * Verify that a Namecoin name maps to the expected pubkey. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 4df5ab134..4756e3fa9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -29,16 +29,27 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.followimport.Kind3EventData import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen +import com.vitorpamplona.amethyst.ui.screen.signup.ImportFollowListSection +import com.vitorpamplona.amethyst.ui.screen.signup.ImportFollowListViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext @Composable fun AccountScreen(accountSessionManager: AccountSessionManager) { @@ -55,9 +66,21 @@ fun AccountScreen(accountSessionManager: AccountSessionManager) { animationSpec = tween(durationMillis = 100), ) { state -> when (state) { - is AccountState.Loading -> LoadingSetup() - is AccountState.LoggedOff -> LoggedOffSetup(accountSessionManager) - is AccountState.LoggedIn -> LoggedInSetup(state, accountSessionManager) + is AccountState.Loading -> { + LoadingSetup() + } + + is AccountState.LoggedOff -> { + LoggedOffSetup(accountSessionManager) + } + + is AccountState.LoggedIn -> { + if (state.isNewAccount) { + NewAccountImportFollowsSetup(state.account, accountSessionManager) + } else { + LoggedInSetup(state, accountSessionManager) + } + } } } } @@ -116,3 +139,80 @@ fun LoggedInSetup( ) } } + +@Composable +fun NewAccountImportFollowsSetup( + account: Account, + accountSessionManager: AccountSessionManager, +) { + val importViewModel: ImportFollowListViewModel = viewModel() + + LaunchedEffect(account) { + importViewModel.configure( + fetchEvent = { kind, author, limit, onEvent -> + val filter = + Filter( + kinds = listOf(kind), + authors = listOf(author), + limit = limit, + ) + val relayUrls = + listOf( + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.nostr.band", + "wss://purplepag.es", + ) + val filterMap = + relayUrls.associate { url -> + RelayUrlNormalizer.normalize(url) to listOf(filter) + } + val listener = + object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener { + override fun onEvent( + event: com.vitorpamplona.quartz.nip01Core.core.Event, + isLive: Boolean, + relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is ContactListEvent) { + onEvent( + Kind3EventData( + pTags = + event.tags + .filter { it.size >= 2 && it[0] == "p" } + .map { it.drop(1) }, + createdAt = event.createdAt, + ), + ) + } + } + } + val subId = + com.vitorpamplona.quartz.nip01Core.relay.client.single + .newSubId() + Amethyst.instance.client.openReqSubscription(subId, filterMap, listener) + AutoCloseable { Amethyst.instance.client.close(subId) } + }, + ) + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + ImportFollowListSection( + onFollowsApplied = { entries -> + withContext(Dispatchers.IO) { + for (entry in entries) { + val user = account.cache.getOrCreateUser(entry.pubkeyHex) + account.follow(user) + } + } + }, + onSkip = { accountSessionManager.finishNewAccountSetup() }, + onDone = { accountSessionManager.finishNewAccountSetup() }, + viewModel = importViewModel, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index 04ae6867e..8a75475c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -85,6 +85,7 @@ sealed class AccountState { class LoggedIn( val account: Account, var route: Route? = null, + val isNewAccount: Boolean = false, ) : AccountState() } @@ -181,10 +182,20 @@ class AccountSessionManager( fun startUI( accountSettings: AccountSettings, route: Route? = null, + isNewAccount: Boolean = false, ) { val account = accountsCache.loadAccount(accountSettings) _accountContent.update { - AccountState.LoggedIn(account, route) + AccountState.LoggedIn(account, route, isNewAccount = isNewAccount) + } + } + + fun finishNewAccountSetup() { + val current = _accountContent.value + if (current is AccountState.LoggedIn && current.isNewAccount) { + _accountContent.update { + AccountState.LoggedIn(current.account, current.route, isNewAccount = false) + } } } @@ -276,7 +287,7 @@ class AccountSessionManager( localPreferences.setDefaultAccount(accountSettings) - startUI(accountSettings) + startUI(accountSettings, isNewAccount = true) scope.launch(Dispatchers.IO) { delay(2000) // waits for the new user to connect to the new relays. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListScreen.kt new file mode 100644 index 000000000..ebf81762a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListScreen.kt @@ -0,0 +1,545 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.signup + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.outlined.Circle +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.service.followimport.FollowEntry + +// ── Public entry points ──────────────────────────────────────────────── + +/** + * Embeddable section for the signup wizard. + */ +@Composable +fun ImportFollowListSection( + onFollowsApplied: suspend (List) -> Unit, + onSkip: () -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ImportFollowListViewModel = viewModel(), +) { + val state by viewModel.state.collectAsState() + + Column( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + ImportHeader() + Spacer(Modifier.height(20.dp)) + InputSection( + enabled = state is ImportFollowState.Idle || state is ImportFollowState.Error, + onLookup = { viewModel.startImport(it) }, + ) + Spacer(Modifier.height(16.dp)) + + Box(modifier = Modifier.weight(1f)) { + when (val s = state) { + is ImportFollowState.Idle -> { + IdleHint() + } + + is ImportFollowState.Resolving -> { + LoadingIndicator("Resolving ${s.identifier}…") + } + + is ImportFollowState.Fetching -> { + LoadingIndicator("Fetching follow list…") + } + + is ImportFollowState.Preview -> { + PreviewList( + state = s, + onToggle = { viewModel.toggleSelection(it) }, + onSelectAll = { viewModel.setSelectAll(it) }, + ) + } + + is ImportFollowState.Applying -> { + LoadingIndicator("Following ${s.count} accounts…") + } + + is ImportFollowState.Done -> { + DoneMessage(s.count, onDone) + } + + is ImportFollowState.Error -> { + ErrorMessage(s.message) { viewModel.reset() } + } + } + } + + Spacer(Modifier.height(16.dp)) + BottomActions(state, onSkip, { viewModel.applySelectedFollows(onFollowsApplied) }, onDone, { viewModel.reset() }) + } +} + +/** + * Standalone dialog for post-signup use (settings / profile screen). + */ +@Composable +fun ImportFollowListDialog( + onDismiss: () -> Unit, + onFollowsApplied: suspend (List) -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth().padding(8.dp), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + ImportFollowListSection( + onFollowsApplied = onFollowsApplied, + onSkip = onDismiss, + onDone = onDismiss, + modifier = Modifier.height(600.dp), + ) + } + } +} + +// ── Internal composables ─────────────────────────────────────────────── + +@Composable +private fun ImportHeader() { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.PersonAdd, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + "Import Follow List", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + "Start with a great feed by following the same people as someone you trust.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun InputSection( + enabled: Boolean, + onLookup: (String) -> Unit, +) { + var identifier by rememberSaveable { mutableStateOf("") } + val kb = LocalSoftwareKeyboardController.current + + Column { + OutlinedTextField( + value = identifier, + onValueChange = { identifier = it }, + label = { Text("Profile to import from") }, + placeholder = { Text("npub1…, alice@example.com, or example.bit") }, + singleLine = true, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + supportingText = { + Text( + "Supports npub, NIP-05, hex, and Namecoin (.bit / d/ / id/)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go), + keyboardActions = + KeyboardActions(onGo = { + kb?.hide() + onLookup(identifier) + }), + ) + Spacer(Modifier.height(8.dp)) + Button( + onClick = { + kb?.hide() + onLookup(identifier) + }, + enabled = enabled && identifier.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { Text("Look Up Follow List") } + } +} + +@Composable +private fun IdleHint() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(24.dp)) { + Text( + "Tip", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Enter the profile of a friend or community leader. " + + "You can use their npub, NIP-05 address, or a Namecoin name " + + "like alice@example.bit or id/alice for blockchain-verified identities.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun LoadingIndicator(message: String) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(Modifier.size(40.dp), strokeWidth = 3.dp) + Spacer(Modifier.height(12.dp)) + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun PreviewList( + state: ImportFollowState.Preview, + onToggle: (String) -> Unit, + onSelectAll: (Boolean) -> Unit, +) { + Column(Modifier.fillMaxSize()) { + // Namecoin badge if resolved via blockchain + AnimatedVisibility( + visible = state.namecoinSource != null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + NamecoinResolvedBadge(state.namecoinSource ?: "") + } + + // Summary + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${state.totalCount} accounts found", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + "${state.selectedCount} selected", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + // Select all + Row( + Modifier.fillMaxWidth().clickable { onSelectAll(state.selectedCount < state.totalCount) }.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = state.selectedCount == state.totalCount, + onCheckedChange = { onSelectAll(it) }, + ) + Spacer(Modifier.width(8.dp)) + Text("Select All", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } + + HorizontalDivider() + + LazyColumn(contentPadding = PaddingValues(vertical = 4.dp), modifier = Modifier.fillMaxSize()) { + items(items = state.follows, key = { it.pubkeyHex }) { entry -> + FollowEntryRow(entry, entry.pubkeyHex in state.selected) { onToggle(entry.pubkeyHex) } + } + } + } +} + +/** + * Badge shown when the source profile was resolved via Namecoin blockchain. + */ +@Composable +private fun NamecoinResolvedBadge(namecoinSource: String) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .background( + color = Color(0xFF4A90D9).copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp), + ).padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("\u26D3", fontSize = 16.sp) // ⛓ chain link + Spacer(Modifier.width(8.dp)) + Column { + Text( + "Resolved via Namecoin", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF4A90D9), + ) + Text( + formatNamecoinDisplay(namecoinSource), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun FollowEntryRow( + entry: FollowEntry, + isSelected: Boolean, + onToggle: () -> Unit, +) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 6.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (isSelected) Icons.Filled.CheckCircle else Icons.Outlined.Circle, + contentDescription = null, + tint = + if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + }, + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.width(10.dp)) + Box( + Modifier.size(32.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + entry.pubkeyHex.take(2).uppercase(), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + entry.petname ?: shortPubkey(entry.pubkeyHex), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (entry.petname != null) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (entry.petname != null) { + Text( + shortPubkey(entry.pubkeyHex), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (entry.relayHint != null) { + Text( + entry.relayHint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun DoneMessage( + count: Int, + onContinue: () -> Unit, +) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.height(12.dp)) + Text( + "Now following $count accounts", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(6.dp)) + Text( + "Your feed is ready.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ErrorMessage( + message: String, + onRetry: () -> Unit, +) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(12.dp)) + OutlinedButton(onClick = onRetry) { Text("Try Again") } + } + } +} + +@Composable +private fun BottomActions( + state: ImportFollowState, + onSkip: () -> Unit, + onApply: () -> Unit, + onDone: () -> Unit, + onSearchAnother: () -> Unit, +) { + when (state) { + is ImportFollowState.Preview -> { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + TextButton(onClick = onSkip) { Text("Skip") } + Row { + OutlinedButton(onClick = onSearchAnother, shape = RoundedCornerShape(12.dp)) { + Text("Search Another") + } + Spacer(Modifier.width(8.dp)) + Button(onClick = onApply, enabled = state.selectedCount > 0, shape = RoundedCornerShape(12.dp)) { + Text("Follow ${state.selectedCount} accounts") + } + } + } + } + + is ImportFollowState.Done -> { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OutlinedButton(onClick = onSearchAnother, shape = RoundedCornerShape(12.dp)) { + Text("Import More") + } + Button(onClick = onDone, shape = RoundedCornerShape(12.dp)) { Text("Continue") } + } + } + + is ImportFollowState.Idle, is ImportFollowState.Error -> { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onSkip) { Text("Skip for now") } + } + } + + else -> {} + } +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +private fun shortPubkey(hex: String): String = if (hex.length < 12) hex else "npub:${hex.take(8)}…${hex.takeLast(4)}" + +private fun formatNamecoinDisplay(source: String): String { + val s = source.trim() + return when { + s.startsWith("d/", ignoreCase = true) -> "${s.removePrefix("d/")}.bit" + s.startsWith("_@") -> s.removePrefix("_@") + else -> s + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListViewModel.kt new file mode 100644 index 000000000..6d3688b39 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListViewModel.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.signup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.service.followimport.FollowEntry +import com.vitorpamplona.amethyst.service.followimport.FollowListImporter +import com.vitorpamplona.amethyst.service.followimport.FollowListResult +import com.vitorpamplona.amethyst.service.followimport.Kind3EventData +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +sealed class ImportFollowState { + data object Idle : ImportFollowState() + + data class Resolving( + val identifier: String, + ) : ImportFollowState() + + data class Fetching( + val pubkeyHex: String, + ) : ImportFollowState() + + data class Preview( + val sourcePubkeyHex: String, + val follows: List, + val selected: Set, + /** Non-null if the source was resolved via Namecoin blockchain */ + val namecoinSource: String? = null, + ) : ImportFollowState() { + val selectedCount get() = selected.size + val totalCount get() = follows.size + } + + data class Applying( + val count: Int, + ) : ImportFollowState() + + data class Done( + val count: Int, + ) : ImportFollowState() + + data class Error( + val message: String, + ) : ImportFollowState() +} + +class ImportFollowListViewModel : ViewModel() { + private val namecoinResolver = NamecoinNameResolver(electrumxClient = ElectrumXClient()) + private val importer = FollowListImporter(resolveNamecoin = namecoinResolver::resolveDetailed) + private val _state = MutableStateFlow(ImportFollowState.Idle) + val state: StateFlow = _state.asStateFlow() + + private var fetchEventFn: (suspend (Int, String, Int, (Kind3EventData) -> Unit) -> AutoCloseable?)? = null + private var resolveNip05Fn: (suspend (String) -> String?)? = null + private var relayUrls: List = + listOf( + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.nostr.band", + "wss://purplepag.es", + ) + + fun configure( + fetchEvent: suspend (Int, String, Int, (Kind3EventData) -> Unit) -> AutoCloseable?, + resolveNip05: (suspend (String) -> String?)? = null, + relayUrls: List? = null, + ) { + this.fetchEventFn = fetchEvent + this.resolveNip05Fn = resolveNip05 + if (relayUrls != null) this.relayUrls = relayUrls + } + + fun startImport(identifier: String) { + if (identifier.isBlank()) { + _state.value = ImportFollowState.Error("Please enter an identifier.") + return + } + val fetch = + fetchEventFn ?: run { + _state.value = ImportFollowState.Error("Not connected to relays.") + return + } + + viewModelScope.launch { + _state.value = ImportFollowState.Resolving(identifier) + val result = + importer.fetchFollowList( + identifier = identifier, + relayUrls = relayUrls, + fetchEvent = fetch, + resolveNip05 = resolveNip05Fn, + ) + _state.value = + when (result) { + is FollowListResult.Success -> { + if (result.follows.isEmpty()) { + ImportFollowState.Error("This user's follow list is empty.") + } else { + ImportFollowState.Preview( + sourcePubkeyHex = result.sourcePubkeyHex, + follows = result.follows, + selected = result.follows.map { it.pubkeyHex }.toSet(), + namecoinSource = result.resolvedViaNamecoin, + ) + } + } + + is FollowListResult.NoFollowList -> { + ImportFollowState.Error("No follow list found on relays for this user.") + } + + is FollowListResult.InvalidIdentifier -> { + ImportFollowState.Error(result.reason) + } + + is FollowListResult.Error -> { + ImportFollowState.Error(result.message) + } + } + } + } + + fun toggleSelection(pubkeyHex: String) { + val c = _state.value as? ImportFollowState.Preview ?: return + _state.value = c.copy(selected = if (pubkeyHex in c.selected) c.selected - pubkeyHex else c.selected + pubkeyHex) + } + + fun setSelectAll(all: Boolean) { + val c = _state.value as? ImportFollowState.Preview ?: return + _state.value = c.copy(selected = if (all) c.follows.map { it.pubkeyHex }.toSet() else emptySet()) + } + + fun applySelectedFollows(applyFollows: suspend (List) -> Unit) { + val c = _state.value as? ImportFollowState.Preview ?: return + val sel = c.follows.filter { it.pubkeyHex in c.selected } + _state.value = ImportFollowState.Applying(sel.size) + viewModelScope.launch { + try { + applyFollows(sel) + _state.value = ImportFollowState.Done(sel.size) + } catch (e: Exception) { + _state.value = ImportFollowState.Error("Failed: ${e.message}") + } + } + } + + fun reset() { + _state.value = ImportFollowState.Idle + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporterTest.kt new file mode 100644 index 000000000..2fb8daa68 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporterTest.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.followimport + +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FollowListImporterTest { + private val importer = FollowListImporter() + + // Importer with a Namecoin resolver that always returns ServersUnreachable + // (simulates no real ElectrumX servers in test env) + private val importerWithNamecoin = + FollowListImporter( + resolveNamecoin = { identifier -> + NamecoinResolveOutcome.ServersUnreachable("Test: no servers available") + }, + ) + + // ── Hex pubkey resolution ────────────────────────────────────────── + + @Test + fun `resolves 64-char hex pubkey`() = + runBlocking { + val hex = "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" + val r = importer.resolveIdentifier(hex) + assertNotNull(r) + assertEquals(hex, r!!.pubkeyHex) + assertNull(r.namecoinSource) + } + + @Test + fun `lowercases hex pubkey`() = + runBlocking { + val hex = "B0635D6A9851D3AED0CD6C495B282167ACF761729078D975FC341B22650B07B9" + assertEquals(hex.lowercase(), importer.resolveIdentifier(hex)!!.pubkeyHex) + } + + @Test + fun `rejects short hex`() = + runBlocking { + assertNull(importer.resolveIdentifier("abcdef")) + } + + // ── npub resolution ──────────────────────────────────────────────── + + @Test + fun `resolves valid npub`() = + runBlocking { + val npub = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6" + val r = importer.resolveIdentifier(npub) + assertNotNull(r) + assertEquals(64, r!!.pubkeyHex.length) + assertTrue(r.pubkeyHex.matches(Regex("^[0-9a-f]{64}$"))) + assertNull(r.namecoinSource) + } + + @Test + fun `rejects invalid npub`() = + runBlocking { + assertNull(importer.resolveIdentifier("npub1invalid")) + } + + // ── NIP-05 resolution ────────────────────────────────────────────── + + @Test + fun `delegates NIP-05 to callback`() = + runBlocking { + val expected = "aaaa000000000000000000000000000000000000000000000000000000000001" + val r = importer.resolveIdentifier("[email protected]", resolveNip05 = { expected }) + assertNotNull(r) + assertEquals(expected, r!!.pubkeyHex) + assertNull(r.namecoinSource) + } + + @Test + fun `returns null for NIP-05 without resolver`() = + runBlocking { + assertNull(importer.resolveIdentifier("[email protected]")) + } + + // ── nsec rejection ───────────────────────────────────────────────── + + @Test + fun `rejects nsec private keys`() = + runBlocking { + assertNull( + importer.resolveIdentifier( + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + resolveNip05 = { "should-not-be-called" }, + ), + ) + } + + // ── Namecoin identifier detection ────────────────────────────────── + + @Test + fun `identifies dot-bit as Namecoin`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("example.bit")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("alice@example.bit")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("_@example.bit")) + } + + @Test + fun `identifies d-slash as Namecoin`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("d/example")) + } + + @Test + fun `identifies id-slash as Namecoin`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("id/alice")) + } + + @Test + fun `rejects non-Namecoin identifiers`() { + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("[email protected]")) + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("npub1abc")) + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("")) + } + + // ── Kind 3 parsing ───────────────────────────────────────────────── + + @Test + fun `parses kind 3 p-tags with relay hints and petnames`() = + runBlocking { + val target = "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" + val followA = "aaaa000000000000000000000000000000000000000000000000000000000001" + val followB = "bbbb000000000000000000000000000000000000000000000000000000000002" + + val result = + importer.fetchFollowList( + identifier = target, + relayUrls = listOf("wss://test"), + fetchEvent = { kind, author, _, onEvent -> + assertEquals(3, kind) + assertEquals(target, author) + onEvent( + Kind3EventData( + pTags = + listOf( + listOf(followA, "wss://relay.example.com", "alice"), + listOf(followB, "", "bob"), + ), + createdAt = 1700000000L, + ), + ) + AutoCloseable {} + }, + ) + + assertTrue(result is FollowListResult.Success) + val s = result as FollowListResult.Success + assertEquals(2, s.follows.size) + assertEquals(followA, s.follows[0].pubkeyHex) + assertEquals("wss://relay.example.com", s.follows[0].relayHint) + assertEquals("alice", s.follows[0].petname) + assertEquals(followB, s.follows[1].pubkeyHex) + assertNull(s.follows[1].relayHint) + assertEquals("bob", s.follows[1].petname) + assertNull(s.resolvedViaNamecoin) + } + + @Test + fun `deduplicates follows`() = + runBlocking { + val pk = "aaaa000000000000000000000000000000000000000000000000000000000001" + val result = + importer.fetchFollowList( + identifier = pk, + relayUrls = listOf("wss://t"), + fetchEvent = { _, _, _, onEvent -> + onEvent(Kind3EventData(pTags = listOf(listOf(pk), listOf(pk)), createdAt = 1L)) + AutoCloseable {} + }, + ) + assertEquals(1, (result as FollowListResult.Success).follows.size) + } + + @Test + fun `skips invalid pubkeys in p-tags`() = + runBlocking { + val result = + importer.fetchFollowList( + identifier = "aaaa000000000000000000000000000000000000000000000000000000000001", + relayUrls = listOf("wss://t"), + fetchEvent = { _, _, _, onEvent -> + onEvent( + Kind3EventData( + pTags = listOf(listOf("tooshort"), listOf(""), listOf("zzzz" + "0".repeat(60))), + createdAt = 1L, + ), + ) + AutoCloseable {} + }, + ) + assertEquals(0, (result as FollowListResult.Success).follows.size) + } + + @Test + fun `returns NoFollowList on timeout`() = + runBlocking { + val result = + importer.fetchFollowList( + identifier = "aaaa000000000000000000000000000000000000000000000000000000000001", + relayUrls = listOf("wss://t"), + fetchEvent = { _, _, _, _ -> AutoCloseable {} }, + timeoutMs = 200, + ) + assertTrue(result is FollowListResult.NoFollowList) + } + + @Test + fun `returns Error on fetch exception`() = + runBlocking { + val result = + importer.fetchFollowList( + identifier = "aaaa000000000000000000000000000000000000000000000000000000000001", + relayUrls = listOf("wss://t"), + fetchEvent = { _, _, _, _ -> throw RuntimeException("Connection refused") }, + ) + assertTrue(result is FollowListResult.Error) + assertTrue((result as FollowListResult.Error).message.contains("Connection refused")) + } + + // ── Namecoin-specific error messages ─────────────────────────────── + + @Test + fun `gives Namecoin-specific error for dot-bit failure`() = + runBlocking { + // Namecoin resolution will fail because NamecoinNameService is not + // configured with real servers in a test environment. The importer + // should give a Namecoin-specific error message. + val result = + importerWithNamecoin.fetchFollowList( + identifier = "nonexistent.bit", + relayUrls = listOf("wss://test"), + fetchEvent = { _, _, _, _ -> AutoCloseable {} }, + timeoutMs = 500, + ) + assertTrue(result is FollowListResult.InvalidIdentifier) + assertTrue((result as FollowListResult.InvalidIdentifier).reason.contains("Namecoin")) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt index e9f4e07d0..72abf7546 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt @@ -27,6 +27,36 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject +/** Detailed outcome of a Namecoin resolution attempt. */ +sealed class NamecoinResolveOutcome { + data class Success( + val result: NamecoinNostrResult, + ) : NamecoinResolveOutcome() + + /** The name does not exist on the Namecoin blockchain. */ + data class NameNotFound( + val name: String, + ) : NamecoinResolveOutcome() + + /** The name exists but has no valid "nostr" field in its value. */ + data class NoNostrField( + val name: String, + ) : NamecoinResolveOutcome() + + /** All ElectrumX servers were unreachable. */ + data class ServersUnreachable( + val message: String, + ) : NamecoinResolveOutcome() + + /** The identifier could not be parsed as a Namecoin name. */ + data class InvalidIdentifier( + val identifier: String, + ) : NamecoinResolveOutcome() + + /** Timed out waiting for a response. */ + data object Timeout : NamecoinResolveOutcome() +} + /** * Result of resolving a Namecoin name to Nostr identity data. */ @@ -88,6 +118,18 @@ class NamecoinNameResolver( } } + /** + * Resolve with detailed outcome for error reporting in UI flows. + */ + suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome { + val parsed = + parseIdentifier(identifier) + ?: return NamecoinResolveOutcome.InvalidIdentifier(identifier) + val result = + withTimeoutOrNull(lookupTimeoutMs) { performLookupDetailed(parsed) } + return result ?: NamecoinResolveOutcome.Timeout + } + // ── Identifier Parsing ───────────────────────────────────────────── /** @@ -175,6 +217,39 @@ class NamecoinNameResolver( } } + private suspend fun performLookupDetailed(parsed: ParsedIdentifier): NamecoinResolveOutcome { + val nameResult: NameShowResult + try { + nameResult = + electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) + ?: return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName) + } catch (e: NamecoinLookupException.NameNotFound) { + return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName) + } catch (e: NamecoinLookupException.NameExpired) { + return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName) + } catch (e: NamecoinLookupException.ServersUnreachable) { + return NamecoinResolveOutcome.ServersUnreachable( + e.message ?: "All ElectrumX servers unreachable", + ) + } + + val valueJson = + tryParseJson(nameResult.value) + ?: return NamecoinResolveOutcome.NoNostrField(parsed.namecoinName) + + val nostrResult = + when (parsed.namespace) { + Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed) + Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed) + } + + return if (nostrResult != null) { + NamecoinResolveOutcome.Success(nostrResult) + } else { + NamecoinResolveOutcome.NoNostrField(parsed.namecoinName) + } + } + /** * Extract Nostr data from a `d/` domain value. *