diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml
index 72b6c62ba88c..6131afb24c14 100644
--- a/WordPress/src/main/AndroidManifest.xml
+++ b/WordPress/src/main/AndroidManifest.xml
@@ -313,6 +313,11 @@
android:theme="@style/WordPress.NoActionBar"
android:launchMode="singleTop"
android:label="@string/comments"/>
+
, String>()
+
+ // Bumped by [clearPostTitles]: a fetch already in flight when the user cleared would
+ // otherwise re-poison the cache with its pre-clear "not found" results.
+ @Volatile
+ private var unresolvedCacheGeneration = 0
+
+ /** A comment mapped from [CommentWithViewContext], shared by the detail and list screens. */
data class RsComment(
+ val remoteCommentId: Long,
val authorName: String,
val authorAvatarUrl: String,
val dateGmt: Date,
@@ -44,6 +65,17 @@ class CommentsRsDataSource @Inject constructor(
data class Error(val message: String?) : RsResult
}
+ /** Result of fetching one page of the comment list. */
+ sealed interface RsCommentsPageResult {
+ data class Success(
+ val comments: List,
+ /** Ready-made params for the next page; null when this was the last page. */
+ val nextPageParams: CommentListParams?
+ ) : RsCommentsPageResult
+
+ data class Error(val message: String?) : RsCommentsPageResult
+ }
+
suspend fun getComment(site: SiteModel, commentId: Long): RsComment? = safe(errorValue = null) {
val client = wpApiClientProvider.getWpApiClient(site)
when (
@@ -56,6 +88,121 @@ class CommentsRsDataSource @Inject constructor(
}
}
+ /**
+ * Fetches one page of the site's comments. Pass [firstPageParams] for the first page and the
+ * previous page's [RsCommentsPageResult.Success.nextPageParams] for subsequent pages.
+ */
+ suspend fun fetchCommentsPage(site: SiteModel, params: CommentListParams): RsCommentsPageResult =
+ safe(errorValue = RsCommentsPageResult.Error(null)) {
+ val client = wpApiClientProvider.getWpApiClient(site)
+ when (val result = client.request { it.comments().listWithViewContext(params) }) {
+ is WpRequestResult.Success -> RsCommentsPageResult.Success(
+ comments = result.response.data.map { it.toRsComment() },
+ nextPageParams = result.response.nextPageParams
+ )
+ is WpRequestResult.WpError -> RsCommentsPageResult.Error(result.errorMessage)
+ else -> RsCommentsPageResult.Error(null)
+ }
+ }
+
+ fun firstPageParams(status: RsCommentStatus?, search: String? = null): CommentListParams =
+ CommentListParams(
+ perPage = COMMENTS_PAGE_SIZE,
+ search = search,
+ status = status,
+ orderby = WpApiParamCommentsOrderBy.DATE_GMT,
+ order = WpApiParamOrder.DESC
+ )
+
+ /**
+ * Fetches post titles for the given [postIds] in batched sparse-field network calls, returning
+ * a map of post id to rendered title. Ids already cached skip the network round-trip.
+ * (List rows show "author commented on {post}", but the comment payload only has the post id.)
+ *
+ * Comments can be attached to posts or pages, so unresolved ids fall back to the pages
+ * endpoint. Ids neither endpoint returns (custom post types, non-published posts) resolve to
+ * an empty title and are negative-cached so they aren't re-requested on every page load.
+ */
+ suspend fun fetchPostTitles(site: SiteModel, postIds: List): Map {
+ val result = mutableMapOf()
+ val uncached = mutableListOf()
+ for (id in postIds.distinct()) {
+ val cached = postTitleCache[site.id to id]
+ if (cached != null) result[id] = cached else uncached.add(id)
+ }
+ if (uncached.isEmpty()) return result
+
+ safe(errorValue = Unit) {
+ val generation = unresolvedCacheGeneration
+ val postsSucceeded = fetchTitlesFromEndpoint(site, PostEndpointType.Posts, uncached, result)
+ val remaining = uncached.filter { it !in result }
+ val pagesSucceeded = if (remaining.isEmpty()) {
+ true
+ } else {
+ fetchTitlesFromEndpoint(site, PostEndpointType.Pages, remaining, result)
+ }
+ // Only when both endpoints succeeded are the leftover ids genuinely unresolvable
+ // (custom post types, non-published posts) rather than a transient failure — and
+ // only when no clear happened mid-flight, so pre-clear results can't re-poison it.
+ if (postsSucceeded && pagesSucceeded && generation == unresolvedCacheGeneration) {
+ for (id in uncached.filter { it !in result }) {
+ postTitleCache[site.id to id] = ""
+ result[id] = ""
+ }
+ }
+ }
+ return result
+ }
+
+ /**
+ * Forgets all cached titles for [site] so they're re-fetched, e.g. on a user-initiated
+ * refresh — a post that wasn't published when first seen may be by now, and a resolved
+ * title may have been renamed since it was cached. Fetches are batched, so re-resolving a
+ * page of titles costs a single request.
+ */
+ fun clearPostTitles(site: SiteModel) {
+ unresolvedCacheGeneration++
+ postTitleCache.entries.removeIf { it.key.first == site.id }
+ }
+
+ /**
+ * Fetches titles for [ids] from [endpointType] into [into] (and the cache), returning whether
+ * every request succeeded. [PostListParams.perPage] is set explicitly (the server default of
+ * 10 would silently truncate larger batches) and ids are chunked to the server's per_page
+ * maximum of 100 (a larger value is rejected outright with `rest_invalid_param`).
+ */
+ private suspend fun fetchTitlesFromEndpoint(
+ site: SiteModel,
+ endpointType: PostEndpointType,
+ ids: List,
+ into: MutableMap
+ ): Boolean {
+ val client = wpApiClientProvider.getWpApiClient(site)
+ var allSucceeded = true
+ for (chunk in ids.chunked(MAX_TITLES_PER_REQUEST)) {
+ val response = client.request {
+ it.posts().filterListWithViewContext(
+ endpointType,
+ PostListParams(include = chunk, perPage = chunk.size.toUInt()),
+ listOf(SparseAnyPostFieldWithViewContext.ID, SparseAnyPostFieldWithViewContext.TITLE)
+ )
+ }
+ if (response is WpRequestResult.Success) {
+ for (post in response.response.data) {
+ val id = post.id ?: continue
+ val title = post.title?.rendered.orEmpty()
+ postTitleCache[site.id to id] = title
+ into[id] = title
+ }
+ } else {
+ val message = (response as? WpRequestResult.WpError<*>)?.errorMessage
+ AppLog.w(AppLog.T.COMMENTS, "fetchPostTitles ($endpointType) failed: $message")
+ allSucceeded = false
+ }
+ }
+ return allSucceeded
+ }
+
suspend fun updateStatus(site: SiteModel, commentId: Long, status: CommentStatus): RsResult =
write(site) { it.comments().update(commentId, CommentUpdateParams(status = status.toRsCommentStatus())) }
@@ -91,20 +238,28 @@ class CommentsRsDataSource @Inject constructor(
errorValue
}
- private fun CommentWithViewContext.toRsComment() = RsComment(
- authorName = authorName,
- authorAvatarUrl = (authorAvatarUrls[UserAvatarSize.Size96]
- ?: authorAvatarUrls.values.firstOrNull { !it.isNullOrEmpty() }).orEmpty(),
- // dateGmt is a UTC java.util.Date (an absolute instant), so relative-time formatting is
- // correct regardless of the site's timezone — unlike the offset-less local `date` field.
- dateGmt = dateGmt,
- contentHtml = content.rendered,
- url = link,
- postId = post,
- status = status.toAppCommentStatus()
- )
+ companion object {
+ internal const val COMMENTS_PAGE_SIZE = 30u
+ private const val MAX_TITLES_PER_REQUEST = 100
+ }
}
+internal fun CommentWithViewContext.toRsComment() = CommentsRsDataSource.RsComment(
+ remoteCommentId = id,
+ authorName = authorName,
+ authorAvatarUrl = pickAvatarUrl(),
+ // dateGmt is a UTC java.util.Date (an absolute instant), so relative-time formatting is
+ // correct regardless of the site's timezone — unlike the offset-less local `date` field.
+ dateGmt = dateGmt,
+ contentHtml = content.rendered,
+ url = link,
+ postId = post,
+ status = status.toAppCommentStatus()
+)
+
+internal fun CommentWithViewContext.pickAvatarUrl(): String =
+ (authorAvatarUrls[UserAvatarSize.Size96] ?: authorAvatarUrls.values.firstOrNull { !it.isNullOrEmpty() }).orEmpty()
+
internal fun CommentStatus.toRsCommentStatus(): RsCommentStatus = when (this) {
CommentStatus.APPROVED -> RsCommentStatus.Approved
CommentStatus.UNAPPROVED -> RsCommentStatus.Hold
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsFragment.kt
index d5870313a1ab..15e62225be12 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsFragment.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsFragment.kt
@@ -102,7 +102,7 @@ class UnifiedCommentDetailsFragment :
private val editCommentLauncher: ActivityResultLauncher =
registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) {
- viewModel.refreshComment()
+ viewModel.onCommentEdited()
}
}
@@ -234,6 +234,12 @@ class UnifiedCommentDetailsFragment :
}
viewModel.onSnackbarMessage.observeEvent(viewLifecycleOwner) { showSnackbar(it) }
+
+ // Report the change to the launching screen (the rs comments list only refreshes when
+ // the result says something actually changed).
+ viewModel.commentChanged.observeEvent(viewLifecycleOwner) {
+ requireActivity().setResult(AppCompatActivity.RESULT_OK)
+ }
}
private fun clearReplyInput() {
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsViewModel.kt
index f1cff717dadc..22f8d361d34d 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsViewModel.kt
@@ -60,11 +60,19 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
private val _uiState = MutableLiveData()
private val _uiActionEvent = MutableLiveData>()
private val _onSnackbarMessage = MutableLiveData>()
+ private val _commentChanged = MutableLiveData>()
val uiState: LiveData = _uiState
val uiActionEvent: LiveData> = _uiActionEvent
val onSnackbarMessage: LiveData> = _onSnackbarMessage
+ /**
+ * Fires when the comment was changed on the server (moderated, replied to, edited or
+ * deleted) — NOT on like, which the list doesn't render. The host activity reports it as
+ * its result so the rs list only refreshes when there's something new to show.
+ */
+ val commentChanged: LiveData> = _commentChanged
+
private var isStarted = false
private lateinit var site: SiteModel
private var remoteCommentId: Long = 0
@@ -83,10 +91,12 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
}
/**
- * Reloads the comment. Used after returning from the edit screen so any edits are reflected.
+ * The edit screen reported saved changes: mark the comment as changed and reload it so the
+ * edits are reflected.
*/
- fun refreshComment() {
+ fun onCommentEdited() {
if (!isStarted) return
+ _commentChanged.value = Event(Unit)
loadComment()
}
@@ -100,16 +110,32 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
if (!isRefresh) {
_uiState.value = CommentDetailsUiState(showProgress = true)
}
- val (rsComment, cached) = withContext(bgDispatcher) {
+ val (rsComment, cached, fallbackPostTitle) = withContext(bgDispatcher) {
val rs = commentsRsDataSource.getComment(site, remoteCommentId)
- val local = commentsStore.getCommentByLocalSiteAndRemoteId(site.id, remoteCommentId).firstOrNull()
- rs to local
+ var local = commentsStore.getCommentByLocalSiteAndRemoteId(site.id, remoteCommentId).firstOrNull()
+ // Opened from the rs list the FluxC cache may not have this comment at all (the
+ // legacy list guaranteed a row before the detail could open). Fetch it so the
+ // post title, like state and the edit screen's local id are available — WP.com
+ // only, since FluxC has no application-password transport.
+ if (local == null && site.isUsingWpComRestApi) {
+ commentsStore.fetchComment(site, remoteCommentId, null)
+ local = commentsStore.getCommentByLocalSiteAndRemoteId(site.id, remoteCommentId).firstOrNull()
+ }
+ // Still no title (self-hosted application-password site, or the fetch failed):
+ // resolve it the way the rs list does — usually a free hit on the shared title
+ // cache the list populated moments earlier.
+ val fallbackTitle = if (local?.postTitle.isNullOrBlank() && rs != null && rs.postId > 0) {
+ commentsRsDataSource.fetchPostTitles(site, listOf(rs.postId))[rs.postId].orEmpty()
+ } else {
+ ""
+ }
+ Triple(rs, local, fallbackTitle)
}
when {
rsComment != null -> {
loadedComment = rsComment
localCommentId = cached?.id?.toInt() ?: 0
- _uiState.value = rsComment.toUiState(cached)
+ _uiState.value = rsComment.toUiState(cached, fallbackPostTitle)
}
isRefresh -> showSnackbar(R.string.error_load_comment)
else -> _onSnackbarMessage.value = Event(
@@ -205,6 +231,7 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
if (result is RsResult.Error) {
showError(result.message, R.string.error_generic)
} else {
+ _commentChanged.value = Event(Unit)
// Replying to an unapproved comment implicitly approves it, matching legacy behaviour
if (currentStatus() == UNAPPROVED) {
approveAfterReply()
@@ -236,8 +263,11 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
if (result is RsResult.Error) {
_uiState.value = _uiState.value?.copy(status = previousStatus)
showError(result.message, R.string.error_moderate_comment)
- } else if (closeOnSuccess) {
- _uiActionEvent.value = Event(Close)
+ } else {
+ _commentChanged.value = Event(Unit)
+ if (closeOnSuccess) {
+ _uiActionEvent.value = Event(Close)
+ }
}
}
}
@@ -284,14 +314,14 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
_onSnackbarMessage.value = Event(SnackbarMessageHolder(uiMessage))
}
- private fun RsComment.toUiState(cached: CommentEntity?) = CommentDetailsUiState(
+ private fun RsComment.toUiState(cached: CommentEntity?, fallbackPostTitle: String) = CommentDetailsUiState(
showProgress = false,
contentVisible = true,
authorName = authorName,
authorAvatarUrl = authorAvatarUrl,
datePublished = dateTimeUtilsWrapper.javaDateToTimeSpan(dateGmt),
commentText = contentHtml,
- postTitle = cached?.postTitle ?: "",
+ postTitle = cached?.postTitle?.takeIf { it.isNotBlank() } ?: fallbackPostTitle,
commentUrl = url,
status = status,
isLiked = cached?.iLike ?: false
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentListFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentListFragment.kt
index e433ac99dc3a..9893a0f21d35 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentListFragment.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentListFragment.kt
@@ -15,7 +15,6 @@ import org.wordpress.android.R
import org.wordpress.android.WordPress
import org.wordpress.android.databinding.UnifiedCommentListFragmentBinding
import org.wordpress.android.fluxc.model.CommentStatus
-import org.wordpress.android.ui.ActivityLauncher
import org.wordpress.android.ui.comments.unified.CommentDetailsActivityContract.CommentDetailsActivityRequest
import org.wordpress.android.ui.comments.unified.CommentDetailsActivityContract.CommentDetailsActivityResponse
import org.wordpress.android.ui.comments.unified.CommentListUiModelHelper.ActionModeUiModel
@@ -25,8 +24,6 @@ import org.wordpress.android.ui.comments.unified.CommentListUiModelHelper.Commen
import org.wordpress.android.ui.comments.unified.CommentListUiModelHelper.ConfirmationDialogUiModel
import org.wordpress.android.ui.comments.unified.CommentListUiModelHelper.ConfirmationDialogUiModel.Visible
import org.wordpress.android.ui.mysite.SelectedSiteRepository
-import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures
-import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures.Feature
import org.wordpress.android.ui.utils.UiHelpers
import org.wordpress.android.util.NetworkUtilsWrapper
import org.wordpress.android.util.SnackbarItem
@@ -54,9 +51,6 @@ class UnifiedCommentListFragment : Fragment(R.layout.unified_comment_list_fragme
@Inject
lateinit var networkUtilsWrapper: NetworkUtilsWrapper
- @Inject
- lateinit var experimentalFeatures: ExperimentalFeatures
-
private lateinit var viewModel: UnifiedCommentListViewModel
private lateinit var activityViewModel: UnifiedCommentActivityViewModel
private lateinit var adapter: UnifiedCommentListAdapter
@@ -166,25 +160,16 @@ class UnifiedCommentListFragment : Fragment(R.layout.unified_comment_list_fragme
private fun showCommentDetails(commentId: Long, commentStatus: CommentStatus) {
currentSnackbar?.dismiss()
- val site = selectedSiteRepository.getSelectedSite()
- // The rs detail can only authenticate WP.com-accessed or application-password sites, so
- // fall back to the legacy detail elsewhere (same gating as the rs pages/posts screens).
- val siteSupportsRs = site != null && (site.isUsingWpComRestApi || site.hasApplicationPassword())
- if (siteSupportsRs && experimentalFeatures.isEnabled(Feature.RS_UNIFIED_COMMENTS)) {
- ActivityLauncher.viewUnifiedCommentsDetails(
- context,
- site,
- commentId
+ // This legacy list is only reached when the RS_UNIFIED_COMMENTS flag is off or the site
+ // can't use wordpress-rs (see ActivityLauncher.viewUnifiedComments), so it always pairs
+ // with the legacy detail; the rs list launches the rs detail itself.
+ commentDetails.launch(
+ CommentDetailsActivityRequest(
+ commentId,
+ commentStatus,
+ selectedSiteRepository.getSelectedSite()!!
)
- } else {
- commentDetails.launch(
- CommentDetailsActivityRequest(
- commentId,
- commentStatus,
- selectedSiteRepository.getSelectedSite()!!
- )
- )
- }
+ )
}
val commentDetails = registerForActivityResult(
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
new file mode 100644
index 000000000000..c4218f09e1ac
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListActivity.kt
@@ -0,0 +1,80 @@
+package org.wordpress.android.ui.commentsrs
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import org.wordpress.android.ui.comments.unified.UnifiedCommentsDetailsActivity
+import org.wordpress.android.ui.compose.theme.AppThemeM3
+import org.wordpress.android.ui.main.BaseAppCompatActivity
+import org.wordpress.android.ui.commentsrs.screens.CommentsRsListScreen
+import org.wordpress.android.util.ToastUtils
+import org.wordpress.android.util.extensions.setContent
+
+@AndroidEntryPoint
+class CommentsRsListActivity : BaseAppCompatActivity() {
+ private val viewModel: CommentsRsListViewModel by viewModels()
+
+ // The detail reports RESULT_OK when it changed the comment (moderation, reply, edit,
+ // delete); only then refresh, so a view-only visit keeps the paged lists and scroll
+ // position intact.
+ private val detailLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == RESULT_OK) {
+ viewModel.refreshAllTabs()
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ observeEvents()
+
+ setContent {
+ val tabStates by viewModel.tabStates.collectAsState()
+ AppThemeM3 {
+ CommentsRsListScreen(
+ tabStates = tabStates,
+ 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
+ )
+ }
+ }
+ }
+
+ private fun observeEvents() {
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.events.collect { event -> handleEvent(event) }
+ }
+ }
+ }
+
+ private fun handleEvent(event: CommentsRsListEvent) {
+ when (event) {
+ is CommentsRsListEvent.OpenCommentDetail -> detailLauncher.launch(
+ UnifiedCommentsDetailsActivity.createIntent(this, event.site, event.remoteCommentId)
+ )
+ is CommentsRsListEvent.ShowToast -> ToastUtils.showToast(this, event.messageResId)
+ is CommentsRsListEvent.Finish -> finish()
+ }
+ }
+
+ companion object {
+ fun createIntent(context: Context) = Intent(context, CommentsRsListActivity::class.java)
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListEvent.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListEvent.kt
new file mode 100644
index 000000000000..9fa94130cac3
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListEvent.kt
@@ -0,0 +1,10 @@
+package org.wordpress.android.ui.commentsrs
+
+import androidx.annotation.StringRes
+import org.wordpress.android.fluxc.model.SiteModel
+
+sealed interface CommentsRsListEvent {
+ data class OpenCommentDetail(val site: SiteModel, val remoteCommentId: Long) : CommentsRsListEvent
+ data class ShowToast(@StringRes val messageResId: Int) : CommentsRsListEvent
+ object Finish : CommentsRsListEvent
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListTab.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListTab.kt
new file mode 100644
index 000000000000..fa614ead0885
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListTab.kt
@@ -0,0 +1,55 @@
+package org.wordpress.android.ui.commentsrs
+
+import androidx.annotation.StringRes
+import org.wordpress.android.R
+import uniffi.wp_api.CommentStatus as RsCommentStatus
+
+/**
+ * Filter tabs for the rs comments list, matching the legacy unified list minus Unreplied
+ * (deferred; it needs client-side threading of ~100 comments).
+ *
+ * [queryStatus] is the `status` query param for `/wp/v2/comments`. Two tabs use
+ * [RsCommentStatus.Custom] because `WP_Comment_Query` only recognises the literal values
+ * `approve` and `all` — wordpress-rs serialises [RsCommentStatus.Approved] as `approved`,
+ * which WordPress core treats as an unknown status and returns nothing for (wordpress-rs
+ * `comments.rs` serialisation test asserts `status=approved`). `all` means approved+hold,
+ * matching the legacy ALL filter (APPROVED+UNAPPROVED).
+ */
+enum class CommentsRsListTab(
+ @StringRes val labelResId: Int,
+ @StringRes val emptyMessageResId: Int,
+ /** The `selected_filter` property value for COMMENT_FILTER_CHANGED, matching the legacy list. */
+ @StringRes val trackingLabelResId: Int,
+ val queryStatus: RsCommentStatus
+) {
+ ALL(
+ R.string.comment_status_all,
+ R.string.comments_empty_list,
+ R.string.comment_tracker_label_all,
+ RsCommentStatus.Custom("all")
+ ),
+ PENDING(
+ R.string.comment_status_unapproved,
+ R.string.comments_empty_list_filtered_pending,
+ R.string.comment_tracker_label_pending,
+ RsCommentStatus.Hold
+ ),
+ APPROVED(
+ R.string.comment_status_approved,
+ R.string.comments_empty_list_filtered_approved,
+ R.string.comment_tracker_label_approved,
+ RsCommentStatus.Custom("approve")
+ ),
+ SPAM(
+ R.string.comment_status_spam,
+ R.string.comments_empty_list_filtered_spam,
+ R.string.comment_tracker_label_spam,
+ RsCommentStatus.Spam
+ ),
+ TRASHED(
+ R.string.comment_status_trash,
+ R.string.comments_empty_list_filtered_trashed,
+ R.string.comment_tracker_label_trashed,
+ RsCommentStatus.Trash
+ )
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListUiState.kt
new file mode 100644
index 000000000000..758c456cfd56
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListUiState.kt
@@ -0,0 +1,27 @@
+package org.wordpress.android.ui.commentsrs
+
+import org.wordpress.android.fluxc.model.CommentStatus
+
+/** One comment row. */
+data class CommentRsUiModel(
+ val remoteCommentId: Long,
+ val authorName: String,
+ val avatarUrl: String,
+ val snippet: String,
+ val relativeDate: String,
+ val status: CommentStatus,
+ val postId: Long,
+ /** Resolved asynchronously in a batched request; null until then. */
+ val postTitle: String? = null
+) {
+ val isPending: Boolean get() = status == CommentStatus.UNAPPROVED
+}
+
+data class CommentsTabUiState(
+ val comments: List = emptyList(),
+ val isLoading: Boolean = false,
+ val isRefreshing: Boolean = false,
+ val isLoadingMore: Boolean = false,
+ val canLoadMore: Boolean = false,
+ val error: String? = null
+)
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
new file mode 100644
index 000000000000..5a52702b0a06
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModel.kt
@@ -0,0 +1,316 @@
+package org.wordpress.android.ui.commentsrs
+
+import androidx.annotation.MainThread
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Job
+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.launch
+import kotlinx.coroutines.withContext
+import org.wordpress.android.R
+import org.wordpress.android.analytics.AnalyticsTracker.Stat
+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.mysite.SelectedSiteRepository
+import org.wordpress.android.ui.postsrs.PostRsErrorUtils
+import org.wordpress.android.ui.postsrs.SnackbarMessage
+import org.wordpress.android.util.DateTimeUtilsWrapper
+import org.wordpress.android.util.HtmlUtils
+import org.wordpress.android.util.NetworkUtilsWrapper
+import org.wordpress.android.util.WPAvatarUtilsWrapper
+import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper
+import org.wordpress.android.viewmodel.ResourceProvider
+import uniffi.wp_api.CommentListParams
+import javax.inject.Inject
+import javax.inject.Named
+
+/**
+ * ViewModel for the wordpress-rs comments list. Unlike the postsrs/pagesrs screens there is no
+ * rs cache support for comments yet, so each tab pages directly against `/wp/v2/comments`,
+ * keeping the response's `nextPageParams` as the pagination cursor.
+ */
+@HiltViewModel
+class CommentsRsListViewModel @Inject constructor(
+ selectedSiteRepository: SelectedSiteRepository,
+ private val commentsRsDataSource: CommentsRsDataSource,
+ private val resourceProvider: ResourceProvider,
+ private val networkUtilsWrapper: NetworkUtilsWrapper,
+ private val dateTimeUtilsWrapper: DateTimeUtilsWrapper,
+ private val avatarUtilsWrapper: WPAvatarUtilsWrapper,
+ private val analyticsTracker: AnalyticsTrackerWrapper,
+ @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher
+) : ViewModel() {
+ private val _tabStates = MutableStateFlow