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.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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -85,6 +85,7 @@ sealed class AccountState {
|
||||
class LoggedIn(
|
||||
val account: Account,
|
||||
var route: Route? = null,
|
||||
val isNewAccount: Boolean = false,
|
||||
) : AccountState()
|
||||
}
|
||||
|
||||
@@ -181,10 +182,20 @@ class AccountSessionManager(
|
||||
fun startUI(
|
||||
accountSettings: AccountSettings,
|
||||
route: Route? = null,
|
||||
isNewAccount: Boolean = false,
|
||||
) {
|
||||
val account = accountsCache.loadAccount(accountSettings)
|
||||
_accountContent.update {
|
||||
AccountState.LoggedIn(account, route)
|
||||
AccountState.LoggedIn(account, route, isNewAccount = isNewAccount)
|
||||
}
|
||||
}
|
||||
|
||||
fun finishNewAccountSetup() {
|
||||
val current = _accountContent.value
|
||||
if (current is AccountState.LoggedIn && current.isNewAccount) {
|
||||
_accountContent.update {
|
||||
AccountState.LoggedIn(current.account, current.route, isNewAccount = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +287,7 @@ class AccountSessionManager(
|
||||
|
||||
localPreferences.setDefaultAccount(accountSettings)
|
||||
|
||||
startUI(accountSettings)
|
||||
startUI(accountSettings, isNewAccount = true)
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(2000) // waits for the new user to connect to the new relays.
|
||||
|
||||
+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.jsonObject
|
||||
|
||||
/** Detailed outcome of a Namecoin resolution attempt. */
|
||||
sealed class NamecoinResolveOutcome {
|
||||
data class Success(
|
||||
val result: NamecoinNostrResult,
|
||||
) : NamecoinResolveOutcome()
|
||||
|
||||
/** The name does not exist on the Namecoin blockchain. */
|
||||
data class NameNotFound(
|
||||
val name: String,
|
||||
) : NamecoinResolveOutcome()
|
||||
|
||||
/** The name exists but has no valid "nostr" field in its value. */
|
||||
data class NoNostrField(
|
||||
val name: String,
|
||||
) : NamecoinResolveOutcome()
|
||||
|
||||
/** All ElectrumX servers were unreachable. */
|
||||
data class ServersUnreachable(
|
||||
val message: String,
|
||||
) : NamecoinResolveOutcome()
|
||||
|
||||
/** The identifier could not be parsed as a Namecoin name. */
|
||||
data class InvalidIdentifier(
|
||||
val identifier: String,
|
||||
) : NamecoinResolveOutcome()
|
||||
|
||||
/** Timed out waiting for a response. */
|
||||
data object Timeout : NamecoinResolveOutcome()
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of resolving a Namecoin name to Nostr identity data.
|
||||
*/
|
||||
@@ -88,6 +118,18 @@ class NamecoinNameResolver(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve with detailed outcome for error reporting in UI flows.
|
||||
*/
|
||||
suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome {
|
||||
val parsed =
|
||||
parseIdentifier(identifier)
|
||||
?: return NamecoinResolveOutcome.InvalidIdentifier(identifier)
|
||||
val result =
|
||||
withTimeoutOrNull(lookupTimeoutMs) { performLookupDetailed(parsed) }
|
||||
return result ?: NamecoinResolveOutcome.Timeout
|
||||
}
|
||||
|
||||
// ── Identifier Parsing ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -175,6 +217,39 @@ class NamecoinNameResolver(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performLookupDetailed(parsed: ParsedIdentifier): NamecoinResolveOutcome {
|
||||
val nameResult: NameShowResult
|
||||
try {
|
||||
nameResult =
|
||||
electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider())
|
||||
?: return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName)
|
||||
} catch (e: NamecoinLookupException.NameNotFound) {
|
||||
return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName)
|
||||
} catch (e: NamecoinLookupException.NameExpired) {
|
||||
return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName)
|
||||
} catch (e: NamecoinLookupException.ServersUnreachable) {
|
||||
return NamecoinResolveOutcome.ServersUnreachable(
|
||||
e.message ?: "All ElectrumX servers unreachable",
|
||||
)
|
||||
}
|
||||
|
||||
val valueJson =
|
||||
tryParseJson(nameResult.value)
|
||||
?: return NamecoinResolveOutcome.NoNostrField(parsed.namecoinName)
|
||||
|
||||
val nostrResult =
|
||||
when (parsed.namespace) {
|
||||
Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed)
|
||||
Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed)
|
||||
}
|
||||
|
||||
return if (nostrResult != null) {
|
||||
NamecoinResolveOutcome.Success(nostrResult)
|
||||
} else {
|
||||
NamecoinResolveOutcome.NoNostrField(parsed.namecoinName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Nostr data from a `d/` domain value.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user