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:
Jorge Martin Espinosa
2026-07-13 15:57:55 +02:00
committed by GitHub
parent 03304b3ece
commit f9112cc583
37 changed files with 477 additions and 54 deletions
@@ -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,
@@ -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 = {},
)
}
@@ -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.
@@ -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>
@@ -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)
}
}
@@ -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>())
@@ -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()
}
}
@@ -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",
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d19e7a09114e76294a0626e5d617cf55fe7c13a0188a7d5b364f57c0d9daedc1
size 12123
oid sha256:b5dc5c492ed3fd552e77c0be85b639d55bc7e976f15b761f927b0b80ecca601d
size 12115
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:42f03759fcffbb750181168256130b6a94a1fcf91ee028e69a19a6e3b68ed0e8
size 11711
oid sha256:5896eb5df6f5398bdb94e74280d0d5c04b6a5a53c2378b7377c60fa58eef544b
size 12013
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6bbc8c05e6f6818d00282228f52424ebe93e6e3dca3431d742e8c652d56460d6
size 11712
oid sha256:e7ce8a8c6b4d1692e4b127b7f17f507552666c631893025082c385b93d835a73
size 11726
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:66f6d0d6b03ee4c999cd4edc9c6ae567d31b8b537ca6f9cc8fbe436d06ce80ab
size 11643
oid sha256:8b93c08531efbeacb55f84b17ac793a7b226e5a2db7bc5072e94bc040cd1e9bc
size 11386
+1
View File
@@ -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\\..*",