Timeline : makes sure all tests are passing

This commit is contained in:
ganfra
2024-04-25 14:35:37 +02:00
parent 9ffed34303
commit cf1c728eab
12 changed files with 108 additions and 49 deletions

View File

@@ -31,6 +31,7 @@ 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 io.element.android.libraries.matrix.api.timeline.getActiveTimeline
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope

View File

@@ -22,17 +22,22 @@ import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.EventId
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.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.io.Closeable
import java.util.Optional
import javax.inject.Inject
@@ -49,12 +54,14 @@ class TimelineController @Inject constructor(
private val room: MatrixRoom,
) : Closeable, TimelineProvider {
private val coroutineScope = CoroutineScope(SupervisorJob())
private val liveTimeline = MutableStateFlow(room.liveTimeline)
private val detachedTimeline = MutableStateFlow<Optional<Timeline>>(Optional.empty())
@OptIn(ExperimentalCoroutinesApi::class)
fun timelineItems(): Flow<List<MatrixTimelineItem>> {
return currentTimelineFlow().flatMapLatest { it.timelineItems }
return currentTimelineFlow.flatMapLatest { it.timelineItems }
}
fun isLive(): Flow<Boolean> {
@@ -62,7 +69,7 @@ class TimelineController @Inject constructor(
}
suspend fun invokeOnCurrentTimeline(block: suspend (Timeline.() -> Any)) {
currentTimelineFlow().first().run {
currentTimelineFlow.value.run {
block(this)
}
}
@@ -89,6 +96,10 @@ class TimelineController @Inject constructor(
* This does close the detached timeline if any.
*/
fun focusOnLive() {
closeDetachedTimeline()
}
private fun closeDetachedTimeline() {
detachedTimeline.getAndUpdate {
when {
it.isPresent -> {
@@ -101,11 +112,12 @@ class TimelineController @Inject constructor(
}
override fun close() {
focusOnLive()
coroutineScope.cancel()
closeDetachedTimeline()
}
suspend fun paginate(direction: Timeline.PaginationDirection): Result<Boolean> {
return currentTimelineFlow().first().paginate(direction)
return currentTimelineFlow.value.paginate(direction)
.onSuccess { hasReachedEnd ->
if (direction == Timeline.PaginationDirection.FORWARDS && hasReachedEnd) {
focusOnLive()
@@ -113,14 +125,14 @@ class TimelineController @Inject constructor(
}
}
private fun currentTimelineFlow() = combine(liveTimeline, detachedTimeline) { live, detached ->
private val currentTimelineFlow = combine(liveTimeline, detachedTimeline) { live, detached ->
when {
detached.isPresent -> detached.get()
else -> live
}
}
}.stateIn(coroutineScope, SharingStarted.Eagerly, room.liveTimeline)
override suspend fun getActiveTimeline(): Timeline {
return currentTimelineFlow().first()
override fun activeTimelineFlow(): StateFlow<Timeline> {
return currentTimelineFlow
}
}

View File

@@ -32,7 +32,9 @@ data class TimelineState(
val focusedEventId : EventId?,
val focusRequestState: FocusRequestState,
val eventSink: (TimelineEvents) -> Unit,
)
){
val isTimelineEmpty = timelineItems.none { it is TimelineItem.Event }
}
sealed interface FocusRequestState {
data object None : FocusRequestState

View File

@@ -63,6 +63,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.TypingNotificationView
import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
@@ -125,12 +126,12 @@ fun TimelineView(
reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp),
) {
/*
item(key = UUID.randomUUID()) {
TypingNotificationView(state = typingNotificationState)
}
*/
if(state.isLive) {
item {
TypingNotificationView(state = typingNotificationState)
}
}
items(
items = state.timelineItems,
contentType = { timelineItem -> timelineItem.contentType() },
@@ -165,7 +166,7 @@ fun TimelineView(
)
TimelineScrollHelper(
isTimelineEmpty = state.timelineItems.isEmpty(),
isTimelineEmpty = state.isTimelineEmpty,
lazyListState = lazyListState,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
newEventState = state.newEventState,
@@ -185,10 +186,6 @@ private fun FocusRequestStateView(
onClearFocusRequestState: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(enabled = focusRequestState is FocusRequestState.Fetching) {
onClearFocusRequestState()
}
when (focusRequestState) {
is FocusRequestState.Failure -> {
ErrorDialog(
@@ -198,7 +195,7 @@ private fun FocusRequestStateView(
)
}
FocusRequestState.Fetching -> {
ProgressDialog(modifier = modifier)
ProgressDialog(modifier = modifier, onDismissRequest = onClearFocusRequestState)
}
is FocusRequestState.Cached, FocusRequestState.None -> Unit
}
@@ -254,7 +251,6 @@ private fun BoxScope.TimelineScrollHelper(
}
LaunchedEffect(canAutoScroll, newEventState) {
Timber.d("TimelineScrollHelper - canAutoScroll: $canAutoScroll, newEventState: $newEventState")
val shouldScrollToBottom = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
if (shouldScrollToBottom) {
scrollToBottom()

View File

@@ -21,15 +21,20 @@ 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.core.RoomId
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
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import java.lang.IllegalStateException
class ForwardMessagesPresenterTests {
@get:Rule
@@ -37,7 +42,7 @@ class ForwardMessagesPresenterTests {
@Test
fun `present - initial state`() = runTest {
val presenter = aPresenter()
val presenter = aForwardMessagesPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -50,7 +55,14 @@ class ForwardMessagesPresenterTests {
@Test
fun `present - forward successful`() = runTest {
val presenter = aPresenter()
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
this.forwardEventLambda = forwardEventLambda
}
val room = FakeMatrixRoom(liveTimeline = timeline)
val presenter = aForwardMessagesPresenter(fakeMatrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -62,18 +74,23 @@ class ForwardMessagesPresenterTests {
val successfulForwardState = awaitItem()
assertThat(successfulForwardState.isForwarding).isFalse()
assertThat(successfulForwardState.forwardingSucceeded).isNotNull()
assert(forwardEventLambda).isCalledOnce()
}
}
@Test
fun `present - select a room and forward failed, then clear`() = runTest {
val room = FakeMatrixRoom()
val presenter = aPresenter(fakeMatrixRoom = room)
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
Result.failure<Unit>(IllegalStateException("error"))
}
val timeline = FakeTimeline().apply {
this.forwardEventLambda = forwardEventLambda
}
val room = FakeMatrixRoom(liveTimeline = timeline)
val presenter = aForwardMessagesPresenter(fakeMatrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Test failed forwarding
room.givenForwardEventResult(Result.failure(Throwable("error")))
skipItems(1)
val summary = aRoomSummaryDetails()
presenter.onRoomSelected(listOf(summary.roomId))
@@ -83,10 +100,11 @@ class ForwardMessagesPresenterTests {
// Then clear error
failedForwardState.eventSink(ForwardMessagesEvents.ClearError)
assertThat(awaitItem().error).isNull()
assert(forwardEventLambda).isCalledOnce()
}
}
private fun CoroutineScope.aPresenter(
private fun CoroutineScope.aForwardMessagesPresenter(
eventId: EventId = AN_EVENT_ID,
fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
coroutineScope: CoroutineScope = this,

View File

@@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.coroutines.flow.first
import javax.inject.Inject

View File

@@ -34,22 +34,23 @@ import io.element.android.features.poll.impl.history.model.PollHistoryItemsFacto
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
class PollHistoryPresenter @Inject constructor(
private val room: MatrixRoom,
private val appCoroutineScope: CoroutineScope,
private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction,
private val pollHistoryItemFactory: PollHistoryItemsFactory,
private val timelineProvider: TimelineProvider,
) : Presenter<PollHistoryState> {
@Composable
override fun present(): PollHistoryState {
// TODO use room.rememberPollHistory() when working properly?
val timeline = room.liveTimeline
val timeline by timelineProvider.activeTimelineFlow().collectAsState()
val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
val pollHistoryItemsFlow = remember {
timeline.timelineItems.map { items ->

View File

@@ -32,6 +32,7 @@ import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
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.Timeline
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
@@ -174,11 +175,11 @@ class PollHistoryPresenterTest {
),
): PollHistoryPresenter {
return PollHistoryPresenter(
room = room,
appCoroutineScope = appCoroutineScope,
sendPollResponseAction = sendPollResponseAction,
endPollAction = endPollAction,
pollHistoryItemFactory = pollHistoryItemFactory,
timelineProvider = LiveTimelineProvider(room),
)
}
}

View File

@@ -19,6 +19,11 @@ package io.element.android.libraries.matrix.api.timeline
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
/**
@@ -26,9 +31,11 @@ import javax.inject.Inject
* It could be the current room timeline, or a timeline for a specific event.
*/
interface TimelineProvider {
suspend fun getActiveTimeline(): Timeline
fun activeTimelineFlow(): StateFlow<Timeline>
}
suspend fun TimelineProvider.getActiveTimeline(): Timeline = activeTimelineFlow().first()
/**
* Default implementation of [TimelineProvider] that provides the live timeline of a room.
*/
@@ -36,6 +43,6 @@ interface TimelineProvider {
class LiveTimelineProvider @Inject constructor(
private val room: MatrixRoom,
) : TimelineProvider {
override suspend fun getActiveTimeline(): Timeline = room.liveTimeline
override fun activeTimelineFlow(): StateFlow<Timeline> = MutableStateFlow(room.liveTimeline)
}

View File

@@ -19,7 +19,7 @@ package io.element.android.libraries.matrix.impl.timeline.postprocessor
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
fun List<MatrixTimelineItem>.hasEncryptionHistoryBanner(): Boolean {
internal fun List<MatrixTimelineItem>.hasEncryptionHistoryBanner(): Boolean {
val firstItem = firstOrNull()
return firstItem is MatrixTimelineItem.Virtual &&
firstItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner

View File

@@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.impl.timeline.postprocessor
import androidx.annotation.VisibleForTesting
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
@@ -43,10 +44,7 @@ class RoomBeginningPostProcessor {
private fun processForRoom(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
if (items.hasEncryptionHistoryBanner()) return items
val roomBeginningItem = MatrixTimelineItem.Virtual(
uniqueId = VirtualTimelineItem.RoomBeginning.toString(),
virtual = VirtualTimelineItem.RoomBeginning
)
val roomBeginningItem = createRoomBeginningItem()
return listOf(roomBeginningItem) + items
}
@@ -77,4 +75,13 @@ class RoomBeginningPostProcessor {
}
return newItems
}
@VisibleForTesting
fun createRoomBeginningItem(): MatrixTimelineItem.Virtual {
return MatrixTimelineItem.Virtual(
uniqueId = VirtualTimelineItem.RoomBeginning.toString(),
virtual = VirtualTimelineItem.RoomBeginning
)
}
}

View File

@@ -22,13 +22,14 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MembershipCha
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import org.junit.Test
class DmBeginningTimelineProcessorTest {
class RoomBeginningPostProcessorTest {
@Test
fun `processor removes room creation event and self-join event from DM timeline`() {
val timelineItems = listOf(
@@ -36,7 +37,7 @@ class DmBeginningTimelineProcessorTest {
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
)
val processor = RoomBeginningPostProcessor()
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false)
assertThat(processedItems).isEmpty()
}
@@ -53,18 +54,30 @@ class DmBeginningTimelineProcessorTest {
MatrixTimelineItem.Event("m.room.message", anEventTimelineItem(content = aMessageContent("hi"))),
)
val processor = RoomBeginningPostProcessor()
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false)
assertThat(processedItems).isEqualTo(expected)
}
@Test
fun `processor won't remove items if it's not a DM`() {
fun `processor will add beginning of room item if it's not a DM`() {
val timelineItems = listOf(
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
)
val processor = RoomBeginningPostProcessor()
val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = true)
val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = false)
assertThat(processedItems).isEqualTo(
listOf(processor.createRoomBeginningItem()) + timelineItems
)
}
@Test
fun `processor will not add beginning of room item if it's not a DM and EncryptedHistoryBanner item is found`() {
val timelineItems = listOf(
MatrixTimelineItem.Virtual("EncryptedHistoryBanner", VirtualTimelineItem.EncryptedHistoryBanner),
)
val processor = RoomBeginningPostProcessor()
val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = false)
assertThat(processedItems).isEqualTo(timelineItems)
}
@@ -75,7 +88,7 @@ class DmBeginningTimelineProcessorTest {
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
)
val processor = RoomBeginningPostProcessor()
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false)
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
assertThat(processedItems).isEqualTo(timelineItems)
}
@@ -85,7 +98,7 @@ class DmBeginningTimelineProcessorTest {
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
)
val processor = RoomBeginningPostProcessor()
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false)
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
assertThat(processedItems).isEqualTo(timelineItems)
}
@@ -96,7 +109,7 @@ class DmBeginningTimelineProcessorTest {
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))),
)
val processor = RoomBeginningPostProcessor()
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false)
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
assertThat(processedItems).isEqualTo(timelineItems)
}
}