Improve a bit timeline pagination
This commit is contained in:
parent
247f6d2188
commit
fa4c8d62cf
13 changed files with 293 additions and 161 deletions
|
|
@ -28,11 +28,13 @@ import io.element.android.features.messages.timeline.factories.TimelineItemsFact
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.matrix.core.EventId
|
import io.element.android.libraries.matrix.core.EventId
|
||||||
import io.element.android.libraries.matrix.room.MatrixRoom
|
import io.element.android.libraries.matrix.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.timeline.MatrixTimeline
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val backPaginationEventLimit = 20
|
private const val backPaginationEventLimit = 20
|
||||||
|
|
@ -55,9 +57,13 @@ class TimelinePresenter @Inject constructor(
|
||||||
.flow()
|
.flow()
|
||||||
.collectAsState(emptyList())
|
.collectAsState(emptyList())
|
||||||
|
|
||||||
|
val paginationState = timeline
|
||||||
|
.paginationState()
|
||||||
|
.collectAsState()
|
||||||
|
|
||||||
fun handleEvents(event: TimelineEvents) {
|
fun handleEvents(event: TimelineEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
TimelineEvents.LoadMore -> localCoroutineScope.loadMore()
|
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState.value)
|
||||||
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +72,11 @@ class TimelinePresenter @Inject constructor(
|
||||||
timeline
|
timeline
|
||||||
.timelineItems()
|
.timelineItems()
|
||||||
.onEach(timelineItemsFactory::replaceWith)
|
.onEach(timelineItemsFactory::replaceWith)
|
||||||
|
.onEach { timelineItems ->
|
||||||
|
if (timelineItems.isEmpty()) {
|
||||||
|
loadMore(paginationState.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,7 +94,11 @@ class TimelinePresenter @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.loadMore() = launch {
|
private fun CoroutineScope.loadMore(paginationState: MatrixTimeline.PaginationState) = launch {
|
||||||
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize)
|
if (paginationState.canBackPaginate && !paginationState.isBackPaginating) {
|
||||||
|
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize)
|
||||||
|
} else {
|
||||||
|
Timber.v("Can't back paginate as paginationState = $paginationState")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
|
@ -54,26 +54,17 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import io.element.android.features.messages.timeline.model.bubble.BubbleState
|
|
||||||
import io.element.android.features.messages.timeline.components.MessageEventBubble
|
import io.element.android.features.messages.timeline.components.MessageEventBubble
|
||||||
import io.element.android.features.messages.timeline.components.TimelineItemEncryptedView
|
|
||||||
import io.element.android.features.messages.timeline.components.TimelineItemImageView
|
|
||||||
import io.element.android.features.messages.timeline.components.TimelineItemReactionsView
|
import io.element.android.features.messages.timeline.components.TimelineItemReactionsView
|
||||||
import io.element.android.features.messages.timeline.components.TimelineItemRedactedView
|
import io.element.android.features.messages.timeline.components.event.TimelineItemEventContentView
|
||||||
import io.element.android.features.messages.timeline.components.TimelineItemTextView
|
|
||||||
import io.element.android.features.messages.timeline.components.TimelineItemUnknownView
|
|
||||||
import io.element.android.features.messages.timeline.components.virtual.TimelineItemDaySeparatorView
|
import io.element.android.features.messages.timeline.components.virtual.TimelineItemDaySeparatorView
|
||||||
import io.element.android.features.messages.timeline.components.virtual.TimelineLoadingMoreIndicator
|
import io.element.android.features.messages.timeline.components.virtual.TimelineLoadingMoreIndicator
|
||||||
import io.element.android.features.messages.timeline.model.TimelineItem
|
import io.element.android.features.messages.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.timeline.model.event.TimelineItemEncryptedContent
|
import io.element.android.features.messages.timeline.model.bubble.BubbleState
|
||||||
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
|
||||||
import io.element.android.features.messages.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContentProvider
|
||||||
import io.element.android.features.messages.timeline.model.event.TimelineItemRedactedContent
|
|
||||||
import io.element.android.features.messages.timeline.model.event.TimelineItemTextBasedContent
|
|
||||||
import io.element.android.features.messages.timeline.model.event.TimelineItemUnknownContent
|
|
||||||
import io.element.android.features.messages.timeline.model.virtual.TimelineItemDaySeparatorModel
|
import io.element.android.features.messages.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||||
import io.element.android.features.messages.timeline.model.virtual.TimelineItemLoadingModel
|
import io.element.android.features.messages.timeline.model.virtual.TimelineItemLoadingModel
|
||||||
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContentProvider
|
|
||||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
|
@ -92,6 +83,11 @@ fun TimelineView(
|
||||||
onMessageClicked: (TimelineItem.Event) -> Unit = {},
|
onMessageClicked: (TimelineItem.Event) -> Unit = {},
|
||||||
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
|
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
fun onReachedLoadMore() {
|
||||||
|
state.eventSink(TimelineEvents.LoadMore)
|
||||||
|
}
|
||||||
|
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
|
@ -101,24 +97,23 @@ fun TimelineView(
|
||||||
verticalArrangement = Arrangement.Bottom,
|
verticalArrangement = Arrangement.Bottom,
|
||||||
reverseLayout = true
|
reverseLayout = true
|
||||||
) {
|
) {
|
||||||
items(
|
itemsIndexed(
|
||||||
items = state.timelineItems,
|
items = state.timelineItems,
|
||||||
contentType = { timelineItem -> timelineItem.contentType() },
|
contentType = { _, timelineItem -> timelineItem.contentType() },
|
||||||
key = { timelineItem -> timelineItem.key() },
|
key = { _, timelineItem -> timelineItem.key() },
|
||||||
) { timelineItem ->
|
) { index, timelineItem ->
|
||||||
TimelineItemRow(
|
TimelineItemRow(
|
||||||
timelineItem = timelineItem,
|
timelineItem = timelineItem,
|
||||||
isHighlighted = timelineItem.key() == state.highlightedEventId?.value,
|
isHighlighted = timelineItem.key() == state.highlightedEventId?.value,
|
||||||
onClick = onMessageClicked,
|
onClick = onMessageClicked,
|
||||||
onLongClick = onMessageLongClicked
|
onLongClick = onMessageLongClicked
|
||||||
)
|
)
|
||||||
|
if (index == state.timelineItems.lastIndex) {
|
||||||
|
onReachedLoadMore()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onReachedLoadMore() {
|
|
||||||
state.eventSink(TimelineEvents.LoadMore)
|
|
||||||
}
|
|
||||||
|
|
||||||
TimelineScrollHelper(
|
TimelineScrollHelper(
|
||||||
lazyListState = lazyListState,
|
lazyListState = lazyListState,
|
||||||
timelineItems = state.timelineItems,
|
timelineItems = state.timelineItems,
|
||||||
|
|
@ -231,31 +226,7 @@ fun TimelineItemEventRow(
|
||||||
.widthIn(max = 320.dp)
|
.widthIn(max = 320.dp)
|
||||||
) {
|
) {
|
||||||
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
when (event.content) {
|
TimelineItemEventContentView(event.content, interactionSource, onClick, onLongClick, contentModifier)
|
||||||
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
|
|
||||||
content = event.content,
|
|
||||||
modifier = contentModifier
|
|
||||||
)
|
|
||||||
is TimelineItemRedactedContent -> TimelineItemRedactedView(
|
|
||||||
content = event.content,
|
|
||||||
modifier = contentModifier
|
|
||||||
)
|
|
||||||
is TimelineItemTextBasedContent -> TimelineItemTextView(
|
|
||||||
content = event.content,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
modifier = contentModifier,
|
|
||||||
onTextClicked = onClick,
|
|
||||||
onTextLongClicked = onLongClick
|
|
||||||
)
|
|
||||||
is TimelineItemUnknownContent -> TimelineItemUnknownView(
|
|
||||||
content = event.content,
|
|
||||||
modifier = contentModifier
|
|
||||||
)
|
|
||||||
is TimelineItemImageContent -> TimelineItemImageView(
|
|
||||||
content = event.content,
|
|
||||||
modifier = contentModifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
TimelineItemReactionsView(
|
TimelineItemReactionsView(
|
||||||
reactionsState = event.reactionsState,
|
reactionsState = event.reactionsState,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.timeline.components.event
|
||||||
|
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import io.element.android.features.messages.timeline.model.event.TimelineItemEncryptedContent
|
||||||
|
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
|
||||||
|
import io.element.android.features.messages.timeline.model.event.TimelineItemImageContent
|
||||||
|
import io.element.android.features.messages.timeline.model.event.TimelineItemRedactedContent
|
||||||
|
import io.element.android.features.messages.timeline.model.event.TimelineItemTextBasedContent
|
||||||
|
import io.element.android.features.messages.timeline.model.event.TimelineItemUnknownContent
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TimelineItemEventContentView(
|
||||||
|
content: TimelineItemEventContent,
|
||||||
|
interactionSource: MutableInteractionSource,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
when (content) {
|
||||||
|
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
|
||||||
|
content = content,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
is TimelineItemRedactedContent -> TimelineItemRedactedView(
|
||||||
|
content = content,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
is TimelineItemTextBasedContent -> TimelineItemTextView(
|
||||||
|
content = content,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier = modifier,
|
||||||
|
onTextClicked = onClick,
|
||||||
|
onTextLongClicked = onLongClick
|
||||||
|
)
|
||||||
|
is TimelineItemUnknownContent -> TimelineItemUnknownView(
|
||||||
|
content = content,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
is TimelineItemImageContent -> TimelineItemImageView(
|
||||||
|
content = content,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.timeline.components
|
package io.element.android.features.messages.timeline.components.event
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.timeline.components
|
package io.element.android.features.messages.timeline.components.event
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.timeline.components
|
package io.element.android.features.messages.timeline.components.event
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.timeline.components
|
package io.element.android.features.messages.timeline.components.event
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.timeline.components
|
package io.element.android.features.messages.timeline.components.event
|
||||||
|
|
||||||
import android.text.SpannableString
|
import android.text.SpannableString
|
||||||
import android.text.style.URLSpan
|
import android.text.style.URLSpan
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.timeline.components
|
package io.element.android.features.messages.timeline.components.event
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
|
@ -18,10 +18,17 @@ package io.element.android.libraries.matrix.timeline
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.core.EventId
|
import io.element.android.libraries.matrix.core.EventId
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.matrix.rustcomponents.sdk.TimelineListener
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface MatrixTimeline {
|
interface MatrixTimeline {
|
||||||
|
|
||||||
|
data class PaginationState(
|
||||||
|
val isBackPaginating: Boolean,
|
||||||
|
val canBackPaginate: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
fun paginationState(): StateFlow<PaginationState>
|
||||||
|
|
||||||
fun timelineItems(): Flow<List<MatrixTimelineItem>>
|
fun timelineItems(): Flow<List<MatrixTimelineItem>>
|
||||||
suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit>
|
suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit>
|
||||||
fun initialize()
|
fun initialize()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
/*
|
||||||
|
* 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.timeline
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.matrix.rustcomponents.sdk.TimelineChange
|
||||||
|
import org.matrix.rustcomponents.sdk.TimelineDiff
|
||||||
|
import org.matrix.rustcomponents.sdk.TimelineListener
|
||||||
|
import org.matrix.rustcomponents.sdk.VirtualTimelineItem
|
||||||
|
|
||||||
|
internal class MatrixTimelineDiffProcessor(
|
||||||
|
private val paginationState: MutableStateFlow<MatrixTimeline.PaginationState>,
|
||||||
|
private val timelineItems: MutableStateFlow<List<MatrixTimelineItem>>,
|
||||||
|
private val coroutineScope: CoroutineScope,
|
||||||
|
private val diffDispatcher: CoroutineDispatcher,
|
||||||
|
) : TimelineListener {
|
||||||
|
|
||||||
|
override fun onUpdate(update: TimelineDiff) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
updateTimelineItems {
|
||||||
|
applyDiff(update)
|
||||||
|
}
|
||||||
|
when (val firstItem = timelineItems.value.firstOrNull()) {
|
||||||
|
is MatrixTimelineItem.Virtual -> updateBackPaginationState(firstItem.virtual)
|
||||||
|
else -> updateBackPaginationState(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateBackPaginationState(virtualItem: VirtualTimelineItem?) {
|
||||||
|
val currentPaginationState = paginationState.value
|
||||||
|
val newPaginationState = when (virtualItem) {
|
||||||
|
VirtualTimelineItem.LoadingIndicator -> currentPaginationState.copy(
|
||||||
|
isBackPaginating = true,
|
||||||
|
canBackPaginate = true
|
||||||
|
)
|
||||||
|
VirtualTimelineItem.TimelineStart -> currentPaginationState.copy(
|
||||||
|
isBackPaginating = false,
|
||||||
|
canBackPaginate = false
|
||||||
|
)
|
||||||
|
else -> currentPaginationState.copy(
|
||||||
|
isBackPaginating = false,
|
||||||
|
canBackPaginate = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
paginationState.value = newPaginationState
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateTimelineItems(block: MutableList<MatrixTimelineItem>.() -> Unit) =
|
||||||
|
withContext(diffDispatcher) {
|
||||||
|
val mutableTimelineItems = timelineItems.value.toMutableList()
|
||||||
|
block(mutableTimelineItems)
|
||||||
|
timelineItems.value = mutableTimelineItems
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<MatrixTimelineItem>.applyDiff(diff: TimelineDiff) {
|
||||||
|
when (diff.change()) {
|
||||||
|
TimelineChange.APPEND -> {
|
||||||
|
val items = diff.append()?.map { it.asMatrixTimelineItem() } ?: return
|
||||||
|
addAll(items)
|
||||||
|
}
|
||||||
|
TimelineChange.PUSH_BACK -> {
|
||||||
|
val item = diff.pushBack()?.asMatrixTimelineItem() ?: return
|
||||||
|
add(item)
|
||||||
|
}
|
||||||
|
TimelineChange.PUSH_FRONT -> {
|
||||||
|
val item = diff.pushFront()?.asMatrixTimelineItem() ?: return
|
||||||
|
add(0, item)
|
||||||
|
}
|
||||||
|
TimelineChange.SET -> {
|
||||||
|
val updateAtData = diff.set() ?: return
|
||||||
|
val item = updateAtData.item.asMatrixTimelineItem()
|
||||||
|
set(updateAtData.index.toInt(), item)
|
||||||
|
}
|
||||||
|
TimelineChange.INSERT -> {
|
||||||
|
val insertAtData = diff.insert() ?: return
|
||||||
|
val item = insertAtData.item.asMatrixTimelineItem()
|
||||||
|
add(insertAtData.index.toInt(), item)
|
||||||
|
}
|
||||||
|
TimelineChange.REMOVE -> {
|
||||||
|
val removeAtData = diff.remove() ?: return
|
||||||
|
removeAt(removeAtData.toInt())
|
||||||
|
}
|
||||||
|
TimelineChange.RESET -> {
|
||||||
|
clear()
|
||||||
|
val items = diff.reset()?.map { it.asMatrixTimelineItem() } ?: return
|
||||||
|
addAll(items)
|
||||||
|
}
|
||||||
|
TimelineChange.POP_FRONT -> {
|
||||||
|
removeFirstOrNull()
|
||||||
|
}
|
||||||
|
TimelineChange.POP_BACK -> {
|
||||||
|
removeLastOrNull()
|
||||||
|
}
|
||||||
|
TimelineChange.CLEAR -> {
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,135 +18,63 @@ package io.element.android.libraries.matrix.timeline
|
||||||
|
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.matrix.core.EventId
|
import io.element.android.libraries.matrix.core.EventId
|
||||||
import io.element.android.libraries.matrix.room.RustMatrixRoom
|
import io.element.android.libraries.matrix.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.util.TaskHandleBag
|
import io.element.android.libraries.matrix.util.TaskHandleBag
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.sample
|
import kotlinx.coroutines.flow.sample
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.matrix.rustcomponents.sdk.PaginationOptions
|
import org.matrix.rustcomponents.sdk.PaginationOptions
|
||||||
import org.matrix.rustcomponents.sdk.Room
|
import org.matrix.rustcomponents.sdk.Room
|
||||||
import org.matrix.rustcomponents.sdk.SlidingSyncRoom
|
import org.matrix.rustcomponents.sdk.SlidingSyncRoom
|
||||||
import org.matrix.rustcomponents.sdk.TimelineChange
|
|
||||||
import org.matrix.rustcomponents.sdk.TimelineDiff
|
|
||||||
import org.matrix.rustcomponents.sdk.TimelineItem
|
import org.matrix.rustcomponents.sdk.TimelineItem
|
||||||
import org.matrix.rustcomponents.sdk.TimelineListener
|
import org.matrix.rustcomponents.sdk.TimelineListener
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
class RustMatrixTimeline(
|
class RustMatrixTimeline(
|
||||||
private val matrixRoom: RustMatrixRoom,
|
private val matrixRoom: MatrixRoom,
|
||||||
private val innerRoom: Room,
|
private val innerRoom: Room,
|
||||||
private val slidingSyncRoom: SlidingSyncRoom,
|
private val slidingSyncRoom: SlidingSyncRoom,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
) : MatrixTimeline {
|
) : MatrixTimeline {
|
||||||
|
|
||||||
private val innerTimelineListener = object : TimelineListener {
|
|
||||||
override fun onUpdate(update: TimelineDiff) {
|
|
||||||
coroutineScope.launch {
|
|
||||||
updateTimelineItems {
|
|
||||||
applyDiff(update)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
|
private val timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
|
||||||
MutableStateFlow(emptyList())
|
MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
private val paginationState = MutableStateFlow(
|
||||||
|
MatrixTimeline.PaginationState(canBackPaginate = true, isBackPaginating = false)
|
||||||
|
)
|
||||||
|
|
||||||
|
private val innerTimelineListener = MatrixTimelineDiffProcessor(
|
||||||
|
paginationState = paginationState,
|
||||||
|
timelineItems = timelineItems,
|
||||||
|
coroutineScope = coroutineScope,
|
||||||
|
diffDispatcher = coroutineDispatchers.diffUpdateDispatcher
|
||||||
|
)
|
||||||
|
|
||||||
private val listenerTokens = TaskHandleBag()
|
private val listenerTokens = TaskHandleBag()
|
||||||
|
override fun paginationState(): StateFlow<MatrixTimeline.PaginationState> {
|
||||||
|
return paginationState
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
override fun timelineItems(): Flow<List<MatrixTimelineItem>> {
|
override fun timelineItems(): Flow<List<MatrixTimelineItem>> {
|
||||||
return timelineItems.sample(50)
|
return timelineItems.sample(50)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MutableList<MatrixTimelineItem>.applyDiff(diff: TimelineDiff) {
|
|
||||||
when (diff.change()) {
|
|
||||||
TimelineChange.APPEND -> {
|
|
||||||
val items = diff.append()?.map { it.asMatrixTimelineItem() } ?: return
|
|
||||||
addAll(items)
|
|
||||||
}
|
|
||||||
TimelineChange.PUSH_BACK -> {
|
|
||||||
val item = diff.pushBack()?.asMatrixTimelineItem() ?: return
|
|
||||||
add(item)
|
|
||||||
}
|
|
||||||
TimelineChange.PUSH_FRONT -> {
|
|
||||||
val item = diff.pushFront()?.asMatrixTimelineItem() ?: return
|
|
||||||
add(0, item)
|
|
||||||
}
|
|
||||||
TimelineChange.SET -> {
|
|
||||||
val updateAtData = diff.set() ?: return
|
|
||||||
val item = updateAtData.item.asMatrixTimelineItem()
|
|
||||||
set(updateAtData.index.toInt(), item)
|
|
||||||
}
|
|
||||||
TimelineChange.INSERT -> {
|
|
||||||
val insertAtData = diff.insert() ?: return
|
|
||||||
val item = insertAtData.item.asMatrixTimelineItem()
|
|
||||||
add(insertAtData.index.toInt(), item)
|
|
||||||
}
|
|
||||||
TimelineChange.REMOVE -> {
|
|
||||||
val removeAtData = diff.remove() ?: return
|
|
||||||
removeAt(removeAtData.toInt())
|
|
||||||
}
|
|
||||||
TimelineChange.RESET -> {
|
|
||||||
clear()
|
|
||||||
val items = diff.reset()?.map { it.asMatrixTimelineItem() } ?: return
|
|
||||||
addAll(items)
|
|
||||||
}
|
|
||||||
TimelineChange.POP_FRONT -> {
|
|
||||||
removeFirstOrNull()
|
|
||||||
}
|
|
||||||
TimelineChange.POP_BACK -> {
|
|
||||||
removeLastOrNull()
|
|
||||||
}
|
|
||||||
TimelineChange.CLEAR -> {
|
|
||||||
clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> = withContext(coroutineDispatchers.io) {
|
|
||||||
runCatching {
|
|
||||||
Timber.v("Start back paginating for room ${slidingSyncRoom.roomId()} ")
|
|
||||||
val paginationOptions = PaginationOptions.UntilNumItems(
|
|
||||||
eventLimit = requestSize.toUShort(),
|
|
||||||
items = untilNumberOfItems.toUShort()
|
|
||||||
)
|
|
||||||
innerRoom.paginateBackwards(paginationOptions)
|
|
||||||
}.onFailure {
|
|
||||||
Timber.e(it, "Fail to paginate for room ${slidingSyncRoom.roomId()}")
|
|
||||||
}.onSuccess {
|
|
||||||
Timber.v("Success back paginating for room ${slidingSyncRoom.roomId()}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun updateTimelineItems(block: MutableList<MatrixTimelineItem>.() -> Unit) =
|
|
||||||
withContext(coroutineDispatchers.diffUpdateDispatcher) {
|
|
||||||
val mutableTimelineItems = timelineItems.value.toMutableList()
|
|
||||||
block(mutableTimelineItems)
|
|
||||||
timelineItems.value = mutableTimelineItems
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun addListener(timelineListener: TimelineListener): Result<List<TimelineItem>> = withContext(coroutineDispatchers.computation) {
|
|
||||||
runCatching {
|
|
||||||
val result = slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, null)
|
|
||||||
listenerTokens += result.taskHandle
|
|
||||||
result.items
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun initialize() {
|
override fun initialize() {
|
||||||
Timber.v("Init timeline for room ${slidingSyncRoom.roomId()}")
|
Timber.v("Init timeline for room ${matrixRoom.roomId}")
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
matrixRoom.fetchMembers()
|
matrixRoom.fetchMembers()
|
||||||
.onFailure {
|
.onFailure {
|
||||||
Timber.e(it, "Fail to fetch members for room ${slidingSyncRoom.roomId()}")
|
Timber.e(it, "Fail to fetch members for room ${matrixRoom.roomId}")
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
Timber.v("Success fetching members for room ${slidingSyncRoom.roomId()}")
|
Timber.v("Success fetching members for room ${matrixRoom.roomId}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|
@ -154,16 +82,18 @@ class RustMatrixTimeline(
|
||||||
result
|
result
|
||||||
.onSuccess { timelineItems ->
|
.onSuccess { timelineItems ->
|
||||||
val matrixTimelineItems = timelineItems.map { it.asMatrixTimelineItem() }
|
val matrixTimelineItems = timelineItems.map { it.asMatrixTimelineItem() }
|
||||||
updateTimelineItems { addAll(matrixTimelineItems) }
|
withContext(coroutineDispatchers.diffUpdateDispatcher) {
|
||||||
|
this@RustMatrixTimeline.timelineItems.value = matrixTimelineItems
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
Timber.e("Failed adding timeline listener on room with identifier: ${slidingSyncRoom.roomId()})")
|
Timber.e("Failed adding timeline listener on room with identifier: ${matrixRoom.roomId})")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dispose() {
|
override fun dispose() {
|
||||||
Timber.v("Dispose timeline for room ${slidingSyncRoom.roomId()}")
|
Timber.v("Dispose timeline for room ${matrixRoom.roomId}")
|
||||||
listenerTokens.dispose()
|
listenerTokens.dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,4 +111,27 @@ class RustMatrixTimeline(
|
||||||
override suspend fun replyMessage(inReplyToEventId: EventId, message: String): Result<Unit> {
|
override suspend fun replyMessage(inReplyToEventId: EventId, message: String): Result<Unit> {
|
||||||
return matrixRoom.replyMessage(inReplyToEventId, message)
|
return matrixRoom.replyMessage(inReplyToEventId, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
|
runCatching {
|
||||||
|
Timber.v("Start back paginating for room ${matrixRoom.roomId} ")
|
||||||
|
val paginationOptions = PaginationOptions.UntilNumItems(
|
||||||
|
eventLimit = requestSize.toUShort(),
|
||||||
|
items = untilNumberOfItems.toUShort()
|
||||||
|
)
|
||||||
|
innerRoom.paginateBackwards(paginationOptions)
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Fail to paginate for room ${matrixRoom.roomId}")
|
||||||
|
}.onSuccess {
|
||||||
|
Timber.v("Success back paginating for room ${matrixRoom.roomId}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun addListener(timelineListener: TimelineListener): Result<List<TimelineItem>> = withContext(coroutineDispatchers.io) {
|
||||||
|
runCatching {
|
||||||
|
val result = slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, null)
|
||||||
|
listenerTokens += result.taskHandle
|
||||||
|
result.items
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,19 @@ import io.element.android.libraries.matrix.timeline.MatrixTimeline
|
||||||
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import org.matrix.rustcomponents.sdk.TimelineListener
|
|
||||||
|
|
||||||
class FakeMatrixTimeline : MatrixTimeline {
|
class FakeMatrixTimeline : MatrixTimeline {
|
||||||
override var callback: MatrixTimeline.Callback? = null
|
|
||||||
|
private val paginationState = MutableStateFlow(
|
||||||
|
MatrixTimeline.PaginationState(canBackPaginate = true, isBackPaginating = false)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun paginationState(): StateFlow<MatrixTimeline.PaginationState> {
|
||||||
|
return paginationState
|
||||||
|
}
|
||||||
|
|
||||||
override fun timelineItems(): Flow<List<MatrixTimelineItem>> {
|
override fun timelineItems(): Flow<List<MatrixTimelineItem>> {
|
||||||
return emptyFlow()
|
return emptyFlow()
|
||||||
|
|
@ -36,8 +44,6 @@ class FakeMatrixTimeline : MatrixTimeline {
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addListener(timelineListener: TimelineListener) = Unit
|
|
||||||
|
|
||||||
override fun initialize() = Unit
|
override fun initialize() = Unit
|
||||||
|
|
||||||
override fun dispose() = Unit
|
override fun dispose() = Unit
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue