Keep the thread of a message that has been deleted

Deleting a message that headed a thread took the thread with it: the "N
replies" summary under the message disappeared and there was no way back to
the replies, which are still there.

Every branch of the timeline content mapper passes `MsgLikeContent`'s thread
information on except the redacted one, which returned a bare
`RedactedContent` and dropped it. The thread summary lives on
`MsgLikeContent` next to `kind`, so it survives the redaction; only the app
was throwing it away. `RedactedContent` now carries the thread info like the
other contents do, and `EventTimelineItem.threadInfo()` reads it.

A deleted message that still heads a thread is also left out of the "N
removed messages" group, which would otherwise hide the summary again as soon
as three deletions ended up next to each other.

Part of #7397
This commit is contained in:
hayaksi1
2026-08-25 21:52:20 +03:00
parent a2d3488426
commit 2278116579
18 changed files with 82 additions and 16 deletions
@@ -91,7 +91,7 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
// Can be grouped // Can be grouped
is FailedToParseMessageLikeContent, is FailedToParseMessageLikeContent,
is MessageContent, is MessageContent,
RedactedContent, is RedactedContent,
is StickerContent, is StickerContent,
is PollContent, is PollContent,
is UnableToDecryptContent, is UnableToDecryptContent,
@@ -12,6 +12,7 @@ import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
@@ -99,7 +100,8 @@ internal const val MIN_REDACTED_RUN_SIZE = 3
* Fold runs of [MIN_REDACTED_RUN_SIZE] or more consecutive deleted (redacted) messages into a single * 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 * [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 * "N removed messages" line, like element-web. Shorter runs, and anything that is not a redacted event
* (day dividers included), are left untouched. * (day dividers included), are left untouched. A deleted message that still heads a thread is left
* untouched too, so that its thread summary stays reachable.
* *
* This runs as the final step of [TimelineItemGrouper.group], after the state and membership events * 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 * have been grouped. It is deliberately kept out of the normal grouping: a run needs a higher minimum
@@ -134,7 +136,7 @@ internal fun List<TimelineItem>.collapseRedactedRuns(groupIds: MutableMap<String
} }
for (item in this) { for (item in this) {
if (item is TimelineItem.Event && item.content is TimelineItemRedactedContent) { if (item is TimelineItem.Event && item.content is TimelineItemRedactedContent && item.threadInfo !is TimelineItemThreadInfo.ThreadRoot) {
run.add(item) run.add(item)
} else { } else {
flushRun() flushRun()
@@ -880,7 +880,7 @@ class TimelinePresenterTest {
(0 until 3).map { index -> (0 until 3).map { index ->
MatrixTimelineItem.Event( MatrixTimelineItem.Event(
uniqueId = UniqueId("redacted_$index"), uniqueId = UniqueId("redacted_$index"),
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = RedactedContent), event = anEventTimelineItem(eventId = EventId("\$R$index"), content = RedactedContent(threadInfo = null)),
) )
} }
), ),
@@ -12,10 +12,15 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
@@ -128,6 +133,43 @@ class TimelineItemsFactoryTest {
} }
} }
@Test
fun `a deleted message keeps the summary of the thread it heads`() = runTest {
val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig(
computeReadReceipts = false,
computeReactions = false,
)
)
val items = listOf(
MatrixTimelineItem.Event(
uniqueId = UniqueId("event-0"),
event = anEventTimelineItem(
sender = A_USER_ID,
content = RedactedContent(
threadInfo = EventThreadInfo.ThreadRoot(
summary = ThreadSummary(latestEvent = AsyncData.Uninitialized, numberOfReplies = 3),
),
),
),
),
)
factory.timelineItems.test {
factory.replaceWith(
timelineItems = items,
roomMembers = emptyList(),
renderReadReceipts = false,
)
val threadInfo = awaitItem()
.filterIsInstance<TimelineItem.Event>()
.single()
.threadInfo
assertThat(threadInfo).isInstanceOf(TimelineItemThreadInfo.ThreadRoot::class.java)
assertThat((threadInfo as TimelineItemThreadInfo.ThreadRoot).summary.numberOfReplies).isEqualTo(3)
cancelAndIgnoreRemainingEvents()
}
}
private suspend fun TestScope.groupPositionsOf(timestamps: List<Long>): List<TimelineItemGroupPosition> { private suspend fun TestScope.groupPositionsOf(timestamps: List<Long>): List<TimelineItemGroupPosition> {
val factory = aTimelineItemsFactory( val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig( config = TimelineItemsFactoryConfig(
@@ -15,11 +15,14 @@ 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.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent 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.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.anAvatarData import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
@@ -190,6 +193,22 @@ class TimelineItemGrouperTest {
) )
} }
@Test
fun `a deleted message that still heads a thread is left out of the removed messages group`() {
val threadRoot = aGroupableItem.copy(
id = UniqueId("r1"),
content = TimelineItemRedactedContent,
threadInfo = TimelineItemThreadInfo.ThreadRoot(
summary = ThreadSummary(latestEvent = AsyncData.Uninitialized, numberOfReplies = 2),
latestEventText = null,
),
)
val r0 = aGroupableItem.copy(id = UniqueId("r0"), content = TimelineItemRedactedContent)
val r2 = aGroupableItem.copy(id = UniqueId("r2"), content = TimelineItemRedactedContent)
val result = sut.group(listOf(r0, threadRoot, r2))
assertThat(result).isEqualTo(listOf(r0, threadRoot, r2))
}
@Test @Test
fun `a run of fewer than three redacted events is left untouched`() { fun `a run of fewer than three redacted events is left untouched`() {
val r0 = aGroupableItem.copy(id = UniqueId("r0"), content = TimelineItemRedactedContent) val r0 = aGroupableItem.copy(id = UniqueId("r0"), content = TimelineItemRedactedContent)
@@ -88,7 +88,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
sender = A_USER_ID, sender = A_USER_ID,
senderProfile = ProfileDetails.Unavailable, senderProfile = ProfileDetails.Unavailable,
timestamp = 9442, timestamp = 9442,
content = RedactedContent, content = RedactedContent(threadInfo = null),
origin = null, origin = null,
timelineItemDebugInfoProvider = { timelineItemDebugInfoProvider = {
TimelineItemDebugInfo( TimelineItemDebugInfo(
@@ -56,7 +56,7 @@ class DefaultPinnedMessagesBannerFormatter(
is PollContent -> { is PollContent -> {
content.question.prefixWith(CommonStrings.a11y_poll) content.question.prefixWith(CommonStrings.a11y_poll)
} }
RedactedContent -> { is RedactedContent -> {
sp.getString(CommonStrings.common_message_removed) sp.getString(CommonStrings.common_message_removed)
} }
else -> { else -> {
@@ -89,7 +89,7 @@ class DefaultRoomLatestEventFormatter(
): CharSequence? { ): CharSequence? {
return when (content) { return when (content) {
is MessageContent -> content.process(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) is MessageContent -> content.process(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
RedactedContent -> { is RedactedContent -> {
val message = sp.getString(CommonStrings.common_message_removed) val message = sp.getString(CommonStrings.common_message_removed)
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
} }
@@ -62,7 +62,7 @@ class DefaultTimelineEventFormatter(
is CallNotifyContent -> { is CallNotifyContent -> {
sp.getString(CommonStrings.common_call_started) sp.getString(CommonStrings.common_call_started)
} }
RedactedContent, is RedactedContent,
is LegacyCallInviteContent, is LegacyCallInviteContent,
is StickerContent, is StickerContent,
is PollContent, is PollContent,
@@ -81,7 +81,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
fun `Redacted content`() { fun `Redacted content`() {
val expected = "Message removed" val expected = "Message removed"
val senderName = "Someone" val senderName = "Someone"
val message = createRoomEvent(false, senderName, RedactedContent) val message = createRoomEvent(false, senderName, RedactedContent(threadInfo = null))
val result = formatter.format(message) val result = formatter.format(message)
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
} }
@@ -85,7 +85,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
val expected = "Message removed" val expected = "Message removed"
val senderName = "Someone" val senderName = "Someone"
sequenceOf(false, true).forEach { isDm -> sequenceOf(false, true).forEach { isDm ->
val message = createLatestEvent(false, senderName, RedactedContent) val message = createLatestEvent(false, senderName, RedactedContent(threadInfo = null))
val result = formatter.format(message, isDm) val result = formatter.format(message, isDm)
if (isDm) { if (isDm) {
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
@@ -32,7 +32,9 @@ data class MessageContent(
val type: MessageType, val type: MessageType,
) : EventContent ) : EventContent
data object RedactedContent : EventContent data class RedactedContent(
val threadInfo: EventThreadInfo?,
) : EventContent
data class StickerContent( data class StickerContent(
val filename: String, val filename: String,
@@ -51,6 +51,7 @@ data class EventTimelineItem(
is PollContent -> content.threadInfo is PollContent -> content.threadInfo
is StickerContent -> content.threadInfo is StickerContent -> content.threadInfo
is UnableToDecryptContent -> content.threadInfo is UnableToDecryptContent -> content.threadInfo
is RedactedContent -> content.threadInfo
else -> null else -> null
} }
@@ -81,7 +81,7 @@ class TimelineEventContentMapper(
) )
} }
is MsgLikeKind.Redacted -> { is MsgLikeKind.Redacted -> {
RedactedContent RedactedContent(threadInfo = extractThreadInfo(it.content))
} }
is MsgLikeKind.Poll -> { is MsgLikeKind.Poll -> {
PollContent( PollContent(
@@ -118,7 +118,7 @@ class InReplyToDetailsDisambiguatedPreviewParam : InReplyToDetailsPreviewParam()
class InReplyToDetailsInformativePreviewParam : InReplyToDetailsPreviewParam() { class InReplyToDetailsInformativePreviewParam : InReplyToDetailsPreviewParam() {
override val values: Sequence<InReplyToDetails> override val values: Sequence<InReplyToDetails>
get() = sequenceOf( get() = sequenceOf(
RedactedContent, RedactedContent(threadInfo = null),
UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null), UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null),
).map { ).map {
aInReplyToDetails( aInReplyToDetails(
@@ -656,7 +656,7 @@ class InReplyToMetadataKtTest : RobolectricTest() {
fun `redacted content`() = runTest { fun `redacted content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady( anInReplyToDetailsReady(
eventContent = RedactedContent eventContent = RedactedContent(threadInfo = null)
).metadata(hideImage = false) ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
@@ -79,7 +79,7 @@ class EventItemFactory(
LegacyCallInviteContent, LegacyCallInviteContent,
is PollContent, is PollContent,
is ProfileChangeContent, is ProfileChangeContent,
RedactedContent, is RedactedContent,
is RoomMembershipContent, is RoomMembershipContent,
is StateContent, is StateContent,
is StickerContent, is StickerContent,
@@ -80,7 +80,7 @@ class EventItemFactoryTest {
LegacyCallInviteContent, LegacyCallInviteContent,
aPollContent(), aPollContent(),
aProfileChangeMessageContent(), aProfileChangeMessageContent(),
RedactedContent, RedactedContent(threadInfo = null),
aRoomMembershipContent( aRoomMembershipContent(
userId = A_USER_ID, userId = A_USER_ID,
), ),