diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 4e62b8649f..5b5319f952 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -30,8 +30,8 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo -import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -154,13 +154,14 @@ internal fun aTimelineItemDebugInfo( model, originalJson, latestEditedJson ) -fun aGroupedEvents(): TimelineItem.GroupedEvents { +fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents { val event = aTimelineItemEvent( isMine = true, content = aTimelineItemStateEventContent(), groupPosition = TimelineItemGroupPosition.None ) return TimelineItem.GroupedEvents( + id = id.toString(), events = listOf( event, event, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 3bbaffab9f..0404edf7db 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -262,6 +262,7 @@ private fun BoxScope.TimelineScrollHelper( } JumpToBottomButton( + // Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered isVisible = !canAutoScroll, modifier = Modifier .align(Alignment.BottomEnd) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index 53034131dc..abbbeccf37 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -572,6 +572,8 @@ class MessagesPresenterTest { val timelinePresenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), room = matrixRoom, + dispatchers = coroutineDispatchers, + appScope = this ) val buildMeta = aBuildMeta() val actionListPresenter = ActionListPresenter(buildMeta = buildMeta) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt index 088df6060f..4f1edcb64f 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt @@ -52,4 +52,5 @@ internal fun aMessageEvent( localSendState = sendState, inReplyTo = inReplyTo, debugInfo = debugInfo, + origin = null ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt index 2835155b14..ad6e41e483 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt @@ -23,22 +23,25 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.messages.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelinePresenter +import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory +import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aMessageContent import io.element.android.libraries.matrix.test.room.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline +import io.element.android.tests.testutils.awaitWithLatch +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test class TimelinePresenterTest { @Test fun `present - initial state`() = runTest { - val presenter = TimelinePresenter( - timelineItemsFactory = aTimelineItemsFactory(), - room = FakeMatrixRoom(), - ) + val presenter = createTimelinePresenter() moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -51,10 +54,7 @@ class TimelinePresenterTest { @Test fun `present - load more`() = runTest { - val presenter = TimelinePresenter( - timelineItemsFactory = aTimelineItemsFactory(), - room = FakeMatrixRoom(), - ) + val presenter = createTimelinePresenter() moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -73,10 +73,7 @@ class TimelinePresenterTest { @Test fun `present - set highlighted event`() = runTest { - val presenter = TimelinePresenter( - timelineItemsFactory = aTimelineItemsFactory(), - room = FakeMatrixRoom(), - ) + val presenter = createTimelinePresenter() moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -94,70 +91,106 @@ class TimelinePresenterTest { @Test fun `present - on scroll finished send read receipt if an event is before the index`() = runTest { - val timeline = FakeMatrixTimeline() - val timelineItemsFactory = aTimelineItemsFactory().apply { - replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem()))) - } - val room = FakeMatrixRoom(matrixTimeline = timeline) - val presenter = TimelinePresenter( - timelineItemsFactory = timelineItemsFactory, - room = room, + val timeline = FakeMatrixTimeline( + initialTimelineItems = listOf( + MatrixTimelineItem.Event(0, anEventTimelineItem()) + ) ) + val presenter = createTimelinePresenter(timeline) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { assertThat(timeline.sendReadReceiptCount).isEqualTo(0) val initialState = awaitItem() - - initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) - + awaitWithLatch { latch -> + timeline.sendReadReceiptLatch = latch + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) + } assertThat(timeline.sendReadReceiptCount).isEqualTo(1) + cancelAndIgnoreRemainingEvents() } } @Test fun `present - on scroll finished will not send read receipt no event is before the index`() = runTest { - val timeline = FakeMatrixTimeline() - val timelineItemsFactory = aTimelineItemsFactory().apply { - replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem()))) - } - val room = FakeMatrixRoom(matrixTimeline = timeline) - val presenter = TimelinePresenter( - timelineItemsFactory = timelineItemsFactory, - room = room, + val timeline = FakeMatrixTimeline( + initialTimelineItems = listOf( + MatrixTimelineItem.Event(0, anEventTimelineItem()) + ) ) + val presenter = createTimelinePresenter(timeline) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { assertThat(timeline.sendReadReceiptCount).isEqualTo(0) val initialState = awaitItem() - - initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) - + awaitWithLatch { latch -> + timeline.sendReadReceiptLatch = latch + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) + } assertThat(timeline.sendReadReceiptCount).isEqualTo(0) + cancelAndIgnoreRemainingEvents() } } @Test fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest { - val timeline = FakeMatrixTimeline() - val timelineItemsFactory = aTimelineItemsFactory().apply { - replaceWith(listOf(MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker))) - } - val room = FakeMatrixRoom(matrixTimeline = timeline) - val presenter = TimelinePresenter( - timelineItemsFactory = timelineItemsFactory, - room = room, + val timeline = FakeMatrixTimeline( + initialTimelineItems = listOf( + MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker) + ) ) + val presenter = createTimelinePresenter(timeline) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { assertThat(timeline.sendReadReceiptCount).isEqualTo(0) val initialState = awaitItem() - - initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) - + awaitWithLatch { latch -> + timeline.sendReadReceiptLatch = latch + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) + } assertThat(timeline.sendReadReceiptCount).isEqualTo(0) + cancelAndIgnoreRemainingEvents() } } + + @Test + fun `present - covers hasNewItems scenarios`() = runTest { + val timeline = FakeMatrixTimeline() + val presenter = createTimelinePresenter(timeline) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.hasNewItems).isFalse() + assertThat(initialState.timelineItems.size).isEqualTo(0) + timeline.updateTimelineItems { + listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(content = aMessageContent()))) + } + skipItems(1) + assertThat(awaitItem().timelineItems.size).isEqualTo(1) + timeline.updateTimelineItems { items -> + items + listOf(MatrixTimelineItem.Event(1, anEventTimelineItem(content = aMessageContent()))) + } + skipItems(1) + assertThat(awaitItem().timelineItems.size).isEqualTo(2) + assertThat(awaitItem().hasNewItems).isTrue() + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) + assertThat(awaitItem().hasNewItems).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + private fun TestScope.createTimelinePresenter( + timeline: MatrixTimeline = FakeMatrixTimeline(), + timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory() + ): TimelinePresenter { + return TimelinePresenter( + timelineItemsFactory = timelineItemsFactory, + room = FakeMatrixRoom(matrixTimeline = timeline), + dispatchers = testCoroutineDispatchers(), + appScope = this + ) + } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt index 3cab1fe44c..71836e9f5e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt @@ -45,6 +45,7 @@ class TimelineItemGrouperTest { localSendState = LocalEventSendState.Sent(AN_EVENT_ID), inReplyTo = null, debugInfo = aTimelineItemDebugInfo(), + origin = null ) private val aNonGroupableItem = aMessageEvent() private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today")) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt index 0a956577ca..d9cd6dc867 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt @@ -25,14 +25,17 @@ import io.element.android.libraries.matrix.api.room.message.RoomMessage import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.EventContent 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.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageType import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME -import io.element.android.libraries.matrix.test.A_UNIQUE_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME @@ -114,6 +117,7 @@ fun anEventTimelineItem( timestamp = timestamp, content = content, debugInfo = debugInfo, + origin = null, ) fun aProfileTimelineDetails( @@ -138,6 +142,21 @@ fun aProfileChangeMessageContent( prevAvatarUrl = prevAvatarUrl, ) +fun aMessageContent( + body: String = "body", + inReplyTo: InReplyTo? = null, + isEdited: Boolean = false, + messageType: MessageType = TextMessageType( + body = body, + formatted = null + ) +) = MessageContent( + body = body, + inReplyTo = inReplyTo, + isEdited = isEdited, + type = messageType +) + fun aTimelineItemDebugInfo( model: String = "Rust(Model())", originalJson: String? = null, @@ -145,3 +164,4 @@ fun aTimelineItemDebugInfo( ) = TimelineItemDebugInfo( model, originalJson, latestEditedJson ) + diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt index b49a5490ce..73bc5fb597 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt @@ -19,6 +19,8 @@ package io.element.android.libraries.matrix.test.timeline import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -36,6 +38,8 @@ class FakeMatrixTimeline( var sendReadReceiptCount = 0 private set + var sendReadReceiptLatch: CompletableDeferred? = null + fun updatePaginationState(update: (MatrixTimeline.PaginationState.() -> MatrixTimeline.PaginationState)) { _paginationState.getAndUpdate(update) } @@ -62,13 +66,13 @@ class FakeMatrixTimeline( return Result.success(Unit) } - - override suspend fun fetchDetailsForEvent(eventId: EventId): Result { - return Result.success(Unit) + override suspend fun fetchDetailsForEvent(eventId: EventId): Result = simulateLongTask { + Result.success(Unit) } - override suspend fun sendReadReceipt(eventId: EventId): Result { + override suspend fun sendReadReceipt(eventId: EventId): Result = simulateLongTask { sendReadReceiptCount++ - return Result.success(Unit) + sendReadReceiptLatch?.complete(Unit) + Result.success(Unit) } } diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt index 154877efe9..8a5158dbf8 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt @@ -16,7 +16,12 @@ package io.element.android.tests.testutils +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds /** * Workaround for https://github.com/cashapp/molecule/issues/249. @@ -26,3 +31,18 @@ suspend inline fun simulateLongTask(lambda: () -> T): T { delay(1) return lambda() } + +/** + * Can be used for testing events in Presenter, where the event does not emit new state. + * If the (virtual) timeout is passed, we release the latch manually. + */ +suspend fun awaitWithLatch(timeout: Duration = 300.milliseconds, block: (CompletableDeferred) -> Unit) { + val latch = CompletableDeferred() + try { + withTimeout(timeout) { + latch.also(block).await() + } + } catch (exception: TimeoutCancellationException) { + latch.complete(Unit) + } +}