diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListRow.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListRow.kt new file mode 100644 index 000000000000..134167e5ceaa --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListRow.kt @@ -0,0 +1,43 @@ +package org.wordpress.android.ui.commentsrs + +/** A rendered row in the rs comment list: either a date-group header or a comment. */ +sealed interface CommentsRsListRow { + /** [key] is a guaranteed-unique LazyColumn key; [label] is the (date) text shown to the user. */ + data class DateHeader(val label: String, val key: String) : CommentsRsListRow + data class Item(val comment: CommentRsUiModel) : CommentsRsListRow +} + +/** + * Interleaves date subheaders into [comments] (already in display order), mirroring the legacy + * list: a header before the first comment and before every comment whose date label differs from + * the previous one. The label is the row's own [CommentRsUiModel.relativeDate] — the same + * javaDateToTimeSpan value the legacy list groups by — so no extra date handling is needed here. + * + * Each header carries a unique [CommentsRsListRow.DateHeader.key] for the LazyColumn. Comments are + * date-sorted, so a label normally maps to one contiguous group and the key is just the label — + * which keeps the header stable when a newer comment is prepended into an existing group. Should + * the list ever arrive out of date order, a repeated label is disambiguated rather than emitting a + * duplicate key (which LazyColumn rejects with a hard crash, unlike the legacy RecyclerView). + */ +fun withDateHeaders(comments: List): List { + val rows = ArrayList(comments.size + 1) + val usedKeys = HashSet() + var lastLabel: String? = null + for (comment in comments) { + if (comment.relativeDate != lastLabel) { + rows.add(CommentsRsListRow.DateHeader(comment.relativeDate, uniqueKey(comment.relativeDate, usedKeys))) + lastLabel = comment.relativeDate + } + rows.add(CommentsRsListRow.Item(comment)) + } + return rows +} + +/** A stable per-label key, suffixed only if the same label recurs non-contiguously (see above). */ +private fun uniqueKey(label: String, used: MutableSet): String { + val base = "header_$label" + if (used.add(base)) return base + var n = 1 + while (!used.add("$base#$n")) n++ + return "$base#$n" +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListItem.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListItem.kt index d97f42a47455..8dff1536f3b9 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListItem.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListItem.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle @@ -36,9 +37,11 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.os.ConfigurationCompat import coil.compose.AsyncImage import org.wordpress.android.R import org.wordpress.android.ui.commentsrs.CommentRsUiModel +import java.util.Locale private val AVATAR_SIZE = 40.dp private val PENDING_INDICATOR_WIDTH = 4.dp @@ -173,3 +176,23 @@ private fun AnnotatedString.Builder.boldRange(formatted: String, part: String) { addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, start + part.length) } } + +/** A date-group subheader row, matching the legacy list's all-caps overline separators. */ +@Composable +fun CommentsRsDateHeader(label: String, modifier: Modifier = Modifier) { + // Locale-aware uppercase, like the legacy subheader's android:textAllCaps (Kotlin's no-arg + // uppercase() is Locale.ROOT and would mis-case e.g. Turkish month names). Read the locale + // observably from the composition so it tracks locale changes. + val locale = ConfigurationCompat.getLocales(LocalConfiguration.current)[0] ?: Locale.ROOT + Text( + text = label.uppercase(locale), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp) + ) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt index 6e310db3aa7e..db530e737c6c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,7 +37,9 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.distinctUntilChanged import org.wordpress.android.R import org.wordpress.android.ui.commentsrs.CommentRsUiModel +import org.wordpress.android.ui.commentsrs.CommentsRsListRow import org.wordpress.android.ui.commentsrs.CommentsTabUiState +import org.wordpress.android.ui.commentsrs.withDateHeaders import org.wordpress.android.ui.compose.components.ShimmerBox @OptIn(ExperimentalMaterial3Api::class) @@ -135,21 +138,35 @@ private fun CommentListContent( } } + // Interleave date subheaders once per comment-list change, like the legacy list. + val rows = remember(comments) { withDateHeaders(comments) } LazyColumn( state = listState, modifier = Modifier.fillMaxSize() ) { items( - items = comments, - key = { it.remoteCommentId } - ) { comment -> - CommentsRsListItem( - comment = comment, - isSelected = comment.remoteCommentId in selectedIds, - onClick = { onCommentClick(comment.remoteCommentId) }, - onLongClick = { onCommentLongClick(comment.remoteCommentId) }, - modifier = Modifier.animateItem() - ) + items = rows, + key = { row -> + when (row) { + is CommentsRsListRow.DateHeader -> row.key + is CommentsRsListRow.Item -> row.comment.remoteCommentId + } + }, + contentType = { it::class } + ) { row -> + when (row) { + is CommentsRsListRow.DateHeader -> CommentsRsDateHeader( + label = row.label, + modifier = Modifier.animateItem() + ) + is CommentsRsListRow.Item -> CommentsRsListItem( + comment = row.comment, + isSelected = row.comment.remoteCommentId in selectedIds, + onClick = { onCommentClick(row.comment.remoteCommentId) }, + onLongClick = { onCommentLongClick(row.comment.remoteCommentId) }, + modifier = Modifier.animateItem() + ) + } } if (isLoadingMore) { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/commentsrs/CommentsRsListRowTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/commentsrs/CommentsRsListRowTest.kt new file mode 100644 index 000000000000..6765094e5946 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/commentsrs/CommentsRsListRowTest.kt @@ -0,0 +1,97 @@ +package org.wordpress.android.ui.commentsrs + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.wordpress.android.fluxc.model.CommentStatus +import org.wordpress.android.ui.commentsrs.CommentsRsListRow.DateHeader +import org.wordpress.android.ui.commentsrs.CommentsRsListRow.Item + +class CommentsRsListRowTest { + @Test + fun `empty list produces no rows`() { + assertThat(withDateHeaders(emptyList())).isEmpty() + } + + @Test + fun `a single comment gets a leading date header`() { + val comment = comment(id = 1, date = "Today") + + assertThat(withDateHeaders(listOf(comment))).containsExactly( + header("Today"), + Item(comment) + ) + } + + @Test + fun `consecutive comments with the same date share one header`() { + val a = comment(id = 1, date = "Today") + val b = comment(id = 2, date = "Today") + + assertThat(withDateHeaders(listOf(a, b))).containsExactly( + header("Today"), + Item(a), + Item(b) + ) + } + + @Test + fun `a new header is inserted whenever the date label changes`() { + val a = comment(id = 1, date = "Today") + val b = comment(id = 2, date = "Today") + val c = comment(id = 3, date = "Yesterday") + val d = comment(id = 4, date = "January 8") + + assertThat(withDateHeaders(listOf(a, b, c, d))).containsExactly( + header("Today"), + Item(a), + Item(b), + header("Yesterday"), + Item(c), + header("January 8"), + Item(d) + ) + } + + @Test + fun `a header stays identical when a newer comment is prepended into its group`() { + // The header is keyed by its label, so adding a same-day comment at the top of the group + // must not change the header's identity (which would make it re-animate on refresh). + val before = withDateHeaders(listOf(comment(id = 1, date = "Today"))) + val after = withDateHeaders(listOf(comment(id = 2, date = "Today"), comment(id = 1, date = "Today"))) + + val beforeHeader = before.filterIsInstance().single() + val afterHeader = after.filterIsInstance().single() + assertThat(afterHeader).isEqualTo(beforeHeader) + } + + @Test + fun `a label that recurs non-contiguously gets distinct header keys instead of crashing`() { + // Defensive: comments are normally date-sorted so a label is one contiguous group, but if + // the list ever arrives out of order two groups can share a label. LazyColumn rejects + // duplicate keys with a crash, so each header must still get a unique key. + val rows = withDateHeaders( + listOf( + comment(id = 1, date = "Today"), + comment(id = 2, date = "Yesterday"), + comment(id = 3, date = "Today") + ) + ) + + val headers = rows.filterIsInstance() + assertThat(headers.map { it.label }).containsExactly("Today", "Yesterday", "Today") + assertThat(headers.map { it.key }).doesNotHaveDuplicates() + } + + /** A header as it appears in the normal (contiguous) case: key derived directly from the label. */ + private fun header(label: String) = DateHeader(label, "header_$label") + + private fun comment(id: Long, date: String) = CommentRsUiModel( + remoteCommentId = id, + authorName = "Jane", + avatarUrl = "", + snippet = "hello", + relativeDate = date, + status = CommentStatus.APPROVED, + postId = 99L + ) +}