Merge pull request #2759 from element-hq/feature/fga/permalink_timeline

Permalink timeline
This commit is contained in:
Benoit Marty 2024-04-30 10:58:33 +02:00 committed by GitHub
commit ae8ee8704f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
383 changed files with 3579 additions and 1506 deletions

View file

@ -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
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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
}
}

View file

@ -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)
}
}
}

View file

@ -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(

View file

@ -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
}

View file

@ -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"
}

View file

@ -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)
}
}
}
}

View file

@ -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
)
}
}

View file

@ -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)
}
}