diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index db46bc0c86..5825d5acfb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -288,7 +288,7 @@ class MessagesPresenter @AssistedInject constructor( emoji: String, eventId: EventId, ) = launch(dispatchers.io) { - timelineController.invokeOnTimeline { + timelineController.invokeOnCurrentTimeline { toggleReaction(emoji, eventId) .onFailure { Timber.e(it) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt index 367a071084..fd26109e0a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.TimelineProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope @@ -37,8 +38,8 @@ import kotlinx.coroutines.launch class ForwardMessagesPresenter @AssistedInject constructor( @Assisted eventId: String, - private val room: MatrixRoom, private val matrixCoroutineScope: CoroutineScope, + private val timelineProvider: TimelineProvider, ) : Presenter { private val eventId: EventId = EventId(eventId) @@ -79,7 +80,7 @@ class ForwardMessagesPresenter @AssistedInject constructor( isForwardMessagesState: MutableState>>, ) = launch { isForwardMessagesState.value = AsyncData.Loading() - room.forwardEvent(eventId, roomIds).fold( + timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds).fold( { isForwardMessagesState.value = AsyncData.Success(roomIds) }, { isForwardMessagesState.value = AsyncData.Failure(it) } ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index f7072922c7..30bdadcbe5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -266,7 +266,7 @@ class MessageComposerPresenter @Inject constructor( is MessageComposerMode.Quote -> null }.let { relatedEventId -> appCoroutineScope.launch { - timelineController.invokeOnTimeline { + timelineController.invokeOnCurrentTimeline { enterSpecialMode(relatedEventId) } } @@ -390,14 +390,14 @@ class MessageComposerPresenter @Inject constructor( is MessageComposerMode.Edit -> { val eventId = capturedMode.eventId val transactionId = capturedMode.transactionId - timelineController.invokeOnTimeline { + timelineController.invokeOnCurrentTimeline { editMessage(eventId, transactionId, message.markdown, message.html, mentions) } } is MessageComposerMode.Quote -> TODO() is MessageComposerMode.Reply -> { - timelineController.invokeOnTimeline { + timelineController.invokeOnCurrentTimeline { replyMessage(capturedMode.eventId, message.markdown, message.html, mentions) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt index 811ad18075..ce6bdfc7d3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt @@ -16,9 +16,7 @@ package io.element.android.features.messages.impl.timeline -import androidx.compose.runtime.MutableState import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.api.core.EventId @@ -26,9 +24,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.timeline.LiveTimelineProvider import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem -import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline -import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -65,7 +61,7 @@ class TimelineController @Inject constructor( return detachedTimeline.map { !it.isPresent } } - suspend fun invokeOnTimeline(block: suspend (Timeline.() -> Any)) { + suspend fun invokeOnCurrentTimeline(block: suspend (Timeline.() -> Any)) { currentTimelineFlow().first().run { block(this) } @@ -124,37 +120,6 @@ class TimelineController @Inject constructor( } } - suspend fun sendReadReceiptIfNeeded( - firstVisibleIndex: Int, - timelineItems: ImmutableList, - lastReadReceiptId: MutableState, - readReceiptType: ReceiptType, - ) { - // If we are at the bottom of timeline, we mark the room as read. - if (firstVisibleIndex == 0) { - room.markAsRead(receiptType = readReceiptType) - } else { - // Get last valid EventId seen by the user, as the first index might refer to a Virtual item - val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems) - if (eventId != null && eventId != lastReadReceiptId.value) { - lastReadReceiptId.value = eventId - currentTimelineFlow() - .first() - .sendReadReceipt(eventId = eventId, receiptType = readReceiptType) - } - } - } - - private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList): EventId? { - for (i in index until items.count()) { - val item = items[i] - if (item is TimelineItem.Event) { - return item.eventId - } - } - return null - } - override suspend fun getActiveTimeline(): Timeline { return currentTimelineFlow().first() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 570c614603..f3ba877888 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -111,6 +111,7 @@ class TimelinePresenter @AssistedInject constructor( if (event.firstIndex == 0) { newEventState.value = NewEventState.None } + println("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}") appScope.sendReadReceiptIfNeeded( firstVisibleIndex = event.firstIndex, timelineItems = timelineItems, @@ -256,7 +257,7 @@ class TimelinePresenter @AssistedInject constructor( val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems) if (eventId != null && eventId != lastReadReceiptId.value) { lastReadReceiptId.value = eventId - //timeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType) + room.liveTimeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType) } } } 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 477314e20e..f15a0807cd 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 @@ -116,10 +116,6 @@ fun TimelineView( // TODO implement this logic once we have support to 'jump to event X' in sliding sync } - LaunchedEffect(key1 = state.timelineItems) { - Timber.d("TimelineView - timelineItem identifiers: ${state.timelineItems.joinToString(", ") { it.identifier() }}") - } - // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms AnimatedVisibility(visible = true, enter = fadeIn()) { Box(modifier) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt index 34049fbaf7..ac9803506c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -62,10 +62,6 @@ class TimelineItemsFactory @Inject constructor( } } - fun items(): StateFlow> { - return timelineItems - } - @Composable fun collectItemsAsState(): State> { return timelineItems.collectAsState() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 8df2c6d705..78dc7612a8 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -31,6 +31,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter import io.element.android.features.messages.impl.textcomposer.TestRichTextEditorStateFactory +import io.element.android.features.messages.impl.timeline.TimelineController +import io.element.android.features.messages.impl.timeline.TimelineItemIndexer import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter import io.element.android.features.messages.impl.timeline.components.customreaction.FakeEmojibaseProvider @@ -748,6 +750,7 @@ class MessagesPresenterTest { currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)), permalinkParser = FakePermalinkParser(), permalinkBuilder = FakePermalinkBuilder(), + timelineController = TimelineController(matrixRoom), ) val voiceMessageComposerPresenter = VoiceMessageComposerPresenter( this, @@ -768,6 +771,8 @@ class MessagesPresenterTest { endPollAction = endPollAction, sendPollResponseAction = FakeSendPollResponseAction(), sessionPreferencesStore = sessionPreferencesStore, + timelineItemIndexer = TimelineItemIndexer(), + timelineController = TimelineController(matrixRoom), ) val timelinePresenterFactory = object : TimelinePresenter.Factory { override fun create(navigator: MessagesNavigator): TimelinePresenter { @@ -804,6 +809,7 @@ class MessagesPresenterTest { buildMeta = aBuildMeta(), dispatchers = coroutineDispatchers, htmlConverterProvider = FakeHtmlConverterProvider(), + timelineController = TimelineController(matrixRoom), ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt index db056dea19..95dffe2bc0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt @@ -16,6 +16,7 @@ package io.element.android.features.messages.impl.fixtures +import io.element.android.features.messages.impl.timeline.TimelineItemIndexer import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseMessageFactory @@ -46,7 +47,9 @@ import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorW import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope -internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory { +internal fun TestScope.aTimelineItemsFactory( + timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer() +): TimelineItemsFactory { val timelineEventFormatter = aTimelineEventFormatter() val matrixClient = FakeMatrixClient() return TimelineItemsFactory( @@ -83,6 +86,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory { ), ), timelineItemGrouper = TimelineItemGrouper(), + timelineItemIndexer = timelineItemIndexer, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt index c9acdba508..d1b054faf8 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt @@ -21,6 +21,7 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.LiveTimelineProvider 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.aRoomSummaryDetails @@ -91,7 +92,7 @@ class ForwardMessagesPresenterTests { coroutineScope: CoroutineScope = this, ) = ForwardMessagesPresenter( eventId = eventId.value, - room = fakeMatrixRoom, + timelineProvider = LiveTimelineProvider(fakeMatrixRoom), matrixCoroutineScope = coroutineScope, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index 290f297117..b099562480 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerState +import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -968,6 +969,7 @@ class MessageComposerPresenterTest { permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permalinkParser = FakePermalinkParser(), permalinkBuilder = permalinkBuilder, + timelineController = TimelineController(room), ) private suspend fun ReceiveTurbine.awaitFirstItem(): T { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 8d5699d4e0..3dd00021fe 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -35,9 +35,11 @@ import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.ReceiptType +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 @@ -47,20 +49,28 @@ 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.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.aRoomMember -import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline +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.anEventTimelineItem import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.awaitLastSequentialItem -import io.element.android.tests.testutils.awaitWithLatch import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -71,7 +81,7 @@ import kotlin.time.Duration.Companion.seconds private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID" private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2" -class TimelinePresenterTest { +@OptIn(ExperimentalCoroutinesApi::class) class TimelinePresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -83,58 +93,49 @@ class TimelinePresenterTest { }.test { val initialState = awaitFirstItem() assertThat(initialState.timelineItems).isEmpty() - val loadedNoTimelineState = awaitItem() - assertThat(loadedNoTimelineState.timelineItems).isEmpty() + assertThat(initialState.isLive).isTrue() + assertThat(initialState.newEventState).isEqualTo(NewEventState.None) + assertThat(initialState.focusedEventId).isNull() + assertThat(initialState.focusRequestState).isEqualTo(FocusRequestState.None) } } @Test fun `present - load more`() = runTest { - val presenter = createTimelinePresenter() + val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection -> + Result.success(false) + } + val timeline = FakeTimeline().apply { + this.paginateLambda = paginateLambda + } + val presenter = createTimelinePresenter(timeline = timeline) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.backPaginationStatus.hasMoreToLoadBackwards).isTrue() - assertThat(initialState.backPaginationStatus.isBackPaginating).isFalse() - initialState.eventSink.invoke(TimelineEvents.LoadMore) - val inPaginationState = awaitItem() - assertThat(inPaginationState.backPaginationStatus.isBackPaginating).isTrue() - assertThat(inPaginationState.backPaginationStatus.hasMoreToLoadBackwards).isTrue() - val postPaginationState = awaitItem() - assertThat(postPaginationState.backPaginationStatus.hasMoreToLoadBackwards).isTrue() - assertThat(postPaginationState.backPaginationStatus.isBackPaginating).isFalse() - } - } - - @Test - fun `present - set highlighted event`() = runTest { - val presenter = createTimelinePresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitFirstItem() - skipItems(1) - assertThat(initialState.highlightedEventId).isNull() - initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(AN_EVENT_ID)) - val withHighlightedState = awaitItem() - assertThat(withHighlightedState.highlightedEventId).isEqualTo(AN_EVENT_ID) - initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(null)) - val withoutHighlightedState = awaitItem() - assertThat(withoutHighlightedState.highlightedEventId).isNull() + initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.FORWARDS)) + assert(paginateLambda) + .isCalledExactly(2) + .withSequence( + listOf(value(Timeline.PaginationDirection.BACKWARDS)), + listOf(value(Timeline.PaginationDirection.FORWARDS)) + ) } } @OptIn(ExperimentalCoroutinesApi::class) @Test fun `present - on scroll finished mark a room as read if the first visible index is 0`() = runTest(StandardTestDispatcher()) { - val timeline = FakeMatrixTimeline( - initialTimelineItems = listOf( - MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()) + val timeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()) + ) ) ) + val room = FakeMatrixRoom(liveTimeline = timeline) val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false) - val room = FakeMatrixRoom(matrixTimeline = timeline) val presenter = createTimelinePresenter( timeline = timeline, room = room, @@ -143,7 +144,6 @@ class TimelinePresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - assertThat(timeline.sentReadReceipts).isEmpty() val initialState = awaitFirstItem() initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) runCurrent() @@ -154,48 +154,62 @@ class TimelinePresenterTest { @Test fun `present - on scroll finished send read receipt if an event is before the index`() = runTest { - val timeline = FakeMatrixTimeline( - initialTimelineItems = listOf( - MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()), - MatrixTimelineItem.Event( - uniqueId = FAKE_UNIQUE_ID_2, - event = anEventTimelineItem( - eventId = AN_EVENT_ID_2, - content = aMessageContent("Test message") + val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType -> + Result.success(Unit) + } + val timeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()), + MatrixTimelineItem.Event( + uniqueId = FAKE_UNIQUE_ID_2, + event = anEventTimelineItem( + eventId = AN_EVENT_ID_2, + content = aMessageContent("Test message") + ) ) ) ) - ) + ).apply { + this.sendReadReceiptLambda = sendReadReceiptsLambda + } val presenter = createTimelinePresenter(timeline) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - assertThat(timeline.sentReadReceipts).isEmpty() - val initialState = awaitFirstItem() - awaitWithLatch { latch -> - timeline.sendReadReceiptLatch = latch - initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) + skipItems(1) + awaitItem().run { + eventSink.invoke(TimelineEvents.OnScrollFinished(1)) } - assertThat(timeline.sentReadReceipts).isNotEmpty() - assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ) + advanceUntilIdle() + assert(sendReadReceiptsLambda) + .isCalledOnce() + .with(any(), value(ReceiptType.READ)) cancelAndIgnoreRemainingEvents() } } @Test fun `present - on scroll finished send a private read receipt if an event is at an index other than 0 and public read receipts are disabled`() = runTest { - val timeline = FakeMatrixTimeline( - initialTimelineItems = listOf( - MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()), - MatrixTimelineItem.Event( - uniqueId = FAKE_UNIQUE_ID_2, - event = anEventTimelineItem( - eventId = AN_EVENT_ID_2, - content = aMessageContent("Test message") + val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType -> + Result.success(Unit) + } + val timeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()), + MatrixTimelineItem.Event( + uniqueId = FAKE_UNIQUE_ID_2, + event = anEventTimelineItem( + eventId = AN_EVENT_ID_2, + content = aMessageContent("Test message") + ) ) ) ) - ) + ).apply { + this.sendReadReceiptLambda = sendReadReceiptsLambda + } val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false) val presenter = createTimelinePresenter( timeline = timeline, @@ -204,75 +218,86 @@ class TimelinePresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - assertThat(timeline.sentReadReceipts).isEmpty() - val initialState = awaitFirstItem() - awaitWithLatch { latch -> - timeline.sendReadReceiptLatch = latch - initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) - initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) + skipItems(1) + awaitItem().run { + eventSink.invoke(TimelineEvents.OnScrollFinished(0)) + eventSink.invoke(TimelineEvents.OnScrollFinished(1)) } - assertThat(timeline.sentReadReceipts).isNotEmpty() - assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ_PRIVATE) + advanceUntilIdle() + assert(sendReadReceiptsLambda) + .isCalledOnce() + .with(any(), value(ReceiptType.READ_PRIVATE)) cancelAndIgnoreRemainingEvents() } } @Test fun `present - on scroll finished will not send read receipt the first visible event is the same as before`() = runTest { - val timeline = FakeMatrixTimeline( - initialTimelineItems = listOf( - MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()), - MatrixTimelineItem.Event( - uniqueId = FAKE_UNIQUE_ID_2, - event = anEventTimelineItem( - eventId = AN_EVENT_ID_2, - content = aMessageContent("Test message") + val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType -> + Result.success(Unit) + } + val timeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()), + MatrixTimelineItem.Event( + uniqueId = FAKE_UNIQUE_ID_2, + event = anEventTimelineItem( + eventId = AN_EVENT_ID_2, + content = aMessageContent("Test message") + ) ) ) ) - ) + ).apply { + this.sendReadReceiptLambda = sendReadReceiptsLambda + } val presenter = createTimelinePresenter(timeline) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - assertThat(timeline.sentReadReceipts).isEmpty() - val initialState = awaitFirstItem() - awaitWithLatch { latch -> - timeline.sendReadReceiptLatch = latch - initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) - initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) + skipItems(1) + awaitItem().run { + eventSink.invoke(TimelineEvents.OnScrollFinished(1)) + eventSink.invoke(TimelineEvents.OnScrollFinished(1)) } - assertThat(timeline.sentReadReceipts).hasSize(1) + advanceUntilIdle() cancelAndIgnoreRemainingEvents() + assert(sendReadReceiptsLambda).isCalledOnce() } } @Test fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest { - val timeline = FakeMatrixTimeline( - initialTimelineItems = listOf( - MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker), - MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker) + val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType -> + Result.success(Unit) + } + val timeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker), + MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker) + ) ) - ) + ).apply { + this.sendReadReceiptLambda = sendReadReceiptsLambda + } val presenter = createTimelinePresenter(timeline) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - assertThat(timeline.sentReadReceipts).isEmpty() + skipItems(1) val initialState = awaitFirstItem() - awaitWithLatch { latch -> - timeline.sendReadReceiptLatch = latch - initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) - } - assertThat(timeline.sentReadReceipts).isEmpty() + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) cancelAndIgnoreRemainingEvents() + assert(sendReadReceiptsLambda).isNeverCalled() } } @Test fun `present - covers newEventState scenarios`() = runTest { - val timeline = FakeMatrixTimeline() + val timelineItems = MutableStateFlow(emptyList()) + val timeline = FakeTimeline(timelineItems = timelineItems) val presenter = createTimelinePresenter(timeline) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -280,12 +305,12 @@ class TimelinePresenterTest { val initialState = awaitFirstItem() assertThat(initialState.newEventState).isEqualTo(NewEventState.None) assertThat(initialState.timelineItems.size).isEqualTo(0) - timeline.updateTimelineItems { + timelineItems.emit( listOf(MatrixTimelineItem.Event("0", anEventTimelineItem(content = aMessageContent()))) - } + ) consumeItemsUntilPredicate { it.timelineItems.size == 1 } // Mimics sending a message, and assert newEventState is FromMe - timeline.updateTimelineItems { items -> + timelineItems.getAndUpdate { items -> val event = anEventTimelineItem(content = aMessageContent(), isOwn = true) items + listOf(MatrixTimelineItem.Event("1", event)) } @@ -294,7 +319,7 @@ class TimelinePresenterTest { assertThat(state.newEventState).isEqualTo(NewEventState.FromMe) } // Mimics receiving a message without clearing the previous FromMe - timeline.updateTimelineItems { items -> + timelineItems.getAndUpdate { items -> val event = anEventTimelineItem(content = aMessageContent()) items + listOf(MatrixTimelineItem.Event("2", event)) } @@ -306,7 +331,7 @@ class TimelinePresenterTest { assertThat(state.newEventState).isEqualTo(NewEventState.None) } // Mimics receiving a message and assert newEventState is FromOther - timeline.updateTimelineItems { items -> + timelineItems.getAndUpdate { items -> val event = anEventTimelineItem(content = aMessageContent()) items + listOf(MatrixTimelineItem.Event("3", event)) } @@ -320,7 +345,10 @@ class TimelinePresenterTest { @Test fun `present - reaction ordering`() = runTest { - val timeline = FakeMatrixTimeline() + val timelineItems = MutableStateFlow(emptyList()) + val timeline = FakeTimeline( + timelineItems = timelineItems, + ) val presenter = createTimelinePresenter(timeline) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -348,10 +376,9 @@ class TimelinePresenterTest { senders = persistentListOf(charlie) ), ) - timeline.updateTimelineItems { + timelineItems.emit( listOf(MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem(reactions = oneReaction))) - } - skipItems(1) + ) val item = awaitItem().timelineItems.first() assertThat(item).isInstanceOf(TimelineItem.Event::class.java) val event = item as TimelineItem.Event @@ -423,8 +450,10 @@ class TimelinePresenterTest { fun `present - side effect on redacted items is invoked`() = runTest { val redactedVoiceMessageManager = FakeRedactedVoiceMessageManager() val presenter = createTimelinePresenter( - timeline = FakeMatrixTimeline( - initialTimelineItems = aRedactedMatrixTimeline(AN_EVENT_ID), + timeline = FakeTimeline( + timelineItems = flowOf( + aRedactedMatrixTimeline(AN_EVENT_ID), + ) ), redactedVoiceMessageManager = redactedVoiceMessageManager, ) @@ -432,32 +461,32 @@ class TimelinePresenterTest { presenter.present() }.test { assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(0) - awaitFirstItem().let { - assertThat(it.timelineItems).isNotEmpty() - } + skipItems(2) assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(1) } } @Test fun `present - when room member info is loaded, read receipts info should be updated`() = runTest { - val timeline = FakeMatrixTimeline( - listOf( - MatrixTimelineItem.Event( - FAKE_UNIQUE_ID, - anEventTimelineItem( - sender = A_USER_ID, - receipts = persistentListOf( - Receipt( - userId = A_USER_ID, - timestamp = 0L, + val timeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + FAKE_UNIQUE_ID, + anEventTimelineItem( + sender = A_USER_ID, + receipts = persistentListOf( + Receipt( + userId = A_USER_ID, + timestamp = 0L, + ) ) ) ) ) ) ) - val room = FakeMatrixRoom(matrixTimeline = timeline).apply { + val room = FakeMatrixRoom(liveTimeline = timeline).apply { givenRoomMembersState(MatrixRoomMembersState.Unknown) } @@ -484,16 +513,12 @@ class TimelinePresenterTest { } private suspend fun ReceiveTurbine.awaitFirstItem(): T { - // Skip 1 item if Mentions feature is enabled - if (FeatureFlags.Mentions.defaultValue) { - skipItems(1) - } return awaitItem() } private fun TestScope.createTimelinePresenter( - timeline: MatrixTimeline = FakeMatrixTimeline(), - room: FakeMatrixRoom = FakeMatrixRoom(matrixTimeline = timeline), + timeline: Timeline = FakeTimeline(), + room: FakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline), timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory(), redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(), messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(), @@ -511,6 +536,8 @@ class TimelinePresenterTest { endPollAction = endPollAction, sendPollResponseAction = sendPollResponseAction, sessionPreferencesStore = sessionPreferencesStore, + timelineItemIndexer = TimelineItemIndexer(), + timelineController = TimelineController(room), ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index be7df31635..0f99e8b558 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -19,10 +19,14 @@ package io.element.android.features.messages.impl.timeline import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel import io.element.android.features.messages.impl.typing.aTypingNotificationState +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams import io.element.android.tests.testutils.EventsRecorder +import kotlinx.collections.immutable.persistentListOf import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -37,10 +41,13 @@ class TimelineViewTest { rule.setContent { TimelineView( aTimelineState( + timelineItems = persistentListOf( + TimelineItem.Virtual( + id = "backward_pagination", + model = TimelineItemLoadingIndicatorModel(Timeline.PaginationDirection.BACKWARDS, 0) + ), + ), eventSink = eventsRecorder, - paginationState = aPaginationState( - hasMoreToLoadBackwards = true, - ) ), typingNotificationState = aTypingNotificationState(), onUserDataClicked = EnsureNeverCalledWithParam(), @@ -55,7 +62,7 @@ class TimelineViewTest { onReadReceiptClick = EnsureNeverCalledWithParam(), ) } - eventsRecorder.assertSingle(TimelineEvents.LoadMore) + eventsRecorder.assertSingle(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) } @Test @@ -65,9 +72,6 @@ class TimelineViewTest { TimelineView( aTimelineState( eventSink = eventsRecorder, - paginationState = aPaginationState( - hasMoreToLoadBackwards = false, - ) ), typingNotificationState = aTypingNotificationState(), onUserDataClicked = EnsureNeverCalledWithParam(), diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/PollFixtures.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/PollFixtures.kt index b54b18b8c0..84ef0aae31 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/PollFixtures.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/PollFixtures.kt @@ -20,15 +20,15 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.PollContent -import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline +import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.aPollContent import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import kotlinx.collections.immutable.persistentListOf fun aPollTimeline( polls: Map = emptyMap(), -): FakeMatrixTimeline { - return FakeMatrixTimeline( +): FakeTimeline { + return FakeTimeline( initialTimelineItems = polls.map { entry -> MatrixTimelineItem.Event( entry.key.value, diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt index d5be6c2bb6..445187168e 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt @@ -49,7 +49,7 @@ class CreatePollPresenterTest { private var navUpInvocationsCount = 0 private val existingPoll = anOngoingPollContent() private val fakeMatrixRoom = FakeMatrixRoom( - matrixTimeline = aPollTimeline( + liveTimeline = aPollTimeline( mapOf(pollEventId to existingPoll) ) ) @@ -80,7 +80,7 @@ class CreatePollPresenterTest { @Test fun `in edit mode, if poll doesn't exist, error is tracked and screen is closed`() = runTest { val room = FakeMatrixRoom( - matrixTimeline = aPollTimeline() + liveTimeline = aPollTimeline() ) val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(AN_EVENT_ID), room = room) moleculeFlow(RecompositionMode.Immediate) { diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt index f7948a951c..c34c9074f0 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt @@ -57,7 +57,7 @@ class PollHistoryPresenterTest { ) ) private val room = FakeMatrixRoom( - matrixTimeline = timeline + liveTimeline = timeline ) @Test diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 96bcd3330d..17f9358df7 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings @@ -53,7 +54,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService -import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline +import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver import io.element.android.tests.testutils.simulateLongTask import kotlinx.collections.immutable.ImmutableMap @@ -83,7 +84,7 @@ class FakeMatrixRoom( override val joinedMemberCount: Long = 123L, override val activeMemberCount: Long = 234L, val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(), - private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(), + override val liveTimeline: Timeline = FakeTimeline(), private var roomPermalinkResult: () -> Result = { Result.success("room link") }, private var eventPermalinkResult: (EventId) -> Result = { Result.success("event link") }, canRedactOwn: Boolean = false, @@ -214,7 +215,9 @@ class FakeMatrixRoom( override val syncUpdateFlow: StateFlow = MutableStateFlow(0L) - override val timeline: MatrixTimeline = matrixTimeline + override suspend fun timelineFocusedOnEvent(eventId: EventId): Timeline { + return FakeTimeline() + } override suspend fun subscribeToSync() = Unit 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 deleted file mode 100644 index f98e8d0fcb..0000000000 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -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.MatrixTimelineItem -import io.element.android.libraries.matrix.api.timeline.ReceiptType -import io.element.android.tests.testutils.simulateLongTask -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.getAndUpdate - -class FakeMatrixTimeline( - initialTimelineItems: List = emptyList(), - initialPaginationState: MatrixTimeline.PaginationState = MatrixTimeline.PaginationState( - hasMoreToLoadBackwards = true, - isBackPaginating = false, - beginningOfRoomReached = false, - ) -) : MatrixTimeline { - private val _paginationState: MutableStateFlow = MutableStateFlow(initialPaginationState) - private val _timelineItems: MutableStateFlow> = MutableStateFlow(initialTimelineItems) - - var sentReadReceipts = mutableListOf>() - private set - - var sendReadReceiptLatch: CompletableDeferred? = null - - fun updatePaginationState(update: (MatrixTimeline.PaginationState.() -> MatrixTimeline.PaginationState)) { - _paginationState.getAndUpdate(update) - } - - fun updateTimelineItems(update: (items: List) -> List) { - _timelineItems.getAndUpdate(update) - } - - override val paginationState: StateFlow = _paginationState - - override val timelineItems: Flow> = _timelineItems - - override suspend fun paginateBackwards(requestSize: Int) = paginateBackwards() - override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int) = paginateBackwards() - - override val membershipChangeEventReceived = MutableSharedFlow() - - private suspend fun paginateBackwards(): Result { - updatePaginationState { - copy(isBackPaginating = true) - } - delay(100) - updatePaginationState { - copy(isBackPaginating = false) - } - updateTimelineItems { timelineItems -> - timelineItems - } - return Result.success(Unit) - } - - fun givenMembershipChangeEventReceived() { - membershipChangeEventReceived.tryEmit(Unit) - } - - override suspend fun fetchDetailsForEvent(eventId: EventId): Result = simulateLongTask { - Result.success(Unit) - } - - override suspend fun sendReadReceipt( - eventId: EventId, - receiptType: ReceiptType, - ): Result = simulateLongTask { - sentReadReceipts.add(eventId to receiptType) - sendReadReceiptLatch?.complete(Unit) - Result.success(Unit) - } - - override fun close() = Unit -} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt new file mode 100644 index 0000000000..5663ad6b5b --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.test.timeline + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.ProgressCallback +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.media.AudioInfo +import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.MediaUploadHandler +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.io.File + +class FakeTimeline( + override val timelineItems: Flow> = MutableStateFlow(emptyList()), + private val backwardPaginationStatus: MutableStateFlow = MutableStateFlow( + Timeline.PaginationStatus( + isPaginating = false, + hasMoreToLoad = true + ) + ), + private val forwardPaginationStatus: MutableStateFlow = MutableStateFlow( + Timeline.PaginationStatus( + isPaginating = false, + hasMoreToLoad = false + ) + ), + override val membershipChangeEventReceived: Flow = MutableSharedFlow(), +) : Timeline { + + var sendMessageLambda: (body: String, htmlBody: String?, mentions: List) -> Result = { _, _, _ -> Result.success(Unit) } + override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result = sendMessageLambda(body, htmlBody, mentions) + + var editMessageLambda: (originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?, mentions: List) -> Result = + { _, _, _, _, _ -> Result.success(Unit) } + + override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?, mentions: List): Result = editMessageLambda( + originalEventId, + transactionId, + body, + htmlBody, + mentions + ) + + var enterSpecialModeLambda: (eventId: EventId?) -> Result = { Result.success(Unit) } + override suspend fun enterSpecialMode(eventId: EventId?): Result = enterSpecialModeLambda(eventId) + + var replyMessageLambda: (eventId: EventId, body: String, htmlBody: String?, mentions: List) -> Result = + { _, _, _, _ -> Result.success(Unit) } + + override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result = replyMessageLambda( + eventId, + body, + htmlBody, + mentions + ) + + var sendImageLambda: (file: File, thumbnailFile: File?, imageInfo: ImageInfo, body: String?, formattedBody: String?, progressCallback: ProgressCallback?) -> Result = + { _, _, _, _, _, _ -> Result.success(FakeMediaUploadHandler()) } + + override suspend fun sendImage(file: File, thumbnailFile: File?, imageInfo: ImageInfo, body: String?, formattedBody: String?, progressCallback: ProgressCallback?): Result = sendImageLambda( + file, + thumbnailFile, + imageInfo, + body, + formattedBody, + progressCallback + ) + + var sendVideoLambda: (file: File, thumbnailFile: File?, videoInfo: VideoInfo, body: String?, formattedBody: String?, progressCallback: ProgressCallback?) -> Result = + { _, _, _, _, _, _ -> Result.success(FakeMediaUploadHandler()) } + + override suspend fun sendVideo(file: File, thumbnailFile: File?, videoInfo: VideoInfo, body: String?, formattedBody: String?, progressCallback: ProgressCallback?): Result = sendVideoLambda( + file, + thumbnailFile, + videoInfo, + body, + formattedBody, + progressCallback + ) + + var sendAudioLambda: (file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?) -> Result = + { _, _, _ -> Result.success(FakeMediaUploadHandler()) } + + override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result = sendAudioLambda( + file, + audioInfo, + progressCallback + ) + + var sendFileLambda: (file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?) -> Result = + { _, _, _ -> Result.success(FakeMediaUploadHandler()) } + + override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result = sendFileLambda( + file, + fileInfo, + progressCallback + ) + + var toggleReactionLambda: (emoji: String, eventId: EventId) -> Result = { _, _ -> Result.success(Unit) } + override suspend fun toggleReaction(emoji: String, eventId: EventId): Result = toggleReactionLambda(emoji, eventId) + + var forwardEventLambda: (eventId: EventId, roomIds: List) -> Result = { _, _ -> Result.success(Unit) } + override suspend fun forwardEvent(eventId: EventId, roomIds: List): Result = forwardEventLambda(eventId, roomIds) + + var retrySendMessageLambda: (transactionId: TransactionId) -> Result = { Result.success(Unit) } + override suspend fun retrySendMessage(transactionId: TransactionId): Result = retrySendMessageLambda(transactionId) + + var cancelSendLambda: (transactionId: TransactionId) -> Result = { Result.success(Unit) } + override suspend fun cancelSend(transactionId: TransactionId): Result = cancelSendLambda(transactionId) + + var sendLocationLambda: (body: String, geoUri: String, description: String?, zoomLevel: Int?, assetType: AssetType?) -> Result = + { _, _, _, _, _ -> Result.success(Unit) } + + override suspend fun sendLocation(body: String, geoUri: String, description: String?, zoomLevel: Int?, assetType: AssetType?): Result = sendLocationLambda( + body, + geoUri, + description, + zoomLevel, + assetType + ) + + var createPollLambda: (question: String, answers: List, maxSelections: Int, pollKind: PollKind) -> Result = + { _, _, _, _ -> Result.success(Unit) } + + override suspend fun createPoll(question: String, answers: List, maxSelections: Int, pollKind: PollKind): Result = createPollLambda( + question, + answers, + maxSelections, + pollKind + ) + + var editPollLambda: (pollStartId: EventId, question: String, answers: List, maxSelections: Int, pollKind: PollKind) -> Result = + { _, _, _, _, _ -> Result.success(Unit) } + + override suspend fun editPoll(pollStartId: EventId, question: String, answers: List, maxSelections: Int, pollKind: PollKind): Result = editPollLambda( + pollStartId, + question, + answers, + maxSelections, + pollKind + ) + + var sendPollResponseLambda: (pollStartId: EventId, answers: List) -> Result = { _, _ -> Result.success(Unit) } + override suspend fun sendPollResponse(pollStartId: EventId, answers: List): Result = sendPollResponseLambda(pollStartId, answers) + + var endPollLambda: (pollStartId: EventId, text: String) -> Result = { _, _ -> Result.success(Unit) } + override suspend fun endPoll(pollStartId: EventId, text: String): Result = endPollLambda(pollStartId, text) + + var sendVoiceMessageLambda: (file: File, audioInfo: AudioInfo, waveform: List, progressCallback: ProgressCallback?) -> Result = + { _, _, _, _ -> Result.success(FakeMediaUploadHandler()) } + + override suspend fun sendVoiceMessage(file: File, audioInfo: AudioInfo, waveform: List, progressCallback: ProgressCallback?): Result = sendVoiceMessageLambda( + file, + audioInfo, + waveform, + progressCallback + ) + + var sendReadReceiptLambda: (eventId: EventId, receiptType: ReceiptType) -> Result = { _, _ -> Result.success(Unit) } + override suspend fun sendReadReceipt( + eventId: EventId, + receiptType: ReceiptType, + ): Result = sendReadReceiptLambda(eventId, receiptType) + + var paginateLambda: (direction: Timeline.PaginationDirection) -> Result = { Result.success(false) } + override suspend fun paginate(direction: Timeline.PaginationDirection): Result = paginateLambda(direction) + + override fun paginationStatus(direction: Timeline.PaginationDirection): StateFlow { + return when (direction) { + Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus + Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus + } + } + + override fun close() = Unit +}