Merge remote-tracking branch 'origin/develop' into HEAD

This commit is contained in:
bxdxnn
2026-08-27 18:40:10 +00:00
596 changed files with 5364 additions and 2447 deletions
@@ -43,7 +43,7 @@ import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPr
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.roommembermoderation.api.ModerationAction
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
import io.element.android.features.roommembermoderation.api.RoomMemberModerationRenderer
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
@@ -348,7 +348,7 @@ class MessagesNode(
onSelectAction = { action, target ->
when (action) {
is ModerationAction.DisplayProfile -> callback.navigateToRoomMemberDetails(target.userId)
else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target))
else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvent.ProcessAction(action, target))
}
},
onAvatarClick = { user ->
@@ -51,10 +51,11 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncData
@@ -263,7 +264,7 @@ class MessagesPresenter(
}
}
is MessagesEvent.OnUserClicked -> {
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
roomMemberModerationState.eventSink(RoomMemberModerationEvent.ShowActionsForUser(event.user))
}
MessagesEvent.StopLiveLocationShare -> {
localCoroutineScope.launch {
@@ -377,7 +378,7 @@ class MessagesPresenter(
TimelineItemAction.Edit,
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState, timelineProtectionState)
TimelineItemAction.ReplyInThread -> {
@@ -546,10 +547,15 @@ class MessagesPresenter(
private suspend fun handleActionEditCaption(
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,
enableTextFormatting: Boolean,
) {
val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId,
content = targetEvent.content.captionOrNull().orEmpty(),
content = if (enableTextFormatting) {
targetEvent.content.htmlCaptionOrNull() ?: targetEvent.content.captionOrNull()
} else {
targetEvent.content.captionOrNull()
}.orEmpty(),
)
composerState.eventSink(
MessageComposerEvent.SetMode(composerMode)
@@ -38,7 +38,7 @@ import io.element.android.features.messages.impl.timeline.protection.TimelinePro
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData
@@ -168,7 +168,7 @@ fun aRoomMemberModerationState(
permissions: RoomMemberModerationPermissions = RoomMemberModerationPermissions.DEFAULT,
) = object : RoomMemberModerationState {
override val permissions: RoomMemberModerationPermissions = permissions
override val eventSink: (RoomMemberModerationEvents) -> Unit = {}
override val eventSink: (RoomMemberModerationEvent) -> Unit = {}
}
fun aUserEventPermissions(
@@ -46,7 +46,7 @@ import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPr
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.roommembermoderation.api.ModerationAction
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
import io.element.android.features.roommembermoderation.api.RoomMemberModerationRenderer
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
@@ -343,7 +343,7 @@ class ThreadedMessagesNode(
onSelectAction = { action, target ->
when (action) {
is ModerationAction.DisplayProfile -> callback.navigateToRoomMemberDetails(target.userId)
else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target))
else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvent.ProcessAction(action, target))
}
},
onAvatarClick = { user ->
@@ -0,0 +1,12 @@
/*
* 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.threads.list
sealed interface ThreadsListEvent {
data object Paginate : ThreadsListEvent
}
@@ -20,13 +20,10 @@ import io.element.android.features.messages.impl.utils.messagesummary.MessageSum
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.onStart
@@ -104,9 +101,9 @@ class ThreadsListPresenter(
derivedStateOf { roomInfo.heroes.map { it.getAvatarData(AvatarSize.CurrentUserTopBar) }.toImmutableList() }
}
fun handleEvent(event: ThreadsListEvents) {
fun handleEvent(event: ThreadsListEvent) {
when (event) {
ThreadsListEvents.Paginate -> if ((paginationStatus as? ThreadListPaginationStatus.Idle)?.hasMoreToLoad == true) {
ThreadsListEvent.Paginate -> if ((paginationStatus as? ThreadListPaginationStatus.Idle)?.hasMoreToLoad == true) {
coroutineScope.launch {
Timber.d("Paginating thread list: $paginationStatus")
threadsListService.paginate()
@@ -129,17 +126,3 @@ class ThreadsListPresenter(
)
}
}
data class ThreadsListState(
val roomId: RoomId,
val roomName: String,
val roomAvatarUrl: String?,
val isRoomTombstoned: Boolean,
val heroes: ImmutableList<AvatarData>,
val threads: ImmutableList<ThreadListRowItem>,
val eventSink: (ThreadsListEvents) -> Unit,
)
sealed interface ThreadsListEvents {
data object Paginate : ThreadsListEvents
}
@@ -0,0 +1,22 @@
/*
* 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.threads.list
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
data class ThreadsListState(
val roomId: RoomId,
val roomName: String,
val roomAvatarUrl: String?,
val isRoomTombstoned: Boolean,
val heroes: ImmutableList<AvatarData>,
val threads: ImmutableList<ThreadListRowItem>,
val eventSink: (ThreadsListEvent) -> Unit,
)
@@ -156,7 +156,7 @@ fun ThreadsListView(
}
ScrollHelper(lazyListState) {
state.eventSink(ThreadsListEvents.Paginate)
state.eventSink(ThreadsListEvent.Paginate)
}
}
}
@@ -108,6 +108,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
@@ -132,6 +133,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
@@ -174,6 +176,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source,
@@ -198,6 +201,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@@ -216,6 +220,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@@ -235,6 +240,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source,
@@ -345,6 +351,7 @@ class TimelineItemContentMessageFactory(
body = messageType.body,
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
formattedCaption = formattedCaption,
htmlCaption = messageType.formatted?.body,
isEdited = content.isEdited,
items = galleryItems.toImmutableList(),
)
@@ -402,6 +409,7 @@ class TimelineItemContentMessageFactory(
body = messageType.body,
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
formattedCaption = formattedCaption,
htmlCaption = messageType.formatted?.body,
isEdited = content.isEdited,
attachments = attachments.toImmutableList(),
)
@@ -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,
@@ -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<TimelineItem>.collapseRedactedRuns(groupIds: MutableMap<String
}
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)
} else {
flushRun()
@@ -14,6 +14,7 @@ data class TimelineItemAttachmentsContent(
val body: String,
val caption: String?,
val formattedCaption: CharSequence?,
val htmlCaption: String? = null,
override val isEdited: Boolean,
val attachments: ImmutableList<AttachmentItem>,
) : TimelineItemEventContent, TimelineItemEventMutableContent {
@@ -17,6 +17,7 @@ data class TimelineItemAudioContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
val duration: Duration,
override val mediaSource: MediaSource,
@@ -33,6 +33,9 @@ sealed interface TimelineItemEventContentWithAttachment :
val fileSize: Long?
val caption: String?
val formattedCaption: CharSequence?
/** The caption as it was written, in HTML, so that editing it can start from the formatted text. */
val htmlCaption: String?
val mediaSource: MediaSource
val mimeType: String
val formattedFileSize: String
@@ -127,6 +130,13 @@ fun TimelineItemEventContent.formattedCaptionOrNull(): CharSequence? = when (thi
else -> null
}
fun TimelineItemEventContent.htmlCaptionOrNull(): String? = when (this) {
is TimelineItemEventContentWithAttachment -> htmlCaption
is TimelineItemGalleryContent -> htmlCaption
is TimelineItemAttachmentsContent -> htmlCaption
else -> null
}
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
return when (this) {
is TimelineItemAudioContent -> duration
@@ -16,6 +16,7 @@ data class TimelineItemFileContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -16,6 +16,7 @@ data class TimelineItemGalleryContent(
val body: String,
val caption: String?,
val formattedCaption: CharSequence?,
val htmlCaption: String? = null,
override val isEdited: Boolean,
val items: ImmutableList<GalleryItem>,
) : TimelineItemEventContent, TimelineItemEventMutableContent {
@@ -19,6 +19,7 @@ data class TimelineItemImageContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -28,6 +28,7 @@ fun aTimelineItemImageContent(
blurhash: String? = A_BLUR_HASH,
filename: String = "A picture.jpg",
caption: String? = null,
htmlCaption: String? = null,
width: Int? = null,
height: Int? = 300,
) = TimelineItemImageContent(
@@ -35,6 +36,7 @@ fun aTimelineItemImageContent(
fileSize = 4 * 1024 * 1024L,
caption = caption,
formattedCaption = null,
htmlCaption = htmlCaption,
isEdited = false,
mediaSource = MediaSource(""),
thumbnailSource = null,
@@ -15,6 +15,7 @@ data class TimelineItemStickerContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -16,6 +16,7 @@ data class TimelineItemVideoContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
val duration: Duration,
override val mediaSource: MediaSource,
@@ -19,6 +19,7 @@ data class TimelineItemVoiceContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
val duration: Duration,
override val mediaSource: MediaSource,
@@ -989,6 +989,33 @@ class MessagesPresenterTest {
}
}
@Test
fun `present - handle action edit caption starts from the formatted caption`() = runTest {
val messageEvent = aMessageEvent(
content = aTimelineItemImageContent(
caption = "Hello world",
htmlCaption = "<b>Hello</b> world",
)
)
val composerRecorder = EventsRecorder<MessageComposerEvent>()
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder, showTextFormatting = true) },
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.EditCaption, messageEvent))
awaitItem()
composerRecorder.assertSingle(
MessageComposerEvent.SetMode(
composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
content = "<b>Hello</b> world",
)
)
)
}
}
@Test
fun `present - handle action add caption`() = runTest {
val composerRecorder = EventsRecorder<MessageComposerEvent>()
@@ -10,7 +10,7 @@ package io.element.android.features.messages.impl.threads
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.fixtures.aTimelineItemContentFactory
import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter
import io.element.android.features.messages.impl.threads.list.ThreadsListEvents
import io.element.android.features.messages.impl.threads.list.ThreadsListEvent
import io.element.android.features.messages.impl.threads.list.ThreadsListPresenter
import io.element.android.features.messages.impl.threads.list.aThreadListItem
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
@@ -48,7 +48,7 @@ class ThreadsListPresenterTest {
// Pagination is automatically triggered on start, so we should have one call to paginate already
paginateRecorder.assertions().isCalledOnce()
initialItem.eventSink(ThreadsListEvents.Paginate)
initialItem.eventSink(ThreadsListEvent.Paginate)
// Simulate a pagination result
threadsListService.emit(listOf(aThreadListItem()))
@@ -51,7 +51,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
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
@@ -69,6 +68,7 @@ import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
@@ -880,7 +880,7 @@ class TimelinePresenterTest {
(0 until 3).map { index ->
MatrixTimelineItem.Event(
uniqueId = UniqueId("redacted_$index"),
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = RedactedContent),
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = aRedactedContent()),
)
}
),
@@ -12,12 +12,18 @@ 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.test.A_THREAD_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.aRedactedContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
@@ -84,6 +90,42 @@ class TimelineItemsFactoryTest {
).inOrder()
}
@Test
fun `redacted message keeps its thread info`() = 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,
timestamp = 0L,
content = aRedactedContent(
threadInfo = EventThreadInfo.ThreadResponse(A_THREAD_ID),
),
),
)
)
var threadInfo: TimelineItemThreadInfo? = null
factory.timelineItems.test {
factory.replaceWith(
timelineItems = items,
roomMembers = emptyList(),
renderReadReceipts = false,
)
threadInfo = awaitItem()
.filterIsInstance<TimelineItem.Event>()
.firstOrNull()
?.threadInfo
cancelAndIgnoreRemainingEvents()
}
assertThat(threadInfo).isEqualTo(TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID))
}
@Test
fun `a key verification request is not rendered`() = runTest {
val factory = aTimelineItemsFactory(
@@ -128,6 +170,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 = aRedactedContent(
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> {
val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig(
@@ -297,6 +297,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
fileSize = 555L,
caption = "body.mp4 caption",
formattedCaption = SpannedString("formatted"),
htmlCaption = "formatted",
isEdited = true,
duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null),
@@ -539,6 +540,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
fileSize = 888L,
caption = "body.jpg caption",
formattedCaption = SpannedString("formatted"),
htmlCaption = "formatted",
isEdited = true,
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
@@ -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)
@@ -16,11 +16,11 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.core.FakeSendHandle
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -88,7 +88,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
sender = A_USER_ID,
senderProfile = ProfileDetails.Unavailable,
timestamp = 9442,
content = RedactedContent,
content = aRedactedContent(),
origin = null,
timelineItemDebugInfoProvider = {
TimelineItemDebugInfo(