Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
2fc6f16
Add wordpress-rs comments list (Phase 2a: core browsing)
nbradbury Jul 2, 2026
aad42b4
Rename RS comments experimental flag to cover the whole comments expe…
nbradbury Jul 2, 2026
a2cc6b3
Simplify rs comments list: drop dead/unused state, share page handlin…
nbradbury Jul 2, 2026
3cd35f7
Use new string keys for the renamed experimental flag copy
nbradbury Jul 2, 2026
b1bf058
Harden rs comments list paging and post-title resolution
nbradbury Jul 2, 2026
8266317
Address review findings in rs comments paging hardening
nbradbury Jul 2, 2026
ca271c8
Close title-resolve race windows and strengthen data source tests
nbradbury Jul 2, 2026
e666ee4
Construct uniffi data classes in tests instead of mocking them
nbradbury Jul 2, 2026
89e2b61
Fix review findings and slim the list PR
nbradbury Jul 3, 2026
0c05ef5
Merge remote-tracking branch 'origin/trunk' into feature/rs-comments-…
nbradbury Jul 3, 2026
0c828e9
Simplify review-pass leftovers
nbradbury Jul 3, 2026
0f0c47d
Merge remote-tracking branch 'origin/trunk' into feature/rs-comments-…
nbradbury Jul 3, 2026
8a944bc
Add batch moderation to the rs comments list (Phase 2b)
nbradbury Jul 2, 2026
8d90ca5
Add search to the rs comments list (Phase 2c)
nbradbury Jul 2, 2026
e6bdeac
Merge remote-tracking branch 'origin/trunk' into feature/rs-comments-…
nbradbury Jul 7, 2026
44a5686
Fix search review findings: clear races, scoping, scroll and focus
nbradbury Jul 7, 2026
68ae990
Address second-pass review findings on comments list search
nbradbury Jul 7, 2026
5296d0d
Address third-pass review findings on comments list search
nbradbury Jul 7, 2026
1e1baf6
Remove stray blank line before brace (checkstyle)
nbradbury Jul 7, 2026
7afff50
Add note mode to the rs comment detail ViewModel
nbradbury Jul 8, 2026
e7e197b
Support notification args and result extras in the rs comment detail
nbradbury Jul 8, 2026
f0990ed
Serve comment notifications with the rs comment detail when gated
nbradbury Jul 8, 2026
7fd68b2
Merge remote-tracking branch 'origin/trunk' into feature/rs-comments-…
nbradbury Jul 8, 2026
0f4f9f3
Give the rs comment detail a surface background
nbradbury Jul 8, 2026
220f878
Convert the unified comment detail to Compose
nbradbury Jul 8, 2026
24496ca
Render images in the Compose comment body
nbradbury Jul 8, 2026
a49da52
Fix detekt findings in the Compose comment detail
nbradbury Jul 8, 2026
3745eed
Merge remote-tracking branch 'origin/trunk' into feature/rs-comment-d…
nbradbury Jul 8, 2026
038e0ed
Simplify the Compose comment detail
nbradbury Jul 8, 2026
4d933a2
Centre the More action in its slot
nbradbury Jul 8, 2026
f9fc781
Merge remote-tracking branch 'origin/trunk' into feature/rs-comment-d…
nbradbury Jul 9, 2026
e9c7328
Merge branch 'trunk' into feature/rs-comment-detail-compose
nbradbury Jul 9, 2026
3b082af
Address review feedback on the Compose comment detail
nbradbury Jul 9, 2026
33e7b5e
Fix review findings: snackbar dismiss, reply newlines, mention panel
nbradbury Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
package org.wordpress.android.ui.comments.unified.compose

import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.wordpress.android.R
import org.wordpress.android.fluxc.model.CommentStatus
import org.wordpress.android.fluxc.model.CommentStatus.APPROVED
import org.wordpress.android.fluxc.model.CommentStatus.SPAM
import org.wordpress.android.fluxc.model.CommentStatus.TRASH
import org.wordpress.android.ui.compose.theme.AppThemeM3

