From 2278116579681b7a129e756d813023783ee311fa Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:52:20 +0300 Subject: [PATCH] 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 --- .../impl/timeline/groups/Groupability.kt | 2 +- .../timeline/groups/TimelineItemGrouper.kt | 6 ++- .../impl/timeline/TimelinePresenterTest.kt | 2 +- .../factories/TimelineItemsFactoryTest.kt | 42 +++++++++++++++++++ .../groups/TimelineItemGrouperTest.kt | 19 +++++++++ .../RedactedVoiceMessageManagerTest.kt | 2 +- .../DefaultPinnedMessagesBannerFormatter.kt | 2 +- .../impl/DefaultRoomLatestEventFormatter.kt | 2 +- .../impl/DefaultTimelineEventFormatter.kt | 2 +- ...efaultPinnedMessagesBannerFormatterTest.kt | 2 +- .../DefaultRoomLatestEventFormatterTest.kt | 2 +- .../api/timeline/item/event/EventContent.kt | 4 +- .../timeline/item/event/EventTimelineItem.kt | 1 + .../item/event/TimelineEventContentMapper.kt | 2 +- .../reply/InReplyToDetailsPreviewParam.kt | 2 +- .../messages/reply/InReplyToMetadataKtTest.kt | 2 +- .../impl/datasource/EventItemFactory.kt | 2 +- .../impl/datasource/EventItemFactoryTest.kt | 2 +- 18 files changed, 82 insertions(+), 16 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt index 431942378a..3b16f79f79 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt @@ -91,7 +91,7 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean { // Can be grouped is FailedToParseMessageLikeContent, is MessageContent, - RedactedContent, + is RedactedContent, is StickerContent, is PollContent, is UnableToDecryptContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt index 2e01e53828..b34c66a08d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt @@ -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.TimelineItemThreadInfo 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 @@ -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 * [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. + * (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 * 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.collapseRedactedRuns(groupIds: MutableMap MatrixTimelineItem.Event( uniqueId = UniqueId("redacted_$index"), - event = anEventTimelineItem(eventId = EventId("\$R$index"), content = RedactedContent), + event = anEventTimelineItem(eventId = EventId("\$R$index"), content = RedactedContent(threadInfo = null)), ) } ), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt index f93c8f196e..eb3b0529ca 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt @@ -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.timeline.model.TimelineItem 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.libraries.architecture.AsyncData 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.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.RedactedContent 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.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() + .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): List { val factory = aTimelineItemsFactory( config = TimelineItemsFactoryConfig( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt index 005eb5730e..f6ef4f0fc2 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt @@ -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.TimelineItem 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.TimelineItemStateEventContent 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.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.test.AN_EVENT_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 fun `a run of fewer than three redacted events is left untouched`() { val r0 = aGroupableItem.copy(id = UniqueId("r0"), content = TimelineItemRedactedContent) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt index a1ffd1f02b..73cfce399e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt @@ -88,7 +88,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf( sender = A_USER_ID, senderProfile = ProfileDetails.Unavailable, timestamp = 9442, - content = RedactedContent, + content = RedactedContent(threadInfo = null), origin = null, timelineItemDebugInfoProvider = { TimelineItemDebugInfo( diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt index 1311c524e4..8025591070 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt @@ -56,7 +56,7 @@ class DefaultPinnedMessagesBannerFormatter( is PollContent -> { content.question.prefixWith(CommonStrings.a11y_poll) } - RedactedContent -> { + is RedactedContent -> { sp.getString(CommonStrings.common_message_removed) } else -> { diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt index e7bbdb1e7f..bd519b60c2 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt @@ -89,7 +89,7 @@ class DefaultRoomLatestEventFormatter( ): CharSequence? { return when (content) { is MessageContent -> content.process(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) - RedactedContent -> { + is RedactedContent -> { val message = sp.getString(CommonStrings.common_message_removed) message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) } diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt index ff5cce7a59..ffb98fe2ec 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt @@ -62,7 +62,7 @@ class DefaultTimelineEventFormatter( is CallNotifyContent -> { sp.getString(CommonStrings.common_call_started) } - RedactedContent, + is RedactedContent, is LegacyCallInviteContent, is StickerContent, is PollContent, diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt index 198da2bf7d..38517dab33 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt @@ -81,7 +81,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() { fun `Redacted content`() { val expected = "Message removed" val senderName = "Someone" - val message = createRoomEvent(false, senderName, RedactedContent) + val message = createRoomEvent(false, senderName, RedactedContent(threadInfo = null)) val result = formatter.format(message) assertThat(result).isEqualTo(expected) } diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt index ecd0509269..c293b67975 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt @@ -85,7 +85,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() { val expected = "Message removed" val senderName = "Someone" 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) if (isDm) { assertThat(result).isEqualTo(expected) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index c2d388ecb0..132b3b3504 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -32,7 +32,9 @@ data class MessageContent( val type: MessageType, ) : EventContent -data object RedactedContent : EventContent +data class RedactedContent( + val threadInfo: EventThreadInfo?, +) : EventContent data class StickerContent( val filename: String, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index 91703f7fa4..8a3221677d 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -51,6 +51,7 @@ data class EventTimelineItem( is PollContent -> content.threadInfo is StickerContent -> content.threadInfo is UnableToDecryptContent -> content.threadInfo + is RedactedContent -> content.threadInfo else -> null } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index 98efa505d0..c8434e207a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -81,7 +81,7 @@ class TimelineEventContentMapper( ) } is MsgLikeKind.Redacted -> { - RedactedContent + RedactedContent(threadInfo = extractThreadInfo(it.content)) } is MsgLikeKind.Poll -> { PollContent( diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsPreviewParam.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsPreviewParam.kt index 1b48e0eb9b..ce9fd884eb 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsPreviewParam.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsPreviewParam.kt @@ -118,7 +118,7 @@ class InReplyToDetailsDisambiguatedPreviewParam : InReplyToDetailsPreviewParam() class InReplyToDetailsInformativePreviewParam : InReplyToDetailsPreviewParam() { override val values: Sequence get() = sequenceOf( - RedactedContent, + RedactedContent(threadInfo = null), UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null), ).map { aInReplyToDetails( diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt index 3fcb2f8f18..64e647ab19 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt @@ -656,7 +656,7 @@ class InReplyToMetadataKtTest : RobolectricTest() { fun `redacted content`() = runTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( - eventContent = RedactedContent + eventContent = RedactedContent(threadInfo = null) ).metadata(hideImage = false) }.test { awaitItem().let { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt index 40a9a0351d..f140fc6628 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt @@ -79,7 +79,7 @@ class EventItemFactory( LegacyCallInviteContent, is PollContent, is ProfileChangeContent, - RedactedContent, + is RedactedContent, is RoomMembershipContent, is StateContent, is StickerContent, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt index 4ed89e8923..56dd5fc937 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt @@ -80,7 +80,7 @@ class EventItemFactoryTest { LegacyCallInviteContent, aPollContent(), aProfileChangeMessageContent(), - RedactedContent, + RedactedContent(threadInfo = null), aRoomMembershipContent( userId = A_USER_ID, ),