diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt index 372fbde825..1ed45199be 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt @@ -3,18 +3,28 @@ package io.element.android.x.features.messages import Avatar -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel import io.element.android.x.core.data.LogCompositions import io.element.android.x.designsystem.components.avatar.AvatarData import io.element.android.x.features.messages.model.MessagesViewState +import io.element.android.x.matrix.timeline.MatrixTimelineItem @Composable fun MessagesScreen(roomId: String) { @@ -22,19 +32,19 @@ fun MessagesScreen(roomId: String) { LogCompositions(tag = "MessagesScreen", msg = "Root") val roomTitle by viewModel.collectAsState(MessagesViewState::roomName) val roomAvatar by viewModel.collectAsState(MessagesViewState::roomAvatar) - MessagesContent(roomTitle, roomAvatar) + val timelineItems by viewModel.collectAsState(MessagesViewState::timelineItems) + MessagesContent(roomTitle, roomAvatar, timelineItems().orEmpty()) } @Composable fun MessagesContent( roomTitle: String?, - roomAvatar: AvatarData? + roomAvatar: AvatarData?, + timelineItems: List, ) { - val appBarState = rememberTopAppBarState() - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState) - LogCompositions(tag = "RoomListScreen", msg = "Content") + LogCompositions(tag = "MessagesScreen", msg = "Content") + val lazyListState = rememberLazyListState() Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar( navigationIcon = { @@ -48,10 +58,81 @@ fun MessagesContent( ) }, content = { padding -> - Box(modifier = Modifier.padding(padding)) + TimelineItems( + padding = padding, + lazyListState = lazyListState, + timelineItems = timelineItems + ) } ) } +@Composable +fun TimelineItems( + padding: PaddingValues, + lazyListState: LazyListState, + timelineItems: List +) { + LazyColumn( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + state = lazyListState, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Bottom, + reverseLayout = true + ) { + items(timelineItems) { timelineItem -> + TimelineItemRow(timelineItem = timelineItem) + } + } +} +@Composable +fun TimelineItemRow( + timelineItem: MatrixTimelineItem +) { + when (timelineItem) { + MatrixTimelineItem.Other -> return + MatrixTimelineItem.Virtual -> return + is MatrixTimelineItem.Event -> { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable( + onClick = { }, + indication = rememberRipple(), + interactionSource = remember { MutableInteractionSource() } + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + fontSize = 14.sp, + text = timelineItem.event.raw() ?: "", + ) + } + } + } + + } +} + +@Composable +internal fun MessagesLoadingMoreIndicator() { + Box( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary) + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt index c8760cb98b..ab7d2aab5a 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt @@ -50,6 +50,11 @@ class MessagesViewModel( ) } }.launchIn(viewModelScope) + + room.timeline().timelineItems() + .execute { + copy(timelineItems = it) + } } private suspend fun loadAvatarData( diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt index 0ccc161395..e2c110b207 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt @@ -1,12 +1,16 @@ package io.element.android.x.features.messages.model +import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState +import com.airbnb.mvrx.Uninitialized import io.element.android.x.designsystem.components.avatar.AvatarData +import io.element.android.x.matrix.timeline.MatrixTimelineItem data class MessagesViewState( val roomId: String, val roomName: String? = null, - val roomAvatar: AvatarData? = null + val roomAvatar: AvatarData? = null, + val timelineItems: Async> = Uninitialized ) : MavericksState { @Suppress("unused") diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/MatrixClient.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/MatrixClient.kt index 13a38fd5b7..15712fa720 100644 --- a/libraries/matrix/src/main/java/io/element/android/x/matrix/MatrixClient.kt +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/MatrixClient.kt @@ -4,9 +4,7 @@ import io.element.android.x.core.data.CoroutineDispatchers import io.element.android.x.matrix.core.UserId import io.element.android.x.matrix.room.MatrixRoom import io.element.android.x.matrix.room.RoomSummaryDataSource -import io.element.android.x.matrix.room.RoomSummaryDetailsFactory import io.element.android.x.matrix.room.RustRoomSummaryDataSource -import io.element.android.x.matrix.room.message.RoomMessageFactory import io.element.android.x.matrix.session.SessionStore import io.element.android.x.matrix.sync.SlidingSyncObserverProxy import kotlinx.coroutines.CoroutineScope @@ -58,7 +56,12 @@ class MatrixClient internal constructor( private val slidingSyncObserverProxy = SlidingSyncObserverProxy(coroutineScope) private val roomSummaryDataSource: RustRoomSummaryDataSource = - RustRoomSummaryDataSource(slidingSyncObserverProxy.updateSummaryFlow, slidingSync, slidingSyncView, dispatchers) + RustRoomSummaryDataSource( + slidingSyncObserverProxy.updateSummaryFlow, + slidingSync, + slidingSyncView, + dispatchers + ) private var slidingSyncObserverToken: StoppableSpawn? = null init { @@ -71,7 +74,8 @@ class MatrixClient internal constructor( return MatrixRoom( slidingSyncUpdateFlow = slidingSyncObserverProxy.updateSummaryFlow, slidingSyncRoom = slidingSyncRoom, - room = room + room = room, + coroutineDispatchers = dispatchers ) } diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/room/MatrixRoom.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/MatrixRoom.kt index 60f510c9e6..b5f0736be9 100644 --- a/libraries/matrix/src/main/java/io/element/android/x/matrix/room/MatrixRoom.kt +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/MatrixRoom.kt @@ -1,20 +1,21 @@ package io.element.android.x.matrix.room +import io.element.android.x.core.data.CoroutineDispatchers import io.element.android.x.matrix.core.RoomId -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import org.matrix.rustcomponents.sdk.Room -import org.matrix.rustcomponents.sdk.SlidingSyncRoom -import org.matrix.rustcomponents.sdk.UpdateSummary +import io.element.android.x.matrix.timeline.MatrixTimeline +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.* class MatrixRoom( private val slidingSyncUpdateFlow: Flow, private val slidingSyncRoom: SlidingSyncRoom, private val room: Room, + private val coroutineDispatchers: CoroutineDispatchers, ) { + private val paginationOutcome = MutableStateFlow(PaginationOutcome(true)) fun syncUpdateFlow(): Flow { return slidingSyncUpdateFlow .filter { @@ -24,6 +25,14 @@ class MatrixRoom( .onStart { emit(Unit) } } + fun timeline(): MatrixTimeline { + return MatrixTimeline(this) + } + + internal fun timelineDiff(): Flow { + return room.timelineDiff() + } + val roomId = RoomId(room.id()) val name: String? @@ -46,5 +55,18 @@ class MatrixRoom( return room.avatarUrl() } + fun addTimelineListener(timelineListener: TimelineListener) { + room.addTimelineListener(timelineListener) + } + + suspend fun paginateBackwards(count: Int): Result = withContext(coroutineDispatchers.io) { + if (!paginationOutcome.value.moreMessages) { + return@withContext Result.failure(IllegalStateException("no more message")) + } + runCatching { + paginationOutcome.value = room.paginateBackwards(count.toUShort()) + } + } + } \ No newline at end of file diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomListenerFlows.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomListenerFlows.kt new file mode 100644 index 0000000000..5b87942159 --- /dev/null +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomListenerFlows.kt @@ -0,0 +1,23 @@ +package io.element.android.x.matrix.room + +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.TimelineDiff +import org.matrix.rustcomponents.sdk.TimelineListener + + +fun Room.timelineDiff(): Flow = callbackFlow { + val listener = object : TimelineListener { + override fun onUpdate(update: TimelineDiff) { + trySend(update) + } + } + addTimelineListener(listener) + awaitClose { + removeTimeline() + } + +} + diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomSummaryDataSource.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomSummaryDataSource.kt index 138d53a3a9..96fa943527 100644 --- a/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomSummaryDataSource.kt +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomSummaryDataSource.kt @@ -95,7 +95,7 @@ internal class RustRoomSummaryDataSource( add(roomSummary) } is SlidingSyncViewRoomsListDiff.UpdateAt -> { - //fillUntil(diff.index.toInt()) + fillUntil(diff.index.toInt()) val roomSummary = buildSummaryForRoomListEntry(diff.value) set(diff.index.toInt(), roomSummary) } diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt new file mode 100644 index 0000000000..db26e2780e --- /dev/null +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt @@ -0,0 +1,105 @@ +package io.element.android.x.matrix.timeline + +import io.element.android.x.matrix.core.EventId +import io.element.android.x.matrix.room.MatrixRoom +import kotlinx.coroutines.flow.* +import org.matrix.rustcomponents.sdk.TimelineChange +import org.matrix.rustcomponents.sdk.TimelineDiff +import timber.log.Timber +import java.util.* + +class MatrixTimeline( + private val room: MatrixRoom, +) { + + interface Callback { + fun onUpdatedTimelineItem(eventId: EventId) + fun onStartedBackPaginating() + fun onFinishedBackPaginating() + } + + var callback: Callback? = null + + private val timelineItems: MutableStateFlow> = + MutableStateFlow(emptyList()) + + + fun timelineItems(): Flow> { + return diffFlow().combine(timelineItems) { _, _ -> + timelineItems.value.reversed() + } + } + + private fun diffFlow(): Flow { + return room.timelineDiff() + .onEach { timelineDiff -> + updateTimelineItems { + applyDiff(timelineDiff) + } + }.map { } + } + + private fun MutableList.applyDiff(diff: TimelineDiff) { + Timber.v("ApplyDiff: ${diff.change()} for list with size: $size") + when (diff.change()) { + TimelineChange.PUSH -> { + val item = diff.push()?.asMatrixTimelineItem() ?: return + add(item) + } + TimelineChange.UPDATE_AT -> { + val updateAtData = diff.updateAt() ?: return + val item = updateAtData.item.asMatrixTimelineItem() + set(updateAtData.index.toInt(), item) + } + TimelineChange.INSERT_AT -> { + val insertAtData = diff.insertAt() ?: return + val item = insertAtData.item.asMatrixTimelineItem() + add(insertAtData.index.toInt(), item) + } + TimelineChange.MOVE -> { + val moveData = diff.move() ?: return + Collections.swap(this, moveData.oldIndex.toInt(), moveData.newIndex.toInt()) + } + TimelineChange.REMOVE_AT -> { + val removeAtData = diff.removeAt() ?: return + removeAt(removeAtData.toInt()) + } + TimelineChange.REPLACE -> { + clear() + val items = diff.replace()?.map { it.asMatrixTimelineItem() } ?: return + addAll(items) + } + TimelineChange.POP -> { + removeLast() + } + TimelineChange.CLEAR -> { + clear() + } + } + } + + private fun updateTimelineItems(block: MutableList.() -> Unit) { + val mutableTimelineItems = timelineItems.value.toMutableList() + block(mutableTimelineItems) + timelineItems.value = mutableTimelineItems + } + + + suspend fun processItemAppearance(itemId: String) { + + } + + suspend fun processItemDisappearance(itemId: String) { + + } + + suspend fun paginateBackwards(count: Int): Result { + return room.paginateBackwards(count) + } + + suspend fun sendMessage(message: String): Result { + return Result.success(Unit) + } + + +} \ No newline at end of file diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimelineItem.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimelineItem.kt new file mode 100644 index 0000000000..ff9695d428 --- /dev/null +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimelineItem.kt @@ -0,0 +1,22 @@ +package io.element.android.x.matrix.timeline + +import org.matrix.rustcomponents.sdk.EventTimelineItem +import org.matrix.rustcomponents.sdk.TimelineItem + +sealed interface MatrixTimelineItem { + data class Event(val event: EventTimelineItem) : MatrixTimelineItem + object Virtual : MatrixTimelineItem + object Other : MatrixTimelineItem +} + +fun TimelineItem.asMatrixTimelineItem(): MatrixTimelineItem { + val asEvent = asEvent() + if (asEvent != null) { + return MatrixTimelineItem.Event(asEvent) + } + val asVirtual = asVirtual() + if (asVirtual != null) { + return MatrixTimelineItem.Virtual + } + return MatrixTimelineItem.Other +}