/**
* The row of comment actions pinned above the reply box: moderate (approve/unapprove/untrash),
* spam, like and a "more" overflow menu (edit, trash, copy/share link, delete permanently).
* Mirrors the legacy comment_action_footer layout: equal-width icon+label buttons, accent colour
* at full opacity when a toggle is on, on-surface at medium opacity when off.
*/
@Composable
@Suppress("LongParameterList")
fun CommentActionFooter(
status: CommentStatus,
isLiked: Boolean,
showLikeButton: Boolean,
showCommentUrlActions: Boolean,
onModerateClick: () -> Unit,
onSpamClick: () -> Unit,
onLikeClick: () -> Unit,
onEditClick: () -> Unit,
onTrashClick: () -> Unit,
onCopyLinkClick: () -> Unit,
onShareLinkClick: () -> Unit,
onDeletePermanentlyClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(modifier = modifier.fillMaxWidth()) {
val (moderateIconRes, moderateLabelRes, moderateIsOn) = when (status) {
APPROVED -> Triple(R.drawable.ic_checkmark_white_24dp, R.string.comment_status_approved, true)
TRASH -> Triple(R.drawable.ic_undo_white_24dp, R.string.mnu_comment_untrash, false)
else -> Triple(R.drawable.ic_checkmark_white_24dp, R.string.mnu_comment_approve, false)
}
ActionButton(
iconRes = moderateIconRes,
labelRes = moderateLabelRes,
isOn = moderateIsOn,
onClick = onModerateClick,
modifier = Modifier.weight(1f)
)

ActionButton(
iconRes = R.drawable.ic_spam_white_24dp,
labelRes = if (status == SPAM) R.string.mnu_comment_unspam else R.string.mnu_comment_spam,
isOn = false,
onClick = onSpamClick,
modifier = Modifier.weight(1f)
)

if (showLikeButton) {
ActionButton(
iconRes = if (isLiked) R.drawable.ic_star_white_24dp else R.drawable.ic_star_outline_white_24dp,
labelRes = if (isLiked) R.string.mnu_comment_liked else R.string.like,
isOn = isLiked,
onClick = onLikeClick,
modifier = Modifier.weight(1f)
)
}

MoreActionButton(
status = status,
showCommentUrlActions = showCommentUrlActions,
onEditClick = onEditClick,
onTrashClick = onTrashClick,
onCopyLinkClick = onCopyLinkClick,
onShareLinkClick = onShareLinkClick,
onDeletePermanentlyClick = onDeletePermanentlyClick,
modifier = Modifier.weight(1f)
)
}
}

@Composable
@Suppress("LongParameterList")
private fun MoreActionButton(
status: CommentStatus,
showCommentUrlActions: Boolean,
onEditClick: () -> Unit,
onTrashClick: () -> Unit,
onCopyLinkClick: () -> Unit,
onShareLinkClick: () -> Unit,
onDeletePermanentlyClick: () -> Unit,
modifier: Modifier = Modifier
) {
var isMenuExpanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
ActionButton(
iconRes = R.drawable.ic_more_horiz_white_24dp,
labelRes = R.string.more,
isOn = false,
onClick = { isMenuExpanded = true },
// Fill the Box (which carries this button's share of the row) so the icon centres in
// its slot like the sibling buttons, instead of hugging the slot's start edge
modifier = Modifier.fillMaxWidth()
)
DropdownMenu(
expanded = isMenuExpanded,
onDismissRequest = { isMenuExpanded = false }
) {
val errorColor = MaterialTheme.colorScheme.error
MoreMenuItem(R.string.edit) {
isMenuExpanded = false
onEditClick()
}
if (status == TRASH) {
MoreMenuItem(R.string.mnu_comment_untrash) {
isMenuExpanded = false
onTrashClick()
}
} else {
MoreMenuItem(R.string.mnu_comment_trash, color = errorColor) {
isMenuExpanded = false
onTrashClick()
}
}
if (showCommentUrlActions) {
MoreMenuItem(R.string.copy_link_address) {
isMenuExpanded = false
onCopyLinkClick()
}
MoreMenuItem(R.string.share_link) {
isMenuExpanded = false
onShareLinkClick()
}
}
if (status == TRASH || status == SPAM) {
MoreMenuItem(R.string.mnu_comment_delete_permanently, color = errorColor) {
isMenuExpanded = false
onDeletePermanentlyClick()
}
}
}
}
}

@Composable
private fun MoreMenuItem(
@StringRes labelRes: Int,
color: Color = Color.Unspecified,
onClick: () -> Unit
) {
DropdownMenuItem(
text = { Text(text = stringResource(labelRes), color = color) },
onClick = onClick
)
}

