Skip to content

Commit dfbc39f

Browse files
authored
Gate RS comment moderation on the moderate_comments capability (#23123)
* Gate RS comment moderation on the moderate_comments capability Non-admin users were shown fully active moderation controls in the new RS comments screens; tapping them only failed server-side with a 403. Fetch the current user's moderate_comments capability via wordpress-rs (reusing SiteCapabilityChecker) and, when absent, disable and dim the moderation controls on both the detail footer and the list's batch actions. Like, copy/share link and reply stay active. The capability handlers are also guarded in the ViewModels as defense-in-depth. SiteCapabilityChecker now keys its cache by the local site id instead of site.siteId, which is 0 for every app-password self-hosted site and would otherwise collide their capabilities. * Suppress ReturnCount detekt warning on onEditClicked * Gray out disabled moderation controls in RS comment footer Show the accent tint only on enabled on-toggles (approved/liked) and let disabled moderation controls fall back to the grayed-out disabled color, including the destructive red menu items, so a non-moderator no longer sees tappable-looking controls. * Address RS comment moderation review findings - Always attempt the implicit approve when replying to an unapproved comment, rather than gating on the async-fetched canModerate flag: a moderator replying before the capability resolved (e.g. from a notification with the reply field focused) would otherwise silently drop the approve. approveAfterReply() reverts on server rejection, so a non-moderator's reply self-heals either way. - Don't cache a failed capability fetch: return a fail-closed value for that call but retry on the next one, so a transient error no longer disables moderation for the whole session. - Use a ConcurrentHashMap for the capability cache now that the singleton is read/written from multiple ViewModels on the default dispatcher. - Rework SiteCapabilityCheckerTest with a success-path mock covering the capability mapping, single-fetch caching, per-site keying, and the failed-fetch-is-retried behavior. * Assign moderation capability directly to the field Drop the shadowing local and the qualified receiver in loadModerationCapability by assigning the fetch result straight to the canModerate field. * Drop data-class mocks from SiteCapabilityCheckerTest The success-path mock helper mocked wordpress-rs data classes, tripping the DoNotMockDataClass lint rule (Debug-variant lint, which CI runs for PRs). Revert to failure-path-only tests: they cover the fail-closed and no-cache-on-failure behavior without mocking data classes. The success path can't be unit-tested here (UserCapabilitiesMap.hasCap is native) and is covered by the ViewModel tests. * Gate RS comments on application password only, like RS posts/pages shouldUseRsComments admitted WP.com-accessed sites too; the RS posts and pages gates both use application password only. Drop the isUsingWpComRestApi branch so the three RS gates are consistent — RS comments are now used only for self-hosted sites with an application password (still behind the RS_UNIFIED_COMMENTS flag, matching the pages gate).
1 parent f01ecdc commit dfbc39f

13 files changed

Lines changed: 336 additions & 64 deletions

File tree

WordPress/src/main/java/org/wordpress/android/ui/ActivityLauncher.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -753,9 +753,10 @@ public static void viewUnifiedComments(Context context, SiteModel site) {
753753
* be used for {@code site} instead of the legacy (FluxC) ones.
754754
*/
755755
public static boolean shouldUseRsComments(@NonNull Context context, @NonNull SiteModel site) {
756-
// The rs client can only authenticate WP.com-accessed sites or self-hosted sites with an
757-
// application password; everywhere else keep the legacy (FluxC) comments screens.
758-
if (!site.isUsingWpComRestApi() && !site.hasApplicationPassword()) {
756+
// Match the RS posts/pages gate: use the rs comments screens only for self-hosted sites
757+
// with an application password, and keep the legacy (FluxC) comments screens everywhere
758+
// else (including WP.com-accessed sites).
759+
if (!site.hasApplicationPassword()) {
759760
return false;
760761
}
761762
ActivityLauncherEntryPoint entryPoint = EntryPointAccessors.fromApplication(

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

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import org.wordpress.android.ui.comments.unified.CommentIdentifier.NotificationC
2828
import org.wordpress.android.ui.comments.unified.CommentIdentifier.SiteCommentIdentifier
2929
import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsComment
3030
import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsResult
31+
import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker
3132
import org.wordpress.android.ui.notifications.utils.NotificationsActionsWrapper
3233
import org.wordpress.android.ui.pages.SnackbarMessageHolder
3334
import org.wordpress.android.ui.utils.UiString
@@ -63,6 +64,7 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
6364
@Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher,
6465
private val commentsRsDataSource: CommentsRsDataSource,
6566
private val commentsStore: CommentsStore,
67+
private val siteCapabilityChecker: SiteCapabilityChecker,
6668
private val localCommentCacheUpdateHandler: LocalCommentCacheUpdateHandler,
6769
private val networkUtilsWrapper: NetworkUtilsWrapper,
6870
private val dateTimeUtilsWrapper: DateTimeUtilsWrapper,
@@ -102,13 +104,26 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
102104
private var isLikeInProgress = false
103105
private var isModerationInProgress = false
104106

107+
// Whether the current user may moderate comments on this site (moderate_comments capability).
108+
// Fetched asynchronously in [start]; false until it resolves so the moderation controls start
109+
// disabled and enable once confirmed, rather than flashing enabled then greying out.
110+
private var canModerate = false
111+
105112
fun start(site: SiteModel, remoteCommentId: Long, noteId: String? = null) {
106113
if (isStarted) return
107114
isStarted = true
108115
this.site = site
109116
this.remoteCommentId = remoteCommentId
110117
this.noteId = noteId
111118
loadComment()
119+
loadModerationCapability()
120+
}
121+
122+
private fun loadModerationCapability() {
123+
launch {
124+
canModerate = withContext(bgDispatcher) { siteCapabilityChecker.canModerateComments(site) }
125+
_uiState.value?.let { _uiState.value = it.copy(canModerate = canModerate) }
126+
}
112127
}
113128

114129
/**
@@ -226,8 +241,12 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
226241
}
227242
}
228243

244+
@Suppress("ReturnCount")
229245
fun onEditClicked() {
230246
if (loadedComment == null) return
247+
// Editing a comment needs moderation rights; the button is disabled without them, but guard
248+
// the action too so nothing (e.g. a stale recomposition) can slip past the disabled UI.
249+
if (!canModerate) return
231250
// The editor loads the comment from the network, so don't open it offline.
232251
if (isOffline()) return
233252
trackCommentAction(Stat.COMMENT_EDITOR_OPENED)
@@ -273,7 +292,12 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
273292
} else {
274293
trackCommentReply(comment)
275294
_commentChanged.value = Event(Unit)
276-
// Replying to an unapproved comment implicitly approves it, matching legacy behaviour
295+
// Replying to an unapproved comment implicitly approves it, matching legacy
296+
// behaviour. Always attempt it rather than gating on canModerate: that flag is
297+
// fetched asynchronously and may still be unresolved when a moderator replies
298+
// (e.g. from a notification, reply field pre-focused), which would silently drop
299+
// the approve. approveAfterReply() flips the status optimistically and reverts if
300+
// the server rejects it, so a non-moderator's reply self-heals either way.
277301
if (currentStatus() == UNAPPROVED) {
278302
approveAfterReply()
279303
}
@@ -314,6 +338,9 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
314338
// before the load completes the ui state holds a default status and the toggle handlers
315339
// would compute (and apply server-side) the wrong target status.
316340
if (loadedComment == null) return
341+
// Moderation controls are disabled without the capability; guard the action too so a stale
342+
// recomposition can't fire a request the server would only reject with a 403.
343+
if (!canModerate) return
317344
if (isOffline()) return
318345
// Guard against a second moderation while one is in flight (fast double-tap): the target
319346
// status is derived from the optimistic ui state, so racing requests could compute (and
@@ -445,7 +472,8 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
445472
postTitle = cached?.postTitle?.takeIf { it.isNotBlank() } ?: fallbackPostTitle,
446473
commentUrl = url,
447474
status = status,
448-
isLiked = cached?.iLike ?: fallbackIsLiked
475+
isLiked = cached?.iLike ?: fallbackIsLiked,
476+
canModerate = canModerate
449477
)
450478

451479
private data class CommentLoadResult(
@@ -466,7 +494,8 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
466494
val commentUrl: String = "",
467495
val status: CommentStatus = CommentStatus.ALL,
468496
val isLiked: Boolean = false,
469-
val isReplyInProgress: Boolean = false
497+
val isReplyInProgress: Boolean = false,
498+
val canModerate: Boolean = false
470499
)
471500
}
472501

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

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,15 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3
3838
/**
3939
* The row of comment actions pinned above the reply box: moderate (approve/unapprove/untrash),
4040
* spam, like and a "more" overflow menu (edit, trash, copy/share link, delete permanently).
41-
* Mirrors the legacy comment_action_footer layout: equal-width icon+label buttons, accent colour
42-
* at full opacity when a toggle is on, on-surface at medium opacity when off.
41+
* Mirrors the legacy comment_action_footer layout: equal-width icon+label buttons in on-surface
42+
* at medium emphasis. An enabled on toggle (approved or liked) is highlighted with the accent
43+
* colour at full opacity; once disabled it dims to on-surface at the reduced disabled opacity, so
44+
* a moderation action the user can't perform no longer shows a tappable-looking tint.
45+
*
46+
* When [canModerate] is false (the current user lacks the moderate_comments capability) the
47+
* moderation controls — moderate, spam, edit, trash and delete permanently — stay visible but are
48+
* disabled and dimmed, mirroring the legacy detail screen's per-capability gating. Like and the
49+
* copy/share-link actions are unaffected; the reply box (rendered separately) also stays active.
4350
*/
4451
@Composable
4552
@Suppress("LongParameterList")
@@ -48,6 +55,7 @@ fun CommentActionFooter(
4855
isLiked: Boolean,
4956
showLikeButton: Boolean,
5057
showCommentUrlActions: Boolean,
58+
canModerate: Boolean,
5159
onModerateClick: () -> Unit,
5260
onSpamClick: () -> Unit,
5361
onLikeClick: () -> Unit,
@@ -59,23 +67,24 @@ fun CommentActionFooter(
5967
modifier: Modifier = Modifier
6068
) {
6169
Row(modifier = modifier.fillMaxWidth()) {
62-
val (moderateIconRes, moderateLabelRes, moderateIsOn) = when (status) {
63-
APPROVED -> Triple(R.drawable.ic_checkmark_white_24dp, R.string.comment_status_approved, true)
64-
TRASH -> Triple(R.drawable.ic_undo_white_24dp, R.string.mnu_comment_untrash, false)
65-
else -> Triple(R.drawable.ic_checkmark_white_24dp, R.string.mnu_comment_approve, false)
70+
val (moderateIconRes, moderateLabelRes) = when (status) {
71+
APPROVED -> Pair(R.drawable.ic_checkmark_white_24dp, R.string.comment_status_approved)
72+
TRASH -> Pair(R.drawable.ic_undo_white_24dp, R.string.mnu_comment_untrash)
73+
else -> Pair(R.drawable.ic_checkmark_white_24dp, R.string.mnu_comment_approve)
6674
}
6775
ActionButton(
6876
iconRes = moderateIconRes,
6977
labelRes = moderateLabelRes,
70-
isOn = moderateIsOn,
78+
isOn = status == APPROVED,
79+
enabled = canModerate,
7180
onClick = onModerateClick,
7281
modifier = Modifier.weight(1f)
7382
)
7483

7584
ActionButton(
7685
iconRes = R.drawable.ic_spam_white_24dp,
7786
labelRes = if (status == SPAM) R.string.mnu_comment_unspam else R.string.mnu_comment_spam,
78-
isOn = false,
87+
enabled = canModerate,
7988
onClick = onSpamClick,
8089
modifier = Modifier.weight(1f)
8190
)
@@ -93,6 +102,7 @@ fun CommentActionFooter(
93102
MoreActionButton(
94103
status = status,
95104
showCommentUrlActions = showCommentUrlActions,
105+
canModerate = canModerate,
96106
onEditClick = onEditClick,
97107
onTrashClick = onTrashClick,
98108
onCopyLinkClick = onCopyLinkClick,
@@ -108,6 +118,7 @@ fun CommentActionFooter(
108118
private fun MoreActionButton(
109119
status: CommentStatus,
110120
showCommentUrlActions: Boolean,
121+
canModerate: Boolean,
111122
onEditClick: () -> Unit,
112123
onTrashClick: () -> Unit,
113124
onCopyLinkClick: () -> Unit,
@@ -117,10 +128,11 @@ private fun MoreActionButton(
117128
) {
118129
var isMenuExpanded by remember { mutableStateOf(false) }
119130
Box(modifier = modifier) {
131+
// The button itself stays enabled even without moderation rights so the copy/share-link
132+
// items remain reachable; the moderation items inside are individually disabled instead.
120133
ActionButton(
121134
iconRes = R.drawable.ic_more_horiz_white_24dp,
122135
labelRes = R.string.more,
123-
isOn = false,
124136
onClick = { isMenuExpanded = true },
125137
// Fill the Box (which carries this button's share of the row) so the icon centres in
126138
// its slot like the sibling buttons, instead of hugging the slot's start edge
@@ -131,17 +143,17 @@ private fun MoreActionButton(
131143
onDismissRequest = { isMenuExpanded = false }
132144
) {
133145
val errorColor = MaterialTheme.colorScheme.error
134-
MoreMenuItem(R.string.edit) {
146+
MoreMenuItem(R.string.edit, enabled = canModerate) {
135147
isMenuExpanded = false
136148
onEditClick()
137149
}
138150
if (status == TRASH) {
139-
MoreMenuItem(R.string.mnu_comment_untrash) {
151+
MoreMenuItem(R.string.mnu_comment_untrash, enabled = canModerate) {
140152
isMenuExpanded = false
141153
onTrashClick()
142154
}
143155
} else {
144-
MoreMenuItem(R.string.mnu_comment_trash, color = errorColor) {
156+
MoreMenuItem(R.string.mnu_comment_trash, color = errorColor, enabled = canModerate) {
145157
isMenuExpanded = false
146158
onTrashClick()
147159
}
@@ -157,7 +169,7 @@ private fun MoreActionButton(
157169
}
158170
}
159171
if (status == TRASH || status == SPAM) {
160-
MoreMenuItem(R.string.mnu_comment_delete_permanently, color = errorColor) {
172+
MoreMenuItem(R.string.mnu_comment_delete_permanently, color = errorColor, enabled = canModerate) {
161173
isMenuExpanded = false
162174
onDeletePermanentlyClick()
163175
}
@@ -170,10 +182,14 @@ private fun MoreActionButton(
170182
private fun MoreMenuItem(
171183
@StringRes labelRes: Int,
172184
color: Color = Color.Unspecified,
185+
enabled: Boolean = true,
173186
onClick: () -> Unit
174187
) {
175188
DropdownMenuItem(
176-
text = { Text(text = stringResource(labelRes), color = color) },
189+
// Only apply the explicit colour (e.g. the destructive red) while enabled; when disabled,
190+
// defer to the menu item's disabled content colour so it greys out instead of staying red.
191+
text = { Text(text = stringResource(labelRes), color = if (enabled) color else Color.Unspecified) },
192+
enabled = enabled,
177193
onClick = onClick
178194
)
179195
}
@@ -182,15 +198,23 @@ private fun MoreMenuItem(
182198
private fun ActionButton(
183199
@DrawableRes iconRes: Int,
184200
@StringRes labelRes: Int,
185-
isOn: Boolean,
186201
onClick: () -> Unit,
187-
modifier: Modifier = Modifier
202+
modifier: Modifier = Modifier,
203+
enabled: Boolean = true,
204+
isOn: Boolean = false
188205
) {
189-
val color = if (isOn) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurface
190-
val alpha = if (isOn) 1f else MEDIUM_EMPHASIS_ALPHA
206+
// An on toggle (approved or liked) is highlighted with the accent colour at full opacity, but
207+
// only while it's actionable; once disabled it drops to on-surface at the reduced disabled
208+
// opacity so a moderation action the user can't perform no longer shows a tappable-looking tint.
209+
val color = if (isOn && enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurface
210+
val alpha = when {
211+
!enabled -> DISABLED_EMPHASIS_ALPHA
212+
isOn -> 1f
213+
else -> MEDIUM_EMPHASIS_ALPHA
214+
}
191215
Column(
192216
modifier = modifier
193-
.clickable(onClick = onClick)
217+
.clickable(enabled = enabled, onClick = onClick)
194218
.padding(horizontal = 4.dp, vertical = 8.dp),
195219
horizontalAlignment = Alignment.CenterHorizontally
196220
) {
@@ -210,9 +234,12 @@ private fun ActionButton(
210234
}
211235
}
212236

213-
/** Matches material_emphasis_medium, used by the legacy footer for "off" action buttons. */
237+
/** Matches material_emphasis_medium, used by the footer for enabled "off" action buttons. */
214238
internal const val MEDIUM_EMPHASIS_ALPHA = 0.6f
215239

240+
/** Material's disabled-content opacity, used to dim moderation buttons the user can't use. */
241+
private const val DISABLED_EMPHASIS_ALPHA = 0.38f
242+
216243
@Preview(showBackground = true)
217244
@Composable
218245
private fun CommentActionFooterPreview() {
@@ -222,6 +249,7 @@ private fun CommentActionFooterPreview() {
222249
isLiked = true,
223250
showLikeButton = true,
224251
showCommentUrlActions = true,
252+
canModerate = true,
225253
onModerateClick = {},
226254
onSpamClick = {},
227255
onLikeClick = {},

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ fun UnifiedCommentDetailsScreen(
104104
isLiked = uiState.isLiked,
105105
showLikeButton = showLikeButton,
106106
showCommentUrlActions = uiState.commentUrl.isNotEmpty(),
107+
canModerate = uiState.canModerate,
107108
onModerateClick = actions.onModerateClick,
108109
onSpamClick = actions.onSpamClick,
109110
onLikeClick = actions.onLikeClick,
@@ -281,7 +282,8 @@ private fun UnifiedCommentDetailsScreenPreview() {
281282
commentText = "This is a <b>great</b> post, thanks for sharing!",
282283
postTitle = "My first post",
283284
commentUrl = "https://example.com/post#comment-1",
284-
status = UNAPPROVED
285+
status = UNAPPROVED,
286+
canModerate = true
285287
),
286288
replyText = TextFieldValue(""),
287289
onReplyTextChange = {},

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class CommentsRsListActivity : BaseAppCompatActivity() {
4242
setContent {
4343
val tabStates by viewModel.tabStates.collectAsState()
4444
val selectedIds by viewModel.selectedIds.collectAsState()
45+
val canModerate by viewModel.canModerate.collectAsState()
4546
val confirmation by viewModel.pendingConfirmation.collectAsState()
4647
val isSearchActive by viewModel.isSearchActive.collectAsState()
4748
val searchQuery by viewModel.searchQuery.collectAsState()
@@ -50,6 +51,7 @@ class CommentsRsListActivity : BaseAppCompatActivity() {
5051
CommentsRsListScreen(
5152
tabStates = tabStates,
5253
selectedIds = selectedIds,
54+
canModerate = canModerate,
5355
pendingConfirmation = confirmation,
5456
isSearchActive = isSearchActive,
5557
searchQuery = searchQuery,

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,17 @@ internal fun CommentsRsListTab.batchActions(): List<CommentsRsBatchAction> = whe
125125
}
126126

127127
/**
128-
* Whether [this] action can act on a selection whose comments have [selectedStatuses]. Approve and
129-
* unapprove are disabled when they would be a no-op — nothing unapproved to approve, or nothing
130-
* approved to unapprove; every other action is always enabled.
128+
* Whether [this] action can act on a selection whose comments have [selectedStatuses]. Every batch
129+
* action requires [canModerate] (the moderate_comments capability) — without it they stay visible
130+
* but disabled, mirroring the detail screen's footer. Approve and unapprove are additionally
131+
* disabled when they would be a no-op — nothing unapproved to approve, or nothing approved to
132+
* unapprove; every other action is enabled whenever the user can moderate.
131133
*/
132-
internal fun CommentsRsBatchAction.isEnabledFor(selectedStatuses: Set<CommentStatus>): Boolean =
133-
when (this) {
134-
CommentsRsBatchAction.APPROVE -> CommentStatus.UNAPPROVED in selectedStatuses
135-
CommentsRsBatchAction.UNAPPROVE -> CommentStatus.APPROVED in selectedStatuses
136-
else -> true
137-
}
134+
internal fun CommentsRsBatchAction.isEnabledFor(
135+
selectedStatuses: Set<CommentStatus>,
136+
canModerate: Boolean
137+
): Boolean = canModerate && when (this) {
138+
CommentsRsBatchAction.APPROVE -> CommentStatus.UNAPPROVED in selectedStatuses
139+
CommentsRsBatchAction.UNAPPROVE -> CommentStatus.APPROVED in selectedStatuses
140+
else -> true
141+
}

0 commit comments

Comments
 (0)