- Breaks the new Import Follow interface into two screens

- Improves user suggestion search by evaluating specific nip-05s and npubs
This commit is contained in:
Vitor Pamplona
2026-03-10 15:48:04 -04:00
parent dbaa02cdb0
commit b2a8a422b2
30 changed files with 646 additions and 1043 deletions
@@ -153,14 +153,12 @@ class AppModules(
// Custom fetcher that considers tor settings and avoids forwarding. // Custom fetcher that considers tor settings and avoids forwarding.
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05) val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
val namecoinElectrumxClient =
ElectrumXClient(
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
)
val namecoinResolver = val namecoinResolver =
NamecoinNameResolver( NamecoinNameResolver(
electrumxClient = namecoinElectrumxClient, electrumxClient =
ElectrumXClient(
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
),
serverListProvider = { serverListProvider = {
// User-configured custom servers take priority // User-configured custom servers take priority
namecoinPrefs.customServersOrNull namecoinPrefs.customServersOrNull
@@ -118,7 +118,7 @@ class Kind3FollowListState(
val contacts = val contacts =
users.map { users.map {
ContactTag(user.pubkeyHex, user.bestRelayHint(), null) ContactTag(it.pubkeyHex, it.bestRelayHint(), null)
} }
return if (contactList != null) { return if (contactList != null) {
@@ -1,257 +0,0 @@
/*
* 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,
)
@@ -111,7 +111,7 @@ open class EditPostViewModel : ViewModel() {
this.editedFromNote = edit this.editedFromNote = edit
this.userSuggestions?.reset() this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountViewModel.account) this.userSuggestions = UserSuggestionState(accountViewModel.account, accountViewModel.nip05Client)
} }
fun sendPost() { fun sendPost() {
@@ -252,5 +252,5 @@ interface Dao {
suspend fun getOrCreateNote(hex: String): Note suspend fun getOrCreateNote(hex: String): Note
suspend fun getOrCreateAddressableNote(address: Address): AddressableNote? fun getOrCreateAddressableNote(address: Address): AddressableNote?
} }
@@ -97,7 +97,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.FollowPackMetadataScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.FollowPackMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleListMetadataScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleListMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListPickFollowsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
@@ -122,6 +123,7 @@ import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -178,7 +180,14 @@ fun AppNavigation(
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) } composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) } composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) } composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ImportFollows> { ImportFollowListScreen(accountViewModel, nav) } composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ImportFollowsPickFollows> {
ImportFollowListPickFollowsScreen(
accountViewModel.getOrCreateAddressableNote(ContactListEvent.createAddress(it.userHex)),
accountViewModel,
nav,
)
}
composableFromEndArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.UpdateZapAmount> { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs<Route.UpdateZapAmount> { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) }
@@ -472,6 +472,14 @@ fun ListContent(
route = Route.Chess, route = Route.Chess,
) )
NavigationRow(
title = R.string.route_import_follows,
icon = Icons.Outlined.GroupAdd,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.ImportFollowsSelectUser,
)
IconRowRelays( IconRowRelays(
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onClick = { onClick = {
@@ -53,7 +53,11 @@ sealed class Route {
@Serializable object BookmarkGroups : Route() @Serializable object BookmarkGroups : Route()
@Serializable object ImportFollows : Route() @Serializable object ImportFollowsSelectUser : Route()
@Serializable data class ImportFollowsPickFollows(
val userHex: HexKey,
) : Route()
@Serializable data class BookmarkGroupView( @Serializable data class BookmarkGroupView(
val dTag: String, val dTag: String,
@@ -46,7 +46,6 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Tunestr import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
@@ -127,7 +126,7 @@ private fun VerifyAndDisplayNIP05OrStatusLine(
if (nip05VerifState.isExpired()) { if (nip05VerifState.isExpired()) {
LaunchedEffect(key1 = nip05VerifState) { LaunchedEffect(key1 = nip05VerifState) {
accountViewModel.runOnIO { accountViewModel.runOnIO {
nip05State.checkAndUpdate(Amethyst.instance.nip05Client) nip05State.checkAndUpdate(accountViewModel.nip05Client)
} }
} }
} }
@@ -393,7 +392,7 @@ fun ObserveAndRenderNIP05VerifiedSymbol(
if (state.isExpired()) { if (state.isExpired()) {
LaunchedEffect(key1 = state) { LaunchedEffect(key1 = state) {
accountViewModel.runOnIO { accountViewModel.runOnIO {
nip05State.checkAndUpdate(Amethyst.instance.nip05Client) nip05State.checkAndUpdate(accountViewModel.nip05Client)
} }
} }
} }
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
@@ -56,6 +55,7 @@ fun ShowUserSuggestionList(
onSelect: (User) -> Unit, onSelect: (User) -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onEmpty: @Composable () -> Unit = {},
) { ) {
UserSearchDataSourceSubscription(userSuggestions, accountViewModel) UserSearchDataSourceSubscription(userSuggestions, accountViewModel)
@@ -76,7 +76,7 @@ fun ShowUserSuggestionList(
} }
} }
WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier) WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier, onEmpty)
} }
@Composable @Composable
@@ -97,7 +97,8 @@ fun WatchResponses(
listState: LazyListState, listState: LazyListState,
onSelect: (User) -> Unit, onSelect: (User) -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
modifier: Modifier = Modifier.heightIn(0.dp, 200.dp), modifier: Modifier = Modifier,
onEmpty: @Composable () -> Unit = {},
) { ) {
val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList())
@@ -114,6 +115,8 @@ fun WatchResponses(
) )
} }
} }
} else {
onEmpty()
} }
} }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions
import android.R.attr.version
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
@@ -27,6 +28,17 @@ import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.startsWithAny
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -38,9 +50,18 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
val userUriPrefixes =
listOf(
DualCase("npub"),
DualCase("nprofile"),
DualCase("nostr:npub"),
DualCase("nostr:nprofile"),
)
@Stable @Stable
class UserSuggestionState( class UserSuggestionState(
val account: Account, val account: Account,
val nip05Client: INip05Client,
) { ) {
val invalidations = MutableStateFlow(0) val invalidations = MutableStateFlow(0)
val currentWord = MutableStateFlow("") val currentWord = MutableStateFlow("")
@@ -54,9 +75,69 @@ class UserSuggestionState(
.map(::userSearchTermOrNull) .map(::userSearchTermOrNull)
.onEach(::updateDataSource) .onEach(::updateDataSource)
@OptIn(FlowPreview::class)
val nip05ResolutionFlow =
searchTerm
.map { prefix ->
if (prefix != null) {
if (prefix.contains('@')) {
runCatching {
Nip05Id.parse(prefix)?.let { nip05 ->
nip05Client.get(nip05)?.let { info ->
val user = account.cache.checkGetOrCreateUser(info.pubkey)
if (user != null) {
info.relays.forEach {
it.normalizeRelayUrlOrNull()?.let { relay ->
account.cache.relayHints.addKey(user.pubkey(), relay)
}
}
}
user
}
}
}.getOrNull()
} else if (prefix.startsWithAny(userUriPrefixes)) {
runCatching {
Nip19Parser.uriToRoute(prefix)?.entity?.let { parsed ->
when (parsed) {
is NSec -> {
account.cache.getOrCreateUser(parsed.toPubKey().toHexKey())
}
is NPub -> {
account.cache.getOrCreateUser(parsed.hex)
}
is NProfile -> {
val user = account.cache.getOrCreateUser(parsed.hex)
parsed.relay.forEach { relay ->
account.cache.relayHints.addKey(user.pubkey(), relay)
}
user
}
else -> {
null
}
}
}
}.getOrNull()
} else if (prefix.length == 64 && Hex.isHex64(prefix)) {
account.cache.getOrCreateUser(prefix)
} else {
null
}
} else {
null
}
}.flowOn(Dispatchers.IO)
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
val results = val results =
combine(searchTerm, invalidations.debounce(100)) { prefix, version -> combine(searchTerm, nip05ResolutionFlow, invalidations.debounce(100)) { prefix, nip05, version ->
if (nip05 != null) {
return@combine listOf(nip05)
}
if (prefix != null) { if (prefix != null) {
logTime("UserSuggestionState Search $prefix version $version") { logTime("UserSuggestionState Search $prefix version $version") {
account.cache.findUsersStartingWith(prefix, account) account.cache.findUsersStartingWith(prefix, account)
@@ -189,7 +189,7 @@ open class CommentPostViewModel :
this.canAddZapRaiser = hasLnAddress() this.canAddZapRaiser = hasLnAddress()
this.userSuggestions?.reset() this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountVM.account) this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
this.emojiSuggestions?.reset() this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account) this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
@@ -278,7 +278,7 @@ class AccountSessionManager(
localPreferences.setDefaultAccount(accountSettings) localPreferences.setDefaultAccount(accountSettings)
startUI(accountSettings, route = Route.ImportFollows) startUI(accountSettings, route = Route.ImportFollowsSelectUser)
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.
@@ -103,6 +103,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip05DnsIdentifiers.EmptyNip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
@@ -164,6 +167,7 @@ class AccountViewModel(
val torSettings: TorSettingsFlow, val torSettings: TorSettingsFlow,
val dataSources: RelaySubscriptionsCoordinator, val dataSources: RelaySubscriptionsCoordinator,
val httpClientBuilder: IRoleBasedHttpClientBuilder, val httpClientBuilder: IRoleBasedHttpClientBuilder,
val nip05Client: INip05Client,
) : ViewModel(), ) : ViewModel(),
Dao { Dao {
var firstRoute: Route? = null var firstRoute: Route? = null
@@ -1101,7 +1105,7 @@ class AccountViewModel(
} }
} }
override suspend fun getOrCreateAddressableNote(address: Address): AddressableNote = LocalCache.getOrCreateAddressableNote(address) override fun getOrCreateAddressableNote(address: Address): AddressableNote = LocalCache.getOrCreateAddressableNote(address)
fun getAddressableNoteIfExists(key: String): AddressableNote? = LocalCache.getAddressableNoteIfExists(key) fun getAddressableNoteIfExists(key: String): AddressableNote? = LocalCache.getAddressableNoteIfExists(key)
@@ -1212,6 +1216,7 @@ class AccountViewModel(
val torSettings: TorSettingsFlow, val torSettings: TorSettingsFlow,
val dataSources: RelaySubscriptionsCoordinator, val dataSources: RelaySubscriptionsCoordinator,
val okHttpClient: RoleBasedHttpClientBuilder, val okHttpClient: RoleBasedHttpClientBuilder,
val nip05Client: Nip05Client,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = override fun <T : ViewModel> create(modelClass: Class<T>): T =
@@ -1221,6 +1226,7 @@ class AccountViewModel(
torSettings, torSettings,
dataSources, dataSources,
okHttpClient, okHttpClient,
nip05Client,
) as T ) as T
} }
@@ -1742,6 +1748,7 @@ fun mockAccountViewModel(): AccountViewModel {
torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)), torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)),
httpClientBuilder = EmptyRoleBasedHttpClientBuilder(), httpClientBuilder = EmptyRoleBasedHttpClientBuilder(),
dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope), dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope),
nip05Client = EmptyNip05Client(),
).also { ).also {
mockedCache = it mockedCache = it
} }
@@ -1792,6 +1799,7 @@ fun mockVitorAccountViewModel(): AccountViewModel {
torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)), torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)),
httpClientBuilder = EmptyRoleBasedHttpClientBuilder(), httpClientBuilder = EmptyRoleBasedHttpClientBuilder(),
dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope), dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope),
nip05Client = EmptyNip05Client(),
).also { ).also {
vitorCache = it vitorCache = it
} }
@@ -71,6 +71,7 @@ fun LoggedInPage(
torSettings = Amethyst.instance.torPrefs.value, torSettings = Amethyst.instance.torPrefs.value,
dataSources = Amethyst.instance.sources, dataSources = Amethyst.instance.sources,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder,
nip05Client = Amethyst.instance.nip05Client,
), ),
) )
@@ -188,7 +188,7 @@ class ChatNewMessageViewModel :
this.canAddZapRaiser = hasLnAddress() this.canAddZapRaiser = hasLnAddress()
this.userSuggestions?.reset() this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountVM.account) this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
this.emojiSuggestions?.reset() this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account) this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
@@ -168,7 +168,7 @@ open class ChannelNewMessageViewModel :
this.canAddZapRaiser = hasLnAddress() this.canAddZapRaiser = hasLnAddress()
this.userSuggestions?.reset() this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountVM.account) this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
this.emojiSuggestions?.reset() this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account) this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
@@ -182,7 +182,7 @@ open class NewProductViewModel :
this.canAddZapRaiser = hasLnAddress() this.canAddZapRaiser = hasLnAddress()
this.userSuggestions?.reset() this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountVM.account) this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
this.emojiSuggestions?.reset() this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account) this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
@@ -260,7 +260,7 @@ open class ShortNotePostViewModel :
this.canAddZapRaiser = hasLnAddress() this.canAddZapRaiser = hasLnAddress()
this.userSuggestions?.reset() this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountVM.account) this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
this.emojiSuggestions?.reset() this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account) this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
@@ -108,7 +108,7 @@ fun PeopleListScreen(
nav: INav, nav: INav,
) { ) {
val viewModel: PeopleListViewModel = viewModel() val viewModel: PeopleListViewModel = viewModel()
viewModel.init(accountViewModel.account, selectedDTag) viewModel.init(accountViewModel, selectedDTag)
val pagerState = rememberPagerState { 2 } val pagerState = rememberPagerState { 2 }
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -66,12 +67,12 @@ class PeopleListViewModel : ViewModel() {
fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress()) fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress())
fun init( fun init(
account: Account, accountVM: AccountViewModel,
selectedDTag: String, selectedDTag: String,
) { ) {
if (!this::account.isInitialized || this.account != account) { if (!this::account.isInitialized || this.account != accountVM.account) {
this.account = account this.account = accountVM.account
this.userSuggestions = UserSuggestionState(account) this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
} }
this.selectedDTag.tryEmit(selectedDTag) this.selectedDTag.tryEmit(selectedDTag)
@@ -99,7 +99,7 @@ fun FollowPackScreen(
nav: INav, nav: INav,
) { ) {
val viewModel: FollowPackViewModel = viewModel() val viewModel: FollowPackViewModel = viewModel()
viewModel.init(accountViewModel.account, selectedDTag) viewModel.init(accountViewModel, selectedDTag)
Scaffold( Scaffold(
topBar = { topBar = {
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -66,12 +67,12 @@ class FollowPackViewModel : ViewModel() {
fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress()) fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress())
fun init( fun init(
account: Account, accountVM: AccountViewModel,
selectedDTag: String, selectedDTag: String,
) { ) {
if (!this::account.isInitialized || this.account != account) { if (!this::account.isInitialized || this.account != accountVM.account) {
this.account = account this.account = accountVM.account
this.userSuggestions = UserSuggestionState(account) this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
} }
this.selectedDTag.tryEmit(selectedDTag) this.selectedDTag.tryEmit(selectedDTag)
@@ -0,0 +1,307 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser
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.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.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.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserLine
import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
@Composable
fun ImportFollowListPickFollowsScreen(
contactListNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopBarExtensibleWithBackButton(
title = {},
popBack = nav::popBack,
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding),
) {
PickFollowsBody(
contactListNote = contactListNote,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
private fun PickFollowsBody(
contactListNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) {
ImportHeader()
Spacer(Modifier.height(20.dp))
DisplayFollowList(contactListNote, Modifier.weight(1f), accountViewModel, nav)
}
}
@Composable
fun DisplayFollowList(
contactListNote: AddressableNote,
modifier: Modifier,
accountViewModel: AccountViewModel,
nav: INav,
) {
val contactsState by observeNoteEventAndMap<ContactListEvent, ImmutableList<User>?>(contactListNote, accountViewModel) { contactList ->
contactList
?.unverifiedFollowKeySet()
?.mapNotNull {
accountViewModel.checkGetOrCreateUser(it)
}?.toPersistentList()
}
val contacts = contactsState
if (contacts == null) {
LoadingIndicator(stringRes(R.string.fetching_follow_list))
} else if (contacts.isEmpty()) {
ErrorMessage(stringRes(R.string.no_follows_found))
} else {
PreviewList(
contacts,
modifier,
accountViewModel,
nav,
)
}
}
@Composable
private fun PreviewList(
contacts: ImmutableList<User>,
modifier: Modifier,
accountViewModel: AccountViewModel,
nav: INav,
) {
var selected by remember { mutableStateOf(setOf<User>()) }
Column(modifier) {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringRes(R.string.accounts_found, contacts.size),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
stringRes(R.string.num_selected, selected.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
// Select all
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = selected.size == contacts.size,
onCheckedChange = {
selected = if (it) contacts.toSet() else setOf()
},
)
Spacer(Modifier.width(8.dp))
Text("Select All", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
HorizontalDivider()
LazyColumn(contentPadding = PaddingValues(vertical = 4.dp), modifier = Modifier.weight(1f)) {
items(items = contacts, key = { it.pubkeyHex }) { entry ->
FollowEntryRow(
user = entry,
isSelected = entry in selected,
onToggle = { selected = if (entry in selected) selected - entry else selected + entry },
accountViewModel = accountViewModel,
nav = nav,
)
}
}
Spacer(Modifier.height(16.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(
onClick = {
accountViewModel.follow(selected.toList())
nav.popUpTo(Route.Home, Route.Home::class)
},
) {
Text(stringRes(R.string.follow_accounts, selected.size))
}
}
}
}
@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 ErrorMessage(message: String) {
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),
)
}
}
}
@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(
stringRes(R.string.select_users_to_follow),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(8.dp))
Text(
stringRes(R.string.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 FollowEntryRow(
user: User,
isSelected: Boolean,
onToggle: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
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))
UserLine(user, accountViewModel) {
nav.nav(Route.Profile(user.pubkeyHex))
}
}
}
@@ -1,568 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser
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.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.Scaffold
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.res.stringResource
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.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.followimport.FollowEntry
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun ImportFollowListScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val importViewModel: ImportFollowListViewModel = viewModel()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopBarExtensibleWithBackButton(
title = {},
popBack = nav::popBack,
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding),
) {
ImportFollowListSection(
onFollowsApplied = { entries ->
withContext(Dispatchers.IO) {
for (entry in entries) {
val user = accountViewModel.getOrCreateUser(entry.pubkeyHex)
accountViewModel.follow(user)
}
}
},
onSkip = nav::popBack,
onDone = nav::popBack,
viewModel = importViewModel,
)
}
}
}
@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(stringResource(R.string.resolving, s.identifier))
}
is ImportFollowState.Fetching -> {
LoadingIndicator(stringResource(R.string.fetching_follow_list))
}
is ImportFollowState.Preview -> {
PreviewList(
state = s,
onToggle = { viewModel.toggleSelection(it) },
onSelectAll = { viewModel.setSelectAll(it) },
)
}
is ImportFollowState.Applying -> {
LoadingIndicator(stringResource(R.string.following_accounts, s.count))
}
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() })
}
}
// ── 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(
stringResource(R.string.import_follow_list),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.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(stringResource(R.string.profile_to_import_from)) },
placeholder = { Text(stringResource(R.string.name_search_npub1_alice_example_com)) },
singleLine = true,
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
supportingText = {
Text(
stringResource(R.string.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(stringResource(R.string.look_up_follow_list)) }
}
}
@Composable
private fun IdleHint() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(24.dp)) {
Text(
stringResource(R.string.tip),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.import_follows_tips),
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(
stringResource(R.string.accounts_found, state.totalCount),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
stringResource(R.string.selected, state.selectedCount),
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(
stringResource(R.string.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(
stringResource(R.string.now_following_accounts, count),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(6.dp))
Text(
stringResource(R.string.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(stringResource(R.string.skip)) }
Row {
OutlinedButton(onClick = onSearchAnother, shape = RoundedCornerShape(12.dp)) {
Text(stringResource(R.string.search_another))
}
Spacer(Modifier.width(8.dp))
Button(onClick = onApply, enabled = state.selectedCount > 0, shape = RoundedCornerShape(12.dp)) {
Text(stringResource(R.string.follow_accounts, state.selectedCount))
}
}
}
}
is ImportFollowState.Done -> {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
OutlinedButton(onClick = onSearchAnother, shape = RoundedCornerShape(12.dp)) {
Text(stringResource(R.string.import_more))
}
Button(onClick = onDone, shape = RoundedCornerShape(12.dp)) { Text(stringResource(R.string.continue_txt)) }
}
}
is ImportFollowState.Idle, is ImportFollowState.Error -> {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onSkip) { Text(stringResource(R.string.skip_for_now)) }
}
}
else -> {}
}
}
// ── Helpers ────────────────────────────────────────────────────────────
private fun shortPubkey(hex: String): String = if (hex.length < 12) hex else "npub:${hex.take(8)}${hex.takeLast(4)}"
private fun formatNamecoinDisplay(source: String): String {
val s = source.trim()
return when {
s.startsWith("d/", ignoreCase = true) -> "${s.removePrefix("d/")}.bit"
s.startsWith("_@") -> s.removePrefix("_@")
else -> s
}
}
@@ -0,0 +1,182 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun ImportFollowListSelectUserScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopBarExtensibleWithBackButton(
title = {},
popBack = nav::popBack,
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding),
) {
InputSelectUserBody(
onLookup = {
nav.nav(Route.ImportFollowsPickFollows(it.pubkeyHex))
},
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
private fun InputSelectUserBody(
onLookup: (User) -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
var identifier by rememberSaveable { mutableStateOf("") }
val userSuggestions = remember { UserSuggestionState(accountViewModel.account, accountViewModel.nip05Client) }
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) {
ImportHeader()
Spacer(Modifier.height(20.dp))
OutlinedTextField(
value = identifier,
onValueChange = {
identifier = it
userSuggestions.processCurrentWord(it)
},
label = { Text(stringRes(R.string.profile_to_import_from)) },
placeholder = { Text(stringRes(R.string.name_search_npub1_alice_example_com)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
supportingText = { Text(stringRes(R.string.supports_npub_nip_05_hex_and_namecoin_bit_d_id)) },
)
Spacer(Modifier.height(8.dp))
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = { user ->
onLookup(user)
userSuggestions.reset()
},
modifier = Modifier.weight(1f),
accountViewModel = accountViewModel,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.weight(1f).padding(24.dp),
) {
Text(
stringRes(R.string.tip),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(5.dp))
Text(
stringRes(R.string.import_follows_tips),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(16.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = nav::popBack) { Text(stringRes(R.string.skip_for_now)) }
}
}
}
@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(
stringRes(R.string.import_follow_list),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(8.dp))
Text(
stringRes(R.string.start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -1,176 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser
import androidx.compose.runtime.Stable
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()
}
@Stable
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
}
}
@@ -191,7 +191,7 @@ class NewPublicMessageViewModel :
this.canAddZapRaiser = hasLnAddress() this.canAddZapRaiser = hasLnAddress()
this.userSuggestions?.reset() this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountVM.account) this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
this.emojiSuggestions?.reset() this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account) this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
+5 -3
View File
@@ -1719,13 +1719,14 @@
<string name="kind_wiki">Wiki</string> <string name="kind_wiki">Wiki</string>
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Start with a great feed by following the same people as someone you trust.</string> <string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Start with a great feed by following the same people as someone you trust.</string>
<string name="import_follow_list">Import Follow List</string> <string name="import_follow_list">Import Follow List</string>
<string name="select_users_to_follow">Select Users to Follow</string>
<string name="profile_to_import_from">Profile to import from</string> <string name="profile_to_import_from">Profile to import from</string>
<string name="name_search_npub1_alice_example_com">name search, npub1…, alice@example.com</string> <string name="name_search_npub1_alice_example_com">search, npub1…, alice@example.com</string>
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Supports npub, NIP-05, hex, and Namecoin (.bit / d/ / id/)</string> <string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Supports npub, nprofile, NIP-05, hex, and namecoin (.bit, d/, id/)</string>
<string name="look_up_follow_list">Look Up Follow List</string> <string name="look_up_follow_list">Look Up Follow List</string>
<string name="tip">Tip</string> <string name="tip">Tip</string>
<string name="accounts_found">%1$d accounts found</string> <string name="accounts_found">%1$d accounts found</string>
<string name="selected">%1$d selected</string> <string name="num_selected">%1$d selected</string>
<string name="resolved_via_namecoin">Resolved via Namecoin</string> <string name="resolved_via_namecoin">Resolved via Namecoin</string>
<string name="now_following_accounts">Now following %1$d accounts</string> <string name="now_following_accounts">Now following %1$d accounts</string>
<string name="your_feed_is_ready">Your feed is ready.</string> <string name="your_feed_is_ready">Your feed is ready.</string>
@@ -1737,6 +1738,7 @@
<string name="skip_for_now">Skip for now</string> <string name="skip_for_now">Skip for now</string>
<string name="resolving">Resolving %1$s…</string> <string name="resolving">Resolving %1$s…</string>
<string name="fetching_follow_list">Fetching follow list…</string> <string name="fetching_follow_list">Fetching follow list…</string>
<string name="no_follows_found">No follows found</string>
<string name="following_accounts">Following %1$d accounts…</string> <string name="following_accounts">Following %1$d accounts…</string>
<string name="import_follows_tips">"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."</string> <string name="import_follows_tips">"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."</string>
</resources> </resources>
@@ -88,8 +88,6 @@ class ContactListEvent(
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey) fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey)
fun blockListFor(pubKeyHex: HexKey): String = "3:$pubKeyHex:"
suspend fun createFromScratch( suspend fun createFromScratch(
followUsers: List<ContactTag>, followUsers: List<ContactTag>,
relayUse: Map<String, ReadWrite>?, relayUse: Map<String, ReadWrite>?,
@@ -132,6 +130,8 @@ class ContactListEvent(
val follows = earlierVersion.verifiedFollowKeySet() val follows = earlierVersion.verifiedFollowKeySet()
val toBeAdded = users.filter { it.pubKey !in follows }.distinctBy { it.pubKey }.map { it.toTagArray() } val toBeAdded = users.filter { it.pubKey !in follows }.distinctBy { it.pubKey }.map { it.toTagArray() }
println("AABBCC Adding ${toBeAdded.size} users from ${users.size} users with ${follows.size} already there")
if (toBeAdded.isEmpty()) return earlierVersion if (toBeAdded.isEmpty()) return earlierVersion
return create( return create(