Merge pull request #2759 from element-hq/feature/fga/permalink_timeline
Permalink timeline
This commit is contained in:
commit
ae8ee8704f
383 changed files with 3579 additions and 1506 deletions
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.room
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.errors.FocusEventException
|
||||
import org.matrix.rustcomponents.sdk.FocusEventException as RustFocusEventException
|
||||
|
||||
fun Throwable.toFocusEventException(): Throwable {
|
||||
return when (this) {
|
||||
is RustFocusEventException -> {
|
||||
when (this) {
|
||||
is RustFocusEventException.InvalidEventId -> {
|
||||
FocusEventException.InvalidEventId(eventId, err)
|
||||
}
|
||||
is RustFocusEventException.EventNotFound -> {
|
||||
FocusEventException.EventNotFound(EventId(eventId))
|
||||
}
|
||||
is RustFocusEventException.Other -> {
|
||||
FocusEventException.Other(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
this
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.impl.room
|
|||
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.coroutine.childScope
|
||||
import io.element.android.libraries.core.extensions.mapFailure
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
|
|
@ -43,21 +44,15 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
|
|||
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||
import io.element.android.libraries.matrix.impl.core.toProgressWatcher
|
||||
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
|
||||
import io.element.android.libraries.matrix.impl.media.map
|
||||
import io.element.android.libraries.matrix.impl.media.toMSC3246range
|
||||
import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
|
||||
import io.element.android.libraries.matrix.impl.poll.toInner
|
||||
import io.element.android.libraries.matrix.impl.room.location.toInner
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
||||
import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
|
||||
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
|
||||
import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType
|
||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
|
||||
|
|
@ -80,24 +75,16 @@ import kotlinx.coroutines.flow.onEach
|
|||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.EventTimelineItem
|
||||
import org.matrix.rustcomponents.sdk.MessageFormat
|
||||
import org.matrix.rustcomponents.sdk.RoomInfo
|
||||
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
||||
import org.matrix.rustcomponents.sdk.RoomListItem
|
||||
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
|
||||
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
|
||||
import org.matrix.rustcomponents.sdk.TypingNotificationsListener
|
||||
import org.matrix.rustcomponents.sdk.UserPowerLevelUpdate
|
||||
import org.matrix.rustcomponents.sdk.WidgetCapabilities
|
||||
import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import uniffi.matrix_sdk.RoomPowerLevelChanges
|
||||
import java.io.File
|
||||
import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody
|
||||
import org.matrix.rustcomponents.sdk.Room as InnerRoom
|
||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||
|
||||
|
|
@ -107,7 +94,7 @@ class RustMatrixRoom(
|
|||
private val isKeyBackupEnabled: Boolean,
|
||||
private val roomListItem: RoomListItem,
|
||||
private val innerRoom: InnerRoom,
|
||||
private val innerTimeline: InnerTimeline,
|
||||
innerTimeline: InnerTimeline,
|
||||
private val roomNotificationSettingsService: RustNotificationSettingsService,
|
||||
sessionCoroutineScope: CoroutineScope,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
|
|
@ -160,7 +147,7 @@ class RustMatrixRoom(
|
|||
private val _roomNotificationSettingsStateFlow = MutableStateFlow<MatrixRoomNotificationSettingsState>(MatrixRoomNotificationSettingsState.Unknown)
|
||||
override val roomNotificationSettingsStateFlow: StateFlow<MatrixRoomNotificationSettingsState> = _roomNotificationSettingsStateFlow
|
||||
|
||||
override val timeline = createMatrixTimeline(innerTimeline) {
|
||||
override val liveTimeline = createTimeline(innerTimeline, isLive = true) {
|
||||
_syncUpdateFlow.value = systemClock.epochMillis()
|
||||
}
|
||||
|
||||
|
|
@ -170,7 +157,7 @@ class RustMatrixRoom(
|
|||
|
||||
init {
|
||||
val powerLevelChanges = roomInfoFlow.map { it.userPowerLevels }.distinctUntilChanged()
|
||||
val membershipChanges = timeline.membershipChangeEventReceived.onStart { emit(Unit) }
|
||||
val membershipChanges = liveTimeline.membershipChangeEventReceived.onStart { emit(Unit) }
|
||||
combine(membershipChanges, powerLevelChanges) { _, _ -> }
|
||||
// Skip initial one
|
||||
.drop(1)
|
||||
|
|
@ -183,12 +170,25 @@ class RustMatrixRoom(
|
|||
|
||||
override suspend fun unsubscribeFromSync() = roomSyncSubscriber.unsubscribe(roomId)
|
||||
|
||||
override suspend fun timelineFocusedOnEvent(eventId: EventId): Result<Timeline> {
|
||||
return runCatching {
|
||||
innerRoom.timelineFocusedOnEvent(
|
||||
eventId = eventId.value,
|
||||
numContextEvents = 50u,
|
||||
internalIdPrefix = "focus_$eventId",
|
||||
).let { inner ->
|
||||
createTimeline(inner, isLive = false)
|
||||
}
|
||||
}.mapFailure {
|
||||
it.toFocusEventException()
|
||||
}
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
roomCoroutineScope.cancel()
|
||||
timeline.close()
|
||||
liveTimeline.close()
|
||||
innerRoom.destroy()
|
||||
roomListItem.destroy()
|
||||
specialModeEventTimelineItem?.destroy()
|
||||
}
|
||||
|
||||
override val name: String?
|
||||
|
|
@ -322,59 +322,8 @@ class RustMatrixRoom(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(roomDispatcher) {
|
||||
messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()).use { content ->
|
||||
runCatching {
|
||||
innerTimeline.send(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun editMessage(
|
||||
originalEventId: EventId?,
|
||||
transactionId: TransactionId?,
|
||||
body: String,
|
||||
htmlBody: String?,
|
||||
mentions: List<Mention>,
|
||||
): Result<Unit> =
|
||||
withContext(roomDispatcher) {
|
||||
if (originalEventId != null) {
|
||||
runCatching {
|
||||
val editedEvent = specialModeEventTimelineItem ?: innerTimeline.getEventTimelineItemByEventId(originalEventId.value)
|
||||
editedEvent.use {
|
||||
innerTimeline.edit(
|
||||
newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()),
|
||||
editItem = it,
|
||||
)
|
||||
}
|
||||
specialModeEventTimelineItem = null
|
||||
}
|
||||
} else {
|
||||
runCatching {
|
||||
transactionId?.let { cancelSend(it) }
|
||||
innerTimeline.send(messageEventContentFromParts(body, htmlBody))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var specialModeEventTimelineItem: EventTimelineItem? = null
|
||||
|
||||
override suspend fun enterSpecialMode(eventId: EventId?): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
specialModeEventTimelineItem?.destroy()
|
||||
specialModeEventTimelineItem = null
|
||||
specialModeEventTimelineItem = eventId?.let { innerTimeline.getEventTimelineItemByEventId(it.value) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
val inReplyTo = specialModeEventTimelineItem ?: innerTimeline.getEventTimelineItemByEventId(eventId.value)
|
||||
inReplyTo.use { eventTimelineItem ->
|
||||
innerTimeline.sendReply(messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), eventTimelineItem)
|
||||
}
|
||||
specialModeEventTimelineItem = null
|
||||
}
|
||||
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> {
|
||||
return liveTimeline.sendMessage(body, htmlBody, mentions)
|
||||
}
|
||||
|
||||
override suspend fun redactEvent(eventId: EventId, reason: String?) = withContext(roomDispatcher) {
|
||||
|
|
@ -457,18 +406,7 @@ class RustMatrixRoom(
|
|||
formattedBody: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
|
||||
innerTimeline.sendImage(
|
||||
url = file.path,
|
||||
thumbnailUrl = thumbnailFile?.path,
|
||||
imageInfo = imageInfo.map(),
|
||||
caption = body,
|
||||
formattedCaption = formattedBody?.let {
|
||||
RustFormattedBody(body = it, format = MessageFormat.Html)
|
||||
},
|
||||
progressWatcher = progressCallback?.toProgressWatcher()
|
||||
)
|
||||
}
|
||||
return liveTimeline.sendImage(file, thumbnailFile, imageInfo, body, formattedBody, progressCallback)
|
||||
}
|
||||
|
||||
override suspend fun sendVideo(
|
||||
|
|
@ -479,63 +417,31 @@ class RustMatrixRoom(
|
|||
formattedBody: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
|
||||
innerTimeline.sendVideo(
|
||||
url = file.path,
|
||||
thumbnailUrl = thumbnailFile?.path,
|
||||
videoInfo = videoInfo.map(),
|
||||
caption = body,
|
||||
formattedCaption = formattedBody?.let {
|
||||
RustFormattedBody(body = it, format = MessageFormat.Html)
|
||||
},
|
||||
progressWatcher = progressCallback?.toProgressWatcher()
|
||||
)
|
||||
}
|
||||
return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, body, formattedBody, progressCallback)
|
||||
}
|
||||
|
||||
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOf(file)) {
|
||||
innerTimeline.sendAudio(
|
||||
url = file.path,
|
||||
audioInfo = audioInfo.map(),
|
||||
// Maybe allow a caption in the future?
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
progressWatcher = progressCallback?.toProgressWatcher()
|
||||
)
|
||||
}
|
||||
return liveTimeline.sendAudio(file, audioInfo, progressCallback)
|
||||
}
|
||||
|
||||
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOf(file)) {
|
||||
innerTimeline.sendFile(file.path, fileInfo.map(), progressCallback?.toProgressWatcher())
|
||||
}
|
||||
return liveTimeline.sendFile(file, fileInfo, progressCallback)
|
||||
}
|
||||
|
||||
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.toggleReaction(key = emoji, eventId = eventId.value)
|
||||
}
|
||||
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> {
|
||||
return liveTimeline.toggleReaction(emoji, eventId)
|
||||
}
|
||||
|
||||
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
roomContentForwarder.forward(fromTimeline = innerTimeline, eventId = eventId, toRoomIds = roomIds)
|
||||
}.onFailure {
|
||||
Timber.e(it)
|
||||
}
|
||||
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> {
|
||||
return liveTimeline.forwardEvent(eventId, roomIds)
|
||||
}
|
||||
|
||||
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.retrySend(transactionId.value)
|
||||
}
|
||||
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> {
|
||||
return liveTimeline.retrySendMessage(transactionId)
|
||||
}
|
||||
|
||||
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.cancelSend(transactionId.value)
|
||||
}
|
||||
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> {
|
||||
return liveTimeline.cancelSend(transactionId)
|
||||
}
|
||||
|
||||
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> = withContext(roomDispatcher) {
|
||||
|
|
@ -613,16 +519,8 @@ class RustMatrixRoom(
|
|||
description: String?,
|
||||
zoomLevel: Int?,
|
||||
assetType: AssetType?,
|
||||
): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.sendLocation(
|
||||
body = body,
|
||||
geoUri = geoUri,
|
||||
description = description,
|
||||
zoomLevel = zoomLevel?.toUByte(),
|
||||
assetType = assetType?.toInner(),
|
||||
)
|
||||
}
|
||||
): Result<Unit> {
|
||||
return liveTimeline.sendLocation(body, geoUri, description, zoomLevel, assetType)
|
||||
}
|
||||
|
||||
override suspend fun createPoll(
|
||||
|
|
@ -630,15 +528,8 @@ class RustMatrixRoom(
|
|||
answers: List<String>,
|
||||
maxSelections: Int,
|
||||
pollKind: PollKind,
|
||||
): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.createPoll(
|
||||
question = question,
|
||||
answers = answers,
|
||||
maxSelections = maxSelections.toUByte(),
|
||||
pollKind = pollKind.toInner(),
|
||||
)
|
||||
}
|
||||
): Result<Unit> {
|
||||
return liveTimeline.createPoll(question, answers, maxSelections, pollKind)
|
||||
}
|
||||
|
||||
override suspend fun editPoll(
|
||||
|
|
@ -647,46 +538,22 @@ class RustMatrixRoom(
|
|||
answers: List<String>,
|
||||
maxSelections: Int,
|
||||
pollKind: PollKind,
|
||||
): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
val pollStartEvent =
|
||||
innerTimeline.getEventTimelineItemByEventId(
|
||||
eventId = pollStartId.value
|
||||
)
|
||||
pollStartEvent.use {
|
||||
innerTimeline.editPoll(
|
||||
question = question,
|
||||
answers = answers,
|
||||
maxSelections = maxSelections.toUByte(),
|
||||
pollKind = pollKind.toInner(),
|
||||
editItem = pollStartEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
): Result<Unit> {
|
||||
return liveTimeline.editPoll(pollStartId, question, answers, maxSelections, pollKind)
|
||||
}
|
||||
|
||||
override suspend fun sendPollResponse(
|
||||
pollStartId: EventId,
|
||||
answers: List<String>
|
||||
): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.sendPollResponse(
|
||||
pollStartId = pollStartId.value,
|
||||
answers = answers,
|
||||
)
|
||||
}
|
||||
): Result<Unit> {
|
||||
return liveTimeline.sendPollResponse(pollStartId, answers)
|
||||
}
|
||||
|
||||
override suspend fun endPoll(
|
||||
pollStartId: EventId,
|
||||
text: String
|
||||
): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.endPoll(
|
||||
pollStartId = pollStartId.value,
|
||||
text = text,
|
||||
)
|
||||
}
|
||||
): Result<Unit> {
|
||||
return liveTimeline.endPoll(pollStartId, text)
|
||||
}
|
||||
|
||||
override suspend fun sendVoiceMessage(
|
||||
|
|
@ -694,16 +561,8 @@ class RustMatrixRoom(
|
|||
audioInfo: AudioInfo,
|
||||
waveform: List<Float>,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> = sendAttachment(listOf(file)) {
|
||||
innerTimeline.sendVoiceMessage(
|
||||
url = file.path,
|
||||
audioInfo = audioInfo.map(),
|
||||
waveform = waveform.toMSC3246range(),
|
||||
// Maybe allow a caption in the future?
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
progressWatcher = progressCallback?.toProgressWatcher(),
|
||||
)
|
||||
): Result<MediaUploadHandler> {
|
||||
return liveTimeline.sendVoiceMessage(file, audioInfo, waveform, progressCallback)
|
||||
}
|
||||
|
||||
override suspend fun typingNotice(isTyping: Boolean) = runCatching {
|
||||
|
|
@ -739,31 +598,22 @@ class RustMatrixRoom(
|
|||
innerRoom.matrixToEventPermalink(eventId.value)
|
||||
}
|
||||
|
||||
private fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> {
|
||||
return runCatching {
|
||||
MediaUploadHandlerImpl(files, handle())
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMatrixTimeline(
|
||||
private fun createTimeline(
|
||||
timeline: InnerTimeline,
|
||||
isLive: Boolean,
|
||||
onNewSyncedEvent: () -> Unit = {},
|
||||
): MatrixTimeline {
|
||||
return RustMatrixTimeline(
|
||||
): Timeline {
|
||||
return RustTimeline(
|
||||
isKeyBackupEnabled = isKeyBackupEnabled,
|
||||
isLive = isLive,
|
||||
matrixRoom = this,
|
||||
systemClock = systemClock,
|
||||
roomCoroutineScope = roomCoroutineScope,
|
||||
dispatcher = roomDispatcher,
|
||||
lastLoginTimestamp = sessionData.loginTimestamp,
|
||||
onNewSyncedEvent = onNewSyncedEvent,
|
||||
innerTimeline = timeline,
|
||||
roomContentForwarder = roomContentForwarder,
|
||||
inner = timeline,
|
||||
)
|
||||
}
|
||||
|
||||
private fun messageEventContentFromParts(body: String, htmlBody: String?): RoomMessageEventContentWithoutRelation =
|
||||
if (htmlBody != null) {
|
||||
messageEventContentFromHtml(body, htmlBody)
|
||||
} else {
|
||||
messageEventContentFromMarkdown(body)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,106 +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.libraries.matrix.impl.timeline
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* This class is a wrapper around a [MatrixTimeline] that will be created asynchronously.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class AsyncMatrixTimeline(
|
||||
coroutineScope: CoroutineScope,
|
||||
dispatcher: CoroutineDispatcher,
|
||||
private val timelineProvider: suspend () -> MatrixTimeline
|
||||
) : MatrixTimeline {
|
||||
private val _timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
|
||||
MutableStateFlow(emptyList())
|
||||
|
||||
private val _paginationState = MutableStateFlow(
|
||||
MatrixTimeline.PaginationState.Initial
|
||||
)
|
||||
private val timeline = coroutineScope.async(context = dispatcher, start = CoroutineStart.LAZY) {
|
||||
timelineProvider()
|
||||
}
|
||||
private val closeSignal = CompletableDeferred<Unit>()
|
||||
|
||||
override val membershipChangeEventReceived = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
|
||||
init {
|
||||
coroutineScope.launch {
|
||||
val delegateTimeline = timeline.await()
|
||||
delegateTimeline.timelineItems
|
||||
.onEach { _timelineItems.value = it }
|
||||
.launchIn(this)
|
||||
delegateTimeline.paginationState
|
||||
.onEach { _paginationState.value = it }
|
||||
.launchIn(this)
|
||||
delegateTimeline.membershipChangeEventReceived
|
||||
.onEach { membershipChangeEventReceived.emit(it) }
|
||||
.launchIn(this)
|
||||
|
||||
launch {
|
||||
withContext(NonCancellable) {
|
||||
closeSignal.await()
|
||||
Timber.d("Close delegate")
|
||||
delegateTimeline.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val paginationState: StateFlow<MatrixTimeline.PaginationState> = _paginationState
|
||||
override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems
|
||||
|
||||
override suspend fun paginateBackwards(requestSize: Int): Result<Unit> {
|
||||
return timeline.await().paginateBackwards(requestSize)
|
||||
}
|
||||
|
||||
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> {
|
||||
return timeline.await().paginateBackwards(requestSize, untilNumberOfItems)
|
||||
}
|
||||
|
||||
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> {
|
||||
return timeline.await().fetchDetailsForEvent(eventId)
|
||||
}
|
||||
|
||||
override suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit> {
|
||||
return timeline.await().sendReadReceipt(eventId, receiptType)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closeSignal.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,10 +16,8 @@
|
|||
|
||||
package io.element.android.libraries.matrix.impl.timeline
|
||||
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
|
||||
import io.element.android.libraries.matrix.impl.util.destroyAll
|
||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
|
|
@ -27,13 +25,11 @@ import kotlinx.coroutines.flow.Flow
|
|||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import org.matrix.rustcomponents.sdk.PaginationStatusListener
|
||||
import org.matrix.rustcomponents.sdk.Timeline
|
||||
import org.matrix.rustcomponents.sdk.TimelineDiff
|
||||
import org.matrix.rustcomponents.sdk.TimelineItem
|
||||
import org.matrix.rustcomponents.sdk.TimelineListener
|
||||
import timber.log.Timber
|
||||
import uniffi.matrix_sdk_ui.PaginationStatus
|
||||
|
||||
internal fun Timeline.timelineDiffFlow(onInitialList: suspend (List<TimelineItem>) -> Unit): Flow<List<TimelineDiff>> =
|
||||
callbackFlow {
|
||||
|
|
@ -58,18 +54,6 @@ internal fun Timeline.timelineDiffFlow(onInitialList: suspend (List<TimelineItem
|
|||
Timber.d(it, "timelineDiffFlow() failed")
|
||||
}.buffer(Channel.UNLIMITED)
|
||||
|
||||
internal fun Timeline.backPaginationStatusFlow(): Flow<PaginationStatus> =
|
||||
mxCallbackFlow {
|
||||
val listener = object : PaginationStatusListener {
|
||||
override fun onUpdate(status: PaginationStatus) {
|
||||
trySendBlocking(status)
|
||||
}
|
||||
}
|
||||
tryOrNull {
|
||||
subscribeToBackPaginationStatus(listener)
|
||||
}
|
||||
}.buffer(Channel.UNLIMITED)
|
||||
|
||||
internal suspend fun Timeline.runWithTimelineListenerRegistered(action: suspend () -> Unit) {
|
||||
val result = addListener(NoOpTimelineListener)
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,278 +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.libraries.matrix.impl.timeline
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.DmBeginningTimelineProcessor
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.PaginationOptions
|
||||
import org.matrix.rustcomponents.sdk.Timeline
|
||||
import org.matrix.rustcomponents.sdk.TimelineDiff
|
||||
import org.matrix.rustcomponents.sdk.TimelineItem
|
||||
import timber.log.Timber
|
||||
import uniffi.matrix_sdk_ui.EventItemOrigin
|
||||
import uniffi.matrix_sdk_ui.PaginationStatus
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
private const val INITIAL_MAX_SIZE = 50
|
||||
|
||||
class RustMatrixTimeline(
|
||||
roomCoroutineScope: CoroutineScope,
|
||||
isKeyBackupEnabled: Boolean,
|
||||
private val matrixRoom: MatrixRoom,
|
||||
private val innerTimeline: Timeline,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
lastLoginTimestamp: Date?,
|
||||
private val onNewSyncedEvent: () -> Unit,
|
||||
) : MatrixTimeline {
|
||||
private val initLatch = CompletableDeferred<Unit>()
|
||||
private val isInit = AtomicBoolean(false)
|
||||
|
||||
private val _timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
|
||||
MutableStateFlow(emptyList())
|
||||
|
||||
private val _paginationState = MutableStateFlow(
|
||||
MatrixTimeline.PaginationState.Initial
|
||||
)
|
||||
|
||||
private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor(
|
||||
lastLoginTimestamp = lastLoginTimestamp,
|
||||
isRoomEncrypted = matrixRoom.isEncrypted,
|
||||
isKeyBackupEnabled = isKeyBackupEnabled,
|
||||
dispatcher = dispatcher,
|
||||
)
|
||||
|
||||
private val dmBeginningTimelineProcessor = DmBeginningTimelineProcessor()
|
||||
|
||||
private val timelineItemFactory = MatrixTimelineItemMapper(
|
||||
fetchDetailsForEvent = this::fetchDetailsForEvent,
|
||||
roomCoroutineScope = roomCoroutineScope,
|
||||
virtualTimelineItemMapper = VirtualTimelineItemMapper(),
|
||||
eventTimelineItemMapper = EventTimelineItemMapper(
|
||||
contentMapper = TimelineEventContentMapper(
|
||||
eventMessageMapper = EventMessageMapper()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private val timelineDiffProcessor = MatrixTimelineDiffProcessor(
|
||||
timelineItems = _timelineItems,
|
||||
timelineItemFactory = timelineItemFactory,
|
||||
)
|
||||
|
||||
override val paginationState: StateFlow<MatrixTimeline.PaginationState> = _paginationState.asStateFlow()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems
|
||||
.mapLatest { items -> encryptedHistoryPostProcessor.process(items) }
|
||||
.mapLatest { items ->
|
||||
dmBeginningTimelineProcessor.process(
|
||||
items = items,
|
||||
isDm = matrixRoom.isDirect && matrixRoom.isOneToOne,
|
||||
isAtStartOfTimeline = paginationState.value.beginningOfRoomReached
|
||||
)
|
||||
}
|
||||
|
||||
override val membershipChangeEventReceived: Flow<Unit> = timelineDiffProcessor.membershipChangeEventReceived
|
||||
|
||||
init {
|
||||
Timber.d("Initialize timeline for room ${matrixRoom.roomId}")
|
||||
|
||||
roomCoroutineScope.launch(dispatcher) {
|
||||
innerTimeline.timelineDiffFlow { initialList ->
|
||||
postItems(initialList)
|
||||
}.onEach { diffs ->
|
||||
if (diffs.any { diff -> diff.eventOrigin() == EventItemOrigin.SYNC }) {
|
||||
onNewSyncedEvent()
|
||||
}
|
||||
postDiffs(diffs)
|
||||
}.launchIn(this)
|
||||
|
||||
paginationStateFlow()
|
||||
.onEach {
|
||||
_paginationState.value = it
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
fetchMembers()
|
||||
}
|
||||
}
|
||||
|
||||
private fun paginationStateFlow(): Flow<MatrixTimeline.PaginationState> {
|
||||
return combine(
|
||||
innerTimeline.backPaginationStatusFlow(),
|
||||
timelineItems,
|
||||
) { paginationStatus, filteredItems ->
|
||||
if (filteredItems.hasEncryptionHistoryBanner()) {
|
||||
return@combine MatrixTimeline.PaginationState(
|
||||
isBackPaginating = false,
|
||||
hasMoreToLoadBackwards = false,
|
||||
beginningOfRoomReached = false,
|
||||
)
|
||||
}
|
||||
when (paginationStatus) {
|
||||
PaginationStatus.IDLE -> {
|
||||
MatrixTimeline.PaginationState(
|
||||
isBackPaginating = false,
|
||||
hasMoreToLoadBackwards = true,
|
||||
beginningOfRoomReached = false,
|
||||
)
|
||||
}
|
||||
PaginationStatus.PAGINATING -> {
|
||||
MatrixTimeline.PaginationState(
|
||||
isBackPaginating = true,
|
||||
hasMoreToLoadBackwards = true,
|
||||
beginningOfRoomReached = false,
|
||||
)
|
||||
}
|
||||
PaginationStatus.TIMELINE_END_REACHED -> {
|
||||
MatrixTimeline.PaginationState(
|
||||
isBackPaginating = false,
|
||||
hasMoreToLoadBackwards = false,
|
||||
beginningOfRoomReached = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchMembers() = withContext(dispatcher) {
|
||||
initLatch.await()
|
||||
try {
|
||||
innerTimeline.fetchMembers()
|
||||
} catch (exception: Exception) {
|
||||
Timber.e(exception, "Error fetching members for room ${matrixRoom.roomId}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun postItems(items: List<TimelineItem>) = coroutineScope {
|
||||
// Split the initial items in multiple list as there is no pagination in the cached data, so we can post timelineItems asap.
|
||||
items.chunked(INITIAL_MAX_SIZE).reversed().forEach {
|
||||
ensureActive()
|
||||
timelineDiffProcessor.postItems(it)
|
||||
}
|
||||
isInit.set(true)
|
||||
initLatch.complete(Unit)
|
||||
}
|
||||
|
||||
private suspend fun postDiffs(diffs: List<TimelineDiff>) {
|
||||
initLatch.await()
|
||||
timelineDiffProcessor.postDiffs(diffs)
|
||||
}
|
||||
|
||||
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.fetchDetailsForEvent(eventId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun paginateBackwards(requestSize: Int): Result<Unit> {
|
||||
val paginationOptions = PaginationOptions.SimpleRequest(
|
||||
eventLimit = requestSize.toUShort(),
|
||||
waitForToken = true,
|
||||
)
|
||||
return paginateBackwards(paginationOptions)
|
||||
}
|
||||
|
||||
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> {
|
||||
val paginationOptions = PaginationOptions.UntilNumItems(
|
||||
eventLimit = requestSize.toUShort(),
|
||||
items = untilNumberOfItems.toUShort(),
|
||||
waitForToken = true,
|
||||
)
|
||||
return paginateBackwards(paginationOptions)
|
||||
}
|
||||
|
||||
private suspend fun paginateBackwards(paginationOptions: PaginationOptions): Result<Unit> = withContext(dispatcher) {
|
||||
initLatch.await()
|
||||
runCatching {
|
||||
if (!canBackPaginate()) throw TimelineException.CannotPaginate
|
||||
Timber.v("Start back paginating for room ${matrixRoom.roomId} ")
|
||||
innerTimeline.paginateBackwards(
|
||||
when (paginationOptions) {
|
||||
is PaginationOptions.SimpleRequest -> paginationOptions.eventLimit
|
||||
is PaginationOptions.UntilNumItems -> paginationOptions.eventLimit
|
||||
}
|
||||
)
|
||||
}.onFailure { error ->
|
||||
if (error is TimelineException.CannotPaginate) {
|
||||
Timber.d("Can't paginate backwards on room ${matrixRoom.roomId}, we're already at the start")
|
||||
} else {
|
||||
Timber.e(error, "Error paginating backwards on room ${matrixRoom.roomId}")
|
||||
}
|
||||
}.onSuccess {
|
||||
Timber.v("Success back paginating for room ${matrixRoom.roomId}")
|
||||
}.map { }
|
||||
}
|
||||
|
||||
private fun canBackPaginate(): Boolean {
|
||||
return isInit.get() && paginationState.value.canBackPaginate
|
||||
}
|
||||
|
||||
override suspend fun sendReadReceipt(
|
||||
eventId: EventId,
|
||||
receiptType: ReceiptType,
|
||||
) = withContext(dispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.sendReadReceipt(
|
||||
receiptType = receiptType.toRustReceiptType(),
|
||||
eventId = eventId.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
innerTimeline.close()
|
||||
}
|
||||
|
||||
fun getItemById(eventId: EventId): MatrixTimelineItem.Event? {
|
||||
return _timelineItems.value.firstOrNull { (it as? MatrixTimelineItem.Event)?.eventId == eventId } as? MatrixTimelineItem.Event
|
||||
}
|
||||
|
||||
private fun List<MatrixTimelineItem>.hasEncryptionHistoryBanner(): Boolean {
|
||||
val firstItem = firstOrNull()
|
||||
return firstItem is MatrixTimelineItem.Virtual &&
|
||||
firstItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,523 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.Mention
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.impl.core.toProgressWatcher
|
||||
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
|
||||
import io.element.android.libraries.matrix.impl.media.map
|
||||
import io.element.android.libraries.matrix.impl.media.toMSC3246range
|
||||
import io.element.android.libraries.matrix.impl.poll.toInner
|
||||
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
|
||||
import io.element.android.libraries.matrix.impl.room.location.toInner
|
||||
import io.element.android.libraries.matrix.impl.room.map
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LastForwardIndicatorsPostProcessor
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIndicatorsPostProcessor
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.EventTimelineItem
|
||||
import org.matrix.rustcomponents.sdk.FormattedBody
|
||||
import org.matrix.rustcomponents.sdk.MessageFormat
|
||||
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
|
||||
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
|
||||
import org.matrix.rustcomponents.sdk.TimelineDiff
|
||||
import org.matrix.rustcomponents.sdk.TimelineItem
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import uniffi.matrix_sdk_ui.EventItemOrigin
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||
|
||||
private const val INITIAL_MAX_SIZE = 50
|
||||
private const val PAGINATION_SIZE = 50
|
||||
|
||||
class RustTimeline(
|
||||
private val inner: InnerTimeline,
|
||||
isLive: Boolean,
|
||||
systemClock: SystemClock,
|
||||
roomCoroutineScope: CoroutineScope,
|
||||
isKeyBackupEnabled: Boolean,
|
||||
private val matrixRoom: MatrixRoom,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
lastLoginTimestamp: Date?,
|
||||
private val roomContentForwarder: RoomContentForwarder,
|
||||
private val onNewSyncedEvent: () -> Unit,
|
||||
) : Timeline {
|
||||
private val initLatch = CompletableDeferred<Unit>()
|
||||
private val isInit = AtomicBoolean(false)
|
||||
|
||||
private val _timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
|
||||
MutableStateFlow(emptyList())
|
||||
|
||||
private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor(
|
||||
lastLoginTimestamp = lastLoginTimestamp,
|
||||
isRoomEncrypted = matrixRoom.isEncrypted,
|
||||
isKeyBackupEnabled = isKeyBackupEnabled,
|
||||
dispatcher = dispatcher,
|
||||
)
|
||||
|
||||
private val roomBeginningPostProcessor = RoomBeginningPostProcessor()
|
||||
private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock)
|
||||
private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(isLive)
|
||||
|
||||
private val timelineItemFactory = MatrixTimelineItemMapper(
|
||||
fetchDetailsForEvent = this::fetchDetailsForEvent,
|
||||
roomCoroutineScope = roomCoroutineScope,
|
||||
virtualTimelineItemMapper = VirtualTimelineItemMapper(),
|
||||
eventTimelineItemMapper = EventTimelineItemMapper(
|
||||
contentMapper = TimelineEventContentMapper(
|
||||
eventMessageMapper = EventMessageMapper()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private val timelineDiffProcessor = MatrixTimelineDiffProcessor(
|
||||
timelineItems = _timelineItems,
|
||||
timelineItemFactory = timelineItemFactory,
|
||||
)
|
||||
|
||||
private val backPaginationStatus = MutableStateFlow(
|
||||
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true)
|
||||
)
|
||||
|
||||
private val forwardPaginationStatus = MutableStateFlow(
|
||||
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = !isLive)
|
||||
)
|
||||
|
||||
init {
|
||||
roomCoroutineScope.launch(dispatcher) {
|
||||
inner.timelineDiffFlow { initialList ->
|
||||
postItems(initialList)
|
||||
}.onEach { diffs ->
|
||||
if (diffs.any { diff -> diff.eventOrigin() == EventItemOrigin.SYNC }) {
|
||||
onNewSyncedEvent()
|
||||
}
|
||||
postDiffs(diffs)
|
||||
}.launchIn(this)
|
||||
|
||||
launch {
|
||||
fetchMembers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val membershipChangeEventReceived: Flow<Unit> = timelineDiffProcessor.membershipChangeEventReceived
|
||||
|
||||
override suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit> {
|
||||
return runCatching {
|
||||
inner.sendReadReceipt(receiptType.toRustReceiptType(), eventId.value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) {
|
||||
when (direction) {
|
||||
Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus.getAndUpdate(update)
|
||||
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.getAndUpdate(update)
|
||||
}
|
||||
}
|
||||
|
||||
// Use NonCancellable to avoid breaking the timeline when the coroutine is cancelled.
|
||||
override suspend fun paginate(direction: Timeline.PaginationDirection): Result<Boolean> = withContext(NonCancellable) {
|
||||
initLatch.await()
|
||||
runCatching {
|
||||
if (!canPaginate(direction)) throw TimelineException.CannotPaginate
|
||||
updatePaginationStatus(direction) { it.copy(isPaginating = true) }
|
||||
when (direction) {
|
||||
Timeline.PaginationDirection.BACKWARDS -> inner.paginateBackwards(PAGINATION_SIZE.toUShort())
|
||||
Timeline.PaginationDirection.FORWARDS -> inner.focusedPaginateForwards(PAGINATION_SIZE.toUShort())
|
||||
}
|
||||
}.onFailure { error ->
|
||||
updatePaginationStatus(direction) { it.copy(isPaginating = false) }
|
||||
if (error is TimelineException.CannotPaginate) {
|
||||
Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backPaginationStatus.value}")
|
||||
} else {
|
||||
Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}")
|
||||
}
|
||||
}.onSuccess { hasReachedEnd ->
|
||||
updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun canPaginate(direction: Timeline.PaginationDirection): Boolean {
|
||||
if (!isInit.get()) return false
|
||||
return when (direction) {
|
||||
Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus.value.canPaginate
|
||||
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.value.canPaginate
|
||||
}
|
||||
}
|
||||
|
||||
override fun paginationStatus(direction: Timeline.PaginationDirection): StateFlow<Timeline.PaginationStatus> {
|
||||
return when (direction) {
|
||||
Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus
|
||||
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus
|
||||
}
|
||||
}
|
||||
|
||||
override val timelineItems: Flow<List<MatrixTimelineItem>> = combine(
|
||||
_timelineItems,
|
||||
backPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(),
|
||||
forwardPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(),
|
||||
) { timelineItems, hasMoreToLoadBackward, hasMoreToLoadForward ->
|
||||
timelineItems
|
||||
.let { items -> encryptedHistoryPostProcessor.process(items) }
|
||||
.let { items ->
|
||||
roomBeginningPostProcessor.process(
|
||||
items = items,
|
||||
isDm = matrixRoom.isDm,
|
||||
hasMoreToLoadBackwards = hasMoreToLoadBackward
|
||||
)
|
||||
}
|
||||
.let { items -> loadingIndicatorsPostProcessor.process(items, hasMoreToLoadBackward, hasMoreToLoadForward) }
|
||||
// Keep lastForwardIndicatorsPostProcessor last
|
||||
.let { items -> lastForwardIndicatorsPostProcessor.process(items) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
inner.close()
|
||||
specialModeEventTimelineItem?.destroy()
|
||||
}
|
||||
|
||||
private suspend fun fetchMembers() = withContext(dispatcher) {
|
||||
initLatch.await()
|
||||
try {
|
||||
inner.fetchMembers()
|
||||
} catch (exception: Exception) {
|
||||
Timber.e(exception, "Error fetching members for room ${matrixRoom.roomId}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun postItems(items: List<TimelineItem>) = coroutineScope {
|
||||
// Split the initial items in multiple list as there is no pagination in the cached data, so we can post timelineItems asap.
|
||||
items.chunked(INITIAL_MAX_SIZE).reversed().forEach {
|
||||
ensureActive()
|
||||
timelineDiffProcessor.postItems(it)
|
||||
}
|
||||
isInit.set(true)
|
||||
initLatch.complete(Unit)
|
||||
}
|
||||
|
||||
private suspend fun postDiffs(diffs: List<TimelineDiff>) {
|
||||
initLatch.await()
|
||||
timelineDiffProcessor.postDiffs(diffs)
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(dispatcher) {
|
||||
messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()).use { content ->
|
||||
runCatching {
|
||||
inner.send(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun editMessage(
|
||||
originalEventId: EventId?,
|
||||
transactionId: TransactionId?,
|
||||
body: String,
|
||||
htmlBody: String?,
|
||||
mentions: List<Mention>,
|
||||
): Result<Unit> =
|
||||
withContext(dispatcher) {
|
||||
if (originalEventId != null) {
|
||||
runCatching {
|
||||
val editedEvent = specialModeEventTimelineItem ?: inner.getEventTimelineItemByEventId(originalEventId.value)
|
||||
editedEvent.use {
|
||||
inner.edit(
|
||||
newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()),
|
||||
editItem = it,
|
||||
)
|
||||
}
|
||||
specialModeEventTimelineItem = null
|
||||
}
|
||||
} else {
|
||||
runCatching {
|
||||
transactionId?.let { cancelSend(it) }
|
||||
inner.send(messageEventContentFromParts(body, htmlBody))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var specialModeEventTimelineItem: EventTimelineItem? = null
|
||||
|
||||
override suspend fun enterSpecialMode(eventId: EventId?): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
specialModeEventTimelineItem?.destroy()
|
||||
specialModeEventTimelineItem = null
|
||||
specialModeEventTimelineItem = eventId?.let { inner.getEventTimelineItemByEventId(it.value) }
|
||||
}.onFailure {
|
||||
Timber.e(it, "Unable to retrieve event for special mode. Are you using the correct timeline?")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
val inReplyTo = specialModeEventTimelineItem ?: inner.getEventTimelineItemByEventId(eventId.value)
|
||||
inReplyTo.use { eventTimelineItem ->
|
||||
inner.sendReply(messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), eventTimelineItem)
|
||||
}
|
||||
specialModeEventTimelineItem = null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendImage(
|
||||
file: File,
|
||||
thumbnailFile: File?,
|
||||
imageInfo: ImageInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
|
||||
inner.sendImage(
|
||||
url = file.path,
|
||||
thumbnailUrl = thumbnailFile?.path,
|
||||
imageInfo = imageInfo.map(),
|
||||
caption = body,
|
||||
formattedCaption = formattedBody?.let {
|
||||
FormattedBody(body = it, format = MessageFormat.Html)
|
||||
},
|
||||
progressWatcher = progressCallback?.toProgressWatcher()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendVideo(
|
||||
file: File,
|
||||
thumbnailFile: File?,
|
||||
videoInfo: VideoInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
|
||||
inner.sendVideo(
|
||||
url = file.path,
|
||||
thumbnailUrl = thumbnailFile?.path,
|
||||
videoInfo = videoInfo.map(),
|
||||
caption = body,
|
||||
formattedCaption = formattedBody?.let {
|
||||
FormattedBody(body = it, format = MessageFormat.Html)
|
||||
},
|
||||
progressWatcher = progressCallback?.toProgressWatcher()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOf(file)) {
|
||||
inner.sendAudio(
|
||||
url = file.path,
|
||||
audioInfo = audioInfo.map(),
|
||||
// Maybe allow a caption in the future?
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
progressWatcher = progressCallback?.toProgressWatcher()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOf(file)) {
|
||||
inner.sendFile(file.path, fileInfo.map(), progressCallback?.toProgressWatcher())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
inner.toggleReaction(key = emoji, eventId = eventId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds)
|
||||
}.onFailure {
|
||||
Timber.e(it)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
inner.retrySend(transactionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
inner.cancelSend(transactionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendLocation(
|
||||
body: String,
|
||||
geoUri: String,
|
||||
description: String?,
|
||||
zoomLevel: Int?,
|
||||
assetType: AssetType?,
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
inner.sendLocation(
|
||||
body = body,
|
||||
geoUri = geoUri,
|
||||
description = description,
|
||||
zoomLevel = zoomLevel?.toUByte(),
|
||||
assetType = assetType?.toInner(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createPoll(
|
||||
question: String,
|
||||
answers: List<String>,
|
||||
maxSelections: Int,
|
||||
pollKind: PollKind,
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
inner.createPoll(
|
||||
question = question,
|
||||
answers = answers,
|
||||
maxSelections = maxSelections.toUByte(),
|
||||
pollKind = pollKind.toInner(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun editPoll(
|
||||
pollStartId: EventId,
|
||||
question: String,
|
||||
answers: List<String>,
|
||||
maxSelections: Int,
|
||||
pollKind: PollKind,
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
val pollStartEvent =
|
||||
inner.getEventTimelineItemByEventId(
|
||||
eventId = pollStartId.value
|
||||
)
|
||||
pollStartEvent.use {
|
||||
inner.editPoll(
|
||||
question = question,
|
||||
answers = answers,
|
||||
maxSelections = maxSelections.toUByte(),
|
||||
pollKind = pollKind.toInner(),
|
||||
editItem = pollStartEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendPollResponse(
|
||||
pollStartId: EventId,
|
||||
answers: List<String>
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
inner.sendPollResponse(
|
||||
pollStartId = pollStartId.value,
|
||||
answers = answers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun endPoll(
|
||||
pollStartId: EventId,
|
||||
text: String
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
inner.endPoll(
|
||||
pollStartId = pollStartId.value,
|
||||
text = text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendVoiceMessage(
|
||||
file: File,
|
||||
audioInfo: AudioInfo,
|
||||
waveform: List<Float>,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> = sendAttachment(listOf(file)) {
|
||||
inner.sendVoiceMessage(
|
||||
url = file.path,
|
||||
audioInfo = audioInfo.map(),
|
||||
waveform = waveform.toMSC3246range(),
|
||||
// Maybe allow a caption in the future?
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
progressWatcher = progressCallback?.toProgressWatcher(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun messageEventContentFromParts(body: String, htmlBody: String?): RoomMessageEventContentWithoutRelation =
|
||||
if (htmlBody != null) {
|
||||
messageEventContentFromHtml(body, htmlBody)
|
||||
} else {
|
||||
messageEventContentFromMarkdown(body)
|
||||
}
|
||||
|
||||
private fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> {
|
||||
return runCatching {
|
||||
MediaUploadHandlerImpl(files, handle())
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchDetailsForEvent(eventId: EventId): Result<Unit> {
|
||||
return runCatching {
|
||||
inner.fetchDetailsForEvent(eventId.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,9 +57,16 @@ class EventMessageMapper {
|
|||
senderProfile = event.senderProfile.map(),
|
||||
)
|
||||
}
|
||||
is RepliedToEventDetails.Error -> InReplyTo.Error
|
||||
is RepliedToEventDetails.Pending -> InReplyTo.Pending
|
||||
is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(inReplyToId)
|
||||
is RepliedToEventDetails.Error -> InReplyTo.Error(
|
||||
eventId = inReplyToId,
|
||||
message = event.message,
|
||||
)
|
||||
RepliedToEventDetails.Pending -> InReplyTo.Pending(
|
||||
eventId = inReplyToId,
|
||||
)
|
||||
is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(
|
||||
eventId = inReplyToId
|
||||
)
|
||||
}
|
||||
}
|
||||
MessageContent(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
|
||||
internal fun List<MatrixTimelineItem>.hasEncryptionHistoryBanner(): Boolean {
|
||||
val firstItem = firstOrNull()
|
||||
return firstItem is MatrixTimelineItem.Virtual &&
|
||||
firstItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
|
||||
/**
|
||||
* This post processor is responsible for adding virtual items to indicate all the previous last forward item.
|
||||
*/
|
||||
class LastForwardIndicatorsPostProcessor(
|
||||
private val isTimelineLive: Boolean,
|
||||
) {
|
||||
private val lastForwardIdentifiers = LinkedHashSet<String>()
|
||||
|
||||
fun process(
|
||||
items: List<MatrixTimelineItem>,
|
||||
): List<MatrixTimelineItem> {
|
||||
// If the timeline is live, we don't have any last forward indicator to display
|
||||
if (isTimelineLive) {
|
||||
return items
|
||||
} else {
|
||||
return buildList {
|
||||
val latestEventIdentifier = items.latestEventIdentifier()
|
||||
// Remove if it always exists (this should happen only when no new events are added)
|
||||
lastForwardIdentifiers.remove(latestEventIdentifier)
|
||||
|
||||
items.forEach { item ->
|
||||
add(item)
|
||||
|
||||
if (item is MatrixTimelineItem.Event) {
|
||||
if (lastForwardIdentifiers.contains(item.uniqueId)) {
|
||||
add(createLastForwardIndicator(item.uniqueId))
|
||||
}
|
||||
}
|
||||
}
|
||||
// This is important to always add this one at the end of the list so it's used to keep the scroll position.
|
||||
add(createLastForwardIndicator(latestEventIdentifier))
|
||||
lastForwardIdentifiers.add(latestEventIdentifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLastForwardIndicator(identifier: String): MatrixTimelineItem {
|
||||
return MatrixTimelineItem.Virtual(
|
||||
uniqueId = "last_forward_indicator_$identifier",
|
||||
virtual = VirtualTimelineItem.LastForwardIndicator
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<MatrixTimelineItem>.latestEventIdentifier(): String {
|
||||
return findLast {
|
||||
it is MatrixTimelineItem.Event
|
||||
}?.let {
|
||||
(it as MatrixTimelineItem.Event).uniqueId
|
||||
} ?: "fake_id"
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
|
||||
class LoadingIndicatorsPostProcessor(private val systemClock: SystemClock) {
|
||||
fun process(
|
||||
items: List<MatrixTimelineItem>,
|
||||
hasMoreToLoadBackward: Boolean,
|
||||
hasMoreToLoadForward: Boolean,
|
||||
): List<MatrixTimelineItem> {
|
||||
val shouldAddBackwardLoadingIndicator = hasMoreToLoadBackward && !items.hasEncryptionHistoryBanner()
|
||||
val shouldAddForwardLoadingIndicator = hasMoreToLoadForward && items.isNotEmpty()
|
||||
val currentTimestamp = systemClock.epochMillis()
|
||||
return buildList {
|
||||
if (shouldAddBackwardLoadingIndicator) {
|
||||
val backwardLoadingIndicator = MatrixTimelineItem.Virtual(
|
||||
uniqueId = "BackwardLoadingIndicator",
|
||||
virtual = VirtualTimelineItem.LoadingIndicator(
|
||||
direction = Timeline.PaginationDirection.BACKWARDS,
|
||||
timestamp = currentTimestamp
|
||||
)
|
||||
)
|
||||
add(backwardLoadingIndicator)
|
||||
}
|
||||
addAll(items)
|
||||
if (shouldAddForwardLoadingIndicator) {
|
||||
val forwardLoadingIndicator = MatrixTimelineItem.Virtual(
|
||||
uniqueId = "ForwardLoadingIndicator",
|
||||
virtual = VirtualTimelineItem.LoadingIndicator(
|
||||
direction = Timeline.PaginationDirection.FORWARDS,
|
||||
timestamp = currentTimestamp
|
||||
)
|
||||
)
|
||||
add(forwardLoadingIndicator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,23 +16,38 @@
|
|||
|
||||
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
|
||||
/**
|
||||
* This timeline post-processor removes the room creation event and the self-join event from the timeline for DMs.
|
||||
* This timeline post-processor removes the room creation event and the self-join event from the timeline for DMs
|
||||
* or add the RoomBeginning item for non DM room.
|
||||
*/
|
||||
class DmBeginningTimelineProcessor {
|
||||
class RoomBeginningPostProcessor {
|
||||
fun process(
|
||||
items: List<MatrixTimelineItem>,
|
||||
isDm: Boolean,
|
||||
isAtStartOfTimeline: Boolean
|
||||
hasMoreToLoadBackwards: Boolean
|
||||
): List<MatrixTimelineItem> {
|
||||
if (!isDm || !isAtStartOfTimeline) return items
|
||||
return when {
|
||||
hasMoreToLoadBackwards -> items
|
||||
isDm -> processForDM(items)
|
||||
else -> processForRoom(items)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processForRoom(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
||||
if (items.hasEncryptionHistoryBanner()) return items
|
||||
val roomBeginningItem = createRoomBeginningItem()
|
||||
return listOf(roomBeginningItem) + items
|
||||
}
|
||||
|
||||
private fun processForDM(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
||||
// Find room creation event. This is usually index 0
|
||||
val roomCreationEventIndex = items.indexOfFirst {
|
||||
val stateEventContent = (it as? MatrixTimelineItem.Event)?.event?.content as? StateContent
|
||||
|
|
@ -58,4 +73,12 @@ class DmBeginningTimelineProcessor {
|
|||
}
|
||||
return newItems
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun createRoomBeginningItem(): MatrixTimelineItem.Virtual {
|
||||
return MatrixTimelineItem.Virtual(
|
||||
uniqueId = VirtualTimelineItem.RoomBeginning.toString(),
|
||||
virtual = VirtualTimelineItem.RoomBeginning
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,21 +22,22 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MembershipCha
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import org.junit.Test
|
||||
|
||||
class DmBeginningTimelineProcessorTest {
|
||||
class RoomBeginningPostProcessorTest {
|
||||
@Test
|
||||
fun `processor removes room creation event and self-join event from DM timeline`() {
|
||||
val timelineItems = listOf(
|
||||
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
|
||||
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
|
||||
)
|
||||
val processor = DmBeginningTimelineProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = true)
|
||||
val processor = RoomBeginningPostProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false)
|
||||
assertThat(processedItems).isEmpty()
|
||||
}
|
||||
|
||||
|
|
@ -52,19 +53,31 @@ class DmBeginningTimelineProcessorTest {
|
|||
MatrixTimelineItem.Event("m.room.member_other", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))),
|
||||
MatrixTimelineItem.Event("m.room.message", anEventTimelineItem(content = aMessageContent("hi"))),
|
||||
)
|
||||
val processor = DmBeginningTimelineProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = true)
|
||||
val processor = RoomBeginningPostProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false)
|
||||
assertThat(processedItems).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processor won't remove items if it's not a DM`() {
|
||||
fun `processor will add beginning of room item if it's not a DM`() {
|
||||
val timelineItems = listOf(
|
||||
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
|
||||
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
|
||||
)
|
||||
val processor = DmBeginningTimelineProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = false, isAtStartOfTimeline = true)
|
||||
val processor = RoomBeginningPostProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = false)
|
||||
assertThat(processedItems).isEqualTo(
|
||||
listOf(processor.createRoomBeginningItem()) + timelineItems
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processor will not add beginning of room item if it's not a DM and EncryptedHistoryBanner item is found`() {
|
||||
val timelineItems = listOf(
|
||||
MatrixTimelineItem.Virtual("EncryptedHistoryBanner", VirtualTimelineItem.EncryptedHistoryBanner),
|
||||
)
|
||||
val processor = RoomBeginningPostProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = false)
|
||||
assertThat(processedItems).isEqualTo(timelineItems)
|
||||
}
|
||||
|
||||
|
|
@ -74,8 +87,8 @@ class DmBeginningTimelineProcessorTest {
|
|||
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
|
||||
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
|
||||
)
|
||||
val processor = DmBeginningTimelineProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = false)
|
||||
val processor = RoomBeginningPostProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
|
||||
assertThat(processedItems).isEqualTo(timelineItems)
|
||||
}
|
||||
|
||||
|
|
@ -84,8 +97,8 @@ class DmBeginningTimelineProcessorTest {
|
|||
val timelineItems = listOf(
|
||||
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
|
||||
)
|
||||
val processor = DmBeginningTimelineProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = false)
|
||||
val processor = RoomBeginningPostProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
|
||||
assertThat(processedItems).isEqualTo(timelineItems)
|
||||
}
|
||||
|
||||
|
|
@ -95,8 +108,8 @@ class DmBeginningTimelineProcessorTest {
|
|||
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
|
||||
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))),
|
||||
)
|
||||
val processor = DmBeginningTimelineProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = false)
|
||||
val processor = RoomBeginningPostProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
|
||||
assertThat(processedItems).isEqualTo(timelineItems)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue