Skip to content

Commit 44f24ca

Browse files
authored
RS comments fixes (#23118)
* Guard write/entry actions in the RS comments screens A review of the wordpress-rs comments feature surfaced four defects sharing one root cause: write and entry actions missing the in-flight / lifecycle guards the like and reply paths already have. - Detail: guard moderation against a double-tap that derives its target from the optimistic status, which fired two racing writes and could desync UI vs server. - Edit: on a load error keep the progress spinner up instead of flashing an empty editable form (which also opened a save-clobber window). - Edit: ignore back/discard while a save is in flight — the edit is already dispatched and would land regardless. - List: bail out of the first-page fetch when no site is selected so the site getter can't throw while init races its async Finish. * Suppress ReturnCount on moderateComment The added in-flight guard gives moderateComment a third early return; suppress ReturnCount to match the neighboring onLikeClicked / onReplyClicked handlers. * Address review feedback on the RS comments guards Follow-up to the write/entry-action guards, fixing gaps a reviewer found: - List: move the no-selected-site guard from fetchFirstPage up to the initTab and refreshTab entry points. The old placement let initTab register a permanently-loading tab and let refreshTab reach clearPostTitles(site) — the same requireNotNull crash — before the deeper guard ran. - Edit: pressing back while a save is in flight now surfaces a "saving" message instead of silently swallowing the press, so a stalled save no longer feels frozen. - Edit: a failed comment load now records a terminal state in the retained ui state (rendered as an error, never the empty form) rather than delegating the close to a one-shot snackbar/action event, which could be dropped on a null context or an Activity recreation and strand the screen on a spinner. * Tighten RS comments guards after self-review - Add a test that a second moderation is allowed once the first completes, covering the in-flight flag's finally reset (a stuck flag would otherwise silently ignore all later moderations with green tests). - Show the "saving" back-press message only once per in-flight save so repeated presses don't queue a stack of identical snackbars. - Fix a now-stale doc comment: only the comment-detail load-error holder still relies on onDismissAction to close the screen. * Guard the implicit approve-after-reply against racing moderations approveAfterReply() called moderate(APPROVED) directly, bypassing the isModerationInProgress guard, so an approve/spam/trash tapped during the auto-approve's network call could fire a second, conflicting updateStatus and desync the UI from the server. Hold the same guard across approveAfterReply's moderate() call, and add a test that a moderation tapped during that window is ignored.
1 parent c3f35fb commit 44f24ca

8 files changed

Lines changed: 209 additions & 34 deletions

File tree

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

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
100100
private var noteId: String? = null
101101
private var loadedComment: RsComment? = null
102102
private var isLikeInProgress = false
103+
private var isModerationInProgress = false
103104

104105
fun start(site: SiteModel, remoteCommentId: Long, noteId: String? = null) {
105106
if (isStarted) return
@@ -283,42 +284,62 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
283284
}
284285

285286
private suspend fun approveAfterReply() {
286-
_uiState.value = _uiState.value?.copy(status = APPROVED)
287-
val result = withContext(bgDispatcher) { moderate(APPROVED) }
288-
if (result is RsResult.Error) {
289-
_uiState.value = _uiState.value?.copy(status = UNAPPROVED)
290-
} else {
291-
// Match the legacy screen, which tracks the implicit approve when replying to an
292-
// unapproved comment (this path is only reached from an unapproved comment).
293-
trackCommentAction(Stat.COMMENT_APPROVED)
294-
if (noteId != null) {
295-
_commentModerated.value = Event(APPROVED)
287+
// Hold the same guard moderateComment uses so this implicit approve can't race a user
288+
// moderation: skip if one is already in flight, and keep the flag set across our own
289+
// (multi-second) network call so an approve/spam/trash tapped during it is ignored rather
290+
// than firing a second, conflicting updateStatus that would desync the UI and server.
291+
if (isModerationInProgress) return
292+
isModerationInProgress = true
293+
try {
294+
_uiState.value = _uiState.value?.copy(status = APPROVED)
295+
val result = withContext(bgDispatcher) { moderate(APPROVED) }
296+
if (result is RsResult.Error) {
297+
_uiState.value = _uiState.value?.copy(status = UNAPPROVED)
298+
} else {
299+
// Match the legacy screen, which tracks the implicit approve when replying to an
300+
// unapproved comment (this path is only reached from an unapproved comment).
301+
trackCommentAction(Stat.COMMENT_APPROVED)
302+
if (noteId != null) {
303+
_commentModerated.value = Event(APPROVED)
304+
}
296305
}
306+
} finally {
307+
isModerationInProgress = false
297308
}
298309
}
299310

311+
@Suppress("ReturnCount")
300312
private fun moderateComment(newStatus: CommentStatus, closeOnSuccess: Boolean) {
301313
// The action footer stays visible while the comment loads, so ignore taps until then:
302314
// before the load completes the ui state holds a default status and the toggle handlers
303315
// would compute (and apply server-side) the wrong target status.
304316
if (loadedComment == null) return
305317
if (isOffline()) return
318+
// Guard against a second moderation while one is in flight (fast double-tap): the target
319+
// status is derived from the optimistic ui state, so racing requests could compute (and
320+
// apply server-side) conflicting statuses and leave the UI and server out of sync.
321+
if (isModerationInProgress) return
306322
val previousStatus = currentStatus()
323+
isModerationInProgress = true
307324
launch {
308-
_uiState.value = _uiState.value?.copy(status = newStatus)
309-
val result = withContext(bgDispatcher) { moderate(newStatus) }
310-
if (result is RsResult.Error) {
311-
_uiState.value = _uiState.value?.copy(status = previousStatus)
312-
showError(result.message, R.string.error_moderate_comment)
313-
} else {
314-
moderationStat(previousStatus, newStatus)?.let { trackCommentAction(it) }
315-
_commentChanged.value = Event(Unit)
316-
if (noteId != null) {
317-
_commentModerated.value = Event(newStatus)
318-
}
319-
if (closeOnSuccess) {
320-
_uiActionEvent.value = Event(Close)
325+
try {
326+
_uiState.value = _uiState.value?.copy(status = newStatus)
327+
val result = withContext(bgDispatcher) { moderate(newStatus) }
328+
if (result is RsResult.Error) {
329+
_uiState.value = _uiState.value?.copy(status = previousStatus)
330+
showError(result.message, R.string.error_moderate_comment)
331+
} else {
332+
moderationStat(previousStatus, newStatus)?.let { trackCommentAction(it) }
333+
_commentChanged.value = Event(Unit)
334+
if (noteId != null) {
335+
_commentModerated.value = Event(newStatus)
336+
}
337+
if (closeOnSuccess) {
338+
_uiActionEvent.value = Event(Close)
339+
}
321340
}
341+
} finally {
342+
isModerationInProgress = false
322343
}
323344
}
324345
}

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

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ class UnifiedCommentsEditViewModel @Inject constructor(
7171
// @Volatile covers the cross-thread clear.
7272
@Volatile
7373
private var isSaving = false
74+
// Guards against queuing a stack of identical "saving" snackbars on repeated back-presses
75+
// during a single in-flight save; reset when a new save starts.
76+
private var savingBackNoticeShown = false
7477

7578
private lateinit var site: SiteModel
7679

@@ -86,6 +89,9 @@ class UnifiedCommentsEditViewModel @Inject constructor(
8689
data class EditCommentUiState(
8790
val canSaveChanges: Boolean = false,
8891
val showProgress: Boolean = false,
92+
// A terminal "couldn't load the comment" state: the screen shows an error (not the form),
93+
// held in retained state so a dropped snackbar/close event can't strand it on a spinner.
94+
val loadFailed: Boolean = false,
8995
val progressText: UiString? = null,
9096
// Lives in ui state (not a one-shot event) so the dialog survives configuration changes,
9197
// like the DialogFragment it replaced.
@@ -171,6 +177,7 @@ class UnifiedCommentsEditViewModel @Inject constructor(
171177
_uiState.value?.let { uiState ->
172178
val editedCommentEssentials = uiState.editedComment
173179
isSaving = true
180+
savingBackNoticeShown = false
174181
launch(bgDispatcher) {
175182
try {
176183
setLoadingState(SAVING)
@@ -183,6 +190,17 @@ class UnifiedCommentsEditViewModel @Inject constructor(
183190
}
184191

185192
fun onBackPressed() {
193+
// A save already in flight can't be discarded — the edit has been dispatched and will land
194+
// server-side regardless. Surface that instead of silently swallowing the press, so a save
195+
// that stalls to a network timeout doesn't leave the screen feeling frozen with no feedback.
196+
// Only once per save, so repeated presses don't queue a stack of identical snackbars.
197+
if (isSaving) {
198+
if (!savingBackNoticeShown) {
199+
savingBackNoticeShown = true
200+
_onSnackbarMessage.value = Event(SnackbarMessageHolder(UiStringRes(R.string.saving_changes)))
201+
}
202+
return
203+
}
186204
_uiState.value?.let {
187205
if (it.editedComment.isNotEqualTo(it.originalComment)) {
188206
_uiState.value = it.copy(showDiscardDialog = true)
@@ -216,14 +234,20 @@ class UnifiedCommentsEditViewModel @Inject constructor(
216234
originalComment = commentEssentials,
217235
editedComment = commentEssentials
218236
)
237+
delay(LOADING_DELAY_MS)
238+
setLoadingState(NOT_VISIBLE)
219239
} else {
220-
_onSnackbarMessage.value = Event(SnackbarMessageHolder(
221-
message = UiStringRes(R.string.error_load_comment),
222-
onDismissAction = { _uiActionEvent.value = Event(CLOSE) }
223-
))
240+
// The comment couldn't be loaded. Record the failure in the retained ui state
241+
// (not just a one-shot event): showSnackbar bails when the fragment has no context,
242+
// and the snackbar/close Events don't survive an Activity recreation — either would
243+
// otherwise strand the screen on a spinner forever. The screen renders a terminal
244+
// error from this state (never the empty form), which the user backs out of normally.
245+
_uiState.value = (_uiState.value ?: EditCommentUiState()).copy(
246+
showProgress = false,
247+
loadFailed = true
248+
)
249+
_onSnackbarMessage.value = Event(SnackbarMessageHolder(UiStringRes(R.string.error_load_comment)))
224250
}
225-
delay(LOADING_DELAY_MS)
226-
setLoadingState(NOT_VISIBLE)
227251
}
228252
}
229253

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ fun UnifiedCommentEditScreen(
105105
)
106106
}
107107
}
108+
} else if (uiState.loadFailed) {
109+
// Terminal load-error state: show the reason instead of an empty, editable form.
110+
Text(
111+
text = stringResource(R.string.error_load_comment),
112+
modifier = Modifier
113+
.align(Alignment.Center)
114+
.padding(16.dp)
115+
)
108116
} else {
109117
EditFields(uiState, onFieldChange, scrollState)
110118
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,12 @@ class CommentsRsListViewModel @Inject constructor(
216216

217217
/** Loads the first page for [tab] unless it's already initialized. */
218218
@MainThread
219+
@Suppress("ReturnCount")
219220
fun initTab(tab: CommentsRsListTab) {
221+
// No selected site: init already queued a toast + Finish. Bail before registering the tab
222+
// so we neither strand a permanently-loading tab nor let a later refreshTab reach the
223+
// site getter (requireNotNull) via clearPostTitles.
224+
if (_site == null) return
220225
// updateTabUiState inserts the tab synchronously, so this also blocks re-entry while
221226
// the first fetch is in flight.
222227
if (_tabStates.value.containsKey(tab)) return
@@ -234,7 +239,11 @@ class CommentsRsListViewModel @Inject constructor(
234239
* keep the current list on screen until the new page arrives.
235240
*/
236241
@MainThread
242+
@Suppress("ReturnCount")
237243
fun refreshTab(tab: CommentsRsListTab, isUserRefresh: Boolean = false) {
244+
// As in initTab: with no selected site the clearPostTitles(site) call below would hit the
245+
// requireNotNull. Guard the entry point rather than deeper down.
246+
if (_site == null) return
238247
if (!_tabStates.value.containsKey(tab)) return
239248
// A first page is already on its way (initial load or another refresh); a second fetch
240249
// would only duplicate the request and double-bump the page generation.
@@ -467,6 +476,7 @@ class CommentsRsListViewModel @Inject constructor(
467476
}
468477

469478
private fun fetchFirstPage(tab: CommentsRsListTab, showErrorSnackbar: Boolean = true) {
479+
// Callers (initTab, refreshTab) guard against a null site, so the site getter below is safe.
470480
firstPageJobs[tab] = viewModelScope.launch {
471481
val params = commentsRsDataSource.firstPageParams(
472482
status = tab.queryStatus,

WordPress/src/main/java/org/wordpress/android/ui/compose/utils/SnackbarHostStateExtensions.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import org.wordpress.android.ui.utils.UiHelpers
1313
* Shows a [SnackbarMessageHolder] on a Compose [SnackbarHostState], bridging the View-world Snackbar
1414
* length constants and dismiss-event codes the holder carries. onDismissAction fires from a finally
1515
* so it still runs if the coroutine is cancelled (e.g. the view is torn down while the snackbar is
16-
* showing) — the load-error holder relies on this to close the screen.
16+
* showing) — the comment-detail load-error holder relies on this to close the screen.
1717
*
1818
* Call from the caller's own coroutine scope, e.g.
1919
* `viewLifecycleOwner.lifecycleScope.launch { snackbarHostState.showMessage(holder, context, uiHelpers) }`.

WordPress/src/test/java/org/wordpress/android/ui/comments/viewmodels/UnifiedCommentDetailsViewModelTest.kt

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,59 @@ class UnifiedCommentDetailsViewModelTest : BaseUnitTest() {
279279
verify(commentsStore, times(0)).likeComment(site, REMOTE_COMMENT_ID, null, false)
280280
}
281281

282+
@Test
283+
fun `onApproveClicked ignores a second tap while a moderation is in flight`() = test {
284+
whenever(commentsRsDataSource.updateStatus(eq(site), eq(REMOTE_COMMENT_ID), any()))
285+
.doSuspendableAnswer {
286+
delay(LOAD_DELAY_MS)
287+
RsResult.Success
288+
}
289+
viewModel.start(site, REMOTE_COMMENT_ID)
290+
291+
viewModel.onApproveClicked()
292+
viewModel.onApproveClicked()
293+
advanceUntilIdle()
294+
295+
verify(commentsRsDataSource, times(1)).updateStatus(site, REMOTE_COMMENT_ID, UNAPPROVED)
296+
verify(commentsRsDataSource, times(0)).updateStatus(site, REMOTE_COMMENT_ID, APPROVED)
297+
}
298+
299+
@Test
300+
fun `a moderation tapped during the auto-approve after a reply is ignored`() = test {
301+
// Replying to an unapproved comment fires an implicit moderate(APPROVED). A moderation
302+
// tapped while that (suspended) approve is in flight must not race a second write.
303+
whenever(commentsRsDataSource.getComment(site, REMOTE_COMMENT_ID)).thenReturn(UNAPPROVED_RS_COMMENT)
304+
whenever(commentsRsDataSource.updateStatus(eq(site), eq(REMOTE_COMMENT_ID), any()))
305+
.doSuspendableAnswer {
306+
delay(LOAD_DELAY_MS)
307+
RsResult.Success
308+
}
309+
viewModel.start(site, REMOTE_COMMENT_ID)
310+
311+
viewModel.onReplyClicked("nice post")
312+
// The reply has completed and the auto-approve's moderate(APPROVED) is now suspended.
313+
viewModel.onApproveClicked()
314+
advanceUntilIdle()
315+
316+
verify(commentsRsDataSource, times(1)).updateStatus(site, REMOTE_COMMENT_ID, APPROVED)
317+
verify(commentsRsDataSource, times(0)).updateStatus(site, REMOTE_COMMENT_ID, UNAPPROVED)
318+
}
319+
320+
@Test
321+
fun `a second moderation is allowed once the first has completed`() = test {
322+
// Guards against the in-flight flag getting stuck true (e.g. a dropped finally reset), which
323+
// would silently ignore every later moderation. The first call completes before the second.
324+
viewModel.start(site, REMOTE_COMMENT_ID)
325+
326+
viewModel.onApproveClicked()
327+
advanceUntilIdle()
328+
viewModel.onApproveClicked()
329+
advanceUntilIdle()
330+
331+
verify(commentsRsDataSource).updateStatus(site, REMOTE_COMMENT_ID, UNAPPROVED)
332+
verify(commentsRsDataSource).updateStatus(site, REMOTE_COMMENT_ID, APPROVED)
333+
}
334+
282335
@Test
283336
fun `onEditClicked emits launch edit event with site comment identifier`() = test {
284337
viewModel.start(site, REMOTE_COMMENT_ID)

WordPress/src/test/java/org/wordpress/android/ui/comments/viewmodels/UnifiedCommentsEditViewModelTest.kt

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.wordpress.android.ui.comments.viewmodels
22

33
import kotlinx.coroutines.ExperimentalCoroutinesApi
4+
import kotlinx.coroutines.delay
45
import kotlinx.coroutines.test.advanceUntilIdle
56
import org.assertj.core.api.Assertions.assertThat
67
import org.junit.Assert.assertEquals
@@ -9,6 +10,7 @@ import org.junit.Test
910
import org.mockito.Mock
1011
import org.mockito.kotlin.any
1112
import org.mockito.kotlin.check
13+
import org.mockito.kotlin.doSuspendableAnswer
1214
import org.mockito.kotlin.eq
1315
import org.mockito.kotlin.mock
1416
import org.mockito.kotlin.never
@@ -172,22 +174,31 @@ class UnifiedCommentsEditViewModelTest : BaseUnitTest() {
172174
}
173175

174176
@Test
175-
fun `Should map CommentIdentifier to default CommentEssentials if CommentIdentifier comment not found`() = test {
177+
fun `Should show a terminal load-failed state and not an empty form if comment not found`() = test {
176178
whenever(getCommentUseCase.execute(site, remoteCommentId))
177179
.thenReturn(null)
178180
viewModel.start(site, siteCommentIdentifier)
179181
advanceUntilIdle()
180182

181-
assertThat(uiState[1].editedComment).isEqualTo(CommentEssentials())
183+
// On load failure the screen shows a terminal error (retained state), never the empty,
184+
// editable form, and the spinner is cleared so it can't strand.
185+
val last = uiState.last()
186+
assertThat(last.loadFailed).isTrue
187+
assertThat(last.showProgress).isFalse
188+
assertThat(last.editedComment).isEqualTo(CommentEssentials())
189+
assertThat(onSnackbarMessage.firstOrNull()).isNotNull
182190
}
183191

184192
@Test
185-
fun `Should map CommentIdentifier to default CommentEssentials if CommentIdentifier not handled`() = test {
193+
fun `Should show a terminal load-failed state if CommentIdentifier not handled`() = test {
186194
// ReaderCommentIdentifier is not supported by this class yet
187195
viewModel.start(site, ReaderCommentIdentifier(0L, 0L, 0L))
188196
advanceUntilIdle()
189197

190-
assertThat(uiState[1].editedComment).isEqualTo(CommentEssentials())
198+
val last = uiState.last()
199+
assertThat(last.loadFailed).isTrue
200+
assertThat(last.showProgress).isFalse
201+
assertThat(onSnackbarMessage.firstOrNull()).isNotNull
191202
}
192203

193204
@Test
@@ -421,6 +432,36 @@ class UnifiedCommentsEditViewModelTest : BaseUnitTest() {
421432
assertThat(uiActionEvent.firstOrNull()).isEqualTo(CLOSE)
422433
}
423434

435+
@Test
436+
fun `onBackPressed is ignored while a save is in flight`() = test {
437+
whenever(commentsStore.getCommentByLocalSiteAndRemoteId(site.id, remoteCommentId))
438+
.thenReturn(listOf(COMMENT_ENTITY))
439+
// A suspending save keeps isSaving true across the back press.
440+
whenever(commentsStore.updateEditComment(eq(site), any())).doSuspendableAnswer {
441+
delay(SAVE_DELAY_MS)
442+
CommentsActionPayload(CommentsActionData(emptyList(), 0))
443+
}
444+
val emailFieldType: FieldType = mock()
445+
whenever(emailFieldType.matches(USER_EMAIL)).thenReturn(true)
446+
whenever(emailFieldType.isValid).thenReturn { true }
447+
448+
viewModel.start(site, siteCommentIdentifier)
449+
// Make an edit so that, without the guard, back would open the discard dialog.
450+
viewModel.onValidateField("edited user email", emailFieldType)
451+
// The save is in flight (suspended), so isSaving is still true when back is pressed.
452+
viewModel.onActionMenuClicked()
453+
viewModel.onBackPressed()
454+
viewModel.onBackPressed()
455+
456+
// Back is swallowed (no discard dialog, no close) but surfaces a "saving" message instead
457+
// of silently doing nothing — and only once, so repeated presses don't queue duplicates.
458+
assertThat(uiState.last().showDiscardDialog).isFalse
459+
assertThat(uiActionEvent).doesNotContain(CLOSE)
460+
assertThat(onSnackbarMessage.filter { it.message == UiStringRes(R.string.saving_changes) }).hasSize(1)
461+
462+
advanceUntilIdle()
463+
}
464+
424465
@Test
425466
fun `Should update notification entity on save if NotificationCommentIdentifier`() = test {
426467
whenever(commentsStore.getCommentByLocalSiteAndRemoteId(site.id, remoteCommentId))
@@ -619,6 +660,7 @@ class UnifiedCommentsEditViewModelTest : BaseUnitTest() {
619660
companion object {
620661
private const val LOCAL_SITE_ID = 123
621662
private const val REMOTE_SITE_ID = 456L
663+
private const val SAVE_DELAY_MS = 1000L
622664

623665
private val COMMENT_ENTITY = CommentEntity(
624666
id = 1000,

0 commit comments

Comments
 (0)