element-x-android/features/messages/src/main/java/io/element/android/x/features/messages/MessageTimelineItemStateFac...

242 lines
10 KiB
Kotlin

package io.element.android.x.features.messages
import androidx.recyclerview.widget.DiffUtil
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.designsystem.components.avatar.AvatarSize
import io.element.android.x.features.messages.diff.CacheInvalidator
import io.element.android.x.features.messages.diff.MatrixTimelineItemsDiffCallback
import io.element.android.x.features.messages.model.AggregatedReaction
import io.element.android.x.features.messages.model.MessagesItemGroupPosition
import io.element.android.x.features.messages.model.MessagesItemReactionState
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.model.content.MessagesTimelineItemContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemEmoteContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemEncryptedContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemImageContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemNoticeContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemUnknownContent
import io.element.android.x.features.messages.util.invalidateLast
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.media.MediaResolver
import io.element.android.x.matrix.room.MatrixRoom
import io.element.android.x.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 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 kotlin.system.measureTimeMillis
class MessageTimelineItemStateFactory(
private val client: MatrixClient,
private val room: MatrixRoom,
private val dispatcher: CoroutineDispatcher,
) {
private val timelineItemStates = MutableStateFlow<List<MessagesTimelineItemState>>(emptyList())
private val timelineItemStatesCache = arrayListOf<MessagesTimelineItemState?>()
// Items from rust sdk, used for diffing
private var timelineItems: List<MatrixTimelineItem> = emptyList()
private val lock = Mutex()
private val cacheInvalidator = CacheInvalidator(timelineItemStatesCache)
fun flow(): StateFlow<List<MessagesTimelineItemState>> = timelineItemStates.asStateFlow()
suspend fun replaceWith(
timelineItems: List<MatrixTimelineItem>,
) = 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)
timelineItemStatesCache.invalidateLast()
timelineItemStatesCache.add(null)
timelineItems = timelineItems + timelineItem
buildAndEmitTimelineItemStates(timelineItems)
}
}
private suspend fun buildAndEmitTimelineItemStates(timelineItems: List<MatrixTimelineItem>) {
val newTimelineItemStates = ArrayList<MessagesTimelineItemState>()
for (index in timelineItemStatesCache.indices.reversed()) {
val cacheItem = timelineItemStatesCache[index]
if (cacheItem == null) {
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
newTimelineItemStates.add(timelineItemState)
}
} else {
newTimelineItemStates.add(cacheItem)
}
}
timelineItemStates.emit(newTimelineItemStates)
}
private fun calculateAndApplyDiff(newTimelineItems: List<MatrixTimelineItem>) {
val timeToDiff = measureTimeMillis {
val diffCallback =
MatrixTimelineItemsDiffCallback(
oldList = timelineItems,
newList = newTimelineItems
)
val diffResult = DiffUtil.calculateDiff(diffCallback, false)
timelineItems = 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<MatrixTimelineItem>,
index: Int
): MessagesTimelineItemState? {
val timelineItemState =
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> {
buildMessageEvent(
currentTimelineItem,
index,
timelineItems,
)
}
is MatrixTimelineItem.Virtual -> MessagesTimelineItemState.Virtual(
"virtual_item_$index"
)
MatrixTimelineItem.Other -> null
}
timelineItemStatesCache[index] = timelineItemState
return timelineItemState
}
private suspend fun buildMessageEvent(
currentTimelineItem: MatrixTimelineItem.Event,
index: Int,
timelineItems: List<MatrixTimelineItem>,
): MessagesTimelineItemState.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 =
loadAvatarData(senderDisplayName ?: currentSender, senderAvatarUrl)
return MessagesTimelineItemState.MessageEvent(
id = 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(): MessagesItemReactionState {
val aggregatedReactions = event.reactions().map {
AggregatedReaction(key = it.key, count = it.count.toString(), isHighlighted = false)
}
return MessagesItemReactionState(aggregatedReactions)
}
private fun MatrixTimelineItem.Event.computeContent(): MessagesTimelineItemContent {
val content = event.content()
content.asUnableToDecrypt()?.let { encryptedMessage ->
return MessagesTimelineItemEncryptedContent(encryptedMessage)
}
if (content.isRedactedMessage()) {
return MessagesTimelineItemRedactedContent
}
val contentAsMessage = content.asMessage()
return when (val messageType = contentAsMessage?.msgtype()) {
is MessageType.Emote -> MessagesTimelineItemEmoteContent(
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
}
MessagesTimelineItemImageContent(
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 -> MessagesTimelineItemNoticeContent(
body = messageType.content.body,
htmlDocument = messageType.content.formatted?.toHtmlDocument()
)
is MessageType.Text -> MessagesTimelineItemTextContent(
body = messageType.content.body,
htmlDocument = messageType.content.formatted?.toHtmlDocument()
)
else -> MessagesTimelineItemUnknownContent
}
}
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<MatrixTimelineItem>,
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
}
}
private suspend fun loadAvatarData(
name: String,
url: String?,
size: AvatarSize = AvatarSize.SMALL
): AvatarData {
val model = client.mediaResolver()
.resolve(url, kind = MediaResolver.Kind.Thumbnail(size.value))
return AvatarData(name, model, size)
}
}