Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2fc6f16
Add wordpress-rs comments list (Phase 2a: core browsing)
nbradbury Jul 2, 2026
aad42b4
Rename RS comments experimental flag to cover the whole comments expe…
nbradbury Jul 2, 2026
a2cc6b3
Simplify rs comments list: drop dead/unused state, share page handlin…
nbradbury Jul 2, 2026
3cd35f7
Use new string keys for the renamed experimental flag copy
nbradbury Jul 2, 2026
b1bf058
Harden rs comments list paging and post-title resolution
nbradbury Jul 2, 2026
8266317
Address review findings in rs comments paging hardening
nbradbury Jul 2, 2026
ca271c8
Close title-resolve race windows and strengthen data source tests
nbradbury Jul 2, 2026
e666ee4
Construct uniffi data classes in tests instead of mocking them
nbradbury Jul 2, 2026
89e2b61
Fix review findings and slim the list PR
nbradbury Jul 3, 2026
0c05ef5
Merge remote-tracking branch 'origin/trunk' into feature/rs-comments-…
nbradbury Jul 3, 2026
0c828e9
Simplify review-pass leftovers
nbradbury Jul 3, 2026
0f0c47d
Merge remote-tracking branch 'origin/trunk' into feature/rs-comments-…
nbradbury Jul 3, 2026
8a944bc
Add batch moderation to the rs comments list (Phase 2b)
nbradbury Jul 2, 2026
1992627
Merge remote-tracking branch 'origin/trunk' into feature/rs-comments-…
nbradbury Jul 3, 2026
6351049
Remove unused onConfirm field from ConfirmationDialogState
nbradbury Jul 3, 2026
a1fa82b
Simplify batch-moderation dialog wiring
nbradbury Jul 3, 2026
2f7fdc6
Merge remote-tracking branch 'origin/trunk' into feature/rs-comments-…
nbradbury Jul 6, 2026
92f4ea7
Use icon actions in batch-moderation action bar
nbradbury Jul 6, 2026
d8011ed
Show only approve/unapprove as action-bar icons
nbradbury Jul 6, 2026
6d30cab
Simplify pending-confirmation wiring
nbradbury Jul 6, 2026
ecaf07d
Disable approve/unapprove when they'd be a no-op
nbradbury Jul 6, 2026
1d37181
Merge remote-tracking branch 'origin/trunk' into feature/rs-comments-…
nbradbury Jul 6, 2026
b28df1e
Fix action-bar flicker and make confirmations data-driven
nbradbury Jul 6, 2026
8cd59ae
Add quick-win UX polish to the rs comments list
nbradbury Jul 6, 2026
dd44793
Remove Select All from the selection overflow
nbradbury Jul 6, 2026
6409894
Polish selection mode: back press, avatar crossfade, bar color
nbradbury Jul 6, 2026
cf425de
Merge branch 'feature/rs-comments-list-quick-wins' into feature/rs-co…
nbradbury Jul 6, 2026
e9f9c8e
Address review findings in batch moderation
nbradbury Jul 7, 2026
25c279b
Merge remote-tracking branch 'origin/trunk' into feature/rs-comments-…
nbradbury Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,25 @@ class CommentsRsListActivity : BaseAppCompatActivity() {

setContent {
val tabStates by viewModel.tabStates.collectAsState()
val selectedIds by viewModel.selectedIds.collectAsState()
val confirmation by viewModel.pendingConfirmation.collectAsState()
AppThemeM3 {
CommentsRsListScreen(
tabStates = tabStates,
selectedIds = selectedIds,
pendingConfirmation = confirmation,
onDismissConfirmation = viewModel::onDismissPendingAction,
snackbarMessages = viewModel.snackbarMessages,
onInitTab = viewModel::initTab,
onTabChanged = viewModel::onTabChanged,
onRefreshTab = { tab -> viewModel.refreshTab(tab, isUserRefresh = true) },
onLoadMore = viewModel::loadMore,
onNavigateBack = { onBackPressedDispatcher.onBackPressed() },
onCommentClick = viewModel::onCommentClick
onCommentClick = viewModel::onCommentClick,
onCommentLongClick = viewModel::onCommentLongClick,
onClearSelection = viewModel::onClearSelection,
onBatchAction = viewModel::onBatchAction,
onConfirmPendingAction = viewModel::onConfirmPendingAction
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package org.wordpress.android.ui.commentsrs

import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.wordpress.android.R
import org.wordpress.android.fluxc.model.CommentStatus

/** One comment row. */
Expand All @@ -25,3 +28,110 @@ data class CommentsTabUiState(
val canLoadMore: Boolean = false,
val error: String? = null
)

/** A destructive batch [action] awaiting user confirmation, to be applied to [commentIds]. */
data class PendingConfirmation(val action: CommentsRsBatchAction, val commentIds: List<Long>)

/**
* Confirmation-dialog copy for a destructive batch action. [messagePluralResId] is shown when more
* than one comment is selected, otherwise [messageResId].
*/
data class ConfirmationCopy(
@StringRes val titleResId: Int,
@StringRes val confirmButtonResId: Int,
@StringRes val messageResId: Int,
@StringRes val messagePluralResId: Int = messageResId
)

/**
* Batch moderation actions offered in selection mode. Each maps to a target [CommentStatus]
* applied to every selected comment (except [DELETE], which deletes permanently); [labelResId]
* doubles as the icon's content description. Mirroring the legacy list's action mode, only the
* [showAsIcon] actions (approve/unapprove) appear as top-bar icon buttons; the rest fall into the
* overflow menu. An action with [confirmation] copy is destructive: it asks before running and is
* tinted accordingly.
*/
enum class CommentsRsBatchAction(
@StringRes val labelResId: Int,
@DrawableRes val iconResId: Int,
val targetStatus: CommentStatus,
val showAsIcon: Boolean = false,
val confirmation: ConfirmationCopy? = null
) {
APPROVE(
R.string.mnu_comment_approve,
R.drawable.ic_thumbs_up_white_24dp,
CommentStatus.APPROVED,
showAsIcon = true
),
UNAPPROVE(
R.string.mnu_comment_unapprove,
R.drawable.ic_thumbs_down_white_24dp,
CommentStatus.UNAPPROVED,
showAsIcon = true
),
SPAM(R.string.mnu_comment_spam, R.drawable.ic_spam_white_24dp, CommentStatus.SPAM),
NOT_SPAM(R.string.mnu_comment_unspam, R.drawable.ic_spam_white_24dp, CommentStatus.APPROVED),
TRASH(
R.string.mnu_comment_trash,
R.drawable.ic_trash_white_24dp,
CommentStatus.TRASH,
confirmation = ConfirmationCopy(
titleResId = R.string.trash,
confirmButtonResId = R.string.dlg_confirm_action_trash,
messageResId = R.string.dlg_confirm_trash_comments
)
),
UNTRASH(R.string.mnu_comment_untrash, R.drawable.ic_trash_white_24dp, CommentStatus.APPROVED),
DELETE(
R.string.mnu_comment_delete_permanently,
R.drawable.ic_trash_white_24dp,
CommentStatus.DELETED,
confirmation = ConfirmationCopy(
titleResId = R.string.delete,
confirmButtonResId = R.string.delete,
messageResId = R.string.dlg_sure_to_delete_comment,
messagePluralResId = R.string.dlg_sure_to_delete_comments
)
)
}

/** The batch actions offered for a tab's selection, mirroring the legacy action mode. */
internal fun CommentsRsListTab.batchActions(): List<CommentsRsBatchAction> = when (this) {
CommentsRsListTab.ALL -> listOf(
CommentsRsBatchAction.APPROVE,
CommentsRsBatchAction.UNAPPROVE,
CommentsRsBatchAction.SPAM,
CommentsRsBatchAction.TRASH
)
CommentsRsListTab.PENDING -> listOf(
CommentsRsBatchAction.APPROVE,
CommentsRsBatchAction.SPAM,
CommentsRsBatchAction.TRASH
)
CommentsRsListTab.APPROVED -> listOf(
CommentsRsBatchAction.UNAPPROVE,
CommentsRsBatchAction.SPAM,
CommentsRsBatchAction.TRASH
)
CommentsRsListTab.SPAM -> listOf(
CommentsRsBatchAction.NOT_SPAM,
CommentsRsBatchAction.TRASH
)
CommentsRsListTab.TRASHED -> listOf(
CommentsRsBatchAction.UNTRASH,
CommentsRsBatchAction.DELETE
)
}

/**
* Whether [this] action can act on a selection whose comments have [selectedStatuses]. Approve and
* unapprove are disabled when they would be a no-op — nothing unapproved to approve, or nothing
* approved to unapprove; every other action is always enabled.
*/
internal fun CommentsRsBatchAction.isEnabledFor(selectedStatuses: Set<CommentStatus>): Boolean =
when (this) {
CommentsRsBatchAction.APPROVE -> CommentStatus.UNAPPROVED in selectedStatuses
CommentsRsBatchAction.UNAPPROVE -> CommentStatus.APPROVED in selectedStatuses
else -> true
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,25 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.wordpress.android.R
import org.wordpress.android.analytics.AnalyticsTracker.Stat
import org.wordpress.android.fluxc.model.CommentStatus
import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.modules.BG_THREAD
import org.wordpress.android.ui.comments.unified.CommentsRsDataSource
import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsComment
import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsCommentsPageResult
import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsResult
import org.wordpress.android.ui.mysite.SelectedSiteRepository
import org.wordpress.android.ui.postsrs.PostRsErrorUtils
import org.wordpress.android.ui.postsrs.SnackbarMessage
Expand Down Expand Up @@ -58,6 +63,13 @@ class CommentsRsListViewModel @Inject constructor(
private val _snackbarMessages = Channel<SnackbarMessage>(Channel.BUFFERED)
val snackbarMessages = _snackbarMessages.receiveAsFlow()

// Selection mode is active while this is non-empty.
private val _selectedIds = MutableStateFlow<Set<Long>>(emptySet())
val selectedIds: StateFlow<Set<Long>> = _selectedIds.asStateFlow()

private val _pendingConfirmation = MutableStateFlow<PendingConfirmation?>(null)
val pendingConfirmation: StateFlow<PendingConfirmation?> = _pendingConfirmation.asStateFlow()

// Pagination cursors: the next-page params returned with each fetched page, per tab.
private val nextPageParams = mutableMapOf<CommentsRsListTab, CommentListParams?>()

Expand Down Expand Up @@ -96,8 +108,8 @@ class CommentsRsListViewModel @Inject constructor(

/**
* Re-fetches the first page for [tab]. The pull-to-refresh indicator is only shown for a
* user-initiated refresh; silent refreshes (returning from the detail) keep the current
* list on screen until the new page arrives.
* user-initiated refresh; silent refreshes (after moderation, returning from the detail)
* keep the current list on screen until the new page arrives.
*/
@MainThread
fun refreshTab(tab: CommentsRsListTab, isUserRefresh: Boolean = false) {
Expand All @@ -106,6 +118,11 @@ class CommentsRsListViewModel @Inject constructor(
// would only duplicate the request and double-bump the page generation.
if (firstPageJobs[tab]?.isActive == true) return
if (isUserRefresh) {
// A refresh can remove selected comments from the list (they changed server-side),
// which would leave the global selection out of sync with the visible checkmarks —
// batch actions acting on off-screen comments, back presses that appear to do
// nothing. Ending selection mode keeps the two in agreement.
onClearSelection()
// An explicit refresh retries every post title for the site: ones that previously
// resolved as unresolvable (e.g. a post published since it was first seen) and ones
// that may have gone stale (e.g. a post renamed since it was cached).
Expand Down Expand Up @@ -176,18 +193,129 @@ class CommentsRsListViewModel @Inject constructor(
}
}

/** Opens the rs comment detail for the tapped row. */
/** Opens the rs comment detail for the tapped row, or toggles it while selecting. */
@MainThread
fun onCommentClick(remoteCommentId: Long) {
_events.trySend(CommentsRsListEvent.OpenCommentDetail(site, remoteCommentId))
if (_selectedIds.value.isNotEmpty()) {
toggleSelection(remoteCommentId)
} else {
_events.trySend(CommentsRsListEvent.OpenCommentDetail(site, remoteCommentId))
}
}

/** Enters selection mode with the long-pressed row selected. */
@MainThread
fun onCommentLongClick(remoteCommentId: Long) {
toggleSelection(remoteCommentId)
}

@MainThread
fun onClearSelection() {
_selectedIds.value = emptySet()
}

/**
* Runs [action] on the selected comments, first asking for confirmation for the destructive
* ones (trash, delete) like the legacy list.
*/
@MainThread
fun onBatchAction(action: CommentsRsBatchAction, tab: CommentsRsListTab) {
val ids = _selectedIds.value.toList()
if (ids.isEmpty()) return
if (action.confirmation != null) {
_pendingConfirmation.value = PendingConfirmation(action, ids)
} else {
performBatchModeration(ids, action.targetStatus, tab)
}
}

@MainThread
fun onConfirmPendingAction(tab: CommentsRsListTab) {
_pendingConfirmation.value?.let { performBatchModeration(it.commentIds, it.action.targetStatus, tab) }
_pendingConfirmation.value = null
}

@MainThread
fun onDismissPendingAction() {
_pendingConfirmation.value = null
}

private fun toggleSelection(remoteCommentId: Long) {
_selectedIds.update { ids ->
if (remoteCommentId in ids) ids - remoteCommentId else ids + remoteCommentId
}
}

/**
* Applies [newStatus] to every comment in [ids] in parallel, then refreshes all initialized
* tabs (moderated comments move between tabs). Failures are aggregated into one snackbar.
*/
private fun performBatchModeration(ids: List<Long>, newStatus: CommentStatus, tab: CommentsRsListTab) {
if (!checkNetwork()) return
trackBatchModeration(newStatus)
onClearSelection()
updateTabUiState(tab) { copy(isRefreshing = true) }
viewModelScope.launch {
val results = withContext(bgDispatcher) {
ids.map { commentId ->
async {
if (newStatus == CommentStatus.DELETED) {
commentsRsDataSource.delete(site, commentId)
} else {
commentsRsDataSource.updateStatus(site, commentId, newStatus)
}
}
}.awaitAll()
}
// awaitAll preserves order, so results line up with ids.
val failedIds = ids.filterIndexed { index, _ -> results[index] is RsResult.Error }
if (failedIds.isNotEmpty()) {
val message = if (ids.size == 1) {
resourceProvider.getString(R.string.comments_rs_moderation_failed_single)
} else {
resourceProvider.getString(
R.string.comments_rs_moderation_failed_multiple, failedIds.size, ids.size
)
}
_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()
}
refreshAllTabs()
}
}

private fun trackBatchModeration(newStatus: CommentStatus) {
val stat = when (newStatus) {
CommentStatus.APPROVED -> Stat.COMMENT_BATCH_APPROVED
CommentStatus.UNAPPROVED -> Stat.COMMENT_BATCH_UNAPPROVED
CommentStatus.SPAM -> Stat.COMMENT_BATCH_SPAMMED
CommentStatus.TRASH -> Stat.COMMENT_BATCH_TRASHED
CommentStatus.DELETED -> Stat.COMMENT_BATCH_DELETED
else -> return
}
analyticsTracker.track(stat)
}

private fun checkNetwork(): Boolean {
if (!networkUtilsWrapper.isNetworkAvailable()) {
_snackbarMessages.trySend(
SnackbarMessage(resourceProvider.getString(R.string.no_network_message))
)
return false
}
return true
}

/**
* Tracks the tab the pager settled on — the same COMMENT_FILTER_CHANGED event (including the
* initial selection) the legacy list emits, so filter metrics stay comparable.
* The pager settled on a new tab: clears any active selection (like the legacy action mode)
* and tracks the same COMMENT_FILTER_CHANGED event (including the initial selection) the
* legacy list emits, so filter metrics stay comparable.
*/
@MainThread
fun onTabChanged(tab: CommentsRsListTab) {
onClearSelection()
if (tab == lastTrackedTab) return
lastTrackedTab = tab
analyticsTracker.track(
Expand Down
Loading
Loading