@Composable
private fun ActionButton(
@DrawableRes iconRes: Int,
@StringRes labelRes: Int,
isOn: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val color = if (isOn) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurface
val alpha = if (isOn) 1f else MEDIUM_EMPHASIS_ALPHA
Column(
modifier = modifier
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(iconRes),
contentDescription = null,
tint = color.copy(alpha = alpha),
modifier = Modifier.size(24.dp)
)
Text(
text = stringResource(labelRes),
color = color.copy(alpha = alpha),
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

/** Matches material_emphasis_medium, used by the legacy footer for "off" action buttons. */
internal const val MEDIUM_EMPHASIS_ALPHA = 0.6f

@Preview(showBackground = true)
@Composable
private fun CommentActionFooterPreview() {
AppThemeM3 {
CommentActionFooter(
status = APPROVED,
isLiked = true,
showLikeButton = true,
showCommentUrlActions = true,
onModerateClick = {},
onSpamClick = {},
onLikeClick = {},
onEditClick = {},
onTrashClick = {},
onCopyLinkClick = {},
onShareLinkClick = {},
onDeletePermanentlyClick = {}
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.wordpress.android.ui.comments.unified.compose

/** The comment detail screen's callbacks, implemented by the hosting fragment's ViewModel. */
@Suppress("LongParameterList")
class CommentDetailsActions(
val onModerateClick: () -> Unit,
val onSpamClick: () -> Unit,
val onLikeClick: () -> Unit,
val onEditClick: () -> Unit,
val onTrashClick: () -> Unit,
val onDeletePermanentlyClick: () -> Unit,
val onCopyLinkClick: () -> Unit,
val onShareLinkClick: () -> Unit,
val onPostTitleClick: () -> Unit,
val onSendReply: (String) -> Unit
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package org.wordpress.android.ui.comments.unified.compose

import android.text.style.URLSpan
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.text.HtmlCompat
import org.wordpress.android.ui.compose.theme.AppThemeM3
import org.wordpress.android.ui.compose.utils.toAnnotatedString
import org.wordpress.android.ui.dataview.compose.RemoteImage
import org.wordpress.android.util.EmoticonsUtils

/**
* Renders a comment's HTML body, following the legacy CommentUtils.displayHtmlComment pipeline:
* emoticon smilies are first replaced with unicode emoji so they stay inline as text, then any
* remaining `<img>` tags render as width-capped block images (the legacy renderer sized inline
* images to the view width, so real images effectively rendered as blocks there too), with the
* HTML between them rendered as selectable text with tappable links.
*/
@Composable
fun CommentHtmlBody(html: String, modifier: Modifier = Modifier) {
val linkColor = MaterialTheme.colorScheme.primary
val segments = remember(html) { splitCommentHtml(html) }
SelectionContainer(modifier = modifier) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
segments.forEach { segment ->
when (segment) {
is CommentBodySegment.Html -> {
val annotated = remember(segment.html, linkColor) {
commentHtmlToAnnotatedString(segment.html, linkColor)
}
if (annotated.isNotEmpty()) {
Text(
text = annotated,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 16.sp
)
}
}
// Route through the shared RemoteImage wrapper for consistency with the
// avatars in this screen. No fallback: a failed inline image renders nothing
// (a person/broken-image placeholder would be wrong for body content).
is CommentBodySegment.Image -> RemoteImage(
imageUrl = segment.url,
contentScale = ContentScale.Inside,
alignment = Alignment.TopStart,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}

internal sealed class CommentBodySegment {
data class Html(val html: String) : CommentBodySegment()
data class Image(val url: String) : CommentBodySegment()
}

private val IMG_TAG_PATTERN = Regex(
"""<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>""",
RegexOption.IGNORE_CASE
)

/**
* Splits comment HTML into text and image segments. Emoticon smilies are converted to unicode
* emoji first — same order as the legacy displayHtmlComment, which prevented smilies from being
* downloaded as images — so only real images remain as `<img>` tags.
*/
internal fun splitCommentHtml(html: String): List<CommentBodySegment> {
val withEmoji = EmoticonsUtils.replaceEmoticonsWithEmoji(html)
val segments = mutableListOf<CommentBodySegment>()
var consumedUpTo = 0
IMG_TAG_PATTERN.findAll(withEmoji).forEach { match ->
val precedingHtml = withEmoji.substring(consumedUpTo, match.range.first)
if (precedingHtml.isNotBlank()) {
segments.add(CommentBodySegment.Html(precedingHtml))
}
segments.add(CommentBodySegment.Image(match.groupValues[1]))
consumedUpTo = match.range.last + 1
}
val remainingHtml = withEmoji.substring(consumedUpTo)
if (remainingHtml.isNotBlank()) {
segments.add(CommentBodySegment.Html(remainingHtml))
}
return segments
}

/**
* Renders an HTML fragment as an [AnnotatedString] with tappable, link-styled URLs and trimmed
* surrounding whitespace.
*/
internal fun commentHtmlToAnnotatedString(html: String, linkColor: Color): AnnotatedString {
val spanned = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY)
val withLinks = buildAnnotatedString {
append(spanned.toAnnotatedString())
spanned.getSpans(0, spanned.length, URLSpan::class.java).forEach { span ->
val start = spanned.getSpanStart(span)
val end = spanned.getSpanEnd(span)
addStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline), start, end)
addLink(LinkAnnotation.Url(span.url), start, end)
}
}
// HtmlCompat pads block elements with trailing newlines; trim without breaking span offsets
val text = withLinks.text
val start = text.indexOfFirst { !it.isWhitespace() }
if (start == -1) return AnnotatedString("")
val end = text.indexOfLast { !it.isWhitespace() } + 1
return if (start == 0 && end == text.length) withLinks else withLinks.subSequence(start, end)
}

@Preview(showBackground = true)
@Composable
private fun CommentHtmlBodyPreview() {
AppThemeM3 {
CommentHtmlBody(
html = "Nice <b>post</b>, see <a href='https://example.com'>this</a>!" +
"<img src=\"https://example.com/photo.jpg\" />And a closing thought."
)
}
}
Loading
Loading