Skip to content

Commit 3884179

Browse files
authored
Add Unreplied tab to the RS comments list (#23133)
* Add Unreplied tab to the RS comments list Ports the legacy Unreplied filter to the wordpress-rs comments list without FluxC. A new UNREPLIED tab fetches status=all and is threaded client-side to keep only top-level comments the current user hasn't replied to. RsComment now carries authorId/parentId (view context has no author email, so "mine" is a user-id comparison); the current user id is read from the same cached retrieveMeWithEditContext fetch that backs the moderate capability. * Simplify Unreplied over-fetch: named val instead of .let * Fix review findings in the Unreplied tab - Seed the swipe session without a cursor for Unreplied so the detail pager can't page the raw stream into the replied-to/own comments the tab hides. - Auto-advance past a filtered-empty first page (and after a re-thread empties one) so unreplied matches on later pages aren't stranded behind a false empty state; also closes the cold-start race where currentUserId resolves after the first page applies. - Carry over resolved post titles when rebuilding rows so load-more no longer flashes titles and the re-thread no longer loses them permanently. - Prune the selection to still-visible rows after a re-thread. * Auto-advance Unreplied paging when a page shrinks the list to empty A load-more page can carry the user's reply to the last visible unreplied comment, dropping the visible count to zero. Widen the auto-advance guard from == to <= sizeBefore so paging continues instead of stranding a false empty state (there's no scroll trigger left once the list is empty).
1 parent 64302cb commit 3884179

12 files changed

Lines changed: 383 additions & 22 deletions

File tree

WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentsRsDataSource.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ class CommentsRsDataSource @Inject constructor(
5151
/** A comment mapped from [CommentWithViewContext], shared by the detail and list screens. */
5252
data class RsComment(
5353
val remoteCommentId: Long,
54+
// The commenter's WP user id (0 when anonymous), and the parent comment id (0 when
55+
// top-level). Both feed the Unreplied tab's client-side threading — the view context has
56+
// no author email, so "is this reply mine?" is a user-id comparison.
57+
val authorId: Long = 0,
58+
val parentId: Long = 0,
5459
val authorName: String,
5560
val authorAvatarUrl: String,
5661
val dateGmt: Date,
@@ -320,6 +325,8 @@ class CommentsRsDataSource @Inject constructor(
320325

321326
internal fun CommentWithViewContext.toRsComment() = CommentsRsDataSource.RsComment(
322327
remoteCommentId = id,
328+
authorId = author,
329+
parentId = parent,
323330
authorName = authorName,
324331
authorAvatarUrl = pickAvatarUrl(),
325332
// dateGmt is a UTC java.util.Date (an absolute instant), so relative-time formatting is

WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListTab.kt

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ import org.wordpress.android.R
55
import uniffi.wp_api.CommentStatus as RsCommentStatus
66

77
/**
8-
* Filter tabs for the rs comments list, matching the legacy unified list minus Unreplied
9-
* (deferred; it needs client-side threading of ~100 comments).
8+
* Filter tabs for the rs comments list, matching the legacy unified list.
109
*
11-
* [queryStatus] is the `status` query param for `/wp/v2/comments`. Two tabs use
10+
* [queryStatus] is the `status` query param for `/wp/v2/comments`. Three tabs use
1211
* [RsCommentStatus.Custom] because `WP_Comment_Query` only recognises the literal values
1312
* `approve` and `all` — wordpress-rs serialises [RsCommentStatus.Approved] as `approved`,
1413
* which WordPress core treats as an unknown status and returns nothing for (wordpress-rs
1514
* `comments.rs` serialisation test asserts `status=approved`). `all` means approved+hold,
1615
* matching the legacy ALL filter (APPROVED+UNAPPROVED).
16+
*
17+
* [UNREPLIED] has no server status: it queries `all` (like [ALL]) and is threaded client-side by
18+
* [filterUnreplied] to keep only top-level comments the user hasn't replied to, mirroring legacy.
1719
*/
1820
enum class CommentsRsListTab(
1921
@StringRes val labelResId: Int,
@@ -34,6 +36,12 @@ enum class CommentsRsListTab(
3436
R.string.comment_tracker_label_pending,
3537
RsCommentStatus.Hold
3638
),
39+
UNREPLIED(
40+
R.string.comment_status_unreplied,
41+
R.string.comments_empty_list_filtered_unreplied,
42+
R.string.comment_tracker_label_unreplied,
43+
RsCommentStatus.Custom("all")
44+
),
3745
APPROVED(
3846
R.string.comment_status_approved,
3947
R.string.comments_empty_list_filtered_approved,

WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListUiState.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ internal fun CommentsRsListTab.batchActions(): List<CommentsRsBatchAction> = whe
109109
CommentsRsBatchAction.SPAM,
110110
CommentsRsBatchAction.TRASH
111111
)
112+
// Unreplied rows are approved or pending comments, so offer the same actions as ALL.
113+
CommentsRsListTab.UNREPLIED -> listOf(
114+
CommentsRsBatchAction.APPROVE,
115+
CommentsRsBatchAction.UNAPPROVE,
116+
CommentsRsBatchAction.SPAM,
117+
CommentsRsBatchAction.TRASH
118+
)
112119
CommentsRsListTab.APPROVED -> listOf(
113120
CommentsRsBatchAction.UNAPPROVE,
114121
CommentsRsBatchAction.SPAM,

WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModel.kt

Lines changed: 94 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,16 @@ class CommentsRsListViewModel @Inject constructor(
103103
private val _canModerate = MutableStateFlow(false)
104104
val canModerate: StateFlow<Boolean> = _canModerate.asStateFlow()
105105

106+
// The current user's WP user id, resolved once on init (from the same "me" fetch as
107+
// canModerate); null until then, or if it couldn't be fetched. Used only to tell the user's
108+
// own replies apart when threading the Unreplied tab. Read and written only on the main thread.
109+
private var currentUserId: Long? = null
110+
111+
// Raw comments fetched per tab, accumulated across pages. The Unreplied tab derives its
112+
// displayed rows from this via client-side threading, so a reply and the parent it answers can
113+
// arrive on different pages and still be matched.
114+
private val rawComments = mutableMapOf<CommentsRsListTab, List<RsComment>>()
115+
106116
// Pagination cursors: the next-page params returned with each fetched page, per tab.
107117
private val nextPageParams = mutableMapOf<CommentsRsListTab, CommentListParams?>()
108118

@@ -142,7 +152,12 @@ class CommentsRsListViewModel @Inject constructor(
142152
_events.trySend(CommentsRsListEvent.Finish)
143153
} else {
144154
viewModelScope.launch {
155+
// Both values come from the same cached "me" fetch inside SiteCapabilityChecker.
145156
_canModerate.value = withContext(bgDispatcher) { siteCapabilityChecker.canModerateComments(site) }
157+
currentUserId = withContext(bgDispatcher) { siteCapabilityChecker.currentUserId(site) }
158+
// If the Unreplied tab loaded before the id resolved, re-thread it now that the
159+
// user's own replies can be identified.
160+
rethreadUnreplied()
146161
}
147162
@OptIn(FlowPreview::class)
148163
viewModelScope.launch {
@@ -220,6 +235,7 @@ class CommentsRsListViewModel @Inject constructor(
220235
loadMoreJobs.clear()
221236
resolveTitleJobs.values.forEach { it.cancel() }
222237
resolveTitleJobs.clear()
238+
rawComments.clear()
223239
nextPageParams.clear()
224240
pageGenerations.clear()
225241
_tabStates.value = emptyMap()
@@ -313,12 +329,14 @@ class CommentsRsListViewModel @Inject constructor(
313329
is RsCommentsPageResult.Success -> {
314330
val sizeBefore = getTabUiState(tab).comments.size
315331
applyPage(tab, result, append = true)
316-
// When a page adds no rows (it deduped away after a server-side shift, or
317-
// came back empty mid-shrink), the scroll position hasn't changed so the
318-
// load-more trigger won't re-fire; advance to the next page directly. The
319-
// depth cap keeps a pathological cursor chain (e.g. a proxy ignoring the
332+
// When a page doesn't grow the visible list, the scroll position hasn't moved
333+
// so the load-more trigger won't re-fire; advance to the next page directly.
334+
// `<=` (not `==`) also covers the shrink-to-empty case: an Unreplied page can
335+
// carry the user's reply to the last visible comment, dropping the visible
336+
// count to zero — with no rows there's no scroll trigger left to resume paging.
337+
// The depth cap keeps a pathological cursor chain (e.g. a proxy ignoring the
320338
// page param) from firing unbounded unattended requests.
321-
if (getTabUiState(tab).comments.size == sizeBefore &&
339+
if (getTabUiState(tab).comments.size <= sizeBefore &&
322340
nextPageParams[tab] != null &&
323341
autoAdvanceDepth < CommentBrowsingSession.MAX_AUTO_ADVANCE_PAGES
324342
) {
@@ -349,7 +367,11 @@ class CommentsRsListViewModel @Inject constructor(
349367
// it can swipe between them and keep loading more (the cursor can't cross an Intent).
350368
val tab = currentTab ?: CommentsRsListTab.ALL
351369
val ids = _tabStates.value[tab]?.comments?.map { it.remoteCommentId } ?: listOf(remoteCommentId)
352-
commentBrowsingSession.start(site, ids, nextPageParams[tab])
370+
// Unreplied's rows are a client-side-threaded subset of the raw stream; the session
371+
// pages the raw stream, so handing it the cursor would let the pager swipe into the
372+
// replied-to and own comments the tab hides. Seed only the visible ids, no cursor.
373+
val cursor = if (tab == CommentsRsListTab.UNREPLIED) null else nextPageParams[tab]
374+
commentBrowsingSession.start(site, ids, cursor)
353375
_events.trySend(CommentsRsListEvent.OpenCommentDetail(site, remoteCommentId))
354376
}
355377
}
@@ -492,13 +514,19 @@ class CommentsRsListViewModel @Inject constructor(
492514
private fun fetchFirstPage(tab: CommentsRsListTab, showErrorSnackbar: Boolean = true) {
493515
// Callers (initTab, refreshTab) guard against a null site, so the site getter below is safe.
494516
firstPageJobs[tab] = viewModelScope.launch {
495-
val params = commentsRsDataSource.firstPageParams(
517+
val base = commentsRsDataSource.firstPageParams(
496518
status = tab.queryStatus,
497519
search = _searchQuery.value.trim().ifBlank { null }
498520
)
521+
// Unreplied over-fetches (like the legacy list) since client-side threading discards
522+
// most of each page, so a single page still yields visible rows.
523+
val params = if (tab == CommentsRsListTab.UNREPLIED) base.copy(perPage = UNREPLIED_PAGE_SIZE) else base
499524
val result = withContext(bgDispatcher) { commentsRsDataSource.fetchCommentsPage(site, params) }
500525
when (result) {
501-
is RsCommentsPageResult.Success -> applyPage(tab, result, append = false)
526+
is RsCommentsPageResult.Success -> {
527+
applyPage(tab, result, append = false)
528+
advanceIfFilteredEmpty(tab)
529+
}
502530
is RsCommentsPageResult.Error -> onFirstPageError(tab, result.message, showErrorSnackbar)
503531
}
504532
}
@@ -512,12 +540,18 @@ class CommentsRsListViewModel @Inject constructor(
512540
private fun applyPage(tab: CommentsRsListTab, result: RsCommentsPageResult.Success, append: Boolean) {
513541
if (!append) pageGenerations[tab] = (pageGenerations[tab] ?: 0) + 1
514542
nextPageParams[tab] = result.nextPageParams
515-
val newRows = result.comments.map { it.toUiModel() }
543+
// A comment can shift pages if the list changed server-side between requests, so dedupe
544+
// the accumulated raw set on append. Displayed rows are always derived from this raw set,
545+
// which lets Unreplied thread a reply against a parent fetched on an earlier page.
546+
val raw = if (append) {
547+
(rawComments[tab].orEmpty() + result.comments).distinctBy { it.remoteCommentId }
548+
} else {
549+
result.comments
550+
}
551+
rawComments[tab] = raw
516552
updateTabUiState(tab) {
517553
copy(
518-
// A comment can shift pages if the list changed server-side between requests,
519-
// so dedupe on append.
520-
comments = if (append) (comments + newRows).distinctBy { it.remoteCommentId } else newRows,
554+
comments = displayRows(tab, raw),
521555
isLoading = false,
522556
isRefreshing = false,
523557
// A replacing page must clear isLoadingMore too: a load-more in flight when the
@@ -531,6 +565,50 @@ class CommentsRsListViewModel @Inject constructor(
531565
resolvePostTitles(tab)
532566
}
533567

568+
/**
569+
* Maps a tab's accumulated raw comments to displayed rows. The Unreplied tab is threaded
570+
* client-side to only the comments the user hasn't replied to; every other tab shows its
571+
* comments as-is.
572+
*/
573+
private fun displayRows(tab: CommentsRsListTab, raw: List<RsComment>): List<CommentRsUiModel> {
574+
// Rows are rebuilt from title-less RsComments, so carry over any post titles already
575+
// resolved for the tab (keyed by post — a title is the same for every comment on a post).
576+
// Without this, each rebuild (load-more, re-thread) would blank titles until resolvePostTitles
577+
// re-fetches them, and re-thread — which never re-resolves — would lose them for good.
578+
val knownTitles = getTabUiState(tab).comments
579+
.filter { it.postTitle != null }
580+
.associate { it.postId to it.postTitle }
581+
val visible = if (tab == CommentsRsListTab.UNREPLIED) filterUnreplied(raw, currentUserId) else raw
582+
return visible.map { it.toUiModel().copy(postTitle = knownTitles[it.postId]) }
583+
}
584+
585+
/** Re-derives the Unreplied tab's rows from its raw comments (e.g. once [currentUserId] resolves). */
586+
private fun rethreadUnreplied() {
587+
val tab = CommentsRsListTab.UNREPLIED
588+
val raw = rawComments[tab] ?: return
589+
val rows = displayRows(tab, raw)
590+
updateTabUiState(tab) { copy(comments = rows) }
591+
// Re-threading can drop rows that were over-reported while currentUserId was still null;
592+
// clear any selection for comments no longer shown so an off-screen id can't intercept
593+
// back presses or target a batch action (the same hazard refreshTab guards against).
594+
val visibleIds = rows.mapTo(mutableSetOf()) { it.remoteCommentId }
595+
_selectedIds.update { it intersect visibleIds }
596+
// Re-threading with the now-known id can empty a page that previously over-reported, so
597+
// keep paging rather than stranding matches behind a false empty state.
598+
advanceIfFilteredEmpty(tab)
599+
}
600+
601+
/**
602+
* Keeps paging while a filtered tab (Unreplied) shows no rows but the raw stream has more:
603+
* a full page can thread away to nothing, and the list's load-more trigger only fires once
604+
* rows are on screen. Bounded by the same auto-advance cap as load-more.
605+
*/
606+
private fun advanceIfFilteredEmpty(tab: CommentsRsListTab) {
607+
if (getTabUiState(tab).comments.isEmpty() && nextPageParams[tab] != null) {
608+
loadMoreInternal(tab, autoAdvanceDepth = 0)
609+
}
610+
}
611+
534612
/**
535613
* First-page failure: when the tab already shows comments keep them and offer a retry
536614
* snackbar; when it's empty, show the full-screen error state. Silent refreshes (returning
@@ -610,5 +688,9 @@ class CommentsRsListViewModel @Inject constructor(
610688

611689
private const val SEARCH_DEBOUNCE_MS = 250L
612690
private const val MIN_SEARCH_QUERY_LENGTH = 3
691+
692+
// Larger page for the Unreplied tab (matching the legacy list) to offset the rows that
693+
// client-side threading filters out.
694+
private const val UNREPLIED_PAGE_SIZE = 100u
613695
}
614696
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package org.wordpress.android.ui.commentsrs
2+
3+
import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsComment
4+
5+
/**
6+
* Client-side "Unreplied" filter for the rs comments list, mirroring the legacy
7+
* `UnrepliedCommentsUtils`. A comment is unreplied when it is a top-level comment
8+
* ([RsComment.parentId] == 0) that the current user did not write and that has no direct reply
9+
* written by the current user.
10+
*
11+
* Threading is computed over whatever comments have been loaded so far, so — exactly like the
12+
* legacy list — a reply that lives on a not-yet-loaded page can briefly leave its parent showing
13+
* as unreplied until that page arrives.
14+
*
15+
* "Mine" is a user-id comparison ([RsComment.authorId] == [myUserId]) because the view context
16+
* carries no author email. When [myUserId] is null (the "me" fetch failed), nothing counts as
17+
* mine and the filter over-reports rather than hiding comments — the same fallback the legacy
18+
* util has when it can't resolve the account email.
19+
*/
20+
internal fun filterUnreplied(comments: List<RsComment>, myUserId: Long?): List<RsComment> {
21+
val myReplyParentIds = comments
22+
.filter { it.parentId != 0L && isMine(it, myUserId) }
23+
.map { it.parentId }
24+
.toSet()
25+
return comments.filter { comment ->
26+
comment.parentId == 0L &&
27+
!isMine(comment, myUserId) &&
28+
comment.remoteCommentId !in myReplyParentIds
29+
}
30+
}
31+
32+
private fun isMine(comment: RsComment, myUserId: Long?): Boolean =
33+
myUserId != null && comment.authorId == myUserId

WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListScreen.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ fun CommentsRsListScreen(
110110
val listStates = mapOf(
111111
CommentsRsListTab.ALL to rememberLazyListState(),
112112
CommentsRsListTab.PENDING to rememberLazyListState(),
113+
CommentsRsListTab.UNREPLIED to rememberLazyListState(),
113114
CommentsRsListTab.APPROVED to rememberLazyListState(),
114115
CommentsRsListTab.SPAM to rememberLazyListState(),
115116
CommentsRsListTab.TRASHED to rememberLazyListState()

WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/SiteCapabilityChecker.kt

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ class SiteCapabilityChecker @Inject constructor(
4242
suspend fun canModerateComments(site: SiteModel): Boolean =
4343
capabilities(site).canModerateComments
4444

45+
/**
46+
* The current user's WP user id for the given site, or null if it couldn't be resolved. Read
47+
* from the same "me" fetch as the capabilities above (no extra request). The rs comments list
48+
* uses it to tell the user's own replies apart when computing the Unreplied filter.
49+
*/
50+
suspend fun currentUserId(site: SiteModel): Long? =
51+
capabilities(site).currentUserId
52+
4553
private suspend fun capabilities(site: SiteModel): CapabilityCache {
4654
capabilityCache[site.id]?.let { return it }
4755
// Only a successful fetch is cached. On failure fall back to a fail-closed value for this
@@ -59,10 +67,11 @@ class SiteCapabilityChecker @Inject constructor(
5967
}
6068
when (response) {
6169
is WpRequestResult.Success -> {
62-
val capabilities = response.response.data.capabilities
70+
val user = response.response.data
6371
CapabilityCache(
64-
hasEditThemeOptions = capabilities.hasCap(UserCapability.EditThemeOptions),
65-
canModerateComments = capabilities.hasCap(UserCapability.ModerateComments)
72+
hasEditThemeOptions = user.capabilities.hasCap(UserCapability.EditThemeOptions),
73+
canModerateComments = user.capabilities.hasCap(UserCapability.ModerateComments),
74+
currentUserId = user.id
6675
)
6776
}
6877
// Return null (not cached) so the next call retries rather than locking in a
@@ -84,6 +93,7 @@ class SiteCapabilityChecker @Inject constructor(
8493

8594
private data class CapabilityCache(
8695
val hasEditThemeOptions: Boolean = false,
87-
val canModerateComments: Boolean = false
96+
val canModerateComments: Boolean = false,
97+
val currentUserId: Long? = null
8898
)
8999
}

WordPress/src/test/java/org/wordpress/android/ui/comments/unified/CommentsRsListMappingTest.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class CommentsRsListMappingTest {
2020
val item = rsComment().toRsComment()
2121

2222
assertThat(item.remoteCommentId).isEqualTo(COMMENT_ID)
23+
assertThat(item.authorId).isEqualTo(7L)
24+
assertThat(item.parentId).isEqualTo(0L)
2325
assertThat(item.authorName).isEqualTo("Jane")
2426
assertThat(item.authorAvatarUrl).isEqualTo("https://example.com/avatar96.png")
2527
assertThat(item.dateGmt).isEqualTo(DATE_GMT)
@@ -29,6 +31,11 @@ class CommentsRsListMappingTest {
2931
assertThat(item.status).isEqualTo(APPROVED)
3032
}
3133

34+
@Test
35+
fun `toRsComment carries the parent id used for Unreplied threading`() {
36+
assertThat(rsComment(parent = 42L).toRsComment().parentId).isEqualTo(42L)
37+
}
38+
3239
@Test
3340
fun `pickAvatarUrl prefers size 96`() {
3441
val comment = rsComment(
@@ -73,6 +80,7 @@ class CommentsRsListMappingTest {
7380

7481
private fun rsComment(
7582
status: RsCommentStatus = RsCommentStatus.Approved,
83+
parent: Long = 0L,
7684
avatarUrls: Map<UserAvatarSize, String> = mapOf(
7785
UserAvatarSize.Size96 to "https://example.com/avatar96.png"
7886
)
@@ -85,7 +93,7 @@ class CommentsRsListMappingTest {
8593
date = "2026-07-01T12:00:00",
8694
dateGmt = DATE_GMT,
8795
link = "https://example.com/post/#comment-42",
88-
parent = 0L,
96+
parent = parent,
8997
post = POST_ID,
9098
status = status,
9199
commentType = CommentType.Comment,

0 commit comments

Comments
 (0)