From cc4e81146fe1e1efcabb526ea9228ad35c79d853 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 10 Mar 2026 18:46:18 -0400 Subject: [PATCH] Saves the position of the screen on import follows dialog --- .../userSuggestions/UserSuggestionState.kt | 6 +- .../ImportFollowListSelectUserScreen.kt | 138 +++++++++++++++--- 2 files changed, 125 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 21497982f..4fc579daa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions -import android.R.attr.version import androidx.compose.runtime.Stable import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue @@ -77,7 +76,10 @@ class UserSuggestionState( @OptIn(FlowPreview::class) val nip05ResolutionFlow = - searchTerm + currentWord + .debounce(300) + .distinctUntilChanged() + .map(::userSearchTermOrNull) .map { prefix -> if (prefix != null) { if (prefix.contains('@')) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt index 83bc620c7..880cec823 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser +import android.R.attr.identifier import androidx.compose.foundation.layout.Arrangement 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 @@ -30,9 +32,13 @@ 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.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -40,30 +46,80 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable 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 androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription 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.UserLine import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@Stable +class ImportFollowListSelectUserViewModel( + val account: Account, + val nip05Client: INip05Client, +) : ViewModel() { + var identifier by mutableStateOf("") + val userSuggestions = UserSuggestionState(account, nip05Client) + val listState = LazyListState(0, 0) + + val searchTerm = + userSuggestions.searchTerm + .onEach { + if (!it.isNullOrBlank()) { + listState.scrollToItem(0, 0) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, userSuggestions.currentWord) + + val results = + userSuggestions.results + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + class Factory( + val account: Account, + val nip05Client: INip05Client, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = ImportFollowListSelectUserViewModel(account, nip05Client) as T + } +} @Composable fun ImportFollowListSelectUserScreen( accountViewModel: AccountViewModel, nav: INav, ) { + val viewModel: ImportFollowListSelectUserViewModel = + viewModel( + factory = ImportFollowListSelectUserViewModel.Factory(accountViewModel.account, accountViewModel.nip05Client), + ) + Scaffold( modifier = Modifier.fillMaxSize(), topBar = { @@ -79,9 +135,7 @@ fun ImportFollowListSelectUserScreen( .padding(padding), ) { InputSelectUserBody( - onLookup = { - nav.nav(Route.ImportFollowsPickFollows(it.pubkeyHex)) - }, + viewModel = viewModel, accountViewModel = accountViewModel, nav = nav, ) @@ -91,22 +145,19 @@ fun ImportFollowListSelectUserScreen( @Composable private fun InputSelectUserBody( - onLookup: (User) -> Unit, + viewModel: ImportFollowListSelectUserViewModel, 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, + value = viewModel.identifier, onValueChange = { - identifier = it - userSuggestions.processCurrentWord(it) + viewModel.identifier = it + viewModel.userSuggestions.processCurrentWord(it) }, label = { Text(stringRes(R.string.profile_to_import_from)) }, placeholder = { Text(stringRes(R.string.name_search_npub1_alice_example_com)) }, @@ -118,11 +169,10 @@ private fun InputSelectUserBody( Spacer(Modifier.height(8.dp)) - ShowUserSuggestionList( - userSuggestions = userSuggestions, + CustomShowUserSuggestionList( + viewModel = viewModel, onSelect = { user -> - onLookup(user) - userSuggestions.reset() + nav.nav(Route.ImportFollowsPickFollows(user.pubkeyHex)) }, modifier = Modifier.weight(1f), accountViewModel = accountViewModel, @@ -180,3 +230,57 @@ private fun ImportHeader() { ) } } + +@Composable +private fun CustomShowUserSuggestionList( + viewModel: ImportFollowListSelectUserViewModel, + onSelect: (User) -> Unit, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + onEmpty: @Composable () -> Unit = {}, +) { + UserSearchDataSourceSubscription(viewModel.userSuggestions, accountViewModel) + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + viewModel.userSuggestions.invalidateData() + } + } + launch(Dispatchers.IO) { + LocalCache.live.deletedEventBundles.collect { + viewModel.userSuggestions.invalidateData() + } + } + } + + CustomWatchResponses(viewModel, onSelect, accountViewModel, modifier, onEmpty) +} + +@Composable +fun CustomWatchResponses( + viewModel: ImportFollowListSelectUserViewModel, + onSelect: (User) -> Unit, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + onEmpty: @Composable () -> Unit = {}, +) { + val suggestions by viewModel.results.collectAsStateWithLifecycle() + + if (suggestions.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues(top = 10.dp), + modifier = modifier, + state = viewModel.listState, + ) { + itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item -> + UserLine(item, accountViewModel) { onSelect(item) } + HorizontalDivider( + thickness = DividerThickness, + ) + } + } + } else { + onEmpty() + } +}