diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListActivity.kt index 21f143d13c28..43be22243395 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListActivity.kt @@ -43,13 +43,22 @@ class CommentsRsListActivity : BaseAppCompatActivity() { val tabStates by viewModel.tabStates.collectAsState() val selectedIds by viewModel.selectedIds.collectAsState() val confirmation by viewModel.pendingConfirmation.collectAsState() + val isSearchActive by viewModel.isSearchActive.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + val isQuerySearchable by viewModel.isQuerySearchable.collectAsState() AppThemeM3 { CommentsRsListScreen( tabStates = tabStates, selectedIds = selectedIds, pendingConfirmation = confirmation, + isSearchActive = isSearchActive, + searchQuery = searchQuery, + isQuerySearchable = isQuerySearchable, onDismissConfirmation = viewModel::onDismissPendingAction, snackbarMessages = viewModel.snackbarMessages, + onSearchOpen = viewModel::onSearchOpen, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onSearchClose = viewModel::onSearchClose, onInitTab = viewModel::initTab, onTabChanged = viewModel::onTabChanged, onRefreshTab = { tab -> viewModel.refreshTab(tab, isUserRefresh = true) }, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModel.kt index cab5987b4e3d..77238c2a1b2e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModel.kt @@ -5,14 +5,23 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -57,6 +66,21 @@ class CommentsRsListViewModel @Inject constructor( private val _tabStates = MutableStateFlow>(emptyMap()) val tabStates: StateFlow> = _tabStates.asStateFlow() + private val _isSearchActive = MutableStateFlow(false) + val isSearchActive: StateFlow = _isSearchActive.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + /** + * Whether the current query is long enough to search. The single owner of the minimum-length + * policy — the debounce filter, the [initTab] guard, and the screen's idle state all derive + * from it rather than re-implementing the trim-and-compare rule. + */ + val isQuerySearchable: StateFlow = _searchQuery + .map { isSearchable(it) } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + private val _events = Channel(Channel.BUFFERED) val events = _events.receiveAsFlow() @@ -80,11 +104,25 @@ class CommentsRsListViewModel @Inject constructor( // The in-flight first-page fetch per tab, so overlapping refreshes don't duplicate it. private val firstPageJobs = mutableMapOf() + // The in-flight load-more fetch per tab, so clearTabs() can cancel it: a load-more that + // survived a clear could otherwise land on a reset page-generation counter and append its + // stale rows into the freshly fetched list. + private val loadMoreJobs = mutableMapOf() + + // The tab the pager last settled on. Kept separate from [lastTrackedTab] (the analytics + // dedupe key) so changes to tracking behavior can't silently change which tab a debounced + // search targets. + private var currentTab: CommentsRsListTab? = null + private var lastTrackedTab: CommentsRsListTab? = null // The in-flight title-resolve job per tab. private val resolveTitleJobs = mutableMapOf() + // Bumped by clearTabs(). Long-lived jobs (batch moderation) capture it at launch and skip + // their completion-time state writes when it moved: the rows they refer to are gone. + private var clearGeneration = 0 + private val _site: SiteModel? = selectedSiteRepository.getSelectedSite() private val site: SiteModel get() = requireNotNull(_site) { "No selected site — Activity should have finished" } @@ -93,15 +131,98 @@ class CommentsRsListViewModel @Inject constructor( if (_site == null) { _events.trySend(CommentsRsListEvent.ShowToast(R.string.blog_not_found)) _events.trySend(CommentsRsListEvent.Finish) + } else { + @OptIn(FlowPreview::class) + viewModelScope.launch { + _searchQuery + // One shared normalization decides both halves of the search policy below: + // trim + dedupe so whitespace-only edits ("abc" -> "abc ") neither clear nor + // refetch, and so a whitespace-only query can't pass the length filter and + // fetch (and present) the full unfiltered list as search results. + .map { it.trim() } + .distinctUntilChanged() + // Skip the initial "" so opening the screen doesn't clear the loading tabs. + .drop(1) + // Clear immediately on any change: displayed rows always belong to the last + // executed search and must not linger as apparent matches for a different + // query — whether edited below the minimum or replaced wholesale (select-all + // + paste, a keyboard suggestion, voice input). The isSearchActive guard + // keeps the close transition intact: onSearchClose resets the query itself + // and rebuilds the tabs synchronously. + .onEach { if (_isSearchActive.value) clearTabs() } + .debounce(SEARCH_DEBOUNCE_MS) + .filter { isSearchable(it) } + .collect { + // No clear here: every mutation path cleared at mutation time, so any + // surviving tab content was already fetched with this query (e.g. by a + // pager settle inside the debounce window) and initTab's containsKey + // guard keeps it. currentTab follows the pager, so this targets the tab + // the user is actually looking at. + initTab(currentTab ?: CommentsRsListTab.ALL) + } + } } } + /** Clears all tab states so the list appears empty while the user types a query. */ + @MainThread + fun onSearchOpen() { + onClearSelection() + _isSearchActive.value = true + clearTabs() + } + + /** + * Updates the search query. The pipeline in `init` reacts to it: an immediate clear on any + * trimmed change, then a debounced fetch once the query is searchable. + */ + @MainThread + fun onSearchQueryChanged(query: String) { + _searchQuery.value = query + } + + /** + * Closes search mode: clears the query and immediately re-initializes [activeTab] so the + * normal tab content appears without debounce delay. + */ + @MainThread + fun onSearchClose(activeTab: CommentsRsListTab) { + onClearSelection() + _isSearchActive.value = false + _searchQuery.value = "" + clearTabs() + initTab(activeTab) + } + + /** + * Drops every tab's rows and pagination bookkeeping so tabs re-initialize from scratch + * (entering, changing, or leaving a search). Every in-flight fetch is cancelled: a fetch + * started before the clear must not resurrect a stale tab, and a surviving load-more would + * land on a reset generation counter and mix its stale page into the fresh list. + */ + private fun clearTabs() { + clearGeneration++ + firstPageJobs.values.forEach { it.cancel() } + firstPageJobs.clear() + loadMoreJobs.values.forEach { it.cancel() } + loadMoreJobs.clear() + resolveTitleJobs.values.forEach { it.cancel() } + resolveTitleJobs.clear() + nextPageParams.clear() + pageGenerations.clear() + _tabStates.value = emptyMap() + } + /** Loads the first page for [tab] unless it's already initialized. */ @MainThread fun initTab(tab: CommentsRsListTab) { // updateTabUiState inserts the tab synchronously, so this also blocks re-entry while // the first fetch is in flight. if (_tabStates.value.containsKey(tab)) return + // While a search is open with a below-minimum query, the screen shows the idle state and + // the debounce collector hasn't fired — an init here (pager settle, Activity recreation) + // would fetch unfiltered or below-minimum results behind the idle screen. + if (_isSearchActive.value && !isSearchable(_searchQuery.value)) return updateTabUiState(tab) { copy(isLoading = true) } fetchFirstPage(tab) } @@ -155,12 +276,16 @@ class CommentsRsListViewModel @Inject constructor( updateTabUiState(tab) { copy(isLoadingMore = true) } val generation = pageGenerations[tab] - viewModelScope.launch { + loadMoreJobs[tab] = viewModelScope.launch { val result = withContext(bgDispatcher) { commentsRsDataSource.fetchCommentsPage(site, params) } if (pageGenerations[tab] != generation) { // A refresh replaced the list while this page was in flight, so its cursor is // stale; appending it would mix old and new pages and corrupt the cursor chain. - updateTabUiState(tab) { copy(isLoadingMore = false) } + // Only touch tabs that still exist: writing through updateTabUiState after a + // clearTabs() would resurrect the tab as a ghost entry that blocks initTab. + if (_tabStates.value.containsKey(tab)) { + updateTabUiState(tab) { copy(isLoadingMore = false) } + } return@launch } when (result) { @@ -255,6 +380,7 @@ class CommentsRsListViewModel @Inject constructor( trackBatchModeration(newStatus) onClearSelection() updateTabUiState(tab) { copy(isRefreshing = true) } + val generationAtStart = clearGeneration viewModelScope.launch { val results = withContext(bgDispatcher) { ids.map { commentId -> @@ -279,8 +405,15 @@ class CommentsRsListViewModel @Inject constructor( } _snackbarMessages.trySend(SnackbarMessage(message)) // Failed comments kept their status and place in the list, so re-selecting them - // lets the user retry immediately instead of hunting them down again. - _selectedIds.value = failedIds.toSet() + // lets the user retry immediately instead of hunting them down again — unless + // clearTabs() ran while the batch was in flight (search opened, closed, or query + // changed): this job outlives the clear, and re-selecting rows that may no longer + // be displayed would leave an invisible selection intercepting back presses and + // row taps. A batch run inside an unchanged search keeps the affordance: its + // failed rows are still in the refreshed results. + if (clearGeneration == generationAtStart) { + _selectedIds.value = failedIds.toSet() + } } refreshAllTabs() } @@ -315,8 +448,11 @@ class CommentsRsListViewModel @Inject constructor( */ @MainThread fun onTabChanged(tab: CommentsRsListTab) { - onClearSelection() + currentTab = tab + // A re-emission of the same tab (Activity recreation restarting the pager's settled-page + // flow) is not a tab change: it must neither re-track nor clear an active selection. if (tab == lastTrackedTab) return + onClearSelection() lastTrackedTab = tab analyticsTracker.track( Stat.COMMENT_FILTER_CHANGED, @@ -326,7 +462,10 @@ class CommentsRsListViewModel @Inject constructor( private fun fetchFirstPage(tab: CommentsRsListTab, showErrorSnackbar: Boolean = true) { firstPageJobs[tab] = viewModelScope.launch { - val params = commentsRsDataSource.firstPageParams(status = tab.queryStatus) + val params = commentsRsDataSource.firstPageParams( + status = tab.queryStatus, + search = _searchQuery.value.trim().ifBlank { null } + ) val result = withContext(bgDispatcher) { commentsRsDataSource.fetchCommentsPage(site, params) } when (result) { is RsCommentsPageResult.Success -> applyPage(tab, result, append = false) @@ -426,6 +565,8 @@ class CommentsRsListViewModel @Inject constructor( postId = postId ) + private fun isSearchable(query: String) = query.trim().length >= MIN_SEARCH_QUERY_LENGTH + private fun getTabUiState(tab: CommentsRsListTab): CommentsTabUiState = _tabStates.value[tab] ?: CommentsTabUiState(isLoading = true) @@ -440,5 +581,8 @@ class CommentsRsListViewModel @Inject constructor( // Same property key as the legacy list's tracking (UnifiedCommentsActivity). private const val SELECTED_FILTER_PROPERTY = "selected_filter" + + private const val SEARCH_DEBOUNCE_MS = 250L + private const val MIN_SEARCH_QUERY_LENGTH = 3 } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListScreen.kt index 1e435d237ad1..d4cf7ce590f9 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListScreen.kt @@ -5,14 +5,18 @@ import androidx.annotation.StringRes import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -28,6 +32,8 @@ import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -36,14 +42,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.fluxc.model.CommentStatus @@ -59,14 +71,24 @@ import org.wordpress.android.ui.postsrs.SnackbarMessage // current selection while keeping them visible. private const val DISABLED_ICON_ALPHA = 0.38f +/** Which of the mutually exclusive top bars is showing: selection wins over search. */ +private enum class TopBarMode { SELECTION, SEARCH, NORMAL } + +@Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable fun CommentsRsListScreen( tabStates: Map, selectedIds: Set, pendingConfirmation: PendingConfirmation?, + isSearchActive: Boolean, + searchQuery: String, + isQuerySearchable: Boolean, onDismissConfirmation: () -> Unit, snackbarMessages: Flow, + onSearchOpen: () -> Unit, + onSearchQueryChanged: (String) -> Unit, + onSearchClose: (CommentsRsListTab) -> Unit, onInitTab: (CommentsRsListTab) -> Unit, onTabChanged: (CommentsRsListTab) -> Unit, onRefreshTab: (CommentsRsListTab) -> Unit, @@ -102,6 +124,35 @@ fun CommentsRsListScreen( .map { it.status } .toSet() val isSelectionActive = selectedStatuses.isNotEmpty() + val focusRequester = remember { FocusRequester() } + // Focus (and the keyboard) is requested once per search open, not every time SearchTopBar + // re-enters composition — a selection round-trip over active search must not pop the keyboard. + var searchFocusPending by remember { mutableStateOf(false) } + val topBarMode = when { + isSelectionActive -> TopBarMode.SELECTION + isSearchActive -> TopBarMode.SEARCH + else -> TopBarMode.NORMAL + } + + // Search opening, closing, or changing the trimmed query replaces each tab's content + // wholesale; reset the scroll so page 1 renders at the top. The hoisted list states otherwise + // keep their old deep offsets, clamping the short new list to its end and tripping the + // load-more trigger. drop(1) skips the initial emission: an Activity recreation must not + // discard the scroll positions the saveable list states just restored. The at-top check + // avoids scrollToItem's forced remeasure (and fling cancellation) when there's nothing to do. + val currentSearchActive by rememberUpdatedState(isSearchActive) + val currentQuery by rememberUpdatedState(searchQuery) + LaunchedEffect(Unit) { + snapshotFlow { currentSearchActive to currentQuery.trim() } + .drop(1) + .collect { + listStates.values.forEach { state -> + if (state.firstVisibleItemIndex > 0 || state.firstVisibleItemScrollOffset > 0) { + state.scrollToItem(0) + } + } + } + } // Like the legacy action mode: the first back press dismisses the selection, the next one // leaves the screen. Enabled on selectedIds (not selectedStatuses) so any live selection is @@ -109,6 +160,10 @@ fun CommentsRsListScreen( BackHandler(enabled = selectedIds.isNotEmpty()) { onClearSelection() } + // Similarly, a back press while searching closes the search before leaving the screen. + BackHandler(enabled = isSearchActive && selectedIds.isEmpty()) { + onSearchClose(activeTab) + } LaunchedEffect(snackbarMessages) { snackbarMessages.collect { msg -> @@ -125,17 +180,31 @@ fun CommentsRsListScreen( Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { - AnimatedContent(targetState = isSelectionActive, label = "topBar") { selectionActive -> - if (selectionActive) { - SelectionTopBar( + AnimatedContent(targetState = topBarMode, label = "topBar") { mode -> + when (mode) { + TopBarMode.SELECTION -> SelectionTopBar( selectedCount = selectedIds.size, actions = activeTab.batchActions(), selectedStatuses = selectedStatuses, onClearSelection = onClearSelection, onBatchAction = { action -> onBatchAction(action, activeTab) } ) - } else { - TopAppBar( + TopBarMode.SEARCH -> { + SearchTopBar( + searchQuery = searchQuery, + focusRequester = focusRequester, + onQueryChanged = onSearchQueryChanged, + onClose = { onSearchClose(activeTab) } + ) + // Runs after SearchTopBar is composed, so the requester is attached. + LaunchedEffect(searchFocusPending) { + if (searchFocusPending) { + focusRequester.requestFocus() + searchFocusPending = false + } + } + } + TopBarMode.NORMAL -> TopAppBar( title = { Text(text = stringResource(R.string.comments)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { @@ -144,6 +213,17 @@ fun CommentsRsListScreen( contentDescription = stringResource(R.string.back) ) } + }, + actions = { + IconButton(onClick = { + searchFocusPending = true + onSearchOpen() + }) { + Icon( + Icons.Default.Search, + contentDescription = stringResource(R.string.comments_rs_search_prompt) + ) + } } ) } @@ -151,6 +231,10 @@ fun CommentsRsListScreen( } ) { contentPadding -> Column(modifier = Modifier.fillMaxSize().padding(contentPadding)) { + // The tab row stays visible while searching: the comments endpoint only accepts a + // single status per request (unlike posts, which search across all statuses), so a + // search is always scoped to one tab — keeping the tabs on screen makes that scope + // visible and lets the user re-run the query against another status. PrimaryScrollableTabRow( selectedTabIndex = pagerState.settledPage, edgePadding = 0.dp @@ -186,13 +270,14 @@ fun CommentsRsListScreen( modifier = Modifier.weight(1f) ) { page -> val tab = tabs[page] - val tabState = tabStates[tab] ?: CommentsTabUiState(isLoading = true) CommentsRsTabListScreen( - state = tabState, + state = tabStates[tab], emptyMessageResId = tab.emptyMessageResId, selectedIds = selectedIds, listState = listStates.getValue(tab), + isSearchActive = isSearchActive, + isQuerySearchable = isQuerySearchable, onRefresh = { onRefreshTab(tab) }, onLoadMore = { onLoadMore(tab) }, onCommentClick = onCommentClick, @@ -279,6 +364,60 @@ private fun SelectionTopBar( ) } +/** + * Search mode: an inline query field replaces the title, the navigation icon closes the search, + * and a clear button empties a non-blank query (like the rs posts list search). The caller owns + * the once-per-open focus request so re-entering composition — e.g. returning from the selection + * top bar — doesn't pop the keyboard uninvited. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchTopBar( + searchQuery: String, + focusRequester: FocusRequester, + onQueryChanged: (String) -> Unit, + onClose: () -> Unit +) { + val focusManager = LocalFocusManager.current + TopAppBar( + title = { + TextField( + value = searchQuery, + onValueChange = onQueryChanged, + placeholder = { Text(stringResource(R.string.comments_rs_search_prompt)) }, + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester) + ) + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + }, + actions = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { onQueryChanged("") }) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.clear) + ) + } + } + } + ) +} + @Composable private fun BatchActionsOverflowMenu( actions: List, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt index ee6a4f11c95d..6e310db3aa7e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt @@ -42,7 +42,8 @@ import org.wordpress.android.ui.compose.components.ShimmerBox @OptIn(ExperimentalMaterial3Api::class) @Composable fun CommentsRsTabListScreen( - state: CommentsTabUiState, + /** Null when the tab isn't initialized: first composition, or cleared awaiting a search. */ + state: CommentsTabUiState?, emptyMessageResId: Int, selectedIds: Set, listState: LazyListState, @@ -50,39 +51,55 @@ fun CommentsRsTabListScreen( onLoadMore: () -> Unit, onCommentClick: (Long) -> Unit, onCommentLongClick: (Long) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isSearchActive: Boolean = false, + isQuerySearchable: Boolean = false ) { + // While searching, a missing tab state means "cleared, waiting for the debounced fetch" + // (initTab inserts an isLoading state the moment it actually fetches) — show the same blank + // idle screen as a below-minimum query rather than flashing the animated shimmer on every + // keystroke. Outside search, a missing state is the pre-init first composition: shimmer. + val isSearchIdle = isSearchActive && (!isQuerySearchable || state == null) + val isSearching = isSearchActive && isQuerySearchable + val tabState = state ?: CommentsTabUiState(isLoading = true) val pullToRefreshState = rememberPullToRefreshState() PullToRefreshBox( modifier = modifier.fillMaxSize(), - isRefreshing = state.isRefreshing, + isRefreshing = tabState.isRefreshing, state = pullToRefreshState, onRefresh = onRefresh, indicator = { PullToRefreshDefaults.Indicator( state = pullToRefreshState, - isRefreshing = state.isRefreshing, + isRefreshing = tabState.isRefreshing, color = MaterialTheme.colorScheme.primary, modifier = Modifier.align(Alignment.TopCenter) ) } ) { when { - state.isLoading -> ShimmerList() - state.error != null && state.comments.isEmpty() -> ErrorContent( - error = state.error, + // Search is open but the query is still below the minimum length: show nothing + // rather than a misleading "no comments" state. + isSearchIdle -> Box(Modifier.fillMaxSize()) + tabState.isLoading -> ShimmerList() + tabState.error != null && tabState.comments.isEmpty() -> ErrorContent( + error = tabState.error, onRetry = onRefresh ) - state.comments.isEmpty() && !state.isRefreshing -> EmptyContent( - emptyMessageResId = emptyMessageResId + tabState.comments.isEmpty() && !tabState.isRefreshing -> EmptyContent( + emptyMessageResId = if (isSearching) { + R.string.comments_rs_search_nothing_found + } else { + emptyMessageResId + } ) else -> CommentListContent( - comments = state.comments, + comments = tabState.comments, selectedIds = selectedIds, listState = listState, - isLoadingMore = state.isLoadingMore, - canLoadMore = state.canLoadMore, + isLoadingMore = tabState.isLoadingMore, + canLoadMore = tabState.canLoadMore, onLoadMore = onLoadMore, onCommentClick = onCommentClick, onCommentLongClick = onCommentLongClick diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index a1585f19cb71..9581494360ce 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -434,6 +434,8 @@ No unreplied comments No spam comments No trashed comments + Search comments + No comments matching your search The comment couldn\'t be updated %1$d of %2$d comments couldn\'t be updated Reply to %s