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:c5572491b675a16724cd9e33ebb477c82edf285fa07d57ebef09c670fcaaddf1
size 21285
oid sha256:4746d11c30648f7c517b0880021e5a1dc6ed2819af6c800b081b0d842ac2c384
size 5325
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:290a2be6838445739b0181006f9fea87337a85cfed3573c153e9f503c66624a6
size 4994
oid sha256:eede6f990a7a1dbddee1cd9e722b30600160d475ad2ae72faebcb773f52aaa7b
size 20793
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4420dfe9f6f95411093eb1c17825a389a31d7a5d5ef146129e5872cdd5d87aff
size 295295
oid sha256:86eac241e9084cc830ff13d231908da2eb0dff246aa5cac64dddaac68a5d5f13
size 295298
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:578a3707102c7754f607c3a14f94f32cafa30ee570db174386533c484cb4773c
size 295031
oid sha256:7076c9a44a433cd9eeca536365ae262d39b4ad99a8f854faadec272cb1b82031
size 294992
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f358da2b9b0521c14e525bcb2bd41c29c96aacaf5f007546e6d362bde2ee3fb
size 612526
oid sha256:d6441c572f64d99bc119d649e5e6422fe516b9d097f310ccbd99a9ddded9609f
size 612494
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a709de55bc68cf12064ebdbde92c0a99bef3252958d7966540458d253e8c12c2
size 611422
oid sha256:5a867dda82242d2b13bd4bc84a9f1c9b8b7b99c030e19a6cce39b2f828b517f3
size 611488
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:54e71f45db31e8664c42f8c05feb91ae839589e00cb89d12088c15c85fb9f25f
size 607824
oid sha256:e3bb005a88e9a4d2bb850478ee8ac2c4f31d32625bd6d2abf455fade461cd399
size 607806
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8bb505fe6d67669339c9366f8ee5bc75974e223837d95e76e637455a51c18711
size 606787
oid sha256:4a2a57f5547f98b9ac2d8bf7195a1e4b49e03834e8866c6cc76660a6a3b16e74
size 606823
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2a83859b4c8ef66b7d22008760b66af367affd8e6c1d7cdc099a122fea64f924
size 7548
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e5d132c02fe441f9bc654ae5fad86e8409413869637f147a9853ceb8883d9f12
size 7270
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7caacb336793b8ae4b83e9a10a82325e25e8b3c036f881898ed28688250eb6e0
size 55744
oid sha256:26e14b77fa3c3aeaea58782309e6de8b9047e07945009322990ebf5f4c283309
size 52770
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:28a95d4b7ebca92c4e0467f3ecd9acdaf7f6257604d190f5d088a9a25e87d637
size 55305
oid sha256:26e14b77fa3c3aeaea58782309e6de8b9047e07945009322990ebf5f4c283309
size 52770
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0ce2519906f1aaf34700ca9ba4bdeaa72030f1f5aff3f7e24352a1435ac67d98
size 53966
oid sha256:e5a2e306087a967849f61872b56a83f194d1a76df5723f0bbae6e6e2f4a98c63
size 55725
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7dfa2767c55a81d3f121b8fa55443b7b05f9369e91639b80e0be349863799ad7
size 55438
oid sha256:0ce2519906f1aaf34700ca9ba4bdeaa72030f1f5aff3f7e24352a1435ac67d98
size 53966
@@ -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,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e8a3ad99763929d2783147814e44fa9ec7e7efb7b99949e3580f23c38fb5d971
size 9398
oid sha256:21aee17ee9a292bb2f0d3d2ec2db565ebb8d5df753454a5a002f033ed5ed39c5
size 3853
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:21aee17ee9a292bb2f0d3d2ec2db565ebb8d5df753454a5a002f033ed5ed39c5
size 3853
oid sha256:1342b6427f5b2e3075ae3ccee60bf1d3b11afaccf3e152643d6ef2d65c21b54e
size 9108
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:520cae2544153ba45f8fe4b021286c2d22da06aa1b60be7f1b13879723ff994c
size 3664
oid sha256:410a8596d4c70b9a3856d959d6d71ade8e831e8581ff410c9900957a93b3eb50
size 8114
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0f3a0cb5d9bae6725a1c527f3d9575aabf85d3edf63a28d9faa23f4f0b65e2d0
size 7624
oid sha256:520cae2544153ba45f8fe4b021286c2d22da06aa1b60be7f1b13879723ff994c
size 3664
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:75de341f705bb22955f9399180519b65ad59c2e1917317d56e50ea4db78f35bc
size 12102
oid sha256:30357bf18792db5ab20085010251170a724ffe8c00313facd04ee704e806af64
size 10982
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4811667dec0f32accc5e2c8a5dd5c0b34017fcff8d1e0376ee7a4708426bca0f
size 9838
oid sha256:58b9491aaa5554b89d86c62a5c9c44975367538c6baa5562f8dc5768f76d8264
size 8705
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:326d59cf768b2dbd96eb7964d54c8eda509a5cd57c6e62087625e3314da0bb4d
size 13658
oid sha256:16dee1dfe746cd40dac9fe8f0af3ecb42cd14bab5cc490917b589beed065ed1b
size 14686
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ef0dc4d53daeb986f378b6e4d85928b4b6729336c89cf21e642c88c53688a3ab
size 10292
oid sha256:d57df7110371c9224e232b707d6a1b10ce14afcb56c803a78220792f2a0d4cf2
size 11354
+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\\..*",