- 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.
|
// 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
|
||||||
|
|||||||
+1
-1
@@ -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) {
|
||||||
|
|||||||
-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.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) }
|
||||||
|
|||||||
+8
@@ -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,
|
||||||
|
|||||||
+2
-3
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+82
-1
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -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.
|
||||||
|
|||||||
+9
-1
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -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 = {
|
||||||
|
|||||||
+5
-4
@@ -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)
|
||||||
|
|||||||
+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.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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+2
-2
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user