feat: import follow list with Namecoin resolution during signup
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
This commit is contained in:
+257
@@ -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,
|
||||||
|
)
|
||||||
+12
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
|
|||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult
|
||||||
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -77,6 +78,17 @@ class NamecoinNameService(
|
|||||||
return result
|
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.
|
* Verify that a Namecoin name maps to the expected pubkey.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -29,16 +29,27 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.vitorpamplona.amethyst.Amethyst
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
import com.vitorpamplona.amethyst.R
|
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.loggedIn.LoggedInPage
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen
|
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.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 com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AccountScreen(accountSessionManager: AccountSessionManager) {
|
fun AccountScreen(accountSessionManager: AccountSessionManager) {
|
||||||
@@ -55,9 +66,21 @@ fun AccountScreen(accountSessionManager: AccountSessionManager) {
|
|||||||
animationSpec = tween(durationMillis = 100),
|
animationSpec = tween(durationMillis = 100),
|
||||||
) { state ->
|
) { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
is AccountState.Loading -> LoadingSetup()
|
is AccountState.Loading -> {
|
||||||
is AccountState.LoggedOff -> LoggedOffSetup(accountSessionManager)
|
LoadingSetup()
|
||||||
is AccountState.LoggedIn -> LoggedInSetup(state, accountSessionManager)
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+13
-2
@@ -85,6 +85,7 @@ sealed class AccountState {
|
|||||||
class LoggedIn(
|
class LoggedIn(
|
||||||
val account: Account,
|
val account: Account,
|
||||||
var route: Route? = null,
|
var route: Route? = null,
|
||||||
|
val isNewAccount: Boolean = false,
|
||||||
) : AccountState()
|
) : AccountState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,10 +182,20 @@ class AccountSessionManager(
|
|||||||
fun startUI(
|
fun startUI(
|
||||||
accountSettings: AccountSettings,
|
accountSettings: AccountSettings,
|
||||||
route: Route? = null,
|
route: Route? = null,
|
||||||
|
isNewAccount: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val account = accountsCache.loadAccount(accountSettings)
|
val account = accountsCache.loadAccount(accountSettings)
|
||||||
_accountContent.update {
|
_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)
|
localPreferences.setDefaultAccount(accountSettings)
|
||||||
|
|
||||||
startUI(accountSettings)
|
startUI(accountSettings, isNewAccount = true)
|
||||||
|
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
delay(2000) // waits for the new user to connect to the new relays.
|
delay(2000) // waits for the new user to connect to the new relays.
|
||||||
|
|||||||
+545
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+174
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+268
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -27,6 +27,36 @@ import kotlinx.serialization.json.JsonPrimitive
|
|||||||
import kotlinx.serialization.json.jsonArray
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
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.
|
* 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 ─────────────────────────────────────────────
|
// ── 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.
|
* Extract Nostr data from a `d/` domain value.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user