Collapse runs of deleted messages in the timeline (#7195)
* Collapse runs of deleted messages in the timeline (#7111) * Collapse runs of deleted messages in the timeline Collapse a run of three or more consecutive deleted messages into a single expandable group, reusing the existing state-change grouping (the same way element-web does it) instead of showing one "Message removed" placeholder per deletion. The header shows the count only ("N deleted messages"); the Rust SDK does not expose who performed the redaction, so we don't attribute it. On by default. Runs shorter than three are left as individual placeholders, and day dividers and read receipts are preserved (a run is broken by any non-redacted item, so a day is never emptied). Run the collapse as the final step of TimelineItemGrouper.group() instead of a separate pass in the presenter, as suggested in review. Kept as its own finalization step rather than via canBeGrouped() so the group stays all redacted and the count-only header is preserved. The group id is resolved through the grouper's existing id registry, like the other groups, so it stays stable however the run grows and the user's expand/collapse state is kept across timeline updates. * Replace temporary strings with final ones * Update screenshots --------- Co-authored-by: Hi Dude! <klarkmonty@tuta.io> Co-authored-by: manfrommedan <manfrommedan@users.noreply.github.com> Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
committed by
GitHub
parent
03304b3ece
commit
f9112cc583
+18
@@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemRead
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
||||
@@ -255,6 +256,23 @@ internal fun aGroupedEvents(
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aRedactedMessagesGroupedEvents(
|
||||
id: UniqueId = UniqueId("redacted_group"),
|
||||
count: Int = 4,
|
||||
): TimelineItem.GroupedEvents {
|
||||
val events = (0 until count).map { index ->
|
||||
aTimelineItemEvent(
|
||||
eventId = EventId("\$redacted_$index"),
|
||||
content = TimelineItemRedactedContent,
|
||||
)
|
||||
}
|
||||
return TimelineItem.GroupedEvents(
|
||||
id = id,
|
||||
events = events.toImmutableList(),
|
||||
aggregatedReadReceipts = persistentListOf(),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aTimelineRoomInfo(
|
||||
name: String = ROOM_NAME,
|
||||
isDm: Boolean = false,
|
||||
|
||||
+41
-5
@@ -19,12 +19,14 @@ import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.aGroupedEvents
|
||||
import io.element.android.features.messages.impl.timeline.aRedactedMessagesGroupedEvents
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
||||
import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
@@ -147,12 +149,18 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
},
|
||||
) {
|
||||
Column(modifier = modifier.animateContentSize()) {
|
||||
val count = timelineItem.events.size
|
||||
// A group made entirely of redacted events is a collapsed run of deleted messages
|
||||
// (element-web style); anything else is the regular run of room state changes. For the
|
||||
// redacted case we show only the count: the SDK does not expose who performed the redaction,
|
||||
// and showing the original authors would be misleading.
|
||||
val headerText = if (timelineItem.isRedactedMessagesGroup()) {
|
||||
pluralStringResource(R.plurals.screen_room_timeline_redacted_messages, count, count)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.screen_room_timeline_state_changes, count, count)
|
||||
}
|
||||
GroupHeaderView(
|
||||
text = pluralStringResource(
|
||||
id = R.plurals.screen_room_timeline_state_changes,
|
||||
count = timelineItem.events.size,
|
||||
timelineItem.events.size
|
||||
),
|
||||
text = headerText,
|
||||
isExpanded = isExpanded,
|
||||
isHighlighted = !isExpanded && timelineItem.events.any { it.isEvent(focusedEventId) },
|
||||
onClick = onExpandGroupClick,
|
||||
@@ -252,3 +260,31 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
|
||||
eventSink = {},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemRedactedMessagesGroupPreview() = ElementPreview {
|
||||
// A collapsed run of deleted messages, shown as a single "N removed messages" header.
|
||||
TimelineItemGroupedEventsRowContent(
|
||||
isExpanded = false,
|
||||
onExpandGroupClick = {},
|
||||
timelineItem = aRedactedMessagesGroupedEvents(count = 11),
|
||||
timelineMode = Timeline.Mode.Live,
|
||||
timelineRoomInfo = aTimelineRoomInfo(),
|
||||
timelineProtectionState = aTimelineProtectionState(),
|
||||
focusedEventId = null,
|
||||
isLastOutgoingMessage = false,
|
||||
displayThreadSummaries = false,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onLinkLongClick = {},
|
||||
inReplyToClick = {},
|
||||
onUserDataClick = {},
|
||||
onLinkClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
eventSink = {},
|
||||
)
|
||||
}
|
||||
|
||||
+8
@@ -44,6 +44,14 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
|
||||
|
||||
/**
|
||||
* Return true when every event in the group is a redacted (deleted) message, i.e. the group is a
|
||||
* collapsed run of deleted messages rather than the usual run of room state changes. Used to pick
|
||||
* the group header label. An empty group is not considered a redacted group.
|
||||
*/
|
||||
internal fun TimelineItem.GroupedEvents.isRedactedMessagesGroup(): Boolean =
|
||||
events.isNotEmpty() && events.all { it.content is TimelineItemRedactedContent }
|
||||
|
||||
/**
|
||||
* Return true if the Event can be grouped in a collapse/expand block
|
||||
* When [canBeGrouped] returns a value, [canBeDisplayedInBubbleBlock] MUST return the opposite value.
|
||||
|
||||
+56
-1
@@ -12,6 +12,7 @@ import androidx.annotation.VisibleForTesting
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -47,7 +48,8 @@ class TimelineItemGrouper {
|
||||
if (currentGroup.isNotEmpty()) {
|
||||
result.addGroup(groupIds, currentGroup)
|
||||
}
|
||||
return result
|
||||
// Finally, fold runs of consecutive deleted messages into a single group (element-web style).
|
||||
return result.collapseRedactedRuns(groupIds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,3 +91,56 @@ private fun MutableMap<String, String>.getOrPutGroupId(timelineItems: List<Timel
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun computeGroupIdWith(timelineItem: TimelineItem): UniqueId = UniqueId("${timelineItem.identifier()}_group")
|
||||
|
||||
// Runs shorter than this are left as individual "Message removed" tiles, like element-web.
|
||||
internal const val MIN_REDACTED_RUN_SIZE = 3
|
||||
|
||||
/**
|
||||
* Fold runs of [MIN_REDACTED_RUN_SIZE] or more consecutive deleted (redacted) messages into a single
|
||||
* [TimelineItem.GroupedEvents], so a long stretch of "Message removed" tiles shows as one expandable
|
||||
* "N removed messages" line, like element-web. Shorter runs, and anything that is not a redacted event
|
||||
* (day dividers included), are left untouched.
|
||||
*
|
||||
* This runs as the final step of [TimelineItemGrouper.group], after the state and membership events
|
||||
* have been grouped. It is deliberately kept out of the normal grouping: a run needs a higher minimum
|
||||
* than an ordinary group, and a redacted group has to stay entirely redacted so its header can show a
|
||||
* plain count rather than being mixed in with the surrounding state changes.
|
||||
*
|
||||
* Events are kept oldest-first to match the other groups, and the group id is resolved through the
|
||||
* grouper's [getOrPutGroupId] registry, also like the other groups, so it stays stable however the
|
||||
* run grows: older history loading in, or a new adjacent message being redacted. A stable id keeps
|
||||
* the user's expand/collapse state across timeline updates.
|
||||
*/
|
||||
internal fun List<TimelineItem>.collapseRedactedRuns(groupIds: MutableMap<String, String>): List<TimelineItem> {
|
||||
val result = mutableListOf<TimelineItem>()
|
||||
val run = mutableListOf<TimelineItem.Event>()
|
||||
|
||||
fun flushRun() {
|
||||
when {
|
||||
run.isEmpty() -> Unit
|
||||
run.size < MIN_REDACTED_RUN_SIZE -> result.addAll(run)
|
||||
else -> {
|
||||
val events = run.reversed()
|
||||
result.add(
|
||||
TimelineItem.GroupedEvents(
|
||||
id = UniqueId(groupIds.getOrPutGroupId(events)),
|
||||
events = events.toImmutableList(),
|
||||
aggregatedReadReceipts = events.flatMap { it.readReceiptState.receipts }.toImmutableList(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
run.clear()
|
||||
}
|
||||
|
||||
for (item in this) {
|
||||
if (item is TimelineItem.Event && item.content is TimelineItemRedactedContent) {
|
||||
run.add(item)
|
||||
} else {
|
||||
flushRun()
|
||||
result.add(item)
|
||||
}
|
||||
}
|
||||
flushRun()
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@
|
||||
<string name="screen_room_timeline_reactions_show_more">"Show more"</string>
|
||||
<string name="screen_room_timeline_reactions_show_reactions_summary">"Show reactions summary"</string>
|
||||
<string name="screen_room_timeline_read_marker_title">"New"</string>
|
||||
<plurals name="screen_room_timeline_redacted_messages">
|
||||
<item quantity="one">"%1$d removed message"</item>
|
||||
<item quantity="other">"%1$d removed messages"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_room_timeline_state_changes">
|
||||
<item quantity="one">"%1$d room change"</item>
|
||||
<item quantity="other">"%1$d room changes"</item>
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
|
||||
import io.element.android.features.messages.impl.timeline.groups.collapseRedactedRuns
|
||||
import io.element.android.features.messages.impl.timeline.groups.computeGroupIdWith
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.junit.Test
|
||||
|
||||
class CollapseRedactedRunsTest {
|
||||
private val groupIds = mutableMapOf<String, String>()
|
||||
|
||||
private fun redacted(id: String) = aTimelineItemEvent(
|
||||
eventId = EventId(id),
|
||||
content = TimelineItemRedactedContent,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `collapses a run of three or more redacted events into a single group`() {
|
||||
val r1 = redacted("\$R1")
|
||||
val r2 = redacted("\$R2")
|
||||
val r3 = redacted("\$R3")
|
||||
val result = listOf<TimelineItem>(r1, r2, r3).collapseRedactedRuns(groupIds)
|
||||
assertThat(result).hasSize(1)
|
||||
val group = result.single() as TimelineItem.GroupedEvents
|
||||
// Stored oldest-first (the input is newest-first), like the existing grouper.
|
||||
assertThat(group.events.map { it.eventId }).containsExactly(
|
||||
EventId("\$R3"),
|
||||
EventId("\$R2"),
|
||||
EventId("\$R1"),
|
||||
).inOrder()
|
||||
// Group id comes from the shared registry, keyed like the other groups (oldest member).
|
||||
assertThat(group.id).isEqualTo(computeGroupIdWith(r3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaves a run shorter than three as individual tiles`() {
|
||||
val r1 = redacted("\$R1")
|
||||
val r2 = redacted("\$R2")
|
||||
val result = listOf<TimelineItem>(r1, r2).collapseRedactedRuns(groupIds)
|
||||
assertThat(result).containsExactly(r1, r2).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps surrounding events and only collapses the redacted run between them`() {
|
||||
val newest = aTimelineItemEvent(eventId = EventId("\$NEW"))
|
||||
val r1 = redacted("\$R1")
|
||||
val r2 = redacted("\$R2")
|
||||
val r3 = redacted("\$R3")
|
||||
val oldest = aTimelineItemEvent(eventId = EventId("\$OLD"))
|
||||
val result = listOf<TimelineItem>(newest, r1, r2, r3, oldest).collapseRedactedRuns(groupIds)
|
||||
assertThat(result).hasSize(3)
|
||||
assertThat(result.first()).isEqualTo(newest)
|
||||
assertThat(result.last()).isEqualTo(oldest)
|
||||
assertThat(result[1]).isInstanceOf(TimelineItem.GroupedEvents::class.java)
|
||||
assertThat((result[1] as TimelineItem.GroupedEvents).events).hasSize(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a day separator breaks a run so each side is evaluated on its own`() {
|
||||
val r1 = redacted("\$R1")
|
||||
val r2 = redacted("\$R2")
|
||||
val r3 = redacted("\$R3")
|
||||
val sep = aTimelineItemDaySeparator()
|
||||
val r4 = redacted("\$R4")
|
||||
val r5 = redacted("\$R5")
|
||||
val result = listOf<TimelineItem>(r1, r2, r3, sep, r4, r5).collapseRedactedRuns(groupIds)
|
||||
// First run (3) collapses, the separator passes through, the second run (2) stays as tiles.
|
||||
assertThat(result).hasSize(4)
|
||||
assertThat(result.first()).isInstanceOf(TimelineItem.GroupedEvents::class.java)
|
||||
assertThat(result[1]).isEqualTo(sep)
|
||||
assertThat(result[2]).isEqualTo(r4)
|
||||
assertThat(result[3]).isEqualTo(r5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not collapse a run interrupted by a non-redacted event`() {
|
||||
val r1 = redacted("\$R1")
|
||||
val r2 = redacted("\$R2")
|
||||
val between = aTimelineItemEvent(eventId = EventId("\$MID"))
|
||||
val r3 = redacted("\$R3")
|
||||
val result = listOf<TimelineItem>(r1, r2, between, r3).collapseRedactedRuns(groupIds)
|
||||
assertThat(result).containsExactly(r1, r2, between, r3).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collapses a long run into a single group keeping every event`() {
|
||||
val run = (1..5).map { redacted("\$R$it") }
|
||||
val result = run.collapseRedactedRuns(groupIds)
|
||||
assertThat(result).hasSize(1)
|
||||
assertThat((result.single() as TimelineItem.GroupedEvents).events).hasSize(5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collapses each qualifying run independently`() {
|
||||
val firstRun = (1..3).map { redacted("\$A$it") }
|
||||
val separator = aTimelineItemEvent(eventId = EventId("\$SEP"))
|
||||
val secondRun = (1..3).map { redacted("\$B$it") }
|
||||
val result = (firstRun + separator + secondRun).collapseRedactedRuns(groupIds)
|
||||
assertThat(result).hasSize(3)
|
||||
assertThat(result[0]).isInstanceOf(TimelineItem.GroupedEvents::class.java)
|
||||
assertThat(result[1]).isEqualTo(separator)
|
||||
assertThat(result[2]).isInstanceOf(TimelineItem.GroupedEvents::class.java)
|
||||
// The two groups get distinct ids so their expand state does not bleed into one another.
|
||||
assertThat((result[0] as TimelineItem.GroupedEvents).id)
|
||||
.isNotEqualTo((result[2] as TimelineItem.GroupedEvents).id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collapses a run that sits at the very end of the list`() {
|
||||
val newest = aTimelineItemEvent(eventId = EventId("\$NEW"))
|
||||
val run = (1..3).map { redacted("\$R$it") }
|
||||
val result = (listOf<TimelineItem>(newest) + run).collapseRedactedRuns(groupIds)
|
||||
assertThat(result).hasSize(2)
|
||||
assertThat(result.first()).isEqualTo(newest)
|
||||
assertThat(result.last()).isInstanceOf(TimelineItem.GroupedEvents::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaves a single redacted event untouched`() {
|
||||
val r1 = redacted("\$R1")
|
||||
assertThat(listOf<TimelineItem>(r1).collapseRedactedRuns(groupIds)).containsExactly(r1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aggregates the read receipts of every event in the collapsed group`() {
|
||||
fun redactedWithReceipt(id: String, receiptIndex: Int) = aTimelineItemEvent(
|
||||
eventId = EventId(id),
|
||||
content = TimelineItemRedactedContent,
|
||||
readReceiptState = TimelineItemReadReceipts(
|
||||
receipts = listOf(aReadReceiptData(receiptIndex)).toImmutableList(),
|
||||
),
|
||||
)
|
||||
val run = listOf<TimelineItem>(
|
||||
redactedWithReceipt("\$R1", 0),
|
||||
redactedWithReceipt("\$R2", 1),
|
||||
redactedWithReceipt("\$R3", 2),
|
||||
)
|
||||
val group = run.collapseRedactedRuns(groupIds).single() as TimelineItem.GroupedEvents
|
||||
assertThat(group.aggregatedReadReceipts).hasSize(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an existing grouped item breaks the run and is passed through untouched`() {
|
||||
val stateGroup = aGroupedEvents(id = UniqueId("state"))
|
||||
val run = (1..3).map { redacted("\$R$it") }
|
||||
val result = (run + stateGroup).collapseRedactedRuns(groupIds)
|
||||
assertThat(result).hasSize(2)
|
||||
assertThat(result.first()).isInstanceOf(TimelineItem.GroupedEvents::class.java)
|
||||
assertThat(result.last()).isEqualTo(stateGroup)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `is a no-op on an empty list`() {
|
||||
assertThat(emptyList<TimelineItem>().collapseRedactedRuns(groupIds)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the group id survives the run growing at either end`() {
|
||||
val r1 = redacted("\$R1")
|
||||
val r2 = redacted("\$R2")
|
||||
val r3 = redacted("\$R3")
|
||||
val first = listOf<TimelineItem>(r1, r2, r3).collapseRedactedRuns(groupIds).single() as TimelineItem.GroupedEvents
|
||||
// A newer adjacent message gets redacted and older history loads in: the id must not move,
|
||||
// or the user's expanded group would snap shut on the update.
|
||||
val grown = listOf<TimelineItem>(redacted("\$R0"), r1, r2, r3, redacted("\$R4"))
|
||||
.collapseRedactedRuns(groupIds)
|
||||
.single() as TimelineItem.GroupedEvents
|
||||
assertThat(grown.id).isEqualTo(first.id)
|
||||
}
|
||||
}
|
||||
+23
@@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
@@ -865,6 +866,28 @@ class TimelinePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - collapses a run of three or more redacted events into a single group`() = runTest {
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
(0 until 3).map { index ->
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = UniqueId("redacted_$index"),
|
||||
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = RedactedContent),
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(timeline = timeline)
|
||||
presenter.test {
|
||||
val state = consumeItemsUntilPredicate { it.timelineItems.size == 1 }.last()
|
||||
val group = state.timelineItems.single()
|
||||
assertThat(group).isInstanceOf(TimelineItem.GroupedEvents::class.java)
|
||||
assertThat((group as TimelineItem.GroupedEvents).events).hasSize(3)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - reaction ordering`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.groups
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.timeline.aGroupedEvents
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.junit.Test
|
||||
|
||||
class GroupabilityTest {
|
||||
private fun redacted(id: String) = aTimelineItemEvent(
|
||||
eventId = EventId(id),
|
||||
content = TimelineItemRedactedContent,
|
||||
)
|
||||
|
||||
private fun groupOf(vararg events: TimelineItem.Event) = TimelineItem.GroupedEvents(
|
||||
id = UniqueId("group"),
|
||||
events = events.toList().toImmutableList(),
|
||||
aggregatedReadReceipts = persistentListOf(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a group made only of redacted events is a redacted messages group`() {
|
||||
val group = groupOf(redacted("\$R1"), redacted("\$R2"), redacted("\$R3"))
|
||||
assertThat(group.isRedactedMessagesGroup()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a group of state changes is not a redacted messages group`() {
|
||||
// aGroupedEvents builds a run of state events.
|
||||
assertThat(aGroupedEvents().isRedactedMessagesGroup()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a group mixing redacted and non-redacted events is not a redacted messages group`() {
|
||||
val group = groupOf(redacted("\$R1"), aTimelineItemEvent(eventId = EventId("\$E")), redacted("\$R2"))
|
||||
assertThat(group.isRedactedMessagesGroup()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty group is not a redacted messages group`() {
|
||||
val group = TimelineItem.GroupedEvents(
|
||||
id = UniqueId("group"),
|
||||
events = persistentListOf(),
|
||||
aggregatedReadReceipts = persistentListOf(),
|
||||
)
|
||||
assertThat(group.isRedactedMessagesGroup()).isFalse()
|
||||
}
|
||||
}
|
||||
+28
@@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
||||
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
||||
@@ -169,4 +170,31 @@ class TimelineItemGrouperTest {
|
||||
// Then
|
||||
assertThat(actualGroupId).isEqualTo(expectedGroupId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a run of three or more redacted events is collapsed into a single group as a final step`() {
|
||||
val r0 = aGroupableItem.copy(id = UniqueId("r0"), content = TimelineItemRedactedContent)
|
||||
val r1 = aGroupableItem.copy(id = UniqueId("r1"), content = TimelineItemRedactedContent)
|
||||
val r2 = aGroupableItem.copy(id = UniqueId("r2"), content = TimelineItemRedactedContent)
|
||||
val result = sut.group(listOf(r0, r1, r2))
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
TimelineItem.GroupedEvents(
|
||||
id = computeGroupIdWith(r2),
|
||||
// Stored oldest-first (the input is newest-first) with the id keyed by the
|
||||
// oldest member through the shared registry, like the other groups.
|
||||
events = listOf(r2, r1, r0).toImmutableList(),
|
||||
aggregatedReadReceipts = emptyList<ReadReceiptData>().toImmutableList(),
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a run of fewer than three redacted events is left untouched`() {
|
||||
val r0 = aGroupableItem.copy(id = UniqueId("r0"), content = TimelineItemRedactedContent)
|
||||
val r1 = aGroupableItem.copy(id = UniqueId("r1"), content = TimelineItemRedactedContent)
|
||||
val result = sut.group(listOf(r0, r1))
|
||||
assertThat(result).isEqualTo(listOf(r0, r1))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ class KonsistPreviewTest {
|
||||
"TimelineItemGroupedEventsRowContentCollapsePreview",
|
||||
"TimelineItemGroupedEventsRowContentExpandedPreview",
|
||||
"TimelineItemImageViewHideMediaContentPreview",
|
||||
"TimelineItemRedactedMessagesGroupPreview",
|
||||
"TimelineItemVideoViewHideMediaContentPreview",
|
||||
"TimelineItemVoiceViewUnifiedPreview",
|
||||
"TimelineVideoWithCaptionRowPreview",
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c5572491b675a16724cd9e33ebb477c82edf285fa07d57ebef09c670fcaaddf1
|
||||
size 21285
|
||||
oid sha256:4746d11c30648f7c517b0880021e5a1dc6ed2819af6c800b081b0d842ac2c384
|
||||
size 5325
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:290a2be6838445739b0181006f9fea87337a85cfed3573c153e9f503c66624a6
|
||||
size 4994
|
||||
oid sha256:eede6f990a7a1dbddee1cd9e722b30600160d475ad2ae72faebcb773f52aaa7b
|
||||
size 20793
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4420dfe9f6f95411093eb1c17825a389a31d7a5d5ef146129e5872cdd5d87aff
|
||||
size 295295
|
||||
oid sha256:86eac241e9084cc830ff13d231908da2eb0dff246aa5cac64dddaac68a5d5f13
|
||||
size 295298
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:578a3707102c7754f607c3a14f94f32cafa30ee570db174386533c484cb4773c
|
||||
size 295031
|
||||
oid sha256:7076c9a44a433cd9eeca536365ae262d39b4ad99a8f854faadec272cb1b82031
|
||||
size 294992
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3f358da2b9b0521c14e525bcb2bd41c29c96aacaf5f007546e6d362bde2ee3fb
|
||||
size 612526
|
||||
oid sha256:d6441c572f64d99bc119d649e5e6422fe516b9d097f310ccbd99a9ddded9609f
|
||||
size 612494
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a709de55bc68cf12064ebdbde92c0a99bef3252958d7966540458d253e8c12c2
|
||||
size 611422
|
||||
oid sha256:5a867dda82242d2b13bd4bc84a9f1c9b8b7b99c030e19a6cce39b2f828b517f3
|
||||
size 611488
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:54e71f45db31e8664c42f8c05feb91ae839589e00cb89d12088c15c85fb9f25f
|
||||
size 607824
|
||||
oid sha256:e3bb005a88e9a4d2bb850478ee8ac2c4f31d32625bd6d2abf455fade461cd399
|
||||
size 607806
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8bb505fe6d67669339c9366f8ee5bc75974e223837d95e76e637455a51c18711
|
||||
size 606787
|
||||
oid sha256:4a2a57f5547f98b9ac2d8bf7195a1e4b49e03834e8866c6cc76660a6a3b16e74
|
||||
size 606823
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2a83859b4c8ef66b7d22008760b66af367affd8e6c1d7cdc099a122fea64f924
|
||||
size 7548
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e5d132c02fe441f9bc654ae5fad86e8409413869637f147a9853ceb8883d9f12
|
||||
size 7270
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7caacb336793b8ae4b83e9a10a82325e25e8b3c036f881898ed28688250eb6e0
|
||||
size 55744
|
||||
oid sha256:26e14b77fa3c3aeaea58782309e6de8b9047e07945009322990ebf5f4c283309
|
||||
size 52770
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:28a95d4b7ebca92c4e0467f3ecd9acdaf7f6257604d190f5d088a9a25e87d637
|
||||
size 55305
|
||||
oid sha256:26e14b77fa3c3aeaea58782309e6de8b9047e07945009322990ebf5f4c283309
|
||||
size 52770
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0ce2519906f1aaf34700ca9ba4bdeaa72030f1f5aff3f7e24352a1435ac67d98
|
||||
size 53966
|
||||
oid sha256:e5a2e306087a967849f61872b56a83f194d1a76df5723f0bbae6e6e2f4a98c63
|
||||
size 55725
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7dfa2767c55a81d3f121b8fa55443b7b05f9369e91639b80e0be349863799ad7
|
||||
size 55438
|
||||
oid sha256:0ce2519906f1aaf34700ca9ba4bdeaa72030f1f5aff3f7e24352a1435ac67d98
|
||||
size 53966
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d19e7a09114e76294a0626e5d617cf55fe7c13a0188a7d5b364f57c0d9daedc1
|
||||
size 12123
|
||||
oid sha256:b5dc5c492ed3fd552e77c0be85b639d55bc7e976f15b761f927b0b80ecca601d
|
||||
size 12115
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:42f03759fcffbb750181168256130b6a94a1fcf91ee028e69a19a6e3b68ed0e8
|
||||
size 11711
|
||||
oid sha256:5896eb5df6f5398bdb94e74280d0d5c04b6a5a53c2378b7377c60fa58eef544b
|
||||
size 12013
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6bbc8c05e6f6818d00282228f52424ebe93e6e3dca3431d742e8c652d56460d6
|
||||
size 11712
|
||||
oid sha256:e7ce8a8c6b4d1692e4b127b7f17f507552666c631893025082c385b93d835a73
|
||||
size 11726
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:66f6d0d6b03ee4c999cd4edc9c6ae567d31b8b537ca6f9cc8fbe436d06ce80ab
|
||||
size 11643
|
||||
oid sha256:8b93c08531efbeacb55f84b17ac793a7b226e5a2db7bc5072e94bc040cd1e9bc
|
||||
size 11386
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e8a3ad99763929d2783147814e44fa9ec7e7efb7b99949e3580f23c38fb5d971
|
||||
size 9398
|
||||
oid sha256:21aee17ee9a292bb2f0d3d2ec2db565ebb8d5df753454a5a002f033ed5ed39c5
|
||||
size 3853
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:21aee17ee9a292bb2f0d3d2ec2db565ebb8d5df753454a5a002f033ed5ed39c5
|
||||
size 3853
|
||||
oid sha256:1342b6427f5b2e3075ae3ccee60bf1d3b11afaccf3e152643d6ef2d65c21b54e
|
||||
size 9108
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:520cae2544153ba45f8fe4b021286c2d22da06aa1b60be7f1b13879723ff994c
|
||||
size 3664
|
||||
oid sha256:410a8596d4c70b9a3856d959d6d71ade8e831e8581ff410c9900957a93b3eb50
|
||||
size 8114
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0f3a0cb5d9bae6725a1c527f3d9575aabf85d3edf63a28d9faa23f4f0b65e2d0
|
||||
size 7624
|
||||
oid sha256:520cae2544153ba45f8fe4b021286c2d22da06aa1b60be7f1b13879723ff994c
|
||||
size 3664
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:75de341f705bb22955f9399180519b65ad59c2e1917317d56e50ea4db78f35bc
|
||||
size 12102
|
||||
oid sha256:30357bf18792db5ab20085010251170a724ffe8c00313facd04ee704e806af64
|
||||
size 10982
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4811667dec0f32accc5e2c8a5dd5c0b34017fcff8d1e0376ee7a4708426bca0f
|
||||
size 9838
|
||||
oid sha256:58b9491aaa5554b89d86c62a5c9c44975367538c6baa5562f8dc5768f76d8264
|
||||
size 8705
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:326d59cf768b2dbd96eb7964d54c8eda509a5cd57c6e62087625e3314da0bb4d
|
||||
size 13658
|
||||
oid sha256:16dee1dfe746cd40dac9fe8f0af3ecb42cd14bab5cc490917b589beed065ed1b
|
||||
size 14686
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ef0dc4d53daeb986f378b6e4d85928b4b6729336c89cf21e642c88c53688a3ab
|
||||
size 10292
|
||||
oid sha256:d57df7110371c9224e232b707d6a1b10ce14afcb56c803a78220792f2a0d4cf2
|
||||
size 11354
|
||||
|
||||
@@ -271,6 +271,7 @@
|
||||
"screen_room_message.*",
|
||||
"screen_room_retry.*",
|
||||
"screen_room_timeline.*",
|
||||
"screen\\.room\\.timeline.*",
|
||||
"screen\\.room_timeline.*",
|
||||
"screen_room_typing.*",
|
||||
"screen\\.image_edition\\..*",
|
||||
|
||||
Reference in New Issue
Block a user