mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
Merge branch 'develop' into feature/valere/timeline_active_call_rendering
This commit is contained in:
+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,
|
||||
@@ -253,3 +261,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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user