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

@ -149,6 +149,10 @@ val SemanticColors.bigIconDefaultBackgroundColor
val SemanticColors.bigCheckmarkBorderColor
get() = if (isLight) LightColorTokens.colorGray400 else DarkColorTokens.colorGray400
@OptIn(CoreColorToken::class)
val SemanticColors.highlightedMessageBackgroundColor
get() = if (isLight) LightColorTokens.colorGreen300 else DarkColorTokens.colorGreen300
@PreviewsDayNight
@Composable
internal fun ColorAliasesPreview() = ElementPreview {
@ -167,6 +171,8 @@ internal fun ColorAliasesPreview() = ElementPreview {
"temporaryColorBgSpecial" to ElementTheme.colors.temporaryColorBgSpecial,
"iconSuccessPrimaryBackground" to ElementTheme.colors.iconSuccessPrimaryBackground,
"bigIconBackgroundColor" to ElementTheme.colors.bigIconDefaultBackgroundColor,
"bigCheckmarkBorderColor" to ElementTheme.colors.bigCheckmarkBorderColor,
"highlightedMessageBackgroundColor" to ElementTheme.colors.highlightedMessageBackgroundColor,
)
)
}

View file

@ -32,8 +32,8 @@ import io.element.android.libraries.matrix.api.poll.PollKind
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.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 kotlinx.coroutines.flow.Flow
@ -98,7 +98,16 @@ interface MatrixRoom : Closeable {
val syncUpdateFlow: StateFlow<Long>
val timeline: MatrixTimeline
/**
* The live timeline of the room. Must be used to send Event to a room.
*/
val liveTimeline: Timeline
/**
* Create a new timeline, focused on the provided Event.
* Should not be used directly, see `TimelineController` to manage the various timelines.
*/
suspend fun timelineFocusedOnEvent(eventId: EventId): Result<Timeline>
fun destroy()
@ -122,12 +131,6 @@ interface MatrixRoom : Closeable {
suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun enterSpecialMode(eventId: EventId?): Result<Unit>
suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
suspend fun sendImage(

View file

@ -0,0 +1,34 @@
/*
* 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.api.room.errors
import io.element.android.libraries.matrix.api.core.EventId
sealed class FocusEventException : Exception() {
data class InvalidEventId(
val eventId: String,
val err: String
) : FocusEventException()
data class EventNotFound(
val eventId: EventId
) : FocusEventException()
data class Other(
val msg: String
) : FocusEventException()
}

View file

@ -1,48 +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.api.timeline
import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface MatrixTimeline : AutoCloseable {
data class PaginationState(
val isBackPaginating: Boolean,
val hasMoreToLoadBackwards: Boolean,
val beginningOfRoomReached: Boolean,
) {
val canBackPaginate = !isBackPaginating && hasMoreToLoadBackwards
companion object {
val Initial = PaginationState(
isBackPaginating = false,
hasMoreToLoadBackwards = true,
beginningOfRoomReached = false
)
}
}
val paginationState: StateFlow<PaginationState>
val timelineItems: Flow<List<MatrixTimelineItem>>
val membershipChangeEventReceived: Flow<Unit>
suspend fun paginateBackwards(requestSize: Int): Result<Unit>
suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit>
suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit>
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit>
}

View file

@ -0,0 +1,165 @@
/*
* 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.api.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.Mention
import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.io.File
interface Timeline : AutoCloseable {
data class PaginationStatus(
val isPaginating: Boolean,
val hasMoreToLoad: Boolean,
) {
val canPaginate: Boolean = !isPaginating && hasMoreToLoad
}
enum class PaginationDirection {
BACKWARDS,
FORWARDS
}
val membershipChangeEventReceived: Flow<Unit>
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit>
suspend fun paginate(direction: PaginationDirection): Result<Boolean>
fun paginationStatus(direction: PaginationDirection): StateFlow<PaginationStatus>
val timelineItems: Flow<List<MatrixTimelineItem>>
suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun enterSpecialMode(eventId: EventId?): Result<Unit>
suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun sendImage(
file: File,
thumbnailFile: File?,
imageInfo: ImageInfo,
body: String?,
formattedBody: String?,
progressCallback: ProgressCallback?
): Result<MediaUploadHandler>
suspend fun sendVideo(
file: File,
thumbnailFile: File?,
videoInfo: VideoInfo,
body: String?,
formattedBody: String?,
progressCallback: ProgressCallback?
): Result<MediaUploadHandler>
suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit>
suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit>
suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit>
suspend fun cancelSend(transactionId: TransactionId): Result<Unit>
/**
* Share a location message in the room.
*
* @param body A human readable textual representation of the location.
* @param geoUri A geo URI (RFC 5870) representing the location e.g. `geo:51.5008,0.1247;u=35`.
* Respectively: latitude, longitude, and (optional) uncertainty.
* @param description Optional description of the location to display to the user.
* @param zoomLevel Optional zoom level to display the map at.
* @param assetType Optional type of the location asset.
* Set to SENDER if sharing own location. Set to PIN if sharing any location.
*/
suspend fun sendLocation(
body: String,
geoUri: String,
description: String? = null,
zoomLevel: Int? = null,
assetType: AssetType? = null,
): Result<Unit>
/**
* Create a poll in the room.
*
* @param question The question to ask.
* @param answers The list of answers.
* @param maxSelections The maximum number of answers that can be selected.
* @param pollKind The kind of poll to create.
*/
suspend fun createPoll(
question: String,
answers: List<String>,
maxSelections: Int,
pollKind: PollKind,
): Result<Unit>
/**
* Edit a poll in the room.
*
* @param pollStartId The event ID of the poll start event.
* @param question The question to ask.
* @param answers The list of answers.
* @param maxSelections The maximum number of answers that can be selected.
* @param pollKind The kind of poll to create.
*/
suspend fun editPoll(
pollStartId: EventId,
question: String,
answers: List<String>,
maxSelections: Int,
pollKind: PollKind,
): Result<Unit>
/**
* Send a response to a poll.
*
* @param pollStartId The event ID of the poll start event.
* @param answers The list of answer ids to send.
*/
suspend fun sendPollResponse(pollStartId: EventId, answers: List<String>): Result<Unit>
/**
* Ends a poll in the room.
*
* @param pollStartId The event ID of the poll start event.
* @param text Fallback text of the poll end event.
*/
suspend fun endPoll(pollStartId: EventId, text: String): Result<Unit>
suspend fun sendVoiceMessage(
file: File,
audioInfo: AudioInfo,
waveform: List<Float>,
progressCallback: ProgressCallback?
): Result<MediaUploadHandler>
}

View file

@ -0,0 +1,30 @@
/*
* 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.api.timeline
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
/**
* This interface defines a way to get the active timeline.
* It could be the current room timeline, or a timeline for a specific event.
*/
interface TimelineProvider {
fun activeTimelineFlow(): StateFlow<Timeline>
}
suspend fun TimelineProvider.getActiveTimeline(): Timeline = activeTimelineFlow().first()

View file

@ -26,7 +26,7 @@ sealed interface InReplyTo {
data class NotLoaded(val eventId: EventId) : InReplyTo
/** The event details are pending to be fetched. We should **not** fetch them again. */
data object Pending : InReplyTo
data class Pending(val eventId: EventId) : InReplyTo
/** The event details are available. */
data class Ready(
@ -44,5 +44,8 @@ sealed interface InReplyTo {
* If the reason for the failure is consistent on the server, we'd enter a loop
* where we keep trying to fetch the same event.
* */
data object Error : InReplyTo
data class Error(
val eventId: EventId,
val message: String,
) : InReplyTo
}

View file

@ -16,6 +16,8 @@
package io.element.android.libraries.matrix.api.timeline.item.virtual
import io.element.android.libraries.matrix.api.timeline.Timeline
sealed interface VirtualTimelineItem {
data class DayDivider(
val timestamp: Long
@ -24,4 +26,13 @@ sealed interface VirtualTimelineItem {
data object ReadMarker : VirtualTimelineItem
data object EncryptedHistoryBanner : VirtualTimelineItem
data object RoomBeginning : VirtualTimelineItem
data object LastForwardIndicator : VirtualTimelineItem
data class LoadingIndicator(
val direction: Timeline.PaginationDirection,
val timestamp: Long,
) : VirtualTimelineItem
}

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

View file

@ -43,8 +43,8 @@ import io.element.android.libraries.matrix.api.room.StateEventType
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.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.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
@ -54,7 +54,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.collections.immutable.ImmutableMap
@ -84,7 +84,7 @@ class FakeMatrixRoom(
override val joinedMemberCount: Long = 123L,
override val activeMemberCount: Long = 234L,
val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
override val liveTimeline: Timeline = FakeTimeline(),
private var roomPermalinkResult: () -> Result<String> = { Result.success("room link") },
private var eventPermalinkResult: (EventId) -> Result<String> = { Result.success("event link") },
canRedactOwn: Boolean = false,
@ -134,7 +134,6 @@ class FakeMatrixRoom(
private var updatePowerLevelsResult = Result.success(Unit)
private var resetPowerLevelsResult = Result.success(defaultRoomPowerLevels())
var sendMessageMentions = emptyList<Mention>()
val editMessageCalls = mutableListOf<Pair<String, String?>>()
private val _typingRecord = mutableListOf<Boolean>()
val typingRecord: List<Boolean>
get() = _typingRecord
@ -215,7 +214,15 @@ class FakeMatrixRoom(
override val syncUpdateFlow: StateFlow<Long> = MutableStateFlow(0L)
override val timeline: MatrixTimeline = matrixTimeline
private var timelineFocusedOnEventResult: Result<Timeline> = Result.success(FakeTimeline())
fun givenTimelineFocusedOnEventResult(result: Result<Timeline>) {
timelineFocusedOnEventResult = result
}
override suspend fun timelineFocusedOnEvent(eventId: EventId): Result<Timeline> = simulateLongTask {
timelineFocusedOnEventResult
}
override suspend fun subscribeToSync() = Unit
@ -288,31 +295,6 @@ class FakeMatrixRoom(
return eventPermalinkResult(eventId)
}
override suspend fun editMessage(
originalEventId: EventId?,
transactionId: TransactionId?,
body: String,
htmlBody: String?,
mentions: List<Mention>
): Result<Unit> {
sendMessageMentions = mentions
editMessageCalls += body to htmlBody
return Result.success(Unit)
}
var replyMessageParameter: Pair<String, String?>? = null
private set
override suspend fun enterSpecialMode(eventId: EventId?): Result<Unit> {
return Result.success(Unit)
}
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> {
sendMessageMentions = mentions
replyMessageParameter = body to htmlBody
return Result.success(Unit)
}
var redactEventEventIdParam: EventId? = null
private set

View file

@ -1,97 +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.test.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 io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
class FakeMatrixTimeline(
initialTimelineItems: List<MatrixTimelineItem> = emptyList(),
initialPaginationState: MatrixTimeline.PaginationState = MatrixTimeline.PaginationState(
hasMoreToLoadBackwards = true,
isBackPaginating = false,
beginningOfRoomReached = false,
)
) : MatrixTimeline {
private val _paginationState: MutableStateFlow<MatrixTimeline.PaginationState> = MutableStateFlow(initialPaginationState)
private val _timelineItems: MutableStateFlow<List<MatrixTimelineItem>> = MutableStateFlow(initialTimelineItems)
var sentReadReceipts = mutableListOf<Pair<EventId, ReceiptType>>()
private set
var sendReadReceiptLatch: CompletableDeferred<Unit>? = null
fun updatePaginationState(update: (MatrixTimeline.PaginationState.() -> MatrixTimeline.PaginationState)) {
_paginationState.getAndUpdate(update)
}
fun updateTimelineItems(update: (items: List<MatrixTimelineItem>) -> List<MatrixTimelineItem>) {
_timelineItems.getAndUpdate(update)
}
override val paginationState: StateFlow<MatrixTimeline.PaginationState> = _paginationState
override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems
override suspend fun paginateBackwards(requestSize: Int) = paginateBackwards()
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int) = paginateBackwards()
override val membershipChangeEventReceived = MutableSharedFlow<Unit>()
private suspend fun paginateBackwards(): Result<Unit> {
updatePaginationState {
copy(isBackPaginating = true)
}
delay(100)
updatePaginationState {
copy(isBackPaginating = false)
}
updateTimelineItems { timelineItems ->
timelineItems
}
return Result.success(Unit)
}
fun givenMembershipChangeEventReceived() {
membershipChangeEventReceived.tryEmit(Unit)
}
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = simulateLongTask {
Result.success(Unit)
}
override suspend fun sendReadReceipt(
eventId: EventId,
receiptType: ReceiptType,
): Result<Unit> = simulateLongTask {
sentReadReceipts.add(eventId to receiptType)
sendReadReceiptLatch?.complete(Unit)
Result.success(Unit)
}
override fun close() = Unit
}

View file

@ -0,0 +1,372 @@
/*
* 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.test.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.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.test.media.FakeMediaUploadHandler
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.io.File
class FakeTimeline(
private val name: String = "FakeTimeline",
override val timelineItems: Flow<List<MatrixTimelineItem>> = MutableStateFlow(emptyList()),
private val backwardPaginationStatus: MutableStateFlow<Timeline.PaginationStatus> = MutableStateFlow(
Timeline.PaginationStatus(
isPaginating = false,
hasMoreToLoad = true
)
),
private val forwardPaginationStatus: MutableStateFlow<Timeline.PaginationStatus> = MutableStateFlow(
Timeline.PaginationStatus(
isPaginating = false,
hasMoreToLoad = false
)
),
override val membershipChangeEventReceived: Flow<Unit> = MutableSharedFlow(),
) : Timeline {
var sendMessageLambda: (
body: String,
htmlBody: String?,
mentions: List<Mention>,
) -> Result<Unit> = { _, _, _ ->
Result.success(Unit)
}
override suspend fun sendMessage(
body: String,
htmlBody: String?,
mentions: List<Mention>,
): Result<Unit> = sendMessageLambda(body, htmlBody, mentions)
var editMessageLambda: (
originalEventId: EventId?,
transactionId: TransactionId?,
body: String,
htmlBody: String?,
mentions: List<Mention>,
) -> Result<Unit> = { _, _, _, _, _ ->
Result.success(Unit)
}
override suspend fun editMessage(
originalEventId: EventId?,
transactionId: TransactionId?,
body: String,
htmlBody: String?,
mentions: List<Mention>,
): Result<Unit> = editMessageLambda(
originalEventId,
transactionId,
body,
htmlBody,
mentions
)
var enterSpecialModeLambda: (eventId: EventId?) -> Result<Unit> = {
Result.success(Unit)
}
override suspend fun enterSpecialMode(eventId: EventId?): Result<Unit> = enterSpecialModeLambda(eventId)
var replyMessageLambda: (
eventId: EventId,
body: String,
htmlBody: String?,
mentions: List<Mention>,
) -> Result<Unit> = { _, _, _, _ ->
Result.success(Unit)
}
override suspend fun replyMessage(
eventId: EventId,
body: String,
htmlBody: String?,
mentions: List<Mention>,
): Result<Unit> = replyMessageLambda(
eventId,
body,
htmlBody,
mentions
)
var sendImageLambda: (
file: File,
thumbnailFile: File?,
imageInfo: ImageInfo,
body: String?,
formattedBody: String?,
progressCallback: ProgressCallback?,
) -> Result<MediaUploadHandler> = { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
override suspend fun sendImage(
file: File,
thumbnailFile: File?,
imageInfo: ImageInfo,
body: String?,
formattedBody: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> = sendImageLambda(
file,
thumbnailFile,
imageInfo,
body,
formattedBody,
progressCallback
)
var sendVideoLambda: (
file: File,
thumbnailFile: File?,
videoInfo: VideoInfo,
body: String?,
formattedBody: String?,
progressCallback: ProgressCallback?,
) -> Result<MediaUploadHandler> = { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
override suspend fun sendVideo(
file: File,
thumbnailFile: File?,
videoInfo: VideoInfo,
body: String?,
formattedBody: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> = sendVideoLambda(
file,
thumbnailFile,
videoInfo,
body,
formattedBody,
progressCallback
)
var sendAudioLambda: (
file: File,
audioInfo: AudioInfo,
progressCallback: ProgressCallback?,
) -> Result<MediaUploadHandler> = { _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
override suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> = sendAudioLambda(
file,
audioInfo,
progressCallback
)
var sendFileLambda: (
file: File,
fileInfo: FileInfo,
progressCallback: ProgressCallback?,
) -> Result<MediaUploadHandler> = { _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
override suspend fun sendFile(
file: File,
fileInfo: FileInfo,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> = sendFileLambda(
file,
fileInfo,
progressCallback
)
var toggleReactionLambda: (emoji: String, eventId: EventId) -> Result<Unit> = { _, _ -> Result.success(Unit) }
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> = toggleReactionLambda(emoji, eventId)
var forwardEventLambda: (eventId: EventId, roomIds: List<RoomId>) -> Result<Unit> = { _, _ -> Result.success(Unit) }
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = forwardEventLambda(eventId, roomIds)
var retrySendMessageLambda: (transactionId: TransactionId) -> Result<Unit> = { Result.success(Unit) }
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> = retrySendMessageLambda(transactionId)
var cancelSendLambda: (transactionId: TransactionId) -> Result<Unit> = { Result.success(Unit) }
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> = cancelSendLambda(transactionId)
var sendLocationLambda: (
body: String,
geoUri: String,
description: String?,
zoomLevel: Int?,
assetType: AssetType?,
) -> Result<Unit> = { _, _, _, _, _ ->
Result.success(Unit)
}
override suspend fun sendLocation(
body: String,
geoUri: String,
description: String?,
zoomLevel: Int?,
assetType: AssetType?,
): Result<Unit> = sendLocationLambda(
body,
geoUri,
description,
zoomLevel,
assetType
)
var createPollLambda: (
question: String,
answers: List<String>,
maxSelections: Int,
pollKind: PollKind,
) -> Result<Unit> = { _, _, _, _ ->
Result.success(Unit)
}
override suspend fun createPoll(
question: String,
answers: List<String>,
maxSelections: Int,
pollKind: PollKind,
): Result<Unit> = createPollLambda(
question,
answers,
maxSelections,
pollKind
)
var editPollLambda: (
pollStartId: EventId,
question: String,
answers: List<String>,
maxSelections: Int,
pollKind: PollKind,
) -> Result<Unit> = { _, _, _, _, _ ->
Result.success(Unit)
}
override suspend fun editPoll(
pollStartId: EventId,
question: String,
answers: List<String>,
maxSelections: Int,
pollKind: PollKind,
): Result<Unit> = editPollLambda(
pollStartId,
question,
answers,
maxSelections,
pollKind
)
var sendPollResponseLambda: (
pollStartId: EventId,
answers: List<String>,
) -> Result<Unit> = { _, _ ->
Result.success(Unit)
}
override suspend fun sendPollResponse(
pollStartId: EventId,
answers: List<String>,
): Result<Unit> = sendPollResponseLambda(pollStartId, answers)
var endPollLambda: (
pollStartId: EventId,
text: String,
) -> Result<Unit> = { _, _ ->
Result.success(Unit)
}
override suspend fun endPoll(
pollStartId: EventId,
text: String,
): Result<Unit> = endPollLambda(pollStartId, text)
var sendVoiceMessageLambda: (
file: File,
audioInfo: AudioInfo,
waveform: List<Float>,
progressCallback: ProgressCallback?,
) -> Result<MediaUploadHandler> = { _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
override suspend fun sendVoiceMessage(
file: File,
audioInfo: AudioInfo,
waveform: List<Float>,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> = sendVoiceMessageLambda(
file,
audioInfo,
waveform,
progressCallback
)
var sendReadReceiptLambda: (
eventId: EventId,
receiptType: ReceiptType,
) -> Result<Unit> = { _, _ ->
Result.success(Unit)
}
override suspend fun sendReadReceipt(
eventId: EventId,
receiptType: ReceiptType,
): Result<Unit> = sendReadReceiptLambda(eventId, receiptType)
var paginateLambda: (direction: Timeline.PaginationDirection) -> Result<Boolean> = {
Result.success(false)
}
override suspend fun paginate(direction: Timeline.PaginationDirection): Result<Boolean> = paginateLambda(direction)
override fun paginationStatus(direction: Timeline.PaginationDirection): StateFlow<Timeline.PaginationStatus> {
return when (direction) {
Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus
}
}
var closeCounter = 0
private set
override fun close() {
closeCounter++
}
override fun toString() = "FakeTimeline: $name"
}

View file

@ -0,0 +1,29 @@
/*
* 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.test.timeline
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class LiveTimelineProvider(
private val room: MatrixRoom,
) : TimelineProvider {
override fun activeTimelineFlow(): StateFlow<Timeline> = MutableStateFlow(room.liveTimeline)
}