- 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:
@@ -153,14 +153,12 @@ class AppModules(
|
||||
// Custom fetcher that considers tor settings and avoids forwarding.
|
||||
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
|
||||
|
||||
val namecoinElectrumxClient =
|
||||
ElectrumXClient(
|
||||
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
|
||||
)
|
||||
|
||||
val namecoinResolver =
|
||||
NamecoinNameResolver(
|
||||
electrumxClient = namecoinElectrumxClient,
|
||||
electrumxClient =
|
||||
ElectrumXClient(
|
||||
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
|
||||
),
|
||||
serverListProvider = {
|
||||
// User-configured custom servers take priority
|
||||
namecoinPrefs.customServersOrNull
|
||||
|
||||
+1
-1
@@ -118,7 +118,7 @@ class Kind3FollowListState(
|
||||
|
||||
val contacts =
|
||||
users.map {
|
||||
ContactTag(user.pubkeyHex, user.bestRelayHint(), null)
|
||||
ContactTag(it.pubkeyHex, it.bestRelayHint(), null)
|
||||
}
|
||||
|
||||
return if (contactList != null) {
|
||||
|
||||
-257
@@ -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.userSuggestions?.reset()
|
||||
this.userSuggestions = UserSuggestionState(accountViewModel.account)
|
||||
this.userSuggestions = UserSuggestionState(accountViewModel.account, accountViewModel.nip05Client)
|
||||
}
|
||||
|
||||
fun sendPost() {
|
||||
|
||||
@@ -252,5 +252,5 @@ interface Dao {
|
||||
|
||||
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.PeopleListMetadataScreen
|
||||
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.publicMessages.NewPublicMessageScreen
|
||||
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.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -178,7 +180,14 @@ fun AppNavigation(
|
||||
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.UserSettings> { UserSettingsScreen(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.UpdateZapAmount> { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) }
|
||||
|
||||
+8
@@ -472,6 +472,14 @@ fun ListContent(
|
||||
route = Route.Chess,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.route_import_follows,
|
||||
icon = Icons.Outlined.GroupAdd,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.ImportFollowsSelectUser,
|
||||
)
|
||||
|
||||
IconRowRelays(
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
|
||||
@@ -53,7 +53,11 @@ sealed class 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(
|
||||
val dTag: String,
|
||||
|
||||
+2
-3
@@ -46,7 +46,6 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
|
||||
@@ -127,7 +126,7 @@ private fun VerifyAndDisplayNIP05OrStatusLine(
|
||||
if (nip05VerifState.isExpired()) {
|
||||
LaunchedEffect(key1 = nip05VerifState) {
|
||||
accountViewModel.runOnIO {
|
||||
nip05State.checkAndUpdate(Amethyst.instance.nip05Client)
|
||||
nip05State.checkAndUpdate(accountViewModel.nip05Client)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,7 +392,7 @@ fun ObserveAndRenderNIP05VerifiedSymbol(
|
||||
if (state.isExpired()) {
|
||||
LaunchedEffect(key1 = state) {
|
||||
accountViewModel.runOnIO {
|
||||
nip05State.checkAndUpdate(Amethyst.instance.nip05Client)
|
||||
nip05State.checkAndUpdate(accountViewModel.nip05Client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
@@ -56,6 +55,7 @@ fun ShowUserSuggestionList(
|
||||
onSelect: (User) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onEmpty: @Composable () -> Unit = {},
|
||||
) {
|
||||
UserSearchDataSourceSubscription(userSuggestions, accountViewModel)
|
||||
|
||||
@@ -76,7 +76,7 @@ fun ShowUserSuggestionList(
|
||||
}
|
||||
}
|
||||
|
||||
WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier)
|
||||
WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier, onEmpty)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -97,7 +97,8 @@ fun WatchResponses(
|
||||
listState: LazyListState,
|
||||
onSelect: (User) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier.heightIn(0.dp, 200.dp),
|
||||
modifier: Modifier = Modifier,
|
||||
onEmpty: @Composable () -> Unit = {},
|
||||
) {
|
||||
val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList())
|
||||
|
||||
@@ -114,6 +115,8 @@ fun WatchResponses(
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+82
-1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions
|
||||
|
||||
import android.R.attr.version
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.text.TextRange
|
||||
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.User
|
||||
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.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -38,9 +50,18 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
val userUriPrefixes =
|
||||
listOf(
|
||||
DualCase("npub"),
|
||||
DualCase("nprofile"),
|
||||
DualCase("nostr:npub"),
|
||||
DualCase("nostr:nprofile"),
|
||||
)
|
||||
|
||||
@Stable
|
||||
class UserSuggestionState(
|
||||
val account: Account,
|
||||
val nip05Client: INip05Client,
|
||||
) {
|
||||
val invalidations = MutableStateFlow(0)
|
||||
val currentWord = MutableStateFlow("")
|
||||
@@ -54,9 +75,69 @@ class UserSuggestionState(
|
||||
.map(::userSearchTermOrNull)
|
||||
.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)
|
||||
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) {
|
||||
logTime("UserSuggestionState Search $prefix version $version") {
|
||||
account.cache.findUsersStartingWith(prefix, account)
|
||||
|
||||
+1
-1
@@ -189,7 +189,7 @@ open class CommentPostViewModel :
|
||||
this.canAddZapRaiser = hasLnAddress()
|
||||
|
||||
this.userSuggestions?.reset()
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account)
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
|
||||
+1
-1
@@ -278,7 +278,7 @@ class AccountSessionManager(
|
||||
|
||||
localPreferences.setDefaultAccount(accountSettings)
|
||||
|
||||
startUI(accountSettings, route = Route.ImportFollows)
|
||||
startUI(accountSettings, route = Route.ImportFollowsSelectUser)
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(2000) // waits for the new user to connect to the new relays.
|
||||
|
||||
+9
-1
@@ -103,6 +103,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder
|
||||
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.nip17Dm.base.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
@@ -164,6 +167,7 @@ class AccountViewModel(
|
||||
val torSettings: TorSettingsFlow,
|
||||
val dataSources: RelaySubscriptionsCoordinator,
|
||||
val httpClientBuilder: IRoleBasedHttpClientBuilder,
|
||||
val nip05Client: INip05Client,
|
||||
) : ViewModel(),
|
||||
Dao {
|
||||
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)
|
||||
|
||||
@@ -1212,6 +1216,7 @@ class AccountViewModel(
|
||||
val torSettings: TorSettingsFlow,
|
||||
val dataSources: RelaySubscriptionsCoordinator,
|
||||
val okHttpClient: RoleBasedHttpClientBuilder,
|
||||
val nip05Client: Nip05Client,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
@@ -1221,6 +1226,7 @@ class AccountViewModel(
|
||||
torSettings,
|
||||
dataSources,
|
||||
okHttpClient,
|
||||
nip05Client,
|
||||
) as T
|
||||
}
|
||||
|
||||
@@ -1742,6 +1748,7 @@ fun mockAccountViewModel(): AccountViewModel {
|
||||
torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)),
|
||||
httpClientBuilder = EmptyRoleBasedHttpClientBuilder(),
|
||||
dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope),
|
||||
nip05Client = EmptyNip05Client(),
|
||||
).also {
|
||||
mockedCache = it
|
||||
}
|
||||
@@ -1792,6 +1799,7 @@ fun mockVitorAccountViewModel(): AccountViewModel {
|
||||
torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)),
|
||||
httpClientBuilder = EmptyRoleBasedHttpClientBuilder(),
|
||||
dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope),
|
||||
nip05Client = EmptyNip05Client(),
|
||||
).also {
|
||||
vitorCache = it
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ fun LoggedInPage(
|
||||
torSettings = Amethyst.instance.torPrefs.value,
|
||||
dataSources = Amethyst.instance.sources,
|
||||
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder,
|
||||
nip05Client = Amethyst.instance.nip05Client,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ class ChatNewMessageViewModel :
|
||||
this.canAddZapRaiser = hasLnAddress()
|
||||
|
||||
this.userSuggestions?.reset()
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account)
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
|
||||
+1
-1
@@ -168,7 +168,7 @@ open class ChannelNewMessageViewModel :
|
||||
this.canAddZapRaiser = hasLnAddress()
|
||||
|
||||
this.userSuggestions?.reset()
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account)
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@ open class NewProductViewModel :
|
||||
this.canAddZapRaiser = hasLnAddress()
|
||||
|
||||
this.userSuggestions?.reset()
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account)
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
|
||||
+1
-1
@@ -260,7 +260,7 @@ open class ShortNotePostViewModel :
|
||||
this.canAddZapRaiser = hasLnAddress()
|
||||
|
||||
this.userSuggestions?.reset()
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account)
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ fun PeopleListScreen(
|
||||
nav: INav,
|
||||
) {
|
||||
val viewModel: PeopleListViewModel = viewModel()
|
||||
viewModel.init(accountViewModel.account, selectedDTag)
|
||||
viewModel.init(accountViewModel, selectedDTag)
|
||||
|
||||
val pagerState = rememberPagerState { 2 }
|
||||
|
||||
|
||||
+5
-4
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -66,12 +67,12 @@ class PeopleListViewModel : ViewModel() {
|
||||
fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress())
|
||||
|
||||
fun init(
|
||||
account: Account,
|
||||
accountVM: AccountViewModel,
|
||||
selectedDTag: String,
|
||||
) {
|
||||
if (!this::account.isInitialized || this.account != account) {
|
||||
this.account = account
|
||||
this.userSuggestions = UserSuggestionState(account)
|
||||
if (!this::account.isInitialized || this.account != accountVM.account) {
|
||||
this.account = accountVM.account
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
|
||||
}
|
||||
|
||||
this.selectedDTag.tryEmit(selectedDTag)
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ fun FollowPackScreen(
|
||||
nav: INav,
|
||||
) {
|
||||
val viewModel: FollowPackViewModel = viewModel()
|
||||
viewModel.init(accountViewModel.account, selectedDTag)
|
||||
viewModel.init(accountViewModel, selectedDTag)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
||||
+5
-4
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -66,12 +67,12 @@ class FollowPackViewModel : ViewModel() {
|
||||
fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress())
|
||||
|
||||
fun init(
|
||||
account: Account,
|
||||
accountVM: AccountViewModel,
|
||||
selectedDTag: String,
|
||||
) {
|
||||
if (!this::account.isInitialized || this.account != account) {
|
||||
this.account = account
|
||||
this.userSuggestions = UserSuggestionState(account)
|
||||
if (!this::account.isInitialized || this.account != accountVM.account) {
|
||||
this.account = accountVM.account
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
|
||||
}
|
||||
|
||||
this.selectedDTag.tryEmit(selectedDTag)
|
||||
|
||||
+307
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
-568
@@ -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
|
||||
}
|
||||
}
|
||||
+182
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-176
@@ -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
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -191,7 +191,7 @@ class NewPublicMessageViewModel :
|
||||
this.canAddZapRaiser = hasLnAddress()
|
||||
|
||||
this.userSuggestions?.reset()
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account)
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
|
||||
@@ -1719,13 +1719,14 @@
|
||||
<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="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="name_search_npub1_alice_example_com">name 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="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, nprofile, NIP-05, hex, and namecoin (.bit, d/, id/)</string>
|
||||
<string name="look_up_follow_list">Look Up Follow List</string>
|
||||
<string name="tip">Tip</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="now_following_accounts">Now following %1$d accounts</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="resolving">Resolving %1$s…</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="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>
|
||||
|
||||
Reference in New Issue
Block a user