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 44d6f0b7a1..ccfb58b5f8 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 @@ -78,7 +78,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( @@ -94,7 +94,7 @@ class MessagesPresenter @Inject constructor( fun CoroutineScope.handleTimelineAction( action: TimelineItemAction, - targetEvent: TimelineItem.MessageEvent, + targetEvent: TimelineItem.Event, composerState: MessageComposerState, ) = launch { when (action) { @@ -110,11 +110,11 @@ class MessagesPresenter @Inject constructor( Timber.v("NotImplementedYet") } - private suspend fun handleActionRedact(event: TimelineItem.MessageEvent) { + private suspend fun handleActionRedact(event: TimelineItem.Event) { room.redactEvent(event.id) } - private fun handleActionEdit(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) { + private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) { val composerMode = MessageComposerMode.Edit( targetEvent.id, (targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty() @@ -124,7 +124,7 @@ class MessagesPresenter @Inject constructor( ) } - private fun handleActionReply(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) { + private fun handleActionReply(targetEvent: TimelineItem.Event, composerState: MessageComposerState) { val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, "") composerState.eventSink( MessageComposerEvents.SetMode(composerMode) 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 706dc48c43..ef47416b48 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 @@ -84,21 +84,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( @@ -138,8 +138,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..f02f21bcbc 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 @@ -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 6bc7630ec8..96134e1207 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 @@ -29,9 +29,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/ActionListView.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/actionlist/ActionListView.kt index 53df8d4b40..b5d7428d56 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 @@ -51,7 +51,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() @@ -65,7 +65,7 @@ fun ActionListView( fun onItemActionClicked( itemAction: TimelineItemAction, - targetItem: TimelineItem.MessageEvent + targetItem: TimelineItem.Event ) { onActionSelected(itemAction, targetItem) coroutineScope.launch { @@ -92,7 +92,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, @@ -110,7 +110,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 ce945f1e88..0000000000 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineItemsFactory.kt +++ /dev/null @@ -1,252 +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.MessagesItemGroupPosition -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( - name = senderDisplayName ?: currentSender, - 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 - ): MessagesItemGroupPosition { - 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 -> MessagesItemGroupPosition.First - previousSender == currentSender && nextSender == currentSender -> MessagesItemGroupPosition.Middle - previousSender == currentSender && nextSender != currentSender -> MessagesItemGroupPosition.Last - else -> MessagesItemGroupPosition.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..d1b9145586 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,14 +24,12 @@ 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 @@ -39,18 +37,15 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch 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, @@ -66,9 +61,6 @@ class TimelinePresenter @Inject constructor( @Composable override fun present(): TimelineState { val localCoroutineScope = rememberCoroutineScope() - val hasMoreToLoad = rememberSaveable { - mutableStateOf(timeline.hasMoreToLoad) - } val highlightedEventId: MutableState = rememberSaveable { mutableStateOf(null) } @@ -78,7 +70,7 @@ class TimelinePresenter @Inject constructor( fun handleEvents(event: TimelineEvents) { when (event) { - TimelineEvents.LoadMore -> localCoroutineScope.loadMore(hasMoreToLoad) + TimelineEvents.LoadMore -> localCoroutineScope.loadMore() is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId } } @@ -102,13 +94,11 @@ class TimelinePresenter @Inject constructor( return TimelineState( highlightedEventId = highlightedEventId.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() = launch { + timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize) } } 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..c86dab1422 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 @@ -24,7 +24,6 @@ import kotlinx.collections.immutable.ImmutableList @Immutable data class TimelineState( val timelineItems: ImmutableList, - val hasMoreToLoad: Boolean, val highlightedEventId: EventId?, val eventSink: (TimelineEvents) -> Unit ) diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineView.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineView.kt index a73c724448..d408656c03 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineView.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineView.kt @@ -90,8 +90,8 @@ import kotlinx.coroutines.launch fun TimelineView( state: TimelineState, modifier: Modifier = Modifier, - onMessageClicked: (TimelineItem.MessageEvent) -> Unit = {}, - onMessageLongClicked: (TimelineItem.MessageEvent) -> Unit = {}, + onMessageClicked: (TimelineItem.Event) -> Unit = {}, + onMessageLongClicked: (TimelineItem.Event) -> Unit = {}, ) { val lazyListState = rememberLazyListState() Box(modifier = modifier) { @@ -114,11 +114,14 @@ fun TimelineView( onLongClick = onMessageLongClicked ) } + /* if (state.hasMoreToLoad) { item { TimelineLoadingMoreIndicator() } } + + */ } fun onReachedLoadMore() { @@ -135,14 +138,14 @@ fun TimelineView( private fun TimelineItem.key(): String { return when (this) { - is TimelineItem.MessageEvent -> id.value + is TimelineItem.Event -> id.value is TimelineItem.Virtual -> id } } private fun TimelineItem.contentType(): Int { return when (this) { - is TimelineItem.MessageEvent -> 0 + is TimelineItem.Event -> 0 is TimelineItem.Virtual -> 1 } } @@ -151,13 +154,13 @@ private fun TimelineItem.contentType(): Int { fun TimelineItemRow( timelineItem: TimelineItem, isHighlighted: Boolean, - onClick: (TimelineItem.MessageEvent) -> Unit, - onLongClick: (TimelineItem.MessageEvent) -> Unit, + onClick: (TimelineItem.Event) -> Unit, + onLongClick: (TimelineItem.Event) -> Unit, ) { when (timelineItem) { is TimelineItem.Virtual -> return - is TimelineItem.MessageEvent -> MessageEventRow( - messageEvent = timelineItem, + is TimelineItem.Event -> MessageEventRow( + event = timelineItem, isHighlighted = isHighlighted, onClick = { onClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) } @@ -167,14 +170,14 @@ fun TimelineItemRow( @Composable fun MessageEventRow( - messageEvent: TimelineItem.MessageEvent, + event: TimelineItem.Event, isHighlighted: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier ) { val interactionSource = remember { MutableInteractionSource() } - val (parentAlignment, contentAlignment) = if (messageEvent.isMine) { + val (parentAlignment, contentAlignment) = if (event.isMine) { Pair(Alignment.CenterEnd, Alignment.End) } else { Pair(Alignment.CenterStart, Alignment.Start) @@ -186,20 +189,20 @@ fun MessageEventRow( contentAlignment = parentAlignment ) { Row { - if (!messageEvent.isMine) { + if (!event.isMine) { Spacer(modifier = Modifier.width(16.dp)) } Column(horizontalAlignment = contentAlignment) { - if (messageEvent.showSenderInformation) { + if (event.showSenderInformation) { MessageSenderInformation( - messageEvent.safeSenderName, - messageEvent.senderAvatar, + event.safeSenderName, + event.senderAvatar, Modifier.zIndex(1f) ) } MessageEventBubble( - groupPosition = messageEvent.groupPosition, - isMine = messageEvent.isMine, + groupPosition = event.groupPosition, + isMine = event.isMine, interactionSource = interactionSource, isHighlighted = isHighlighted, onClick = onClick, @@ -209,45 +212,45 @@ fun MessageEventRow( .widthIn(max = 320.dp) ) { val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) - when (messageEvent.content) { + when (event.content) { is TimelineItemEncryptedContent -> TimelineItemEncryptedView( - content = messageEvent.content, + content = event.content, modifier = contentModifier ) is TimelineItemRedactedContent -> TimelineItemRedactedView( - content = messageEvent.content, + content = event.content, modifier = contentModifier ) is TimelineItemTextBasedContent -> TimelineItemTextView( - content = messageEvent.content, + content = event.content, interactionSource = interactionSource, modifier = contentModifier, onTextClicked = onClick, onTextLongClicked = onLongClick ) is TimelineItemUnknownContent -> TimelineItemUnknownView( - content = messageEvent.content, + content = event.content, modifier = contentModifier ) is TimelineItemImageContent -> TimelineItemImageView( - content = messageEvent.content, + content = event.content, modifier = contentModifier ) } } TimelineItemReactionsView( - reactionsState = messageEvent.reactionsState, + reactionsState = event.reactionsState, modifier = Modifier .zIndex(1f) - .offset(x = if (messageEvent.isMine) 0.dp else 20.dp, y = -(16.dp)) + .offset(x = if (event.isMine) 0.dp else 20.dp, y = -(16.dp)) ) } - if (messageEvent.isMine) { + if (event.isMine) { Spacer(modifier = Modifier.width(16.dp)) } } } - if (messageEvent.groupPosition.isNew()) { + if (event.groupPosition.isNew()) { Spacer(modifier = modifier.height(8.dp)) } else { Spacer(modifier = modifier.height(2.dp)) @@ -399,7 +402,6 @@ fun TimelineItemsPreview( TimelineView( state = TimelineState( timelineItems = timelineItems, - hasMoreToLoad = true, highlightedEventId = null, eventSink = {} ) @@ -411,7 +413,7 @@ private fun createMessageEvent( content: TimelineItemContent, groupPosition: MessagesItemGroupPosition ): TimelineItem { - return TimelineItem.MessageEvent( + return TimelineItem.Event( id = EventId(Math.random().toString()), senderId = "senderId", senderAvatar = AvatarData("sender"), diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentFactory.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentFactory.kt new file mode 100644 index 0000000000..a8c9ce9db0 --- /dev/null +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentFactory.kt @@ -0,0 +1,50 @@ +/* + * 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.factories + +import io.element.android.features.messages.timeline.model.content.TimelineItemContent +import org.matrix.rustcomponents.sdk.TimelineItemContentKind +import javax.inject.Inject + +typealias RustTimelineItemContent = org.matrix.rustcomponents.sdk.TimelineItemContent + +class TimelineItemContentFactory @Inject constructor( + private val messageFactory: TimelineItemContentMessageFactory, + private val redactedMessageFactory: TimelineItemContentRedactedFactory, + private val stickerFactory: TimelineItemContentStickerFactory, + private val utdFactory: TimelineItemContentUTDFactory, + private val roomMembershipFactory: TimelineItemContentRoomMembershipFactory, + private val profileChangeFactory: TimelineItemContentProfileChangeFactory, + private val stateFactory: TimelineItemContentStateFactory, + private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory, + private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory +) { + + fun create(itemContent: RustTimelineItemContent): TimelineItemContent { + return when (val kind = itemContent.kind()) { + is TimelineItemContentKind.Message -> messageFactory.create(itemContent.asMessage()) + is TimelineItemContentKind.RedactedMessage -> redactedMessageFactory.create(kind) + is TimelineItemContentKind.Sticker -> stickerFactory.create(kind) + is TimelineItemContentKind.UnableToDecrypt -> utdFactory.create(kind) + is TimelineItemContentKind.RoomMembership -> roomMembershipFactory.create(kind) + is TimelineItemContentKind.ProfileChange -> profileChangeFactory.create(kind) + is TimelineItemContentKind.State -> stateFactory.create(kind) + is TimelineItemContentKind.FailedToParseMessageLike -> failedToParseMessageFactory.create(kind) + is TimelineItemContentKind.FailedToParseState -> failedToParseStateFactory.create(kind) + } + } +} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentFailedToParseMessageFactory.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentFailedToParseMessageFactory.kt new file mode 100644 index 0000000000..382bc0d84a --- /dev/null +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentFailedToParseMessageFactory.kt @@ -0,0 +1,29 @@ +/* + * 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.factories + +import io.element.android.features.messages.timeline.model.content.TimelineItemContent +import io.element.android.features.messages.timeline.model.content.TimelineItemUnknownContent +import org.matrix.rustcomponents.sdk.TimelineItemContentKind +import javax.inject.Inject + +class TimelineItemContentFailedToParseMessageFactory @Inject constructor() { + + fun create(kind: TimelineItemContentKind.FailedToParseMessageLike): TimelineItemContent { + return TimelineItemUnknownContent + } +} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentFailedToParseStateFactory.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentFailedToParseStateFactory.kt new file mode 100644 index 0000000000..57d25673ab --- /dev/null +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentFailedToParseStateFactory.kt @@ -0,0 +1,29 @@ +/* + * 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.factories + +import io.element.android.features.messages.timeline.model.content.TimelineItemContent +import io.element.android.features.messages.timeline.model.content.TimelineItemUnknownContent +import org.matrix.rustcomponents.sdk.TimelineItemContentKind +import javax.inject.Inject + +class TimelineItemContentFailedToParseStateFactory @Inject constructor() { + + fun create(kind: TimelineItemContentKind.FailedToParseState): TimelineItemContent { + return TimelineItemUnknownContent + } +} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentMessageFactory.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentMessageFactory.kt new file mode 100644 index 0000000000..5ac1dec13b --- /dev/null +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentMessageFactory.kt @@ -0,0 +1,68 @@ +/* + * 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.factories + +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.TimelineItemImageContent +import io.element.android.features.messages.timeline.model.content.TimelineItemNoticeContent +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.toHtmlDocument +import io.element.android.libraries.matrix.media.MediaResolver +import org.matrix.rustcomponents.sdk.Message +import org.matrix.rustcomponents.sdk.MessageType +import javax.inject.Inject + +class TimelineItemContentMessageFactory @Inject constructor() { + + fun create(contentAsMessage: Message?): TimelineItemContent { + 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 + } + } +} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentProfileChangeFactory.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentProfileChangeFactory.kt new file mode 100644 index 0000000000..40027de1b4 --- /dev/null +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentProfileChangeFactory.kt @@ -0,0 +1,30 @@ +/* + * 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.factories + +import io.element.android.features.messages.timeline.model.content.TimelineItemContent +import io.element.android.features.messages.timeline.model.content.TimelineItemRedactedContent +import io.element.android.features.messages.timeline.model.content.TimelineItemUnknownContent +import org.matrix.rustcomponents.sdk.TimelineItemContentKind +import javax.inject.Inject + +class TimelineItemContentProfileChangeFactory @Inject constructor() { + + fun create(kind: TimelineItemContentKind.ProfileChange): TimelineItemContent { + return TimelineItemUnknownContent + } +} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentRedactedFactory.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentRedactedFactory.kt new file mode 100644 index 0000000000..3bb8550071 --- /dev/null +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentRedactedFactory.kt @@ -0,0 +1,29 @@ +/* + * 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.factories + +import io.element.android.features.messages.timeline.model.content.TimelineItemContent +import io.element.android.features.messages.timeline.model.content.TimelineItemRedactedContent +import org.matrix.rustcomponents.sdk.TimelineItemContentKind +import javax.inject.Inject + +class TimelineItemContentRedactedFactory @Inject constructor() { + + fun create(kind: TimelineItemContentKind.RedactedMessage): TimelineItemContent { + return TimelineItemRedactedContent + } +} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentRoomMembershipFactory.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentRoomMembershipFactory.kt new file mode 100644 index 0000000000..e2dcfeea19 --- /dev/null +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentRoomMembershipFactory.kt @@ -0,0 +1,29 @@ +/* + * 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.factories + +import io.element.android.features.messages.timeline.model.content.TimelineItemContent +import io.element.android.features.messages.timeline.model.content.TimelineItemUnknownContent +import org.matrix.rustcomponents.sdk.TimelineItemContentKind +import javax.inject.Inject + +class TimelineItemContentRoomMembershipFactory @Inject constructor() { + + fun create(kind: TimelineItemContentKind.RoomMembership): TimelineItemContent { + return TimelineItemUnknownContent + } +} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentStateFactory.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentStateFactory.kt new file mode 100644 index 0000000000..abfa405ecd --- /dev/null +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentStateFactory.kt @@ -0,0 +1,29 @@ +/* + * 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.factories + +import io.element.android.features.messages.timeline.model.content.TimelineItemContent +import io.element.android.features.messages.timeline.model.content.TimelineItemUnknownContent +import org.matrix.rustcomponents.sdk.TimelineItemContentKind +import javax.inject.Inject + +class TimelineItemContentStateFactory @Inject constructor() { + + fun create(kind: TimelineItemContentKind.State): TimelineItemContent { + return TimelineItemUnknownContent + } +} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentStickerFactory.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentStickerFactory.kt new file mode 100644 index 0000000000..d69ea61341 --- /dev/null +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentStickerFactory.kt @@ -0,0 +1,29 @@ +/* + * 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.factories + +import io.element.android.features.messages.timeline.model.content.TimelineItemContent +import io.element.android.features.messages.timeline.model.content.TimelineItemUnknownContent +import org.matrix.rustcomponents.sdk.TimelineItemContentKind +import javax.inject.Inject + +class TimelineItemContentStickerFactory @Inject constructor() { + + fun create(kind: TimelineItemContentKind.Sticker): TimelineItemContent { + return TimelineItemUnknownContent + } +} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentUTDFactory.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentUTDFactory.kt new file mode 100644 index 0000000000..4f2285a402 --- /dev/null +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemContentUTDFactory.kt @@ -0,0 +1,29 @@ +/* + * 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.factories + +import io.element.android.features.messages.timeline.model.content.TimelineItemContent +import io.element.android.features.messages.timeline.model.content.TimelineItemEncryptedContent +import org.matrix.rustcomponents.sdk.TimelineItemContentKind +import javax.inject.Inject + +class TimelineItemContentUTDFactory @Inject constructor() { + + fun create(kind: TimelineItemContentKind.UnableToDecrypt): TimelineItemContent { + return TimelineItemEncryptedContent(kind.msg) + } +} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemEventFactory.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemEventFactory.kt new file mode 100644 index 0000000000..c49b007a43 --- /dev/null +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemEventFactory.kt @@ -0,0 +1,90 @@ +/* + * 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.factories + +import io.element.android.features.messages.timeline.model.AggregatedReaction +import io.element.android.features.messages.timeline.model.MessagesItemGroupPosition +import io.element.android.features.messages.timeline.model.TimelineItem +import io.element.android.features.messages.timeline.model.TimelineItemReactions +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.room.MatrixRoom +import io.element.android.libraries.matrix.timeline.MatrixTimelineItem +import kotlinx.collections.immutable.toImmutableList +import javax.inject.Inject + +class TimelineItemEventFactory @Inject constructor( + private val room: MatrixRoom, + private val contentFactory: TimelineItemContentFactory, +) { + + suspend fun create( + currentTimelineItem: MatrixTimelineItem.Event, + index: Int, + timelineItems: List, + ): TimelineItem.Event { + 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( + name = senderDisplayName ?: currentSender, + url = senderAvatarUrl, + size = AvatarSize.SMALL + ) + return TimelineItem.Event( + id = EventId(currentTimelineItem.uniqueId), + senderId = currentSender, + senderDisplayName = senderDisplayName, + senderAvatar = senderAvatarData, + content = contentFactory.create(currentTimelineItem.event.content()), + isMine = currentTimelineItem.event.isOwn(), + groupPosition = groupPosition, + reactionsState = currentTimelineItem.computeReactionsState() + ) + } + + private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions { + val aggregatedReactions = event.reactions().orEmpty().map { + AggregatedReaction(key = it.key, count = it.count.toString(), isHighlighted = false) + } + return TimelineItemReactions(aggregatedReactions.toImmutableList()) + } + + private fun computeGroupPosition( + currentTimelineItem: MatrixTimelineItem.Event, + timelineItems: List, + index: Int + ): MessagesItemGroupPosition { + 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 -> MessagesItemGroupPosition.First + previousSender == currentSender && nextSender == currentSender -> MessagesItemGroupPosition.Middle + previousSender == currentSender && nextSender != currentSender -> MessagesItemGroupPosition.Last + else -> MessagesItemGroupPosition.None + } + } +} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemVirtualFactory.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemVirtualFactory.kt new file mode 100644 index 0000000000..b0f4cc3bbb --- /dev/null +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemVirtualFactory.kt @@ -0,0 +1,34 @@ +/* + * 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.factories + +import io.element.android.features.messages.timeline.model.TimelineItem +import io.element.android.libraries.matrix.timeline.MatrixTimelineItem +import javax.inject.Inject + +class TimelineItemVirtualFactory @Inject constructor() { + + suspend fun create( + currentTimelineItem: MatrixTimelineItem.Virtual, + index: Int, + timelineItems: List, + ): TimelineItem.Virtual { + return TimelineItem.Virtual( + id = "virtual_item_$index" + ) + } +} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemsFactory.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemsFactory.kt new file mode 100644 index 0000000000..2f94967c3f --- /dev/null +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/factories/TimelineItemsFactory.kt @@ -0,0 +1,117 @@ +/* + * 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.factories + +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.TimelineItem +import io.element.android.features.messages.timeline.util.invalidateLast +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.timeline.MatrixTimelineItem +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 timber.log.Timber +import javax.inject.Inject +import kotlin.system.measureTimeMillis + +class TimelineItemsFactory @Inject constructor( + private val dispatchers: CoroutineDispatchers, + private val eventItemFactory: TimelineItemEventFactory, + private val virtualItemFactory: TimelineItemVirtualFactory, +) { + + 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(dispatchers.computation) { + lock.withLock { + calculateAndApplyDiff(timelineItems) + buildAndEmitTimelineItemStates(timelineItems) + } + } + + suspend fun pushItem( + timelineItem: MatrixTimelineItem, + ) = withContext(dispatchers.computation) { + 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 -> eventItemFactory.create(currentTimelineItem, index, timelineItems) + is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem, index, timelineItems) + MatrixTimelineItem.Other -> null + } + timelineItemsCache[index] = timelineItemState + return timelineItemState + } +} diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/model/TimelineItem.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/model/TimelineItem.kt index 1216765ee8..5abbcb1d00 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/model/TimelineItem.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/model/TimelineItem.kt @@ -27,7 +27,7 @@ sealed interface TimelineItem { val id: String ) : TimelineItem - data class MessageEvent( + data class Event( val id: EventId, val senderId: String, val senderDisplayName: String?, diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/util/toHtmlDocument.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/util/toHtmlDocument.kt new file mode 100644 index 0000000000..199a6739b8 --- /dev/null +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/util/toHtmlDocument.kt @@ -0,0 +1,28 @@ +/* + * 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.util + +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.matrix.rustcomponents.sdk.FormattedBody +import org.matrix.rustcomponents.sdk.MessageFormat + +fun FormattedBody.toHtmlDocument(): Document? { + return takeIf { it.format == MessageFormat.HTML }?.body?.let { formattedBody -> + Jsoup.parse(formattedBody) + } +} diff --git a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/di/MatrixModule.kt b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/di/MatrixModule.kt index 9b0583d2d1..eec74e9e03 100644 --- a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/di/MatrixModule.kt +++ b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/di/MatrixModule.kt @@ -31,6 +31,6 @@ object MatrixModule { @Provides @SingleIn(AppScope::class) fun providesRustAuthenticationService(baseDirectory: File): AuthenticationService { - return AuthenticationService(baseDirectory.absolutePath) + return AuthenticationService(baseDirectory.absolutePath, null) } } diff --git a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/room/RoomSummaryDataSource.kt b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/room/RoomSummaryDataSource.kt index 11ae71795d..7965a6698d 100644 --- a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/room/RoomSummaryDataSource.kt +++ b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/room/RoomSummaryDataSource.kt @@ -158,6 +158,12 @@ internal class RustRoomSummaryDataSource( clear() addAll(diff.values.map { buildSummaryForRoomListEntry(it) }) } + SlidingSyncViewRoomsListDiff.Pop -> { + removeLastOrNull() + } + SlidingSyncViewRoomsListDiff.Clear -> { + clear() + } } } diff --git a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/room/message/RoomMessageFactory.kt b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/room/message/RoomMessageFactory.kt index 348551835d..ebeb1d6eb0 100644 --- a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/room/message/RoomMessageFactory.kt +++ b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/room/message/RoomMessageFactory.kt @@ -27,7 +27,7 @@ class RoomMessageFactory { eventId = EventId(eventTimelineItem.eventId() ?: ""), body = eventTimelineItem.content().asMessage()?.body() ?: "", sender = UserId(eventTimelineItem.sender()), - originServerTs = eventTimelineItem.originServerTs()?.toLong() ?: 0L + originServerTs = eventTimelineItem.timestamp().toLong() ) } } diff --git a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/timeline/MatrixTimeline.kt b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/timeline/MatrixTimeline.kt index 67ab8969b3..a4c6a1cc81 100644 --- a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/timeline/MatrixTimeline.kt +++ b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/timeline/MatrixTimeline.kt @@ -22,7 +22,6 @@ import org.matrix.rustcomponents.sdk.TimelineListener interface MatrixTimeline { var callback: Callback? - val hasMoreToLoad: Boolean interface Callback { fun onUpdatedTimelineItem(timelineItem: MatrixTimelineItem) = Unit @@ -30,7 +29,7 @@ interface MatrixTimeline { } fun timelineItems(): Flow> - suspend fun paginateBackwards(count: Int): Result + suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result fun addListener(timelineListener: TimelineListener) fun initialize() fun dispose() diff --git a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/timeline/MatrixTimelineItem.kt b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/timeline/MatrixTimelineItem.kt index 022988fd4f..35ad5ef7e5 100644 --- a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/timeline/MatrixTimelineItem.kt +++ b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/timeline/MatrixTimelineItem.kt @@ -18,15 +18,10 @@ package io.element.android.libraries.matrix.timeline import org.matrix.rustcomponents.sdk.EventTimelineItem import org.matrix.rustcomponents.sdk.TimelineItem -import org.matrix.rustcomponents.sdk.TimelineKey sealed interface MatrixTimelineItem { data class Event(val event: EventTimelineItem) : MatrixTimelineItem { - val uniqueId: String - get() = when (val eventKey = event.key()) { - is TimelineKey.TransactionId -> eventKey.txnId - is TimelineKey.EventId -> eventKey.eventId - } + val uniqueId: String = event.uniqueIdentifier() } object Virtual : MatrixTimelineItem diff --git a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/timeline/RustMatrixTimeline.kt b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/timeline/RustMatrixTimeline.kt index 1f6722d823..d2a7c1d8be 100644 --- a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/timeline/RustMatrixTimeline.kt +++ b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/timeline/RustMatrixTimeline.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.timeline import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.core.EventId import io.element.android.libraries.matrix.room.RustMatrixRoom +import io.element.android.libraries.matrix.util.StoppableSpawnBag import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow @@ -26,14 +27,14 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.matrix.rustcomponents.sdk.PaginationOutcome +import org.matrix.rustcomponents.sdk.PaginationOptions import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.SlidingSyncRoom import org.matrix.rustcomponents.sdk.TimelineChange import org.matrix.rustcomponents.sdk.TimelineDiff import org.matrix.rustcomponents.sdk.TimelineListener import timber.log.Timber -import java.util.Collections +import java.util.* class RustMatrixTimeline( private val matrixRoom: RustMatrixRoom, @@ -45,20 +46,16 @@ class RustMatrixTimeline( override var callback: MatrixTimeline.Callback? = null - private val paginationOutcome = MutableStateFlow(PaginationOutcome(true)) private val timelineItems: MutableStateFlow> = MutableStateFlow(emptyList()) + private val listenerTokens = StoppableSpawnBag() + @OptIn(FlowPreview::class) override fun timelineItems(): Flow> { return timelineItems.sample(50) } - override val hasMoreToLoad: Boolean - get() { - return paginationOutcome.value.moreMessages - } - private fun MutableList.applyDiff(diff: TimelineDiff) { when (diff.change()) { TimelineChange.PUSH -> { @@ -107,12 +104,13 @@ class RustMatrixTimeline( } } - override suspend fun paginateBackwards(count: Int): Result = withContext(coroutineDispatchers.io) { - if (!paginationOutcome.value.moreMessages) { - return@withContext Result.failure(IllegalStateException("no more message")) - } + override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result = withContext(coroutineDispatchers.io) { runCatching { - paginationOutcome.value = room.paginateBackwards(count.toUShort()) + val paginationOptions = PaginationOptions.UntilNumItems( + eventLimit = requestSize.toUShort(), + items = untilNumberOfItems.toUShort() + ) + room.paginateBackwards(paginationOptions) } } @@ -124,7 +122,7 @@ class RustMatrixTimeline( } override fun addListener(timelineListener: TimelineListener) { - slidingSyncRoom.addTimelineListener(timelineListener) + listenerTokens += slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, settings = null) } override fun initialize() { @@ -132,7 +130,7 @@ class RustMatrixTimeline( } override fun dispose() { - slidingSyncRoom.removeTimeline() + listenerTokens.dispose() } /** diff --git a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/util/StoppableSpawnBag.kt b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/util/StoppableSpawnBag.kt new file mode 100644 index 0000000000..a84833c54d --- /dev/null +++ b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/util/StoppableSpawnBag.kt @@ -0,0 +1,33 @@ +/* + * 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.util + +import org.matrix.rustcomponents.sdk.StoppableSpawn +import java.util.concurrent.CopyOnWriteArraySet + +class StoppableSpawnBag(private val tokens: MutableSet = CopyOnWriteArraySet()) : Set by tokens { + + operator fun plusAssign(stoppableSpawn: StoppableSpawn?) { + if (stoppableSpawn == null) return + tokens += stoppableSpawn + } + + fun dispose() { + tokens.forEach { it.cancel() } + tokens.clear() + } +} diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt index 60fa211b1d..a4e00897ec 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt @@ -29,14 +29,11 @@ class FakeMatrixTimeline : MatrixTimeline { get() = null set(value) {} - override val hasMoreToLoad: Boolean - get() = true - override fun timelineItems(): Flow> { return emptyFlow() } - override suspend fun paginateBackwards(count: Int): Result { + override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result { return Result.success(Unit) }