Merge branch 'develop' into feature/valere/timeline_active_call_rendering

This commit is contained in:
Valere
2026-07-13 15:59:48 +02:00
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,
@@ -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 = {},
)
}
@@ -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\\..*",