* 'main' of https://github.com/vitorpamplona/amethyst:
  fix(quartz): connect() returns Unit, caller calls getPublicKey separately
  fix(quartz): fix NIP-44 key mutation and NIP-46 connect response handling
  feat: add custom ElectrumX server settings for Namecoin resolution
  feat: import follow list with Namecoin resolution during signup
This commit is contained in:
Vitor Pamplona
2026-03-10 08:20:03 -04:00
21 changed files with 2770 additions and 17 deletions
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
@@ -105,6 +106,11 @@ class AppModules(
TorSharedPreferences(appContext, applicationIOScope)
}
// Namecoin ElectrumX server preferences (global, like Tor settings)
val namecoinPrefs by lazy {
NamecoinSharedPreferences(appContext, applicationIOScope)
}
// App services that should be run as soon as there are subscribers to their flows
val locationManager = LocationState(appContext, applicationIOScope)
val connManager = ConnectivityManager(appContext, applicationIOScope)
@@ -156,11 +162,13 @@ class AppModules(
NamecoinNameResolver(
electrumxClient = namecoinElectrumxClient,
serverListProvider = {
if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
TOR_ELECTRUMX_SERVERS
} else {
DEFAULT_ELECTRUMX_SERVERS
}
// User-configured custom servers take priority
namecoinPrefs.customServersOrNull
?: if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
TOR_ELECTRUMX_SERVERS
} else {
DEFAULT_ELECTRUMX_SERVERS
}
},
)
val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver)
@@ -0,0 +1,143 @@
/*
* 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.model.preferences
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.coroutines.cancellation.CancellationException
/**
* Persistent storage for [NamecoinSettings], following the same pattern as
* [TorSharedPreferences].
*
* Uses the app-wide [sharedPreferencesDataStore] so Namecoin resolution
* settings (like Tor settings) are global — not per-account.
*
* The current settings are available synchronously via [settings] (a
* [StateFlow]) and can be read in non-suspend contexts (e.g. in a
* `serverListProvider` lambda).
*/
@Stable
class NamecoinSharedPreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
private val json = Json { ignoreUnknownKeys = true }
companion object {
val KEY_ENABLED = booleanPreferencesKey("namecoin.enabled")
val KEY_CUSTOM_SERVERS = stringPreferencesKey("namecoin.customServers")
}
/**
* Current settings, loaded synchronously at init to avoid races.
*/
private val _settings =
MutableStateFlow(
runBlocking { loadFromDisk() ?: NamecoinSettings.DEFAULT },
)
val settings: StateFlow<NamecoinSettings> = _settings
/** Synchronous snapshot — safe to call from `serverListProvider` lambdas. */
val current: NamecoinSettings get() = _settings.value
/**
* Parsed [ElectrumxServer] list from current custom settings, or `null`
* if the user hasn't configured any (meaning "use defaults").
*/
val customServersOrNull: List<ElectrumxServer>?
get() = current.toElectrumxServers()
// ── Mutators ───────────────────────────────────────────────────────
suspend fun setEnabled(enabled: Boolean) {
val updated = current.copy(enabled = enabled)
persist(updated)
}
suspend fun addServer(server: String) {
if (server.isBlank() || server in current.customServers) return
val updated = current.copy(customServers = current.customServers + server)
persist(updated)
}
suspend fun removeServer(server: String) {
val updated = current.copy(customServers = current.customServers - server)
persist(updated)
}
suspend fun reset() {
persist(NamecoinSettings.DEFAULT)
}
// ── Internal ───────────────────────────────────────────────────────
private suspend fun persist(settings: NamecoinSettings) {
_settings.value = settings
try {
context.sharedPreferencesDataStore.edit { prefs ->
prefs[KEY_ENABLED] = settings.enabled
prefs[KEY_CUSTOM_SERVERS] =
json.encodeToString(
settings.customServers.filter { it.isNotBlank() },
)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("NamecoinPrefs", "Error writing DataStore: ${e.message}")
}
}
private suspend fun loadFromDisk(): NamecoinSettings? =
try {
val prefs = context.sharedPreferencesDataStore.data.first()
val enabled = prefs[KEY_ENABLED] ?: true
val serversJson = prefs[KEY_CUSTOM_SERVERS]
val servers =
if (serversJson != null) {
try {
json.decodeFromString<List<String>>(serversJson)
} catch (_: Exception) {
emptyList()
}
} else {
emptyList()
}
NamecoinSettings(enabled = enabled, customServers = servers)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("NamecoinPrefs", "Error reading DataStore: ${e.message}")
null
}
}
@@ -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<FollowEntry>,
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<List<String>>,
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<String>,
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<Kind3EventData?>()
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,
)
@@ -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.
*
@@ -0,0 +1,94 @@
/*
* 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.namecoin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
import kotlinx.serialization.Serializable
/**
* Immutable data class representing the current Namecoin resolution config.
*
* When custom servers are configured, they are used EXCLUSIVELY and the
* hardcoded defaults are ignored. This gives privacy-conscious users full
* control over which ElectrumX servers observe their name lookups.
*/
@Serializable
data class NamecoinSettings(
/** Whether Namecoin resolution is enabled at all. */
val enabled: Boolean = true,
/**
* Custom ElectrumX servers. When non-empty, these replace the defaults.
*
* Each entry is `host:port` (TLS) or `host:port:tcp` (plaintext).
*/
val customServers: List<String> = emptyList(),
) {
/** True when the user has configured at least one custom server. */
val hasCustomServers: Boolean get() = customServers.isNotEmpty()
/**
* Convert to [ElectrumxServer] instances used by the resolver.
* Returns `null` when no valid custom servers are configured (use defaults).
*/
fun toElectrumxServers(): List<ElectrumxServer>? {
if (customServers.isEmpty()) return null
return customServers
.mapNotNull { parseServerString(it) }
.ifEmpty { null }
}
companion object {
val DEFAULT = NamecoinSettings()
/**
* Parse `host:port` or `host:port:tcp` into an [ElectrumxServer].
*
* TLS is the default protocol. Append `:tcp` for plaintext
* (useful for `.onion` addresses and local servers).
*
* `.onion` addresses automatically get `trustAllCerts = true`
* since certificate verification is meaningless over Tor.
*/
fun parseServerString(s: String): ElectrumxServer? {
val parts = s.trim().split(":")
if (parts.size < 2) return null
val host = parts[0].trim()
val port = parts[1].trim().toIntOrNull() ?: return null
if (host.isEmpty() || port <= 0 || port > 65535) return null
val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp"
val isOnion = host.endsWith(".onion")
return ElectrumxServer(
host = host,
port = port,
useSsl = useSsl,
trustAllCerts = isOnion || !useSsl,
)
}
/**
* Format an [ElectrumxServer] back to the `host:port[:tcp]` string form.
*/
fun formatServerString(server: ElectrumxServer): String {
val base = "${server.host}:${server.port}"
return if (server.useSsl) base else "$base:tcp"
}
}
}
@@ -171,7 +171,7 @@ fun AppNavigation(
composableFromEnd<Route.AllSettings> { AllSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.AccountBackup> { AccountBackupScreen(accountViewModel, nav) }
composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(accountViewModel, nav) }
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) }
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, Amethyst.instance.namecoinPrefs, nav) }
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
@@ -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<Filter>?,
) {
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,
)
}
}
@@ -87,6 +87,7 @@ sealed class AccountState {
class LoggedIn(
val account: Account,
var route: Route? = null,
val isNewAccount: Boolean = false,
) : AccountState()
}
@@ -183,10 +184,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)
}
}
}
@@ -278,7 +289,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.
@@ -21,29 +21,38 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsSection
import com.vitorpamplona.amethyst.ui.tor.PrivacySettingsBody
import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivacyOptionsScreen(
torSettingsFlow: TorSettingsFlow,
namecoinPrefs: NamecoinSharedPreferences,
nav: INav,
) {
val dialogViewModel = viewModel<TorDialogViewModel>()
@@ -58,16 +67,20 @@ fun PrivacyOptionsScreen(
torSettings
}
PrivacyOptionsScreenContents(dialogViewModel, onPost = torSettingsFlow::update, nav)
PrivacyOptionsScreenContents(dialogViewModel, namecoinPrefs, onPost = torSettingsFlow::update, nav)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivacyOptionsScreenContents(
dialogViewModel: TorDialogViewModel,
namecoinPrefs: NamecoinSharedPreferences,
onPost: (TorSettings) -> Unit,
nav: INav,
) {
val namecoinSettings by namecoinPrefs.settings.collectAsState()
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
SavingTopBar(
@@ -91,6 +104,26 @@ fun PrivacyOptionsScreenContents(
).padding(horizontal = 10.dp),
) {
PrivacySettingsBody(dialogViewModel)
Spacer(Modifier.height(16.dp))
NamecoinSettingsSection(
settings = namecoinSettings,
onToggleEnabled = { enabled ->
scope.launch { namecoinPrefs.setEnabled(enabled) }
},
onAddServer = { server ->
scope.launch { namecoinPrefs.addServer(server) }
},
onRemoveServer = { server ->
scope.launch { namecoinPrefs.removeServer(server) }
},
onReset = {
scope.launch { namecoinPrefs.reset() }
},
)
Spacer(Modifier.height(16.dp))
}
}
}
@@ -0,0 +1,416 @@
/*
* 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.loggedIn.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.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.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontFamily
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 com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
/**
* Complete settings section for Namecoin ElectrumX server configuration.
*
* Designed to sit in the Privacy / Settings screen alongside existing
* Tor settings.
*
* @param settings Current [NamecoinSettings] state
* @param onToggleEnabled Called when user toggles the master switch
* @param onAddServer Called with `host:port[:tcp]` when user adds a server
* @param onRemoveServer Called with the server string to remove
* @param onReset Called when user resets to defaults
*/
@Composable
fun NamecoinSettingsSection(
settings: NamecoinSettings,
onToggleEnabled: (Boolean) -> Unit,
onAddServer: (String) -> Unit,
onRemoveServer: (String) -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
),
) {
Column(modifier = Modifier.padding(16.dp)) {
// ── Section header ─────────────────────────────────────────
SectionHeader(enabled = settings.enabled, onToggle = onToggleEnabled)
AnimatedVisibility(
visible = settings.enabled,
enter = expandVertically(),
exit = shrinkVertically(),
) {
Column {
Spacer(Modifier.height(12.dp))
// ── Explanation ─────────────────────────────────────
Text(
"Namecoin names (.bit, d/, id/) are resolved via ElectrumX servers. " +
"By default, public community servers are used. " +
"For maximum privacy, add your own server below — when custom " +
"servers are set, the defaults are completely ignored.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
// ── Active servers display ─────────────────────────
ActiveServersDisplay(settings = settings)
Spacer(Modifier.height(12.dp))
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
Spacer(Modifier.height(12.dp))
// ── Custom servers list ────────────────────────────
CustomServersList(
servers = settings.customServers,
onRemove = onRemoveServer,
)
// ── Add server input ───────────────────────────────
AddServerInput(onAdd = onAddServer)
Spacer(Modifier.height(8.dp))
// ── Reset button ───────────────────────────────────
if (settings.hasCustomServers) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onReset) {
Icon(
Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Reset to defaults")
}
}
}
}
}
}
}
}
// ── Sub-composables ────────────────────────────────────────────────────
@Composable
private fun SectionHeader(
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Lock,
contentDescription = null,
tint = Color(0xFF4A90D9), // Namecoin blue
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(10.dp))
Column {
Text(
"Namecoin Resolution",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
"Blockchain identity lookups (.bit)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Switch(
checked = enabled,
onCheckedChange = onToggle,
)
}
}
@Composable
private fun ActiveServersDisplay(settings: NamecoinSettings) {
val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS
val isCustom = settings.hasCustomServers
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Active servers",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
)
if (isCustom) {
Text(
"CUSTOM",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = Color(0xFF4A90D9),
modifier =
Modifier
.background(
Color(0xFF4A90D9).copy(alpha = 0.1f),
RoundedCornerShape(4.dp),
).padding(horizontal = 6.dp, vertical = 2.dp),
)
} else {
Text(
"DEFAULT",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(6.dp))
servers.forEach { server ->
ServerRow(
displayText =
"${server.host}:${server.port}" +
if (!server.useSsl) " (tcp)" else " (tls)",
isActive = true,
)
}
}
}
@Composable
private fun CustomServersList(
servers: List<String>,
onRemove: (String) -> Unit,
) {
if (servers.isEmpty()) {
Text(
"No custom servers configured",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.padding(vertical = 4.dp),
)
} else {
Text(
"Custom servers (used exclusively)",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(bottom = 4.dp),
)
servers.forEach { server ->
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = server,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = { onRemove(server) },
modifier = Modifier.size(28.dp),
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove server",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
}
}
}
}
Spacer(Modifier.height(8.dp))
}
@Composable
private fun AddServerInput(onAdd: (String) -> Unit) {
var input by rememberSaveable { mutableStateOf("") }
var validationError by remember { mutableStateOf<String?>(null) }
val kb = LocalSoftwareKeyboardController.current
fun tryAdd() {
val trimmed = input.trim()
if (trimmed.isBlank()) {
validationError = "Enter a server address"
return
}
val parsed = NamecoinSettings.parseServerString(trimmed)
if (parsed == null) {
validationError = "Invalid format. Use host:port or host:port:tcp"
return
}
validationError = null
onAdd(trimmed)
input = ""
kb?.hide()
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
OutlinedTextField(
value = input,
onValueChange = {
input = it
validationError = null
},
label = { Text("Add ElectrumX server") },
placeholder = { Text("host:port or host:port:tcp") },
singleLine = true,
isError = validationError != null,
supportingText =
validationError?.let { err ->
{ Text(err, color = MaterialTheme.colorScheme.error) }
},
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { tryAdd() }),
textStyle =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = { tryAdd() },
modifier =
Modifier
.padding(top = 8.dp)
.size(40.dp)
.background(
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
RoundedCornerShape(8.dp),
),
) {
Icon(
Icons.Default.Add,
contentDescription = "Add server",
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun ServerRow(
displayText: String,
isActive: Boolean,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "",
fontSize = 10.sp,
color =
if (isActive) {
Color(0xFF2E8B57)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(end = 6.dp),
)
Text(
text = displayText,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -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<FollowEntry>) -> 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<FollowEntry>) -> 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
}
}
@@ -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<FollowEntry>,
val selected: Set<String>,
/** 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>(ImportFollowState.Idle)
val state: StateFlow<ImportFollowState> = _state.asStateFlow()
private var fetchEventFn: (suspend (Int, String, Int, (Kind3EventData) -> Unit) -> AutoCloseable?)? = null
private var resolveNip05Fn: (suspend (String) -> String?)? = null
private var relayUrls: List<String> =
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<String>? = 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<FollowEntry>) -> 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
}
}
@@ -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"))
}
}
@@ -0,0 +1,181 @@
/*
* 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.namecoin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
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 NamecoinSettingsTest {
// ── Server string parsing ──────────────────────────────────────────
@Test
fun `parses host colon port as TLS`() {
val s = NamecoinSettings.parseServerString("example.com:50006")
assertNotNull(s)
assertEquals("example.com", s!!.host)
assertEquals(50006, s.port)
assertTrue(s.useSsl)
}
@Test
fun `parses host colon port colon tcp as plaintext`() {
val s = NamecoinSettings.parseServerString("example.com:50001:tcp")
assertNotNull(s)
assertEquals("example.com", s!!.host)
assertEquals(50001, s.port)
assertFalse(s.useSsl)
}
@Test
fun `parses onion address`() {
val s = NamecoinSettings.parseServerString("abc123def.onion:50001:tcp")
assertNotNull(s)
assertEquals("abc123def.onion", s!!.host)
assertEquals(50001, s.port)
assertFalse(s.useSsl)
assertTrue(s.trustAllCerts)
}
@Test
fun `trims whitespace`() {
val s = NamecoinSettings.parseServerString(" example.com : 50006 ")
assertNotNull(s)
assertEquals("example.com", s!!.host)
assertEquals(50006, s.port)
}
@Test
fun `rejects empty host`() {
assertNull(NamecoinSettings.parseServerString(":50006"))
}
@Test
fun `rejects invalid port`() {
assertNull(NamecoinSettings.parseServerString("example.com:abc"))
assertNull(NamecoinSettings.parseServerString("example.com:0"))
assertNull(NamecoinSettings.parseServerString("example.com:99999"))
}
@Test
fun `rejects no port`() {
assertNull(NamecoinSettings.parseServerString("example.com"))
}
// ── Format round-trip ──────────────────────────────────────────────
@Test
fun `formats TLS server without suffix`() {
val server = ElectrumxServer("example.com", 50006, true)
assertEquals("example.com:50006", NamecoinSettings.formatServerString(server))
}
@Test
fun `formats TCP server with tcp suffix`() {
val server = ElectrumxServer("example.com", 50001, false)
assertEquals("example.com:50001:tcp", NamecoinSettings.formatServerString(server))
}
@Test
fun `round-trips server string through parse and format`() {
val original = "myserver.onion:50001:tcp"
val parsed = NamecoinSettings.parseServerString(original)!!
val formatted = NamecoinSettings.formatServerString(parsed)
assertEquals(original, formatted)
}
// ── toElectrumxServers ─────────────────────────────────────────────
@Test
fun `returns null when no custom servers`() {
val settings = NamecoinSettings(customServers = emptyList())
assertNull(settings.toElectrumxServers())
}
@Test
fun `returns parsed list for valid custom servers`() {
val settings =
NamecoinSettings(
customServers =
listOf(
"server1.com:50006",
"server2.onion:50001:tcp",
),
)
val servers = settings.toElectrumxServers()
assertNotNull(servers)
assertEquals(2, servers!!.size)
assertEquals("server1.com", servers[0].host)
assertTrue(servers[0].useSsl)
assertEquals("server2.onion", servers[1].host)
assertFalse(servers[1].useSsl)
assertTrue(servers[1].trustAllCerts)
}
@Test
fun `skips invalid entries in custom server list`() {
val settings =
NamecoinSettings(
customServers =
listOf(
"valid.com:50006",
"invalid", // no port
"also-invalid:abc", // non-numeric port
),
)
val servers = settings.toElectrumxServers()
assertNotNull(servers)
assertEquals(1, servers!!.size)
assertEquals("valid.com", servers[0].host)
}
@Test
fun `returns null when all custom servers are invalid`() {
val settings = NamecoinSettings(customServers = listOf("bad", "also-bad"))
assertNull(settings.toElectrumxServers())
}
// ── hasCustomServers flag ──────────────────────────────────────────
@Test
fun `hasCustomServers is false when empty`() {
assertFalse(NamecoinSettings().hasCustomServers)
}
@Test
fun `hasCustomServers is true when populated`() {
assertTrue(NamecoinSettings(customServers = listOf("x:1")).hasCustomServers)
}
// ── Default settings ───────────────────────────────────────────────
@Test
fun `default settings are enabled with no custom servers`() {
val d = NamecoinSettings.DEFAULT
assertTrue(d.enabled)
assertTrue(d.customServers.isEmpty())
assertFalse(d.hasCustomServers)
}
}