Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -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<CommentRsUiModel>): List<CommentsRsListRow> {
val rows = ArrayList<CommentsRsListRow>(comments.size + 1)
val usedKeys = HashSet<String>()
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>): String {
val base = "header_$label"
if (used.add(base)) return base
var n = 1
while (!used.add("$base#$n")) n++
return "$base#$n"
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,19 @@ 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
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
Expand Down Expand Up @@ -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)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DateHeader>().single()
val afterHeader = after.filterIsInstance<DateHeader>().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<DateHeader>()
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
)
}
Loading