@@ -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}
0 commit comments