Timeline permalink : continue to iterate (try a strategy to avoid forward insertion to "auto-scroll")
This commit is contained in:
parent
ff92551472
commit
0d7cffe400
25 changed files with 599 additions and 218 deletions
|
|
@ -30,6 +30,7 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
|
@ -116,6 +117,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
private fun onLinkClicked(
|
||||
context: Context,
|
||||
url: String,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
) {
|
||||
when (val permalink = permalinkParser.parse(url)) {
|
||||
is PermalinkData.UserLink -> {
|
||||
|
|
@ -124,7 +126,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
callback?.onUserDataClicked(permalink.userId)
|
||||
}
|
||||
is PermalinkData.RoomLink -> {
|
||||
handleRoomLinkClicked(permalink)
|
||||
handleRoomLinkClicked(permalink, eventSink)
|
||||
}
|
||||
is PermalinkData.FallbackLink,
|
||||
is PermalinkData.RoomEmailInviteLink -> {
|
||||
|
|
@ -133,11 +135,11 @@ class MessagesNode @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleRoomLinkClicked(roomLink: PermalinkData.RoomLink) {
|
||||
private fun handleRoomLinkClicked(roomLink: PermalinkData.RoomLink, eventSink: (TimelineEvents) -> Unit) {
|
||||
if (room.matches(roomLink.roomIdOrAlias)) {
|
||||
if (roomLink.eventId != null) {
|
||||
// TODO Handle navigation to the Event
|
||||
context.toast("TODO Handle navigation to the Event ${roomLink.eventId}")
|
||||
val eventId = roomLink.eventId
|
||||
if (eventId != null) {
|
||||
eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||
} else {
|
||||
// Click on the same room, ignore
|
||||
context.toast("Already viewing this room!")
|
||||
|
|
@ -189,7 +191,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
onEventClicked = this::onEventClicked,
|
||||
onPreviewAttachments = this::onPreviewAttachments,
|
||||
onUserDataClicked = this::onUserDataClicked,
|
||||
onLinkClicked = { onLinkClicked(context, it) },
|
||||
onLinkClicked = { onLinkClicked(context, it, state.timelineState.eventSink) },
|
||||
onSendLocationClicked = this::onSendLocationClicked,
|
||||
onCreatePollClicked = this::onCreatePollClicked,
|
||||
onJoinCallClicked = this::onJoinCallClicked,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
* Copyright (c) 2024 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.impl.timeline
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
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
|
||||
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.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
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.util.Optional
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
class TimelineController @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
) {
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
fun isLive(): Flow<Boolean> {
|
||||
return detachedTimeline.map { !it.isPresent }
|
||||
}
|
||||
|
||||
suspend fun focusOnEvent(eventId: EventId): Result<Unit> {
|
||||
return try {
|
||||
val newDetachedTimeline = room.timelineFocusedOnEvent(eventId)
|
||||
detachedTimeline.getAndUpdate { current ->
|
||||
if (current.isPresent) {
|
||||
current.get().close()
|
||||
}
|
||||
Optional.of(newDetachedTimeline)
|
||||
}
|
||||
Result.success(Unit)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (exception: Exception) {
|
||||
Result.failure(exception)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure the controller is focused on the live timeline.
|
||||
* This does close the detached timeline if any.
|
||||
*/
|
||||
fun focusOnLive() {
|
||||
detachedTimeline.getAndUpdate {
|
||||
when {
|
||||
it.isPresent -> {
|
||||
it.get().close()
|
||||
Optional.empty()
|
||||
}
|
||||
else -> Optional.empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun paginate(direction: Timeline.PaginationDirection): Result<Boolean> {
|
||||
return currentTimelineFlow().first().paginate(direction)
|
||||
}
|
||||
|
||||
private fun currentTimelineFlow() = combine(liveTimeline, detachedTimeline) { live, detached ->
|
||||
when {
|
||||
detached.isPresent -> detached.get()
|
||||
else -> live
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendReadReceiptIfNeeded(
|
||||
firstVisibleIndex: Int,
|
||||
timelineItems: ImmutableList<TimelineItem>,
|
||||
lastReadReceiptId: MutableState<EventId?>,
|
||||
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()
|
||||
.filterIsInstance(Timeline::class)
|
||||
.first()
|
||||
.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList<TimelineItem>): EventId? {
|
||||
for (i in index until items.count()) {
|
||||
val item = items[i]
|
||||
if (item is TimelineItem.Event) {
|
||||
return item.eventId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -17,17 +17,21 @@
|
|||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
sealed interface TimelineEvents {
|
||||
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
|
||||
data class OnScrollFinished(val firstIndex: Int) : TimelineEvents
|
||||
data class FocusOnEvent(val eventId: EventId) : TimelineEvents
|
||||
data object ClearFocusRequestState: TimelineEvents
|
||||
data object JumpToLive : TimelineEvents
|
||||
|
||||
/**
|
||||
* Events coming from a timeline item.
|
||||
*/
|
||||
sealed interface EventFromTimelineItem : TimelineEvents
|
||||
|
||||
data class LoadMore(val backwards: Boolean) : EventFromTimelineItem
|
||||
data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem
|
||||
|
||||
/**
|
||||
* Events coming from a poll item.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright (c) 2024 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.impl.timeline
|
||||
|
||||
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
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
class TimelineItemIndexer @Inject constructor() {
|
||||
|
||||
private val timelineEventsIndexes = mutableMapOf<EventId, Int>()
|
||||
|
||||
fun isKnown(eventId: EventId): Boolean {
|
||||
return timelineEventsIndexes.containsKey(eventId).also {
|
||||
Timber.d("$eventId isKnown = $it")
|
||||
}
|
||||
}
|
||||
|
||||
fun indexOf(eventId: EventId): Int {
|
||||
return (timelineEventsIndexes[eventId] ?: -1).also {
|
||||
Timber.d("indexOf $eventId= $it")
|
||||
}
|
||||
}
|
||||
|
||||
fun process(timelineItems: List<TimelineItem>) {
|
||||
Timber.d("process ${timelineItems.size} items")
|
||||
timelineEventsIndexes.clear()
|
||||
timelineItems.forEachIndexed { index, timelineItem ->
|
||||
when (timelineItem) {
|
||||
is TimelineItem.Event -> {
|
||||
processEvent(timelineItem, index)
|
||||
}
|
||||
is TimelineItem.GroupedEvents -> {
|
||||
timelineItem.events.forEach { event ->
|
||||
processEvent(event, index)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processEvent(event: TimelineItem.Event, index: Int) {
|
||||
if (event.eventId == null) return
|
||||
timelineEventsIndexes[event.eventId] = index
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@ import kotlinx.coroutines.withContext
|
|||
|
||||
class TimelinePresenter @AssistedInject constructor(
|
||||
private val timelineItemsFactory: TimelineItemsFactory,
|
||||
private val timelineItemIndexer: TimelineItemIndexer,
|
||||
private val room: MatrixRoom,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val appScope: CoroutineScope,
|
||||
|
|
@ -64,14 +65,13 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
private val sendPollResponseAction: SendPollResponseAction,
|
||||
private val endPollAction: EndPollAction,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
private val timelineController: TimelineController,
|
||||
) : Presenter<TimelineState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(navigator: MessagesNavigator): TimelinePresenter
|
||||
}
|
||||
|
||||
private val timeline = room.liveTimeline
|
||||
|
||||
@Composable
|
||||
override fun present(): TimelineState {
|
||||
val localScope = rememberCoroutineScope()
|
||||
|
|
@ -79,41 +79,52 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
mutableStateOf(null)
|
||||
}
|
||||
|
||||
val focusedEventId: MutableState<EventId?> = rememberSaveable {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
val focusRequestState: MutableState<FocusRequestState> = remember {
|
||||
mutableStateOf(FocusRequestState.None)
|
||||
}
|
||||
|
||||
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
|
||||
|
||||
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
||||
val paginationState by timeline.backPaginationStatus.collectAsState()
|
||||
|
||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
|
||||
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
||||
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value)
|
||||
|
||||
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val newItemState = remember { mutableStateOf(NewEventState.None) }
|
||||
|
||||
val newEventState = remember { mutableStateOf(NewEventState.None) }
|
||||
|
||||
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
|
||||
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
|
||||
val isLive by timelineController.isLive().collectAsState(initial = true)
|
||||
|
||||
fun handleEvents(event: TimelineEvents) {
|
||||
when (event) {
|
||||
is TimelineEvents.LoadMore -> {
|
||||
if(event.backwards) {
|
||||
localScope.paginateBackwards()
|
||||
}else{
|
||||
//TODO implement pagination forward
|
||||
localScope.launch {
|
||||
timelineController.paginate(direction = event.direction)
|
||||
}
|
||||
}
|
||||
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
||||
is TimelineEvents.OnScrollFinished -> {
|
||||
if (event.firstIndex == 0) {
|
||||
newItemState.value = NewEventState.None
|
||||
if (isLive) {
|
||||
if (event.firstIndex == 0) {
|
||||
newEventState.value = NewEventState.None
|
||||
}
|
||||
appScope.sendReadReceiptIfNeeded(
|
||||
firstVisibleIndex = event.firstIndex,
|
||||
timelineItems = timelineItems,
|
||||
lastReadReceiptId = lastReadReceiptId,
|
||||
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
|
||||
)
|
||||
} else {
|
||||
newEventState.value = NewEventState.None
|
||||
}
|
||||
appScope.sendReadReceiptIfNeeded(
|
||||
firstVisibleIndex = event.firstIndex,
|
||||
timelineItems = timelineItems,
|
||||
lastReadReceiptId = lastReadReceiptId,
|
||||
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
|
||||
)
|
||||
}
|
||||
is TimelineEvents.PollAnswerSelected -> appScope.launch {
|
||||
sendPollResponseAction.execute(
|
||||
|
|
@ -126,28 +137,55 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
pollStartId = event.pollStartId,
|
||||
)
|
||||
}
|
||||
is TimelineEvents.PollEditClicked ->
|
||||
is TimelineEvents.PollEditClicked -> {
|
||||
navigator.onEditPollClicked(event.pollStartId)
|
||||
}
|
||||
is TimelineEvents.FocusOnEvent -> localScope.launch {
|
||||
focusedEventId.value = event.eventId
|
||||
if (timelineItemIndexer.isKnown(event.eventId)) {
|
||||
val index = timelineItemIndexer.indexOf(event.eventId)
|
||||
focusRequestState.value = FocusRequestState.Cached(index)
|
||||
} else {
|
||||
focusRequestState.value = FocusRequestState.Fetching
|
||||
timelineController.focusOnEvent(event.eventId)
|
||||
.fold(
|
||||
onSuccess = {
|
||||
focusRequestState.value = FocusRequestState.None
|
||||
},
|
||||
onFailure = {
|
||||
focusRequestState.value = FocusRequestState.Failure(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
is TimelineEvents.ClearFocusRequestState -> {
|
||||
focusRequestState.value = FocusRequestState.None
|
||||
}
|
||||
is TimelineEvents.JumpToLive -> {
|
||||
localScope.launch {
|
||||
timelineController.focusOnLive()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Makes sure to get back to live when there is nothing more to load forwards
|
||||
LaunchedEffect(isLive) {
|
||||
|
||||
}
|
||||
|
||||
LaunchedEffect(timelineItems.size) {
|
||||
computeNewItemState(timelineItems, prevMostRecentItemId, newItemState)
|
||||
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
combine(timeline.timelineItems, room.membersStateFlow) { items, membersState ->
|
||||
combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState ->
|
||||
timelineItemsFactory.replaceWith(
|
||||
timelineItems = items,
|
||||
roomMembers = membersState.roomMembers().orEmpty()
|
||||
)
|
||||
items
|
||||
}
|
||||
.onEach { timelineItems ->
|
||||
if (timelineItems.isEmpty()) {
|
||||
paginateBackwards()
|
||||
}
|
||||
}
|
||||
.onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem)
|
||||
.launchIn(this)
|
||||
}
|
||||
|
|
@ -165,10 +203,12 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
return TimelineState(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
highlightedEventId = highlightedEventId.value,
|
||||
backPaginationStatus = paginationState,
|
||||
timelineItems = timelineItems,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
newEventState = newItemState.value,
|
||||
newEventState = newEventState.value,
|
||||
isLive = isLive,
|
||||
focusedEventId = focusedEventId.value,
|
||||
focusRequestState = focusRequestState.value,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
|
@ -194,6 +234,7 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
newMostRecentItem is TimelineItem.Event &&
|
||||
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
|
||||
newMostRecentItemId != prevMostRecentItemIdValue
|
||||
|
||||
if (hasNewEvent) {
|
||||
val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event
|
||||
// Scroll to bottom if the new event is from me, even if sent from another device
|
||||
|
|
@ -221,7 +262,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)
|
||||
//timeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -235,8 +276,4 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun CoroutineScope.paginateBackwards() = launch {
|
||||
timeline.paginateBackwards()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import androidx.compose.runtime.Immutable
|
|||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
|
|
@ -29,11 +28,20 @@ data class TimelineState(
|
|||
val timelineRoomInfo: TimelineRoomInfo,
|
||||
val renderReadReceipts: Boolean,
|
||||
val highlightedEventId: EventId?,
|
||||
val backPaginationStatus: Timeline.PaginationStatus,
|
||||
val newEventState: NewEventState,
|
||||
val eventSink: (TimelineEvents) -> Unit
|
||||
val isLive: Boolean,
|
||||
val focusedEventId : EventId?,
|
||||
val focusRequestState: FocusRequestState,
|
||||
val eventSink: (TimelineEvents) -> Unit,
|
||||
)
|
||||
|
||||
sealed interface FocusRequestState {
|
||||
data object None : FocusRequestState
|
||||
data class Cached(val index: Int): FocusRequestState
|
||||
data object Fetching : FocusRequestState
|
||||
data class Failure(val throwable: Throwable) : FocusRequestState
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class TimelineRoomInfo(
|
||||
val isDm: Boolean,
|
||||
|
|
|
|||
|
|
@ -46,17 +46,18 @@ import kotlin.random.Random
|
|||
|
||||
fun aTimelineState(
|
||||
timelineItems: ImmutableList<TimelineItem> = persistentListOf(),
|
||||
paginationState: Timeline.PaginationStatus = aPaginationStatus(),
|
||||
renderReadReceipts: Boolean = false,
|
||||
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
|
||||
eventSink: (TimelineEvents) -> Unit = {},
|
||||
) = TimelineState(
|
||||
timelineItems = timelineItems,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
backPaginationStatus = paginationState,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
highlightedEventId = null,
|
||||
newEventState = NewEventState.None,
|
||||
isLive = true,
|
||||
focusedEventId = null,
|
||||
focusRequestState = FocusRequestState.None,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
|
|
@ -55,7 +56,6 @@ import androidx.compose.ui.unit.dp
|
|||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
|
|
@ -63,8 +63,9 @@ 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
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
||||
|
|
@ -73,6 +74,8 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.math.abs
|
||||
|
||||
@Composable
|
||||
fun TimelineView(
|
||||
|
|
@ -92,6 +95,10 @@ fun TimelineView(
|
|||
forceJumpToBottomVisibility: Boolean = false
|
||||
) {
|
||||
|
||||
fun clearFocusRequestState() {
|
||||
state.eventSink(TimelineEvents.ClearFocusRequestState)
|
||||
}
|
||||
|
||||
fun onScrollFinishedAt(firstVisibleIndex: Int) {
|
||||
state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex))
|
||||
}
|
||||
|
|
@ -109,6 +116,10 @@ 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) {
|
||||
|
|
@ -118,9 +129,12 @@ fun TimelineView(
|
|||
reverseLayout = useReverseLayout,
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
item {
|
||||
/*
|
||||
item(key = UUID.randomUUID()) {
|
||||
TypingNotificationView(state = typingNotificationState)
|
||||
}
|
||||
|
||||
*/
|
||||
items(
|
||||
items = state.timelineItems,
|
||||
contentType = { timelineItem -> timelineItem.contentType() },
|
||||
|
|
@ -149,30 +163,68 @@ fun TimelineView(
|
|||
}
|
||||
}
|
||||
|
||||
FocusRequestStateView(
|
||||
focusRequestState = state.focusRequestState,
|
||||
onClearFocusRequestState = ::clearFocusRequestState
|
||||
)
|
||||
|
||||
TimelineScrollHelper(
|
||||
isTimelineEmpty = state.timelineItems.isEmpty(),
|
||||
lazyListState = lazyListState,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
newEventState = state.newEventState,
|
||||
onScrollFinishedAt = ::onScrollFinishedAt
|
||||
isLive = state.isLive,
|
||||
focusRequestState = state.focusRequestState,
|
||||
onScrollFinishedAt = ::onScrollFinishedAt,
|
||||
onClearFocusRequestState = ::clearFocusRequestState,
|
||||
onJumpToLive = { state.eventSink(TimelineEvents.JumpToLive) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FocusRequestStateView(
|
||||
focusRequestState: FocusRequestState,
|
||||
onClearFocusRequestState: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BackHandler(enabled = focusRequestState is FocusRequestState.Fetching) {
|
||||
onClearFocusRequestState()
|
||||
}
|
||||
|
||||
when (focusRequestState) {
|
||||
is FocusRequestState.Failure -> {
|
||||
ErrorDialog(
|
||||
content = stringResource(id = CommonStrings.common_failed),
|
||||
onDismiss = onClearFocusRequestState,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
FocusRequestState.Fetching -> {
|
||||
ProgressDialog(modifier = modifier)
|
||||
}
|
||||
is FocusRequestState.Cached, FocusRequestState.None -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.TimelineScrollHelper(
|
||||
isTimelineEmpty: Boolean,
|
||||
lazyListState: LazyListState,
|
||||
newEventState: NewEventState,
|
||||
isLive: Boolean,
|
||||
forceJumpToBottomVisibility: Boolean,
|
||||
focusRequestState: FocusRequestState,
|
||||
onClearFocusRequestState: () -> Unit,
|
||||
onScrollFinishedAt: (Int) -> Unit,
|
||||
onJumpToLive: () -> Unit,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
||||
val canAutoScroll by remember {
|
||||
derivedStateOf {
|
||||
lazyListState.firstVisibleItemIndex < 3
|
||||
lazyListState.firstVisibleItemIndex < 3 && isLive
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -186,9 +238,29 @@ private fun BoxScope.TimelineScrollHelper(
|
|||
}
|
||||
}
|
||||
|
||||
fun jumpToBottom() {
|
||||
if (isLive) {
|
||||
scrollToBottom()
|
||||
} else {
|
||||
onJumpToLive()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = focusRequestState) {
|
||||
if (focusRequestState is FocusRequestState.Cached) {
|
||||
if (abs(lazyListState.firstVisibleItemIndex - focusRequestState.index) < 10) {
|
||||
lazyListState.animateScrollToItem(focusRequestState.index)
|
||||
} else {
|
||||
lazyListState.scrollToItem(focusRequestState.index)
|
||||
}
|
||||
onClearFocusRequestState()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(canAutoScroll, newEventState) {
|
||||
val shouldAutoScroll = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
|
||||
if (shouldAutoScroll) {
|
||||
Timber.d("TimelineScrollHelper - canAutoScroll: $canAutoScroll, newEventState: $newEventState")
|
||||
val shouldScrollToBottom = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
|
||||
if (shouldScrollToBottom) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
|
@ -203,11 +275,11 @@ private fun BoxScope.TimelineScrollHelper(
|
|||
|
||||
JumpToBottomButton(
|
||||
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
|
||||
isVisible = !canAutoScroll || forceJumpToBottomVisibility,
|
||||
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 24.dp, bottom = 12.dp),
|
||||
onClick = ::scrollToBottom,
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 24.dp, bottom = 12.dp),
|
||||
onClick = { jumpToBottom() },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -233,8 +305,8 @@ private fun JumpToBottomButton(
|
|||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.rotate(90f),
|
||||
.size(24.dp)
|
||||
.rotate(90f),
|
||||
imageVector = CompoundIcons.ArrowRight(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -29,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.components.virtual.Tim
|
|||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemInvisibleIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel
|
||||
|
|
@ -46,10 +48,11 @@ fun TimelineItemVirtualRow(
|
|||
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
|
||||
TimelineItemRoomBeginningModel -> TimelineItemRoomBeginningView(roomName = timelineRoomInfo.name, modifier = modifier)
|
||||
is TimelineItemLoadingIndicatorModel -> {
|
||||
TimelineLoadingMoreIndicator()
|
||||
TimelineLoadingMoreIndicator(modifier)
|
||||
LaunchedEffect(key1 = virtual.model.timestamp) {
|
||||
eventSink(TimelineEvents.LoadMore(virtual.model.backwards))
|
||||
eventSink(TimelineEvents.LoadMore(virtual.model.direction))
|
||||
}
|
||||
}
|
||||
TimelineItemInvisibleIndicatorModel -> Spacer(modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.components.virtual
|
|||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -26,19 +27,19 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||
|
||||
@Composable
|
||||
internal fun TimelineLoadingMoreIndicator(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.padding(8.dp),
|
||||
.padding(2.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
LinearProgressIndicator(modifier = Modifier
|
||||
.height(1.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.factories
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
|
||||
import io.element.android.features.messages.impl.timeline.diff.TimelineItemsCacheInvalidator
|
||||
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
|
||||
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
||||
|
|
@ -33,6 +34,7 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -43,9 +45,9 @@ class TimelineItemsFactory @Inject constructor(
|
|||
private val eventItemFactory: TimelineItemEventFactory,
|
||||
private val virtualItemFactory: TimelineItemVirtualFactory,
|
||||
private val timelineItemGrouper: TimelineItemGrouper,
|
||||
private val timelineItemIndexer: TimelineItemIndexer,
|
||||
) {
|
||||
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
|
||||
|
||||
private val lock = Mutex()
|
||||
private val diffCache = MutableListDiffCache<TimelineItem>()
|
||||
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, TimelineItem>(
|
||||
|
|
@ -60,6 +62,10 @@ class TimelineItemsFactory @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun items(): StateFlow<ImmutableList<TimelineItem>> {
|
||||
return timelineItems
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun collectItemsAsState(): State<ImmutableList<TimelineItem>> {
|
||||
return timelineItems.collectAsState()
|
||||
|
|
@ -80,6 +86,7 @@ class TimelineItemsFactory @Inject constructor(
|
|||
roomMembers: List<RoomMember>,
|
||||
) {
|
||||
val newTimelineItemStates = ArrayList<TimelineItem>()
|
||||
val newTimelineById = mutableMapOf<String, TimelineItem>()
|
||||
for (index in diffCache.indices().reversed()) {
|
||||
val cacheItem = diffCache.get(index)
|
||||
if (cacheItem == null) {
|
||||
|
|
@ -96,10 +103,12 @@ class TimelineItemsFactory @Inject constructor(
|
|||
} else {
|
||||
cacheItem
|
||||
}
|
||||
newTimelineById[updatedItem.identifier()] = updatedItem
|
||||
newTimelineItemStates.add(updatedItem)
|
||||
}
|
||||
}
|
||||
val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList()
|
||||
timelineItemIndexer.process(result)
|
||||
this.timelineItems.emit(result)
|
||||
}
|
||||
|
||||
|
|
@ -108,13 +117,13 @@ class TimelineItemsFactory @Inject constructor(
|
|||
index: Int,
|
||||
roomMembers: List<RoomMember>,
|
||||
): TimelineItem? {
|
||||
val timelineItemState =
|
||||
val timelineItem =
|
||||
when (val currentTimelineItem = timelineItems[index]) {
|
||||
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers)
|
||||
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
|
||||
MatrixTimelineItem.Other -> null
|
||||
}
|
||||
diffCache[index] = timelineItemState
|
||||
return timelineItemState
|
||||
diffCache[index] = timelineItem
|
||||
return timelineItem
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.factories.virtual
|
|||
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemInvisibleIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel
|
||||
|
|
@ -45,9 +46,10 @@ class TimelineItemVirtualFactory @Inject constructor(
|
|||
is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
is VirtualTimelineItem.RoomBeginning -> TimelineItemRoomBeginningModel
|
||||
is VirtualTimelineItem.LoadingIndicator -> TimelineItemLoadingIndicatorModel(
|
||||
backwards = inner.backwards,
|
||||
direction = inner.direction,
|
||||
timestamp = inner.timestamp
|
||||
)
|
||||
VirtualTimelineItem.LatestKnownEventIndicator -> TimelineItemInvisibleIndicatorModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright (c) 2024 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.impl.timeline.model.virtual
|
||||
|
||||
data object TimelineItemInvisibleIndicatorModel : TimelineItemVirtualModel {
|
||||
override val type: String = "TimelineItemInvisibleIndicatorModel"
|
||||
}
|
||||
|
|
@ -16,8 +16,10 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.model.virtual
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
data class TimelineItemLoadingIndicatorModel(
|
||||
val backwards: Boolean,
|
||||
val direction: Timeline.PaginationDirection,
|
||||
val timestamp: Long,
|
||||
) : TimelineItemVirtualModel {
|
||||
override val type: String = "TimelineItemLoadingIndicatorModel"
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class PollHistoryPresenter @Inject constructor(
|
|||
override fun present(): PollHistoryState {
|
||||
// TODO use room.rememberPollHistory() when working properly?
|
||||
val timeline = room.liveTimeline
|
||||
val paginationState by timeline.backPaginationStatus.collectAsState()
|
||||
val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
|
||||
val pollHistoryItemsFlow = remember {
|
||||
timeline.timelineItems.map { items ->
|
||||
pollHistoryItemFactory.create(items)
|
||||
|
|
@ -96,6 +96,6 @@ class PollHistoryPresenter @Inject constructor(
|
|||
}
|
||||
|
||||
private fun CoroutineScope.loadMore(pollHistory: Timeline) = launch {
|
||||
pollHistory.paginateBackwards()
|
||||
pollHistory.paginate(Timeline.PaginationDirection.BACKWARDS)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue