diff --git a/.maestro/README.md b/.maestro/README.md index 9505769de9..76268e144e 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -26,7 +26,7 @@ maestro test \ -e USERNAME=user \ -e PASSWORD=123 \ -e ROOM_NAME="my room" \ - .maestro/allTest.yaml + .maestro/allTests.yaml ``` ### Output diff --git a/README.md b/README.md index e40206fe49..593c76cdc6 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Come chat with the community in the dedicated Matrix [room](https://matrix.to/#/ ## Build instructions Just clone the project and open it in Android Studio. +Makes sure to select the `app` configuration when building (as we also have sample apps in the project). ## Support diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7b8f71184f..90ea0dbc33 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,9 +38,9 @@ android { defaultConfig { applicationId = "io.element.android.x" - targetSdk = 33 // TODO Use Versions.targetSdk - versionCode = 1 - versionName = "1.0" + targetSdk = Versions.targetSdk + versionCode = Versions.versionCode + versionName = Versions.versionName vectorDrawables { useSupportLibrary = true @@ -109,24 +109,9 @@ android { } } } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } kotlinOptions { jvmTarget = "1.8" } - buildFeatures { - compose = true - } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.composecompiler.get() - } - packagingOptions { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } // Waiting for https://github.com/google/ksp/issues/37 applicationVariants.all { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aa64ea3f53..31033b0500 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,8 +34,7 @@ android:theme="@style/Theme.ElementX.Splash" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode" android:exported="true" - android:windowSoftInputMode="adjustResize" - tools:ignore="LockedOrientationActivity"> + android:windowSoftInputMode="adjustResize"> diff --git a/features/login/src/main/kotlin/io/element/android/features/login/root/LoginRootPresenter.kt b/features/login/src/main/kotlin/io/element/android/features/login/root/LoginRootPresenter.kt index cb80d3784a..d51dc5b5f8 100644 --- a/features/login/src/main/kotlin/io/element/android/features/login/root/LoginRootPresenter.kt +++ b/features/login/src/main/kotlin/io/element/android/features/login/root/LoginRootPresenter.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.matrix.auth.MatrixAuthenticationService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -66,8 +67,11 @@ class LoginRootPresenter @Inject constructor(private val authenticationService: private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState) = launch { loggedInState.value = LoggedInState.LoggingIn - try { + //TODO rework the setHomeserver flow + tryOrNull { authenticationService.setHomeserver(homeserver) + } + try { val sessionId = authenticationService.login(formState.login.trim(), formState.password.trim()) loggedInState.value = LoggedInState.LoggedIn(sessionId) } catch (failure: Throwable) { diff --git a/features/messages/build.gradle.kts b/features/messages/build.gradle.kts index b6f6fe4f33..1eb054d3d0 100644 --- a/features/messages/build.gradle.kts +++ b/features/messages/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.textcomposer) + implementation(projects.libraries.dateformatter) implementation(libs.coil.compose) implementation(libs.datetime) implementation(libs.accompanist.flowlayout) diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesEvents.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesEvents.kt index d8d5c0c795..4e6505f31c 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesEvents.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesEvents.kt @@ -20,5 +20,5 @@ import io.element.android.features.messages.actionlist.model.TimelineItemAction import io.element.android.features.messages.timeline.model.TimelineItem sealed interface MessagesEvents { - data class HandleAction(val action: TimelineItemAction, val messageEvent: TimelineItem.MessageEvent) : MessagesEvents + data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents } diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesPresenter.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesPresenter.kt index a173ba4293..da149fbe0c 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesPresenter.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesPresenter.kt @@ -32,7 +32,7 @@ import io.element.android.features.messages.textcomposer.MessageComposerState import io.element.android.features.messages.timeline.TimelineEvents import io.element.android.features.messages.timeline.TimelinePresenter import io.element.android.features.messages.timeline.model.TimelineItem -import io.element.android.features.messages.timeline.model.content.TimelineItemTextBasedContent +import io.element.android.features.messages.timeline.model.event.TimelineItemTextBasedContent import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -79,7 +79,7 @@ class MessagesPresenter @Inject constructor( } fun handleEvents(event: MessagesEvents) { when (event) { - is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.messageEvent, composerState) + is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.event, composerState) } } return MessagesState( @@ -95,7 +95,7 @@ class MessagesPresenter @Inject constructor( fun CoroutineScope.handleTimelineAction( action: TimelineItemAction, - targetEvent: TimelineItem.MessageEvent, + targetEvent: TimelineItem.Event, composerState: MessageComposerState, ) = launch { when (action) { @@ -111,13 +111,15 @@ class MessagesPresenter @Inject constructor( Timber.v("NotImplementedYet") } - private suspend fun handleActionRedact(event: TimelineItem.MessageEvent) { - room.redactEvent(event.id) + private suspend fun handleActionRedact(event: TimelineItem.Event) { + if (event.eventId == null) return + room.redactEvent(event.eventId) } - private fun handleActionEdit(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) { + private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) { + if (targetEvent.eventId == null) return val composerMode = MessageComposerMode.Edit( - targetEvent.id, + targetEvent.eventId, (targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty() ) composerState.eventSink( @@ -125,8 +127,9 @@ class MessagesPresenter @Inject constructor( ) } - private fun handleActionReply(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) { - val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, "") + private fun handleActionReply(targetEvent: TimelineItem.Event, composerState: MessageComposerState) { + if (targetEvent.eventId == null) return + val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.eventId, "") composerState.eventSink( MessageComposerEvents.SetMode(composerMode) ) diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesStateProvider.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesStateProvider.kt index ee846e9f6c..7310ba76f4 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesStateProvider.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesStateProvider.kt @@ -45,7 +45,6 @@ fun aMessagesState() = MessagesState( ), timelineState = aTimelineState().copy( timelineItems = aTimelineItemList(aTimelineItemContent()), - hasMoreToLoad = false, ), actionListState = anActionListState(), eventSink = {} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesView.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesView.kt index 385e61c010..ae6f5326d0 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesView.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/MessagesView.kt @@ -88,21 +88,21 @@ fun MessagesView( LogCompositions(tag = "MessagesScreen", msg = "Content") - fun onMessageClicked(messageEvent: TimelineItem.MessageEvent) { - Timber.v("OnMessageClicked= ${messageEvent.id}") + fun onMessageClicked(event: TimelineItem.Event) { + Timber.v("OnMessageClicked= ${event.id}") } - fun onMessageLongClicked(messageEvent: TimelineItem.MessageEvent) { - Timber.v("OnMessageLongClicked= ${messageEvent.id}") + fun onMessageLongClicked(event: TimelineItem.Event) { + Timber.v("OnMessageLongClicked= ${event.id}") focusManager.clearFocus(force = true) - state.actionListState.eventSink(ActionListEvents.ComputeForMessage(messageEvent)) + state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event)) coroutineScope.launch { itemActionsBottomSheetState.show() } } - fun onActionSelected(action: TimelineItemAction, messageEvent: TimelineItem.MessageEvent) { - state.eventSink(MessagesEvents.HandleAction(action, messageEvent)) + fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) { + state.eventSink(MessagesEvents.HandleAction(action, event)) } Scaffold( @@ -142,8 +142,8 @@ fun MessagesView( fun MessagesViewContent( state: MessagesState, modifier: Modifier = Modifier, - onMessageClicked: (TimelineItem.MessageEvent) -> Unit = {}, - onMessageLongClicked: (TimelineItem.MessageEvent) -> Unit = {}, + onMessageClicked: (TimelineItem.Event) -> Unit = {}, + onMessageLongClicked: (TimelineItem.Event) -> Unit = {}, ) { Column( modifier = modifier diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListEvents.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListEvents.kt index f760f08640..99e41de3e1 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListEvents.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListEvents.kt @@ -20,5 +20,5 @@ import io.element.android.features.messages.timeline.model.TimelineItem sealed interface ActionListEvents { object Clear : ActionListEvents - data class ComputeForMessage(val messageEvent: TimelineItem.MessageEvent) : ActionListEvents + data class ComputeForMessage(val event: TimelineItem.Event) : ActionListEvents } diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListPresenter.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListPresenter.kt index a0114f2420..194a7f103e 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListPresenter.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListPresenter.kt @@ -23,7 +23,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import io.element.android.features.messages.actionlist.model.TimelineItemAction import io.element.android.features.messages.timeline.model.TimelineItem -import io.element.android.features.messages.timeline.model.content.TimelineItemRedactedContent +import io.element.android.features.messages.timeline.model.event.TimelineItemRedactedContent import io.element.android.libraries.architecture.Presenter import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope @@ -43,7 +43,7 @@ class ActionListPresenter @Inject constructor() : Presenter { fun handleEvents(event: ActionListEvents) { when (event) { ActionListEvents.Clear -> target.value = ActionListState.Target.None - is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(event.messageEvent, target) + is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(event.event, target) } } @@ -53,7 +53,7 @@ class ActionListPresenter @Inject constructor() : Presenter { ) } - fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.MessageEvent, target: MutableState) = launch { + fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.Event, target: MutableState) = launch { target.value = ActionListState.Target.Loading(timelineItem) val actions = if (timelineItem.content is TimelineItemRedactedContent) { diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListState.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListState.kt index c3ce691c62..0bb8f9dd5d 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListState.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListState.kt @@ -28,9 +28,9 @@ data class ActionListState( ) { sealed interface Target { object None : Target - data class Loading(val messageEvent: TimelineItem.MessageEvent) : Target + data class Loading(val event: TimelineItem.Event) : Target data class Success( - val messageEvent: TimelineItem.MessageEvent, + val event: TimelineItem.Event, val actions: ImmutableList, ) : Target } diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListStateProvider.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListStateProvider.kt index e88acf42b7..8d63af518c 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListStateProvider.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListStateProvider.kt @@ -18,17 +18,17 @@ package io.element.android.features.messages.actionlist import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.messages.actionlist.model.TimelineItemAction -import io.element.android.features.messages.timeline.aMessageEvent +import io.element.android.features.messages.timeline.aTimelineItemEvent import kotlinx.collections.immutable.persistentListOf open class ActionListStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( anActionListState(), - anActionListState().copy(target = ActionListState.Target.Loading(aMessageEvent())), + anActionListState().copy(target = ActionListState.Target.Loading(aTimelineItemEvent())), anActionListState().copy( target = ActionListState.Target.Success( - messageEvent = aMessageEvent(), + event = aTimelineItemEvent(), actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListView.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListView.kt index 01719a7f9c..9b0ee80ed5 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListView.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListView.kt @@ -53,7 +53,7 @@ import kotlinx.coroutines.launch fun ActionListView( state: ActionListState, modalBottomSheetState: ModalBottomSheetState, - onActionSelected: (action: TimelineItemAction, TimelineItem.MessageEvent) -> Unit, + onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit, modifier: Modifier = Modifier ) { val coroutineScope = rememberCoroutineScope() @@ -67,7 +67,7 @@ fun ActionListView( fun onItemActionClicked( itemAction: TimelineItemAction, - targetItem: TimelineItem.MessageEvent + targetItem: TimelineItem.Event ) { onActionSelected(itemAction, targetItem) coroutineScope.launch { @@ -94,7 +94,7 @@ fun ActionListView( private fun SheetContent( state: ActionListState, modifier: Modifier = Modifier, - onActionClicked: (TimelineItemAction, TimelineItem.MessageEvent) -> Unit = { _, _ -> }, + onActionClicked: (TimelineItemAction, TimelineItem.Event) -> Unit = { _, _ -> }, ) { when (val target = state.target) { is ActionListState.Target.Loading, @@ -112,7 +112,7 @@ private fun SheetContent( ) { action -> ListItem( modifier = Modifier.clickable { - onActionClicked(action, target.messageEvent) + onActionClicked(action, target.event) }, text = { Text( diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineItemsFactory.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineItemsFactory.kt deleted file mode 100644 index 2e473695af..0000000000 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineItemsFactory.kt +++ /dev/null @@ -1,253 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.features.messages.timeline - -import androidx.recyclerview.widget.DiffUtil -import io.element.android.features.messages.timeline.diff.CacheInvalidator -import io.element.android.features.messages.timeline.diff.MatrixTimelineItemsDiffCallback -import io.element.android.features.messages.timeline.model.AggregatedReaction -import io.element.android.features.messages.timeline.model.TimelineItemGroupPosition -import io.element.android.features.messages.timeline.model.TimelineItem -import io.element.android.features.messages.timeline.model.TimelineItemReactions -import io.element.android.features.messages.timeline.model.content.TimelineItemContent -import io.element.android.features.messages.timeline.model.content.TimelineItemEmoteContent -import io.element.android.features.messages.timeline.model.content.TimelineItemEncryptedContent -import io.element.android.features.messages.timeline.model.content.TimelineItemImageContent -import io.element.android.features.messages.timeline.model.content.TimelineItemNoticeContent -import io.element.android.features.messages.timeline.model.content.TimelineItemRedactedContent -import io.element.android.features.messages.timeline.model.content.TimelineItemTextContent -import io.element.android.features.messages.timeline.model.content.TimelineItemUnknownContent -import io.element.android.features.messages.timeline.util.invalidateLast -import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.matrix.core.EventId -import io.element.android.libraries.matrix.media.MediaResolver -import io.element.android.libraries.matrix.room.MatrixRoom -import io.element.android.libraries.matrix.timeline.MatrixTimelineItem -import io.element.android.libraries.matrix.ui.MatrixItemHelper -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import org.jsoup.Jsoup -import org.jsoup.nodes.Document -import org.matrix.rustcomponents.sdk.FormattedBody -import org.matrix.rustcomponents.sdk.MessageFormat -import org.matrix.rustcomponents.sdk.MessageType -import timber.log.Timber -import javax.inject.Inject -import kotlin.system.measureTimeMillis - -class TimelineItemsFactory @Inject constructor( - private val matrixItemHelper: MatrixItemHelper, - private val room: MatrixRoom, - private val dispatcher: CoroutineDispatcher, -) { - - private val timelineItems = MutableStateFlow>(emptyList()) - private val timelineItemsCache = arrayListOf() - - // Items from rust sdk, used for diffing - private var matrixTimelineItems: List = emptyList() - - private val lock = Mutex() - private val cacheInvalidator = CacheInvalidator(timelineItemsCache) - - fun flow(): StateFlow> = timelineItems.asStateFlow() - - suspend fun replaceWith( - timelineItems: List, - ) = withContext(dispatcher) { - lock.withLock { - calculateAndApplyDiff(timelineItems) - buildAndEmitTimelineItemStates(timelineItems) - } - } - - suspend fun pushItem( - timelineItem: MatrixTimelineItem, - ) = withContext(dispatcher) { - lock.withLock { - // Makes sure to invalidate last as we need to recompute some data (like groupPosition) - timelineItemsCache.invalidateLast() - timelineItemsCache.add(null) - matrixTimelineItems = matrixTimelineItems + timelineItem - buildAndEmitTimelineItemStates(matrixTimelineItems) - } - } - - private suspend fun buildAndEmitTimelineItemStates(timelineItems: List) { - val newTimelineItemStates = ArrayList() - for (index in timelineItemsCache.indices.reversed()) { - val cacheItem = timelineItemsCache[index] - if (cacheItem == null) { - buildAndCacheItem(timelineItems, index)?.also { timelineItemState -> - newTimelineItemStates.add(timelineItemState) - } - } else { - newTimelineItemStates.add(cacheItem) - } - } - this.timelineItems.emit(newTimelineItemStates) - } - - private fun calculateAndApplyDiff(newTimelineItems: List) { - val timeToDiff = measureTimeMillis { - val diffCallback = - MatrixTimelineItemsDiffCallback( - oldList = matrixTimelineItems, - newList = newTimelineItems - ) - val diffResult = DiffUtil.calculateDiff(diffCallback, false) - matrixTimelineItems = newTimelineItems - diffResult.dispatchUpdatesTo(cacheInvalidator) - } - Timber.v("Time to apply diff on new list of ${newTimelineItems.size} items: $timeToDiff ms") - } - - private suspend fun buildAndCacheItem( - timelineItems: List, - index: Int - ): TimelineItem? { - val timelineItemState = - when (val currentTimelineItem = timelineItems[index]) { - is MatrixTimelineItem.Event -> { - buildMessageEvent( - currentTimelineItem, - index, - timelineItems, - ) - } - is MatrixTimelineItem.Virtual -> TimelineItem.Virtual( - "virtual_item_$index" - ) - MatrixTimelineItem.Other -> null - } - timelineItemsCache[index] = timelineItemState - return timelineItemState - } - - private suspend fun buildMessageEvent( - currentTimelineItem: MatrixTimelineItem.Event, - index: Int, - timelineItems: List, - ): TimelineItem.MessageEvent { - val currentSender = currentTimelineItem.event.sender() - val groupPosition = - computeGroupPosition(currentTimelineItem, timelineItems, index) - val senderDisplayName = room.userDisplayName(currentSender).getOrNull() - val senderAvatarUrl = room.userAvatarUrl(currentSender).getOrNull() - val senderAvatarData = AvatarData( - id = currentSender, - name = senderDisplayName, - url = senderAvatarUrl, - size = AvatarSize.SMALL - ) - return TimelineItem.MessageEvent( - id = EventId(currentTimelineItem.uniqueId), - senderId = currentSender, - senderDisplayName = senderDisplayName, - senderAvatar = senderAvatarData, - content = currentTimelineItem.computeContent(), - isMine = currentTimelineItem.event.isOwn(), - groupPosition = groupPosition, - reactionsState = currentTimelineItem.computeReactionsState() - ) - } - - private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions { - val aggregatedReactions = event.reactions().map { - AggregatedReaction(key = it.key, count = it.count.toString(), isHighlighted = false) - } - return TimelineItemReactions(aggregatedReactions.toImmutableList()) - } - - private fun MatrixTimelineItem.Event.computeContent(): TimelineItemContent { - val content = event.content() - content.asUnableToDecrypt()?.let { encryptedMessage -> - return TimelineItemEncryptedContent(encryptedMessage) - } - if (content.isRedactedMessage()) { - return TimelineItemRedactedContent - } - val contentAsMessage = content.asMessage() - return when (val messageType = contentAsMessage?.msgtype()) { - is MessageType.Emote -> TimelineItemEmoteContent( - body = messageType.content.body, - htmlDocument = messageType.content.formatted?.toHtmlDocument() - ) - is MessageType.Image -> { - val height = messageType.content.info?.height?.toFloat() - val width = messageType.content.info?.width?.toFloat() - val aspectRatio = if (height != null && width != null) { - width / height - } else { - 0.7f - } - TimelineItemImageContent( - body = messageType.content.body, - imageMeta = MediaResolver.Meta( - source = messageType.content.source, - kind = MediaResolver.Kind.Content - ), - blurhash = messageType.content.info?.blurhash, - aspectRatio = aspectRatio - ) - } - is MessageType.Notice -> TimelineItemNoticeContent( - body = messageType.content.body, - htmlDocument = messageType.content.formatted?.toHtmlDocument() - ) - is MessageType.Text -> TimelineItemTextContent( - body = messageType.content.body, - htmlDocument = messageType.content.formatted?.toHtmlDocument() - ) - else -> TimelineItemUnknownContent - } - } - - private fun FormattedBody.toHtmlDocument(): Document? { - return takeIf { it.format == MessageFormat.HTML }?.body?.let { formattedBody -> - Jsoup.parse(formattedBody) - } - } - - private fun computeGroupPosition( - currentTimelineItem: MatrixTimelineItem.Event, - timelineItems: List, - index: Int - ): TimelineItemGroupPosition { - val prevTimelineItem = - timelineItems.getOrNull(index - 1) as? MatrixTimelineItem.Event - val nextTimelineItem = - timelineItems.getOrNull(index + 1) as? MatrixTimelineItem.Event - val currentSender = currentTimelineItem.event.sender() - val previousSender = prevTimelineItem?.event?.sender() - val nextSender = nextTimelineItem?.event?.sender() - - return when { - previousSender != currentSender && nextSender == currentSender -> TimelineItemGroupPosition.First - previousSender == currentSender && nextSender == currentSender -> TimelineItemGroupPosition.Middle - previousSender == currentSender && nextSender != currentSender -> TimelineItemGroupPosition.Last - else -> TimelineItemGroupPosition.None - } - } -} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelinePresenter.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelinePresenter.kt index 18ce07896e..514ec34d78 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelinePresenter.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelinePresenter.kt @@ -24,61 +24,46 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.features.messages.timeline.factories.TimelineItemsFactory import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.matrix.MatrixClient import io.element.android.libraries.matrix.core.EventId import io.element.android.libraries.matrix.room.MatrixRoom import io.element.android.libraries.matrix.timeline.MatrixTimeline -import io.element.android.libraries.matrix.timeline.MatrixTimelineItem -import io.element.android.libraries.matrix.ui.MatrixItemHelper import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject -private const val PAGINATION_COUNT = 50 +private const val backPaginationEventLimit = 20 +private const val backPaginationPageSize = 50 class TimelinePresenter @Inject constructor( - coroutineDispatchers: CoroutineDispatchers, - client: MatrixClient, + private val timelineItemsFactory: TimelineItemsFactory, room: MatrixRoom, ) : Presenter { private val timeline = room.timeline() - private val matrixItemHelper = MatrixItemHelper(client) - private val timelineItemsFactory = - TimelineItemsFactory(matrixItemHelper, room, coroutineDispatchers.computation) - - private class TimelineCallback( - private val coroutineScope: CoroutineScope, - private val timelineItemsFactory: TimelineItemsFactory, - ) : MatrixTimeline.Callback { - override fun onPushedTimelineItem(timelineItem: MatrixTimelineItem) { - coroutineScope.launch { - timelineItemsFactory.pushItem(timelineItem) - } - } - } @Composable override fun present(): TimelineState { val localCoroutineScope = rememberCoroutineScope() - val hasMoreToLoad = rememberSaveable { - mutableStateOf(timeline.hasMoreToLoad) - } val highlightedEventId: MutableState = rememberSaveable { mutableStateOf(null) } val timelineItems = timelineItemsFactory .flow() - .collectAsState(emptyList()) + .collectAsState() + + val paginationState = timeline + .paginationState() + .collectAsState() fun handleEvents(event: TimelineEvents) { when (event) { - TimelineEvents.LoadMore -> localCoroutineScope.loadMore(hasMoreToLoad) + TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState.value) is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId } } @@ -87,28 +72,34 @@ class TimelinePresenter @Inject constructor( timeline .timelineItems() .onEach(timelineItemsFactory::replaceWith) + .onEach { timelineItems -> + if (timelineItems.isEmpty()) { + loadMore(paginationState.value) + } + } .launchIn(this) } DisposableEffect(Unit) { - timeline.callback = TimelineCallback(localCoroutineScope, timelineItemsFactory) timeline.initialize() onDispose { - timeline.callback = null timeline.dispose() } } return TimelineState( highlightedEventId = highlightedEventId.value, + paginationState = paginationState.value, timelineItems = timelineItems.value.toImmutableList(), - hasMoreToLoad = hasMoreToLoad.value, eventSink = ::handleEvents ) } - private fun CoroutineScope.loadMore(hasMoreToLoad: MutableState) = launch { - timeline.paginateBackwards(PAGINATION_COUNT) - hasMoreToLoad.value = timeline.hasMoreToLoad + private fun CoroutineScope.loadMore(paginationState: MatrixTimeline.PaginationState) = launch { + if (paginationState.canBackPaginate && !paginationState.isBackPaginating) { + timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize) + } else { + Timber.v("Can't back paginate as paginationState = $paginationState") + } } } diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineState.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineState.kt index 6b8c715f5d..affe32ee66 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineState.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineState.kt @@ -19,12 +19,13 @@ package io.element.android.features.messages.timeline import androidx.compose.runtime.Immutable import io.element.android.features.messages.timeline.model.TimelineItem import io.element.android.libraries.matrix.core.EventId +import io.element.android.libraries.matrix.timeline.MatrixTimeline import kotlinx.collections.immutable.ImmutableList @Immutable data class TimelineState( val timelineItems: ImmutableList, - val hasMoreToLoad: Boolean, val highlightedEventId: EventId?, + val paginationState: MatrixTimeline.PaginationState, val eventSink: (TimelineEvents) -> Unit ) diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineStateProvider.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineStateProvider.kt index cadffd1262..6f3c04a7c6 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineStateProvider.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineStateProvider.kt @@ -17,53 +17,54 @@ package io.element.android.features.messages.timeline import io.element.android.features.messages.timeline.model.AggregatedReaction -import io.element.android.features.messages.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.timeline.model.TimelineItem +import io.element.android.features.messages.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.timeline.model.TimelineItemReactions -import io.element.android.features.messages.timeline.model.content.TimelineItemContent -import io.element.android.features.messages.timeline.model.content.TimelineItemTextContent +import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.timeline.model.event.TimelineItemTextContent import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.core.EventId +import io.element.android.libraries.matrix.timeline.MatrixTimeline import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf fun aTimelineState() = TimelineState( timelineItems = persistentListOf(), - hasMoreToLoad = false, + paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true), highlightedEventId = null, eventSink = {} ) -internal fun aTimelineItemList(content: TimelineItemContent): ImmutableList { +internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList { return persistentListOf( // 3 items (First Middle Last) with isMine = false - aMessageEvent( + aTimelineItemEvent( isMine = false, content = content, groupPosition = TimelineItemGroupPosition.Last ), - aMessageEvent( + aTimelineItemEvent( isMine = false, content = content, groupPosition = TimelineItemGroupPosition.Middle ), - aMessageEvent( + aTimelineItemEvent( isMine = false, content = content, groupPosition = TimelineItemGroupPosition.First ), // 3 items (First Middle Last) with isMine = true - aMessageEvent( + aTimelineItemEvent( isMine = true, content = content, groupPosition = TimelineItemGroupPosition.Last ), - aMessageEvent( + aTimelineItemEvent( isMine = true, content = content, groupPosition = TimelineItemGroupPosition.Middle ), - aMessageEvent( + aTimelineItemEvent( isMine = true, content = content, groupPosition = TimelineItemGroupPosition.First @@ -71,13 +72,15 @@ internal fun aTimelineItemList(content: TimelineItemContent): ImmutableList