Merge pull request #2759 from element-hq/feature/fga/permalink_timeline
Permalink timeline
This commit is contained in:
@@ -282,7 +282,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
is PermalinkData.RoomLink -> {
|
||||
backstack.push(
|
||||
NavTarget.Room(
|
||||
data.roomIdOrAlias,
|
||||
roomIdOrAlias = data.roomIdOrAlias,
|
||||
initialElement = RoomNavigationTarget.Messages(data.eventId),
|
||||
// TODO Use the viaParameters
|
||||
)
|
||||
|
||||
@@ -83,7 +83,7 @@ class RoomFlowNode @AssistedInject constructor(
|
||||
data class Inputs(
|
||||
val roomIdOrAlias: RoomIdOrAlias,
|
||||
val roomDescription: Optional<RoomDescription>,
|
||||
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(),
|
||||
val initialElement: RoomNavigationTarget,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
@@ -177,7 +177,10 @@ class RoomFlowNode @AssistedInject constructor(
|
||||
}
|
||||
is NavTarget.JoinedRoom -> {
|
||||
val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
|
||||
val inputs = JoinedRoomFlowNode.Inputs(navTarget.roomId, initialElement = inputs.initialElement)
|
||||
val inputs = JoinedRoomFlowNode.Inputs(
|
||||
roomId = navTarget.roomId,
|
||||
initialElement = inputs.initialElement
|
||||
)
|
||||
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
|
||||
) {
|
||||
data class Inputs(
|
||||
val roomId: RoomId,
|
||||
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(),
|
||||
val initialElement: RoomNavigationTarget,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
@@ -106,7 +106,10 @@ class JoinedRoomFlowNode @AssistedInject constructor(
|
||||
val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
|
||||
val awaitRoomState = loadingRoomStateStateFlow.value
|
||||
if (awaitRoomState is LoadingRoomState.Loaded) {
|
||||
val inputs = JoinedRoomLoadedFlowNode.Inputs(awaitRoomState.room, initialElement = inputs.initialElement)
|
||||
val inputs = JoinedRoomLoadedFlowNode.Inputs(
|
||||
room = awaitRoomState.room,
|
||||
initialElement = inputs.initialElement
|
||||
)
|
||||
createNode<JoinedRoomLoadedFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
|
||||
} else {
|
||||
loadingNode(buildContext, this::navigateUp)
|
||||
|
||||
@@ -84,7 +84,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
|
||||
|
||||
data class Inputs(
|
||||
val room: MatrixRoom,
|
||||
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(),
|
||||
val initialElement: RoomNavigationTarget,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
|
||||
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.appnav.di.RoomComponentFactory
|
||||
import io.element.android.appnav.room.RoomNavigationTarget
|
||||
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
|
||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||
@@ -124,7 +125,7 @@ class JoinRoomLoadedFlowNodeTest {
|
||||
// GIVEN
|
||||
val room = FakeMatrixRoom()
|
||||
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
|
||||
val inputs = JoinedRoomLoadedFlowNode.Inputs(room)
|
||||
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
|
||||
val roomFlowNode = createJoinedRoomLoadedFlowNode(
|
||||
plugins = listOf(inputs),
|
||||
messagesEntryPoint = fakeMessagesEntryPoint,
|
||||
@@ -146,7 +147,7 @@ class JoinRoomLoadedFlowNodeTest {
|
||||
val room = FakeMatrixRoom()
|
||||
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
|
||||
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
|
||||
val inputs = JoinedRoomLoadedFlowNode.Inputs(room)
|
||||
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
|
||||
val roomFlowNode = createJoinedRoomLoadedFlowNode(
|
||||
plugins = listOf(inputs),
|
||||
messagesEntryPoint = fakeMessagesEntryPoint,
|
||||
|
||||
1
changelog.d/2759.feature
Normal file
1
changelog.d/2759.feature
Normal file
@@ -0,0 +1 @@
|
||||
Handle permalink navigation to Events.
|
||||
@@ -32,7 +32,7 @@ class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
|
||||
|
||||
return object : MessagesEntryPoint.NodeBuilder {
|
||||
override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
|
||||
plugins += MessagesNode.Inputs(focusedEventId = params.focusedEventId)
|
||||
plugins += MessagesFlowNode.Inputs(focusedEventId = params.focusedEventId)
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.architecture.overlay.Overlay
|
||||
import io.element.android.libraries.architecture.overlay.operation.show
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
@@ -81,7 +82,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Messages(plugins.filterIsInstance<Inputs>().firstOrNull()?.focusedEventId),
|
||||
initialElement = NavTarget.Messages,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
overlay = Overlay(
|
||||
@@ -91,15 +92,14 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
plugins = plugins
|
||||
) {
|
||||
data class Inputs(val focusedEventId: EventId?) : NodeInputs
|
||||
private val inputs = inputs<Inputs>()
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Empty : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class Messages(
|
||||
val focusedEventId: EventId? = null,
|
||||
) : NavTarget
|
||||
data object Messages : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class MediaViewer(
|
||||
@@ -191,10 +191,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
ElementCallActivity.start(context, inputs)
|
||||
}
|
||||
}
|
||||
val params = MessagesNode.Inputs(
|
||||
focusedEventId = navTarget.focusedEventId,
|
||||
val inputs = MessagesNode.Inputs(
|
||||
focusedEventId = inputs.focusedEventId,
|
||||
)
|
||||
createNode<MessagesNode>(buildContext, listOf(callback, params))
|
||||
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
|
||||
}
|
||||
is NavTarget.MediaViewer -> {
|
||||
val inputs = MediaViewerNode.Inputs(
|
||||
|
||||
@@ -19,6 +19,11 @@ package io.element.android.features.messages.impl
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
@@ -30,12 +35,15 @@ import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
||||
import io.element.android.libraries.androidutils.system.toast
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
@@ -64,13 +72,15 @@ class MessagesNode @AssistedInject constructor(
|
||||
private val permalinkParser: PermalinkParser,
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
private val timelineController: TimelineController,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
private val presenter = presenterFactory.create(this)
|
||||
private val callback = plugins<Callback>().firstOrNull()
|
||||
|
||||
// TODO Handle navigation to the Event
|
||||
data class Inputs(val focusedEventId: EventId?) : NodeInputs
|
||||
|
||||
private val inputs = inputs<Inputs>()
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onRoomDetailsClicked()
|
||||
fun onEventClicked(event: TimelineItem.Event): Boolean
|
||||
@@ -86,12 +96,14 @@ class MessagesNode @AssistedInject constructor(
|
||||
fun onJoinCallClicked(roomId: RoomId)
|
||||
}
|
||||
|
||||
init {
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
lifecycle.subscribe(
|
||||
onCreate = {
|
||||
analyticsService.capture(room.toAnalyticsViewRoom())
|
||||
},
|
||||
onDestroy = {
|
||||
timelineController.close()
|
||||
mediaPlayer.close()
|
||||
}
|
||||
)
|
||||
@@ -116,6 +128,7 @@ class MessagesNode @AssistedInject constructor(
|
||||
private fun onLinkClicked(
|
||||
context: Context,
|
||||
url: String,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
) {
|
||||
when (val permalink = permalinkParser.parse(url)) {
|
||||
is PermalinkData.UserLink -> {
|
||||
@@ -124,7 +137,7 @@ class MessagesNode @AssistedInject constructor(
|
||||
callback?.onUserDataClicked(permalink.userId)
|
||||
}
|
||||
is PermalinkData.RoomLink -> {
|
||||
handleRoomLinkClicked(permalink)
|
||||
handleRoomLinkClicked(permalink, eventSink)
|
||||
}
|
||||
is PermalinkData.FallbackLink,
|
||||
is PermalinkData.RoomEmailInviteLink -> {
|
||||
@@ -133,11 +146,11 @@ class MessagesNode @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRoomLinkClicked(roomLink: PermalinkData.RoomLink) {
|
||||
private fun handleRoomLinkClicked(roomLink: PermalinkData.RoomLink, eventSink: (TimelineEvents) -> Unit) {
|
||||
if (room.matches(roomLink.roomIdOrAlias)) {
|
||||
if (roomLink.eventId != null) {
|
||||
// TODO Handle navigation to the Event
|
||||
context.toast("TODO Handle navigation to the Event ${roomLink.eventId}")
|
||||
val eventId = roomLink.eventId
|
||||
if (eventId != null) {
|
||||
eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||
} else {
|
||||
// Click on the same room, ignore
|
||||
context.toast("Already viewing this room!")
|
||||
@@ -189,12 +202,23 @@ class MessagesNode @AssistedInject constructor(
|
||||
onEventClicked = this::onEventClicked,
|
||||
onPreviewAttachments = this::onPreviewAttachments,
|
||||
onUserDataClicked = this::onUserDataClicked,
|
||||
onLinkClicked = { onLinkClicked(context, it) },
|
||||
onLinkClicked = { onLinkClicked(context, it, state.timelineState.eventSink) },
|
||||
onSendLocationClicked = this::onSendLocationClicked,
|
||||
onCreatePollClicked = this::onCreatePollClicked,
|
||||
onJoinCallClicked = this::onJoinCallClicked,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
var focusedEventId by rememberSaveable {
|
||||
mutableStateOf(inputs.focusedEventId)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
focusedEventId?.also { eventId ->
|
||||
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||
}
|
||||
// Reset the focused event id to null to avoid refocusing when restoring node.
|
||||
focusedEventId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||
@@ -116,6 +117,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
private val htmlConverterProvider: HtmlConverterProvider,
|
||||
@Assisted private val navigator: MessagesNavigator,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val timelineController: TimelineController,
|
||||
) : Presenter<MessagesState> {
|
||||
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
|
||||
|
||||
@@ -185,10 +187,6 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
|
||||
LaunchedEffect(composerState.mode.relatedEventId) {
|
||||
timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId))
|
||||
}
|
||||
|
||||
val enableTextFormatting by appPreferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
||||
|
||||
var enableVoiceMessages by remember { mutableStateOf(false) }
|
||||
@@ -290,8 +288,10 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
emoji: String,
|
||||
eventId: EventId,
|
||||
) = launch(dispatchers.io) {
|
||||
room.toggleReaction(emoji, eventId)
|
||||
.onFailure { Timber.e(it) }
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
toggleReaction(emoji, eventId)
|
||||
.onFailure { Timber.e(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<AsyncData<Unit>>) = launch(dispatchers.io) {
|
||||
|
||||
@@ -106,6 +106,8 @@ fun aMessagesState(
|
||||
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
|
||||
timelineState: TimelineState = aTimelineState(
|
||||
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||
// Render a focused event for an event with sender information displayed
|
||||
focusedEventIndex = 2,
|
||||
),
|
||||
retrySendMenuState: RetrySendMenuState = aRetrySendMenuState(),
|
||||
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
|
||||
|
||||
@@ -382,20 +382,19 @@ private fun MessagesViewContent(
|
||||
},
|
||||
content = { paddingValues ->
|
||||
TimelineView(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
state = state.timelineState,
|
||||
roomName = state.roomName.dataOrNull(),
|
||||
typingNotificationState = state.typingNotificationState,
|
||||
onMessageClicked = onMessageClicked,
|
||||
onMessageLongClicked = onMessageLongClicked,
|
||||
onUserDataClicked = onUserDataClicked,
|
||||
onLinkClicked = onLinkClicked,
|
||||
onMessageClicked = onMessageClicked,
|
||||
onMessageLongClicked = onMessageLongClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
onReactionClicked = onReactionClicked,
|
||||
onReactionLongClicked = onReactionLongClicked,
|
||||
onMoreReactionsClicked = onMoreReactionsClicked,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -29,7 +29,8 @@ import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -37,8 +38,8 @@ import kotlinx.coroutines.launch
|
||||
|
||||
class ForwardMessagesPresenter @AssistedInject constructor(
|
||||
@Assisted eventId: String,
|
||||
private val room: MatrixRoom,
|
||||
private val matrixCoroutineScope: CoroutineScope,
|
||||
private val timelineProvider: TimelineProvider,
|
||||
) : Presenter<ForwardMessagesState> {
|
||||
private val eventId: EventId = EventId(eventId)
|
||||
|
||||
@@ -79,7 +80,7 @@ class ForwardMessagesPresenter @AssistedInject constructor(
|
||||
isForwardMessagesState: MutableState<AsyncData<ImmutableList<RoomId>>>,
|
||||
) = launch {
|
||||
isForwardMessagesState.value = AsyncData.Loading()
|
||||
room.forwardEvent(eventId, roomIds).fold(
|
||||
timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds).fold(
|
||||
{ isForwardMessagesState.value = AsyncData.Success(roomIds) },
|
||||
{ isForwardMessagesState.value = AsyncData.Failure(it) }
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||
import io.element.android.features.messages.impl.mentions.MentionSuggestion
|
||||
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
@@ -100,6 +101,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val permalinkBuilder: PermalinkBuilder,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
private val timelineController: TimelineController,
|
||||
) : Presenter<MessageComposerState> {
|
||||
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
|
||||
private var pendingEvent: MessageComposerEvents? = null
|
||||
@@ -264,7 +266,9 @@ class MessageComposerPresenter @Inject constructor(
|
||||
is MessageComposerMode.Quote -> null
|
||||
}.let { relatedEventId ->
|
||||
appCoroutineScope.launch {
|
||||
room.enterSpecialMode(relatedEventId)
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
enterSpecialMode(relatedEventId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -386,16 +390,17 @@ class MessageComposerPresenter @Inject constructor(
|
||||
is MessageComposerMode.Edit -> {
|
||||
val eventId = capturedMode.eventId
|
||||
val transactionId = capturedMode.transactionId
|
||||
room.editMessage(eventId, transactionId, message.markdown, message.html, mentions)
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
editMessage(eventId, transactionId, message.markdown, message.html, mentions)
|
||||
}
|
||||
}
|
||||
|
||||
is MessageComposerMode.Quote -> TODO()
|
||||
is MessageComposerMode.Reply -> room.replyMessage(
|
||||
capturedMode.eventId,
|
||||
message.markdown,
|
||||
message.html,
|
||||
mentions
|
||||
)
|
||||
is MessageComposerMode.Reply -> {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
replyMessage(capturedMode.eventId, message.markdown, message.html, mentions)
|
||||
}
|
||||
}
|
||||
}
|
||||
analyticsService.capture(
|
||||
Composer(
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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.features.messages.impl.timeline
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
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.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.io.Closeable
|
||||
import java.util.Optional
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* This controller is responsible of using the right timeline to display messages and make associated actions.
|
||||
* It can be focused on the live timeline or on a detached timeline (focusing an unknown event).
|
||||
*/
|
||||
@SingleIn(RoomScope::class)
|
||||
@ContributesBinding(RoomScope::class, boundType = TimelineProvider::class)
|
||||
class TimelineController @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
) : Closeable, TimelineProvider {
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob())
|
||||
|
||||
private val liveTimeline = flowOf(room.liveTimeline)
|
||||
private val detachedTimeline = MutableStateFlow<Optional<Timeline>>(Optional.empty())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun timelineItems(): Flow<List<MatrixTimelineItem>> {
|
||||
return currentTimelineFlow.flatMapLatest { it.timelineItems }
|
||||
}
|
||||
|
||||
fun isLive(): Flow<Boolean> {
|
||||
return detachedTimeline.map { !it.isPresent }
|
||||
}
|
||||
|
||||
suspend fun invokeOnCurrentTimeline(block: suspend (Timeline.() -> Any)) {
|
||||
currentTimelineFlow.value.run {
|
||||
block(this)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun focusOnEvent(eventId: EventId): Result<Unit> {
|
||||
return room.timelineFocusedOnEvent(eventId)
|
||||
.onFailure {
|
||||
if (it is CancellationException) {
|
||||
throw it
|
||||
}
|
||||
}
|
||||
.map { newDetachedTimeline ->
|
||||
detachedTimeline.getAndUpdate { current ->
|
||||
if (current.isPresent) {
|
||||
current.get().close()
|
||||
}
|
||||
Optional.of(newDetachedTimeline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure the controller is focused on the live timeline.
|
||||
* This does close the detached timeline if any.
|
||||
*/
|
||||
fun focusOnLive() {
|
||||
closeDetachedTimeline()
|
||||
}
|
||||
|
||||
private fun closeDetachedTimeline() {
|
||||
detachedTimeline.getAndUpdate {
|
||||
when {
|
||||
it.isPresent -> {
|
||||
it.get().close()
|
||||
Optional.empty()
|
||||
}
|
||||
else -> Optional.empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
coroutineScope.cancel()
|
||||
closeDetachedTimeline()
|
||||
}
|
||||
|
||||
suspend fun paginate(direction: Timeline.PaginationDirection): Result<Boolean> {
|
||||
return currentTimelineFlow.value.paginate(direction)
|
||||
.onSuccess { hasReachedEnd ->
|
||||
if (direction == Timeline.PaginationDirection.FORWARDS && hasReachedEnd) {
|
||||
focusOnLive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val currentTimelineFlow = combine(liveTimeline, detachedTimeline) { live, detached ->
|
||||
when {
|
||||
detached.isPresent -> detached.get()
|
||||
else -> live
|
||||
}
|
||||
}.stateIn(coroutineScope, SharingStarted.Eagerly, room.liveTimeline)
|
||||
|
||||
override fun activeTimelineFlow(): StateFlow<Timeline> {
|
||||
return currentTimelineFlow
|
||||
}
|
||||
}
|
||||
@@ -17,17 +17,21 @@
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
sealed interface TimelineEvents {
|
||||
data object LoadMore : TimelineEvents
|
||||
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
|
||||
data class OnScrollFinished(val firstIndex: Int) : TimelineEvents
|
||||
data class FocusOnEvent(val eventId: EventId) : TimelineEvents
|
||||
data object ClearFocusRequestState : TimelineEvents
|
||||
data object JumpToLive : TimelineEvents
|
||||
|
||||
/**
|
||||
* Events coming from a timeline item.
|
||||
*/
|
||||
sealed interface EventFromTimelineItem : TimelineEvents
|
||||
|
||||
data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem
|
||||
|
||||
/**
|
||||
* Events coming from a poll item.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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.features.messages.impl.timeline
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
class TimelineItemIndexer @Inject constructor() {
|
||||
private val timelineEventsIndexes = mutableMapOf<EventId, Int>()
|
||||
|
||||
fun isKnown(eventId: EventId): Boolean {
|
||||
return timelineEventsIndexes.containsKey(eventId).also {
|
||||
Timber.d("$eventId isKnown = $it")
|
||||
}
|
||||
}
|
||||
|
||||
fun indexOf(eventId: EventId): Int {
|
||||
return (timelineEventsIndexes[eventId] ?: -1).also {
|
||||
Timber.d("indexOf $eventId= $it")
|
||||
}
|
||||
}
|
||||
|
||||
fun process(timelineItems: List<TimelineItem>) {
|
||||
Timber.d("process ${timelineItems.size} items")
|
||||
timelineEventsIndexes.clear()
|
||||
timelineItems.forEachIndexed { index, timelineItem ->
|
||||
when (timelineItem) {
|
||||
is TimelineItem.Event -> {
|
||||
processEvent(timelineItem, index)
|
||||
}
|
||||
is TimelineItem.GroupedEvents -> {
|
||||
timelineItem.events.forEach { event ->
|
||||
processEvent(event, index)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processEvent(event: TimelineItem.Event, index: Int) {
|
||||
if (event.eventId == null) return
|
||||
timelineEventsIndexes[event.eventId] = index
|
||||
}
|
||||
}
|
||||
@@ -54,11 +54,9 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val BACK_PAGINATION_EVENT_LIMIT = 20
|
||||
private const val BACK_PAGINATION_PAGE_SIZE = 50
|
||||
|
||||
class TimelinePresenter @AssistedInject constructor(
|
||||
private val timelineItemsFactory: TimelineItemsFactory,
|
||||
private val timelineItemIndexer: TimelineItemIndexer,
|
||||
private val room: MatrixRoom,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val appScope: CoroutineScope,
|
||||
@@ -67,50 +65,62 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
private val sendPollResponseAction: SendPollResponseAction,
|
||||
private val endPollAction: EndPollAction,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
private val timelineController: TimelineController,
|
||||
) : Presenter<TimelineState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(navigator: MessagesNavigator): TimelinePresenter
|
||||
}
|
||||
|
||||
private val timeline = room.timeline
|
||||
|
||||
@Composable
|
||||
override fun present(): TimelineState {
|
||||
val localScope = rememberCoroutineScope()
|
||||
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
|
||||
val focusedEventId: MutableState<EventId?> = rememberSaveable {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
val focusRequestState: MutableState<FocusRequestState> = remember {
|
||||
mutableStateOf(FocusRequestState.None)
|
||||
}
|
||||
|
||||
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
|
||||
|
||||
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
||||
val paginationState by timeline.paginationState.collectAsState()
|
||||
|
||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
|
||||
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
||||
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value)
|
||||
|
||||
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val newItemState = remember { mutableStateOf(NewEventState.None) }
|
||||
|
||||
val newEventState = remember { mutableStateOf(NewEventState.None) }
|
||||
|
||||
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
|
||||
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
|
||||
val isLive by timelineController.isLive().collectAsState(initial = true)
|
||||
|
||||
fun handleEvents(event: TimelineEvents) {
|
||||
when (event) {
|
||||
TimelineEvents.LoadMore -> localScope.paginateBackwards()
|
||||
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
||||
is TimelineEvents.OnScrollFinished -> {
|
||||
if (event.firstIndex == 0) {
|
||||
newItemState.value = NewEventState.None
|
||||
is TimelineEvents.LoadMore -> {
|
||||
localScope.launch {
|
||||
timelineController.paginate(direction = event.direction)
|
||||
}
|
||||
}
|
||||
is TimelineEvents.OnScrollFinished -> {
|
||||
if (isLive) {
|
||||
if (event.firstIndex == 0) {
|
||||
newEventState.value = NewEventState.None
|
||||
}
|
||||
println("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
|
||||
appScope.sendReadReceiptIfNeeded(
|
||||
firstVisibleIndex = event.firstIndex,
|
||||
timelineItems = timelineItems,
|
||||
lastReadReceiptId = lastReadReceiptId,
|
||||
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
|
||||
)
|
||||
} else {
|
||||
newEventState.value = NewEventState.None
|
||||
}
|
||||
appScope.sendReadReceiptIfNeeded(
|
||||
firstVisibleIndex = event.firstIndex,
|
||||
timelineItems = timelineItems,
|
||||
lastReadReceiptId = lastReadReceiptId,
|
||||
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
|
||||
)
|
||||
}
|
||||
is TimelineEvents.PollAnswerSelected -> appScope.launch {
|
||||
sendPollResponseAction.execute(
|
||||
@@ -123,28 +133,58 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
pollStartId = event.pollStartId,
|
||||
)
|
||||
}
|
||||
is TimelineEvents.PollEditClicked ->
|
||||
is TimelineEvents.PollEditClicked -> {
|
||||
navigator.onEditPollClicked(event.pollStartId)
|
||||
}
|
||||
is TimelineEvents.FocusOnEvent -> localScope.launch {
|
||||
focusedEventId.value = event.eventId
|
||||
if (timelineItemIndexer.isKnown(event.eventId)) {
|
||||
val index = timelineItemIndexer.indexOf(event.eventId)
|
||||
focusRequestState.value = FocusRequestState.Cached(index)
|
||||
} else {
|
||||
focusRequestState.value = FocusRequestState.Fetching
|
||||
timelineController.focusOnEvent(event.eventId)
|
||||
.fold(
|
||||
onSuccess = {
|
||||
focusRequestState.value = FocusRequestState.Fetched
|
||||
},
|
||||
onFailure = {
|
||||
focusRequestState.value = FocusRequestState.Failure(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
is TimelineEvents.ClearFocusRequestState -> {
|
||||
focusRequestState.value = FocusRequestState.None
|
||||
}
|
||||
is TimelineEvents.JumpToLive -> {
|
||||
timelineController.focusOnLive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(timelineItems.size) {
|
||||
computeNewItemState(timelineItems, prevMostRecentItemId, newItemState)
|
||||
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
|
||||
}
|
||||
|
||||
LaunchedEffect(timelineItems.size, focusRequestState.value, focusedEventId.value) {
|
||||
val currentFocusedEventId = focusedEventId.value
|
||||
if (focusRequestState.value is FocusRequestState.Fetched && currentFocusedEventId != null) {
|
||||
if (timelineItemIndexer.isKnown(currentFocusedEventId)) {
|
||||
val index = timelineItemIndexer.indexOf(currentFocusedEventId)
|
||||
focusRequestState.value = FocusRequestState.Cached(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
combine(timeline.timelineItems, room.membersStateFlow) { items, membersState ->
|
||||
combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState ->
|
||||
timelineItemsFactory.replaceWith(
|
||||
timelineItems = items,
|
||||
roomMembers = membersState.roomMembers().orEmpty()
|
||||
)
|
||||
items
|
||||
}
|
||||
.onEach { timelineItems ->
|
||||
if (timelineItems.isEmpty()) {
|
||||
paginateBackwards()
|
||||
}
|
||||
}
|
||||
.onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem)
|
||||
.launchIn(this)
|
||||
}
|
||||
@@ -152,6 +192,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
val timelineRoomInfo by remember {
|
||||
derivedStateOf {
|
||||
TimelineRoomInfo(
|
||||
name = room.displayName,
|
||||
isDm = room.isDm,
|
||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
|
||||
@@ -160,11 +201,12 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
}
|
||||
return TimelineState(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
highlightedEventId = highlightedEventId.value,
|
||||
paginationState = paginationState,
|
||||
timelineItems = timelineItems,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
newEventState = newItemState.value,
|
||||
newEventState = newEventState.value,
|
||||
isLive = isLive,
|
||||
focusedEventId = focusedEventId.value,
|
||||
focusRequestState = focusRequestState.value,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
@@ -190,6 +232,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
newMostRecentItem is TimelineItem.Event &&
|
||||
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
|
||||
newMostRecentItemId != prevMostRecentItemIdValue
|
||||
|
||||
if (hasNewEvent) {
|
||||
val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event
|
||||
// Scroll to bottom if the new event is from me, even if sent from another device
|
||||
@@ -217,7 +260,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
|
||||
if (eventId != null && eventId != lastReadReceiptId.value) {
|
||||
lastReadReceiptId.value = eventId
|
||||
timeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
|
||||
room.liveTimeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,8 +274,4 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun CoroutineScope.paginateBackwards() = launch {
|
||||
timeline.paginateBackwards(BACK_PAGINATION_EVENT_LIMIT, BACK_PAGINATION_PAGE_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
@@ -28,15 +27,28 @@ data class TimelineState(
|
||||
val timelineItems: ImmutableList<TimelineItem>,
|
||||
val timelineRoomInfo: TimelineRoomInfo,
|
||||
val renderReadReceipts: Boolean,
|
||||
val highlightedEventId: EventId?,
|
||||
val paginationState: MatrixTimeline.PaginationState,
|
||||
val newEventState: NewEventState,
|
||||
val eventSink: (TimelineEvents) -> Unit
|
||||
)
|
||||
val isLive: Boolean,
|
||||
val focusedEventId: EventId?,
|
||||
val focusRequestState: FocusRequestState,
|
||||
val eventSink: (TimelineEvents) -> Unit,
|
||||
) {
|
||||
val hasAnyEvent = timelineItems.any { it is TimelineItem.Event }
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface FocusRequestState {
|
||||
data object None : FocusRequestState
|
||||
data class Cached(val index: Int) : FocusRequestState
|
||||
data object Fetching : FocusRequestState
|
||||
data object Fetched : FocusRequestState
|
||||
data class Failure(val throwable: Throwable) : FocusRequestState
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class TimelineRoomInfo(
|
||||
val isDm: Boolean,
|
||||
val name: String?,
|
||||
val userHasPermissionToSendMessage: Boolean,
|
||||
val userHasPermissionToSendReaction: Boolean,
|
||||
)
|
||||
|
||||
@@ -35,7 +35,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -47,32 +46,22 @@ import kotlin.random.Random
|
||||
|
||||
fun aTimelineState(
|
||||
timelineItems: ImmutableList<TimelineItem> = persistentListOf(),
|
||||
paginationState: MatrixTimeline.PaginationState = aPaginationState(),
|
||||
renderReadReceipts: Boolean = false,
|
||||
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
|
||||
focusedEventIndex: Int = -1,
|
||||
isLive: Boolean = true,
|
||||
eventSink: (TimelineEvents) -> Unit = {},
|
||||
) = TimelineState(
|
||||
timelineItems = timelineItems,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
paginationState = paginationState,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
highlightedEventId = null,
|
||||
newEventState = NewEventState.None,
|
||||
isLive = isLive,
|
||||
focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId,
|
||||
focusRequestState = FocusRequestState.None,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aPaginationState(
|
||||
isBackPaginating: Boolean = false,
|
||||
hasMoreToLoadBackwards: Boolean = true,
|
||||
beginningOfRoomReached: Boolean = false,
|
||||
): MatrixTimeline.PaginationState {
|
||||
return MatrixTimeline.PaginationState(
|
||||
isBackPaginating = isBackPaginating,
|
||||
hasMoreToLoadBackwards = hasMoreToLoadBackwards,
|
||||
beginningOfRoomReached = beginningOfRoomReached,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
|
||||
return persistentListOf(
|
||||
// 3 items (First Middle Last) with isMine = false
|
||||
@@ -235,10 +224,12 @@ internal fun aGroupedEvents(
|
||||
}
|
||||
|
||||
internal fun aTimelineRoomInfo(
|
||||
name: String = "Room name",
|
||||
isDm: Boolean = false,
|
||||
userHasPermissionToSendMessage: Boolean = true,
|
||||
) = TimelineRoomInfo(
|
||||
isDm = isDm,
|
||||
name = name,
|
||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||
userHasPermissionToSendReaction = true,
|
||||
)
|
||||
|
||||
@@ -55,10 +55,9 @@ import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
@@ -74,12 +73,12 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
@Composable
|
||||
fun TimelineView(
|
||||
state: TimelineState,
|
||||
typingNotificationState: TypingNotificationState,
|
||||
roomName: String?,
|
||||
onUserDataClicked: (UserId) -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
onMessageClicked: (TimelineItem.Event) -> Unit,
|
||||
@@ -93,8 +92,8 @@ fun TimelineView(
|
||||
modifier: Modifier = Modifier,
|
||||
forceJumpToBottomVisibility: Boolean = false
|
||||
) {
|
||||
fun onReachedLoadMore() {
|
||||
state.eventSink(TimelineEvents.LoadMore)
|
||||
fun clearFocusRequestState() {
|
||||
state.eventSink(TimelineEvents.ClearFocusRequestState)
|
||||
}
|
||||
|
||||
fun onScrollFinishedAt(firstVisibleIndex: Int) {
|
||||
@@ -109,9 +108,8 @@ fun TimelineView(
|
||||
accessibilityManager.isTouchExplorationEnabled.not()
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun inReplyToClicked(eventId: EventId) {
|
||||
// TODO implement this logic once we have support to 'jump to event X' in sliding sync
|
||||
state.eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||
}
|
||||
|
||||
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
|
||||
@@ -123,8 +121,10 @@ fun TimelineView(
|
||||
reverseLayout = useReverseLayout,
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
item {
|
||||
TypingNotificationView(state = typingNotificationState)
|
||||
if (state.isLive) {
|
||||
item {
|
||||
TypingNotificationView(state = typingNotificationState)
|
||||
}
|
||||
}
|
||||
items(
|
||||
items = state.timelineItems,
|
||||
@@ -137,7 +137,7 @@ fun TimelineView(
|
||||
renderReadReceipts = state.renderReadReceipts,
|
||||
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true &&
|
||||
state.timelineItems.first().identifier() == timelineItem.identifier(),
|
||||
highlightedItem = state.highlightedEventId?.value,
|
||||
focusedEventId = state.focusedEventId,
|
||||
onClick = onMessageClicked,
|
||||
onLongClick = onMessageLongClicked,
|
||||
onUserDataClick = onUserDataClicked,
|
||||
@@ -152,28 +152,23 @@ fun TimelineView(
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
)
|
||||
}
|
||||
if (state.paginationState.hasMoreToLoadBackwards) {
|
||||
// Do not use key parameter to avoid wrong positioning
|
||||
item(contentType = "TimelineLoadingMoreIndicator") {
|
||||
TimelineLoadingMoreIndicator()
|
||||
LaunchedEffect(Unit) {
|
||||
onReachedLoadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.paginationState.beginningOfRoomReached && !state.timelineRoomInfo.isDm) {
|
||||
item(contentType = "BeginningOfRoomReached") {
|
||||
TimelineItemRoomBeginningView(roomName = roomName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FocusRequestStateView(
|
||||
focusRequestState = state.focusRequestState,
|
||||
onClearFocusRequestState = ::clearFocusRequestState
|
||||
)
|
||||
|
||||
TimelineScrollHelper(
|
||||
isTimelineEmpty = state.timelineItems.isEmpty(),
|
||||
hasAnyEvent = state.hasAnyEvent,
|
||||
lazyListState = lazyListState,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
newEventState = state.newEventState,
|
||||
onScrollFinishedAt = ::onScrollFinishedAt
|
||||
isLive = state.isLive,
|
||||
focusRequestState = state.focusRequestState,
|
||||
onScrollFinishedAt = ::onScrollFinishedAt,
|
||||
onClearFocusRequestState = ::clearFocusRequestState,
|
||||
onJumpToLive = { state.eventSink(TimelineEvents.JumpToLive) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -181,17 +176,21 @@ fun TimelineView(
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.TimelineScrollHelper(
|
||||
isTimelineEmpty: Boolean,
|
||||
hasAnyEvent: Boolean,
|
||||
lazyListState: LazyListState,
|
||||
newEventState: NewEventState,
|
||||
isLive: Boolean,
|
||||
forceJumpToBottomVisibility: Boolean,
|
||||
focusRequestState: FocusRequestState,
|
||||
onClearFocusRequestState: () -> Unit,
|
||||
onScrollFinishedAt: (Int) -> Unit,
|
||||
onJumpToLive: () -> Unit,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
||||
val canAutoScroll by remember {
|
||||
derivedStateOf {
|
||||
lazyListState.firstVisibleItemIndex < 3
|
||||
lazyListState.firstVisibleItemIndex < 3 && isLive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,16 +204,36 @@ private fun BoxScope.TimelineScrollHelper(
|
||||
}
|
||||
}
|
||||
|
||||
fun jumpToBottom() {
|
||||
if (isLive) {
|
||||
scrollToBottom()
|
||||
} else {
|
||||
onJumpToLive()
|
||||
}
|
||||
}
|
||||
|
||||
val latestOnClearFocusRequestState by rememberUpdatedState(onClearFocusRequestState)
|
||||
LaunchedEffect(focusRequestState) {
|
||||
if (focusRequestState is FocusRequestState.Cached) {
|
||||
if (abs(lazyListState.firstVisibleItemIndex - focusRequestState.index) < 10) {
|
||||
lazyListState.animateScrollToItem(focusRequestState.index)
|
||||
} else {
|
||||
lazyListState.scrollToItem(focusRequestState.index)
|
||||
}
|
||||
latestOnClearFocusRequestState()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(canAutoScroll, newEventState) {
|
||||
val shouldAutoScroll = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
|
||||
if (shouldAutoScroll) {
|
||||
val shouldScrollToBottom = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
|
||||
if (shouldScrollToBottom) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
val latestOnScrollFinishedAt by rememberUpdatedState(onScrollFinishedAt)
|
||||
LaunchedEffect(isScrollFinished, isTimelineEmpty) {
|
||||
if (isScrollFinished && !isTimelineEmpty) {
|
||||
LaunchedEffect(isScrollFinished, hasAnyEvent) {
|
||||
if (isScrollFinished && hasAnyEvent) {
|
||||
// Notify the parent composable about the first visible item index when scrolling finishes
|
||||
latestOnScrollFinishedAt(lazyListState.firstVisibleItemIndex)
|
||||
}
|
||||
@@ -222,11 +241,11 @@ private fun BoxScope.TimelineScrollHelper(
|
||||
|
||||
JumpToBottomButton(
|
||||
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
|
||||
isVisible = !canAutoScroll || forceJumpToBottomVisibility,
|
||||
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 24.dp, bottom = 12.dp),
|
||||
onClick = ::scrollToBottom,
|
||||
onClick = { jumpToBottom() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -271,18 +290,20 @@ internal fun TimelineViewPreview(
|
||||
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
|
||||
) {
|
||||
TimelineView(
|
||||
state = aTimelineState(timelineItems),
|
||||
roomName = null,
|
||||
state = aTimelineState(
|
||||
timelineItems = timelineItems,
|
||||
focusedEventIndex = 0,
|
||||
),
|
||||
typingNotificationState = aTypingNotificationState(),
|
||||
onMessageClicked = {},
|
||||
onTimestampClicked = {},
|
||||
onUserDataClicked = {},
|
||||
onLinkClicked = {},
|
||||
onMessageClicked = {},
|
||||
onMessageLongClicked = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
onReactionClicked = { _, _ -> },
|
||||
onReactionLongClicked = { _, _ -> },
|
||||
onMoreReactionsClicked = {},
|
||||
onSwipeToReply = {},
|
||||
onReadReceiptClick = {},
|
||||
forceJumpToBottomVisibility = true,
|
||||
)
|
||||
|
||||
@@ -24,7 +24,6 @@ import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -93,7 +92,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
|
||||
import io.element.android.features.messages.impl.timeline.model.eventId
|
||||
import io.element.android.features.messages.impl.timeline.model.metadata
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
|
||||
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
|
||||
import io.element.android.libraries.designsystem.components.EqualWidthColumn
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
@@ -146,7 +147,7 @@ fun TimelineItemEventRow(
|
||||
}
|
||||
|
||||
fun inReplyToClicked() {
|
||||
val inReplyToEventId = event.inReplyTo?.eventId ?: return
|
||||
val inReplyToEventId = event.inReplyTo?.eventId() ?: return
|
||||
inReplyToClick(inReplyToEventId)
|
||||
}
|
||||
|
||||
@@ -417,7 +418,6 @@ private fun MessageSenderInformation(
|
||||
private fun MessageEventBubbleContent(
|
||||
event: TimelineItem.Event,
|
||||
onMessageLongClick: () -> Unit,
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
inReplyToClick: () -> Unit,
|
||||
onTimestampClicked: () -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
@@ -437,7 +437,7 @@ private fun MessageEventBubbleContent(
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = spacedBy(4.dp, Alignment.Start),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.Start),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
@@ -565,16 +565,30 @@ private fun MessageEventBubbleContent(
|
||||
}
|
||||
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
|
||||
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
|
||||
ReplyToContent(
|
||||
senderId = inReplyTo.senderId,
|
||||
senderProfile = inReplyTo.senderProfile,
|
||||
metadata = inReplyTo.metadata(),
|
||||
modifier = Modifier
|
||||
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
val inReplyToModifier = Modifier
|
||||
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
// FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent
|
||||
// .clickable(enabled = true, onClick = inReplyToClick)
|
||||
)
|
||||
.clickable(onClick = inReplyToClick)
|
||||
when (inReplyTo) {
|
||||
is InReplyToDetails.Ready -> {
|
||||
ReplyToContent(
|
||||
senderId = inReplyTo.senderId,
|
||||
senderProfile = inReplyTo.senderProfile,
|
||||
metadata = inReplyTo.metadata(),
|
||||
modifier = inReplyToModifier,
|
||||
)
|
||||
}
|
||||
is InReplyToDetails.Error ->
|
||||
ReplyToErrorContent(
|
||||
data = inReplyTo,
|
||||
modifier = inReplyToModifier,
|
||||
)
|
||||
is InReplyToDetails.Loading ->
|
||||
ReplyToLoadingContent(
|
||||
modifier = inReplyToModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (inReplyToDetails != null) {
|
||||
// Use SubComposeLayout only if necessary as it can have consequences on the performance.
|
||||
@@ -584,7 +598,7 @@ private fun MessageEventBubbleContent(
|
||||
contentWithTimestamp()
|
||||
}
|
||||
} else {
|
||||
Column(modifier = modifier, verticalArrangement = spacedBy(8.dp)) {
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
threadDecoration()
|
||||
contentWithTimestamp()
|
||||
}
|
||||
@@ -652,6 +666,44 @@ private fun ReplyToContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyToLoadingContent(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
||||
Row(
|
||||
modifier
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(paddings)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
PlaceholderAtom(width = 80.dp, height = 12.dp)
|
||||
PlaceholderAtom(width = 140.dp, height = 14.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyToErrorContent(
|
||||
data: InReplyToDetails.Error,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
||||
Row(
|
||||
modifier
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(paddings)
|
||||
) {
|
||||
Text(
|
||||
text = data.message,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyToContentText(metadata: InReplyToMetadata?) {
|
||||
val text = when (metadata) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowWithReplyOtherPreview(
|
||||
@PreviewParameter(InReplyToDetailsOtherProvider::class) inReplyToDetails: InReplyToDetails,
|
||||
) = ElementPreview {
|
||||
TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
|
||||
}
|
||||
|
||||
class InReplyToDetailsOtherProvider : InReplyToDetailsProvider() {
|
||||
override val values: Sequence<InReplyToDetails>
|
||||
get() = sequenceOf(
|
||||
InReplyToDetails.Loading(eventId = EventId("\$anEventId")),
|
||||
InReplyToDetails.Error(eventId = EventId("\$anEventId"), message = "An error message."),
|
||||
)
|
||||
}
|
||||
@@ -170,7 +170,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
|
||||
protected fun aInReplyToDetails(
|
||||
eventContent: EventContent,
|
||||
displayNameAmbiguous: Boolean = false,
|
||||
) = InReplyToDetails(
|
||||
) = InReplyToDetails.Ready(
|
||||
eventId = EventId("\$event"),
|
||||
eventContent = eventContent,
|
||||
senderId = UserId("@Sender:domain"),
|
||||
|
||||
@@ -43,7 +43,7 @@ fun TimelineItemGroupedEventsRow(
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
highlightedItem: String?,
|
||||
focusedEventId: EventId?,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
@@ -68,7 +68,7 @@ fun TimelineItemGroupedEventsRow(
|
||||
onExpandGroupClick = ::onExpandGroupClick,
|
||||
timelineItem = timelineItem,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
highlightedItem = highlightedItem,
|
||||
focusedEventId = focusedEventId,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
onClick = onClick,
|
||||
@@ -92,7 +92,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
onExpandGroupClick: () -> Unit,
|
||||
timelineItem: TimelineItem.GroupedEvents,
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
highlightedItem: String?,
|
||||
focusedEventId: EventId?,
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
@@ -116,7 +116,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
timelineItem.events.size
|
||||
),
|
||||
isExpanded = isExpanded,
|
||||
isHighlighted = !isExpanded && timelineItem.events.any { it.identifier() == highlightedItem },
|
||||
isHighlighted = !isExpanded && timelineItem.events.any { it.isEvent(focusedEventId) },
|
||||
onClick = onExpandGroupClick,
|
||||
)
|
||||
if (isExpanded) {
|
||||
@@ -127,7 +127,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
highlightedItem = highlightedItem,
|
||||
focusedEventId = focusedEventId,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
@@ -160,12 +160,13 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPreview {
|
||||
val events = aGroupedEvents(withReadReceipts = true)
|
||||
TimelineItemGroupedEventsRowContent(
|
||||
isExpanded = true,
|
||||
onExpandGroupClick = {},
|
||||
timelineItem = aGroupedEvents(withReadReceipts = true),
|
||||
timelineItem = events,
|
||||
timelineRoomInfo = aTimelineRoomInfo(),
|
||||
highlightedItem = null,
|
||||
focusedEventId = events.events.first().eventId,
|
||||
renderReadReceipts = true,
|
||||
isLastOutgoingMessage = false,
|
||||
onClick = {},
|
||||
@@ -190,7 +191,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
|
||||
onExpandGroupClick = {},
|
||||
timelineItem = aGroupedEvents(withReadReceipts = true),
|
||||
timelineRoomInfo = aTimelineRoomInfo(),
|
||||
highlightedItem = null,
|
||||
focusedEventId = null,
|
||||
renderReadReceipts = true,
|
||||
isLastOutgoingMessage = false,
|
||||
onClick = {},
|
||||
|
||||
@@ -16,13 +16,24 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||
import io.element.android.libraries.designsystem.text.toPx
|
||||
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
@@ -32,7 +43,7 @@ internal fun TimelineItemRow(
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
highlightedItem: String?,
|
||||
focusedEventId: EventId?,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
@@ -47,69 +58,111 @@ internal fun TimelineItemRow(
|
||||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when (timelineItem) {
|
||||
is TimelineItem.Virtual -> {
|
||||
TimelineItemVirtualRow(
|
||||
virtual = timelineItem,
|
||||
modifier = modifier,
|
||||
)
|
||||
val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) {
|
||||
val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
|
||||
14.dp
|
||||
} else {
|
||||
2.dp
|
||||
}
|
||||
is TimelineItem.Event -> {
|
||||
if (timelineItem.content is TimelineItemStateContent || timelineItem.content is TimelineItemLegacyCallInviteContent) {
|
||||
TimelineItemStateEventRow(
|
||||
event = timelineItem,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
||||
onClick = { onClick(timelineItem) },
|
||||
onReadReceiptsClick = onReadReceiptClick,
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
Modifier.focusedEvent(focusedEventOffset)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(modifier = modifier.then(backgroundModifier)) {
|
||||
when (timelineItem) {
|
||||
is TimelineItem.Virtual -> {
|
||||
TimelineItemVirtualRow(
|
||||
virtual = timelineItem,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
eventSink = eventSink,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
TimelineItemEventRow(
|
||||
event = timelineItem,
|
||||
}
|
||||
is TimelineItem.Event -> {
|
||||
if (timelineItem.content is TimelineItemStateContent || timelineItem.content is TimelineItemLegacyCallInviteContent) {
|
||||
TimelineItemStateEventRow(
|
||||
event = timelineItem,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
isHighlighted = timelineItem.isEvent(focusedEventId),
|
||||
onClick = { onClick(timelineItem) },
|
||||
onReadReceiptsClick = onReadReceiptClick,
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
eventSink = eventSink,
|
||||
)
|
||||
} else {
|
||||
TimelineItemEventRow(
|
||||
event = timelineItem,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
isHighlighted = timelineItem.isEvent(focusedEventId),
|
||||
onClick = { onClick(timelineItem) },
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClicked = onLinkClicked,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
}
|
||||
is TimelineItem.GroupedEvents -> {
|
||||
TimelineItemGroupedEventsRow(
|
||||
timelineItem = timelineItem,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
||||
onClick = { onClick(timelineItem) },
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
focusedEventId = focusedEventId,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClicked = onLinkClicked,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||
eventSink = eventSink,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
is TimelineItem.GroupedEvents -> {
|
||||
TimelineItemGroupedEventsRow(
|
||||
timelineItem = timelineItem,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
highlightedItem = highlightedItem,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClicked = onLinkClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
eventSink = eventSink,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ModifierComposable")
|
||||
@Composable
|
||||
private fun Modifier.focusedEvent(
|
||||
focusedEventOffset: Dp
|
||||
): Modifier {
|
||||
val highlightedLineColor = ElementTheme.colors.textActionAccent
|
||||
val gradientColors = listOf(
|
||||
ElementTheme.colors.highlightedMessageBackgroundColor,
|
||||
ElementTheme.materialColors.background
|
||||
)
|
||||
val verticalOffset = focusedEventOffset.toPx()
|
||||
val verticalRatio = 0.7f
|
||||
return drawWithCache {
|
||||
val brush = Brush.verticalGradient(
|
||||
colors = gradientColors,
|
||||
endY = size.height * verticalRatio,
|
||||
)
|
||||
onDrawBehind {
|
||||
drawRect(
|
||||
brush,
|
||||
topLeft = Offset(0f, verticalOffset),
|
||||
size = Size(size.width, size.height * verticalRatio)
|
||||
)
|
||||
drawLine(
|
||||
highlightedLineColor,
|
||||
start = Offset(0f, verticalOffset),
|
||||
end = Offset(size.width, verticalOffset)
|
||||
)
|
||||
}
|
||||
}.padding(top = 4.dp)
|
||||
}
|
||||
|
||||
@@ -16,24 +16,51 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemReadMarkerView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLastForwardIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel
|
||||
|
||||
@Composable
|
||||
fun TimelineItemVirtualRow(
|
||||
virtual: TimelineItem.Virtual,
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when (virtual.model) {
|
||||
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
|
||||
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
|
||||
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
|
||||
Box(modifier = modifier) {
|
||||
when (virtual.model) {
|
||||
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model)
|
||||
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
|
||||
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView()
|
||||
TimelineItemRoomBeginningModel -> TimelineItemRoomBeginningView(roomName = timelineRoomInfo.name)
|
||||
is TimelineItemLoadingIndicatorModel -> {
|
||||
TimelineLoadingMoreIndicator(virtual.model.direction)
|
||||
val latestEventSink by rememberUpdatedState(eventSink)
|
||||
LaunchedEffect(virtual.model.timestamp) {
|
||||
latestEventSink(TimelineEvents.LoadMore(virtual.model.direction))
|
||||
}
|
||||
}
|
||||
is TimelineItemLastForwardIndicatorModel -> {
|
||||
Spacer(modifier = Modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.virtual
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -27,24 +29,45 @@ import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
@Composable
|
||||
internal fun TimelineLoadingMoreIndicator(modifier: Modifier = Modifier) {
|
||||
internal fun TimelineLoadingMoreIndicator(
|
||||
direction: Timeline.PaginationDirection,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.padding(8.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
when (direction) {
|
||||
Timeline.PaginationDirection.FORWARDS -> {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp)
|
||||
.height(1.dp)
|
||||
)
|
||||
}
|
||||
Timeline.PaginationDirection.BACKWARDS -> {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineLoadingMoreIndicatorPreview() = ElementPreview {
|
||||
TimelineLoadingMoreIndicator()
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TimelineLoadingMoreIndicator(Timeline.PaginationDirection.BACKWARDS)
|
||||
TimelineLoadingMoreIndicator(Timeline.PaginationDirection.FORWARDS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.factories
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
|
||||
import io.element.android.features.messages.impl.timeline.diff.TimelineItemsCacheInvalidator
|
||||
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
|
||||
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
||||
@@ -43,9 +44,9 @@ class TimelineItemsFactory @Inject constructor(
|
||||
private val eventItemFactory: TimelineItemEventFactory,
|
||||
private val virtualItemFactory: TimelineItemVirtualFactory,
|
||||
private val timelineItemGrouper: TimelineItemGrouper,
|
||||
private val timelineItemIndexer: TimelineItemIndexer,
|
||||
) {
|
||||
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
|
||||
|
||||
private val lock = Mutex()
|
||||
private val diffCache = MutableListDiffCache<TimelineItem>()
|
||||
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, TimelineItem>(
|
||||
@@ -100,6 +101,7 @@ class TimelineItemsFactory @Inject constructor(
|
||||
}
|
||||
}
|
||||
val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList()
|
||||
timelineItemIndexer.process(result)
|
||||
this.timelineItems.emit(result)
|
||||
}
|
||||
|
||||
@@ -108,13 +110,13 @@ class TimelineItemsFactory @Inject constructor(
|
||||
index: Int,
|
||||
roomMembers: List<RoomMember>,
|
||||
): TimelineItem? {
|
||||
val timelineItemState =
|
||||
val timelineItem =
|
||||
when (val currentTimelineItem = timelineItems[index]) {
|
||||
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers)
|
||||
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
|
||||
MatrixTimelineItem.Other -> null
|
||||
}
|
||||
diffCache[index] = timelineItemState
|
||||
return timelineItemState
|
||||
diffCache[index] = timelineItem
|
||||
return timelineItem
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ package io.element.android.features.messages.impl.timeline.factories.virtual
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLastForwardIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
@@ -41,6 +44,12 @@ class TimelineItemVirtualFactory @Inject constructor(
|
||||
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
|
||||
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
|
||||
is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
is VirtualTimelineItem.RoomBeginning -> TimelineItemRoomBeginningModel
|
||||
is VirtualTimelineItem.LoadingIndicator -> TimelineItemLoadingIndicatorModel(
|
||||
direction = inner.direction,
|
||||
timestamp = inner.timestamp
|
||||
)
|
||||
is VirtualTimelineItem.LastForwardIndicator -> TimelineItemLastForwardIndicatorModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.features.messages.impl.timeline.focus
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.timeline.FocusRequestState
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.errors.FocusEventException
|
||||
|
||||
open class FocusRequestStateProvider : PreviewParameterProvider<FocusRequestState> {
|
||||
override val values: Sequence<FocusRequestState>
|
||||
get() = sequenceOf(
|
||||
FocusRequestState.Fetching,
|
||||
FocusRequestState.Failure(
|
||||
FocusEventException.EventNotFound(
|
||||
eventId = EventId("\$anEventId"),
|
||||
)
|
||||
),
|
||||
FocusRequestState.Failure(
|
||||
FocusEventException.InvalidEventId(
|
||||
eventId = "invalid",
|
||||
err = "An error"
|
||||
)
|
||||
),
|
||||
FocusRequestState.Failure(
|
||||
FocusEventException.Other(
|
||||
msg = "An error"
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.features.messages.impl.timeline.focus
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.messages.impl.timeline.FocusRequestState
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.room.errors.FocusEventException
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun FocusRequestStateView(
|
||||
focusRequestState: FocusRequestState,
|
||||
onClearFocusRequestState: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (focusRequestState) {
|
||||
is FocusRequestState.Failure -> {
|
||||
val errorMessage = when (focusRequestState.throwable) {
|
||||
is FocusEventException.EventNotFound,
|
||||
is FocusEventException.InvalidEventId -> stringResource(id = CommonStrings.error_message_not_found)
|
||||
is FocusEventException.Other -> stringResource(id = CommonStrings.error_unknown)
|
||||
else -> stringResource(id = CommonStrings.error_unknown)
|
||||
}
|
||||
ErrorDialog(
|
||||
content = errorMessage,
|
||||
onDismiss = onClearFocusRequestState,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
FocusRequestState.Fetching -> {
|
||||
ProgressDialog(modifier = modifier, onDismissRequest = onClearFocusRequestState)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun FocusRequestStateViewPreview(
|
||||
@PreviewParameter(FocusRequestStateProvider::class) state: FocusRequestState,
|
||||
) = ElementPreview {
|
||||
FocusRequestStateView(
|
||||
focusRequestState = state,
|
||||
onClearFocusRequestState = {},
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
@@ -27,18 +28,30 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||
|
||||
data class InReplyToDetails(
|
||||
val eventId: EventId,
|
||||
val senderId: UserId,
|
||||
val senderProfile: ProfileTimelineDetails,
|
||||
val eventContent: EventContent?,
|
||||
val textContent: String?,
|
||||
)
|
||||
@Immutable
|
||||
sealed interface InReplyToDetails {
|
||||
data class Ready(
|
||||
val eventId: EventId,
|
||||
val senderId: UserId,
|
||||
val senderProfile: ProfileTimelineDetails,
|
||||
val eventContent: EventContent?,
|
||||
val textContent: String?,
|
||||
) : InReplyToDetails
|
||||
|
||||
data class Loading(val eventId: EventId) : InReplyToDetails
|
||||
data class Error(val eventId: EventId, val message: String) : InReplyToDetails
|
||||
}
|
||||
|
||||
fun InReplyToDetails.eventId() = when (this) {
|
||||
is InReplyToDetails.Ready -> eventId
|
||||
is InReplyToDetails.Loading -> eventId
|
||||
is InReplyToDetails.Error -> eventId
|
||||
}
|
||||
|
||||
fun InReplyTo.map(
|
||||
permalinkParser: PermalinkParser,
|
||||
) = when (this) {
|
||||
is InReplyTo.Ready -> InReplyToDetails(
|
||||
is InReplyTo.Ready -> InReplyToDetails.Ready(
|
||||
eventId = eventId,
|
||||
senderId = senderId,
|
||||
senderProfile = senderProfile,
|
||||
@@ -55,5 +68,7 @@ fun InReplyTo.map(
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
else -> null
|
||||
is InReplyTo.Error -> InReplyToDetails.Error(eventId, message)
|
||||
is InReplyTo.NotLoaded -> InReplyToDetails.Loading(eventId)
|
||||
is InReplyTo.Pending -> InReplyToDetails.Loading(eventId)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ internal sealed interface InReplyToMetadata {
|
||||
* Metadata can be either a thumbnail with a text OR just a text.
|
||||
*/
|
||||
@Composable
|
||||
internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventContent) {
|
||||
internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (eventContent) {
|
||||
is MessageContent -> when (val type = eventContent.type) {
|
||||
is ImageMessageType -> InReplyToMetadata.Thumbnail(
|
||||
AttachmentThumbnailInfo(
|
||||
|
||||
@@ -40,6 +40,14 @@ sealed interface TimelineItem {
|
||||
is GroupedEvents -> id
|
||||
}
|
||||
|
||||
fun isEvent(eventId: EventId?): Boolean {
|
||||
if (eventId == null) return false
|
||||
return when (this) {
|
||||
is Event -> this.eventId == eventId
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun contentType(): String = when (this) {
|
||||
is Event -> content.type
|
||||
is Virtual -> model.type
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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.features.messages.impl.timeline.model.virtual
|
||||
|
||||
data object TimelineItemLastForwardIndicatorModel : TimelineItemVirtualModel {
|
||||
override val type: String = "TimelineItemLastForwardIndicatorModel"
|
||||
}
|
||||
@@ -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.features.messages.impl.timeline.model.virtual
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
data class TimelineItemLoadingIndicatorModel(
|
||||
val direction: Timeline.PaginationDirection,
|
||||
val timestamp: Long,
|
||||
) : TimelineItemVirtualModel {
|
||||
override val type: String = "TimelineItemLoadingIndicatorModel"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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.features.messages.impl.timeline.model.virtual
|
||||
|
||||
data object TimelineItemRoomBeginningModel : TimelineItemVirtualModel {
|
||||
override val type: String = "TimelineItemRoomBeginningModel"
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter
|
||||
import io.element.android.features.messages.impl.textcomposer.TestRichTextEditorStateFactory
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.FakeEmojibaseProvider
|
||||
@@ -63,6 +65,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
|
||||
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
@@ -81,6 +84,7 @@ import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
@@ -95,6 +99,9 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -167,7 +174,13 @@ class MessagesPresenterTest {
|
||||
@Test
|
||||
fun `present - handle toggling a reaction`() = runTest {
|
||||
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
||||
val room = FakeMatrixRoom()
|
||||
val toggleReactionSuccess = lambdaRecorder { _: String, _: EventId -> Result.success(Unit) }
|
||||
val toggleReactionFailure = lambdaRecorder { _: String, _: EventId -> Result.failure<Unit>(IllegalStateException("Failed to send reaction")) }
|
||||
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.toggleReactionLambda = toggleReactionSuccess
|
||||
}
|
||||
val room = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -175,29 +188,42 @@ class MessagesPresenterTest {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||
assertThat(room.myReactions.count()).isEqualTo(1)
|
||||
// No crashes when sending a reaction failed
|
||||
room.givenToggleReactionResult(Result.failure(IllegalStateException("Failed to send reaction")))
|
||||
timeline.apply { toggleReactionLambda = toggleReactionFailure }
|
||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||
assertThat(room.myReactions.count()).isEqualTo(1)
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
||||
assert(toggleReactionSuccess)
|
||||
.isCalledOnce()
|
||||
.with(value("👍"), value(AN_EVENT_ID))
|
||||
assert(toggleReactionFailure)
|
||||
.isCalledOnce()
|
||||
.with(value("👍"), value(AN_EVENT_ID))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle toggling a reaction twice`() = runTest {
|
||||
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
||||
val room = FakeMatrixRoom()
|
||||
val toggleReactionSuccess = lambdaRecorder { _: String, _: EventId -> Result.success(Unit) }
|
||||
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.toggleReactionLambda = toggleReactionSuccess
|
||||
}
|
||||
val room = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||
assertThat(room.myReactions.count()).isEqualTo(1)
|
||||
|
||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||
assertThat(room.myReactions.count()).isEqualTo(0)
|
||||
assert(toggleReactionSuccess)
|
||||
.isCalledExactly(2)
|
||||
.withSequence(
|
||||
listOf(value("👍"), value(AN_EVENT_ID)),
|
||||
listOf(value("👍"), value(AN_EVENT_ID)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +298,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(3)
|
||||
skipItems(2)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
@@ -428,7 +454,6 @@ class MessagesPresenterTest {
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
|
||||
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
skipItems(1) // back paginating
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,6 +773,7 @@ class MessagesPresenterTest {
|
||||
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
permalinkBuilder = FakePermalinkBuilder(),
|
||||
timelineController = TimelineController(matrixRoom),
|
||||
)
|
||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
|
||||
this,
|
||||
@@ -768,6 +794,8 @@ class MessagesPresenterTest {
|
||||
endPollAction = endPollAction,
|
||||
sendPollResponseAction = FakeSendPollResponseAction(),
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
timelineItemIndexer = TimelineItemIndexer(),
|
||||
timelineController = TimelineController(matrixRoom),
|
||||
)
|
||||
val timelinePresenterFactory = object : TimelinePresenter.Factory {
|
||||
override fun create(navigator: MessagesNavigator): TimelinePresenter {
|
||||
@@ -804,6 +832,7 @@ class MessagesPresenterTest {
|
||||
buildMeta = aBuildMeta(),
|
||||
dispatchers = coroutineDispatchers,
|
||||
htmlConverterProvider = FakeHtmlConverterProvider(),
|
||||
timelineController = TimelineController(matrixRoom),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package io.element.android.features.messages.impl.fixtures
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory
|
||||
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseMessageFactory
|
||||
@@ -46,7 +47,9 @@ import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorW
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
|
||||
internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
|
||||
internal fun TestScope.aTimelineItemsFactory(
|
||||
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer()
|
||||
): TimelineItemsFactory {
|
||||
val timelineEventFormatter = aTimelineEventFormatter()
|
||||
val matrixClient = FakeMatrixClient()
|
||||
return TimelineItemsFactory(
|
||||
@@ -83,6 +86,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
|
||||
),
|
||||
),
|
||||
timelineItemGrouper = TimelineItemGrouper(),
|
||||
timelineItemIndexer = timelineItemIndexer,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,14 +21,20 @@ import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.lang.IllegalStateException
|
||||
|
||||
class ForwardMessagesPresenterTests {
|
||||
@get:Rule
|
||||
@@ -36,7 +42,7 @@ class ForwardMessagesPresenterTests {
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = aPresenter()
|
||||
val presenter = aForwardMessagesPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -49,7 +55,14 @@ class ForwardMessagesPresenterTests {
|
||||
|
||||
@Test
|
||||
fun `present - forward successful`() = runTest {
|
||||
val presenter = aPresenter()
|
||||
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.forwardEventLambda = forwardEventLambda
|
||||
}
|
||||
val room = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = aForwardMessagesPresenter(fakeMatrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -61,18 +74,23 @@ class ForwardMessagesPresenterTests {
|
||||
val successfulForwardState = awaitItem()
|
||||
assertThat(successfulForwardState.isForwarding).isFalse()
|
||||
assertThat(successfulForwardState.forwardingSucceeded).isNotNull()
|
||||
assert(forwardEventLambda).isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - select a room and forward failed, then clear`() = runTest {
|
||||
val room = FakeMatrixRoom()
|
||||
val presenter = aPresenter(fakeMatrixRoom = room)
|
||||
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
|
||||
Result.failure<Unit>(IllegalStateException("error"))
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.forwardEventLambda = forwardEventLambda
|
||||
}
|
||||
val room = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = aForwardMessagesPresenter(fakeMatrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
// Test failed forwarding
|
||||
room.givenForwardEventResult(Result.failure(Throwable("error")))
|
||||
skipItems(1)
|
||||
val summary = aRoomSummaryDetails()
|
||||
presenter.onRoomSelected(listOf(summary.roomId))
|
||||
@@ -82,16 +100,17 @@ class ForwardMessagesPresenterTests {
|
||||
// Then clear error
|
||||
failedForwardState.eventSink(ForwardMessagesEvents.ClearError)
|
||||
assertThat(awaitItem().error).isNull()
|
||||
assert(forwardEventLambda).isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.aPresenter(
|
||||
private fun CoroutineScope.aForwardMessagesPresenter(
|
||||
eventId: EventId = AN_EVENT_ID,
|
||||
fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
|
||||
coroutineScope: CoroutineScope = this,
|
||||
) = ForwardMessagesPresenter(
|
||||
eventId = eventId.value,
|
||||
room = fakeMatrixRoom,
|
||||
timelineProvider = LiveTimelineProvider(fakeMatrixRoom),
|
||||
matrixCoroutineScope = coroutineScope,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
@@ -65,6 +66,7 @@ import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
@@ -81,6 +83,10 @@ import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.waitForPredicate
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -259,7 +265,13 @@ class MessageComposerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - edit sent message`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List<Mention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.editMessageLambda = editMessageLambda
|
||||
}
|
||||
val fakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = createPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
@@ -283,7 +295,13 @@ class MessageComposerPresenterTest {
|
||||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assert(editMessageLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), any(), value(ANOTHER_MESSAGE), value(ANOTHER_MESSAGE), any())
|
||||
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||
Composer(
|
||||
inThread = false,
|
||||
@@ -297,7 +315,13 @@ class MessageComposerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - edit not sent message`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List<Mention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.editMessageLambda = editMessageLambda
|
||||
}
|
||||
val fakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = createPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
@@ -321,7 +345,13 @@ class MessageComposerPresenterTest {
|
||||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assert(editMessageLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), any(), value(ANOTHER_MESSAGE), value(ANOTHER_MESSAGE), any())
|
||||
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||
Composer(
|
||||
inThread = false,
|
||||
@@ -335,7 +365,13 @@ class MessageComposerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - reply message`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<Mention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.replyMessageLambda = replyMessageLambda
|
||||
}
|
||||
val fakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = createPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
@@ -355,7 +391,13 @@ class MessageComposerPresenterTest {
|
||||
state.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY to A_REPLY)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assert(replyMessageLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), value(A_REPLY), value(A_REPLY), any())
|
||||
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||
Composer(
|
||||
inThread = false,
|
||||
@@ -831,7 +873,17 @@ class MessageComposerPresenterTest {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - send messages with intentional mentions`() = runTest {
|
||||
val room = FakeMatrixRoom()
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<Mention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List<Mention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.replyMessageLambda = replyMessageLambda
|
||||
this.editMessageLambda = editMessageLambda
|
||||
}
|
||||
val room = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = createPresenter(room = room, coroutineScope = this)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -866,7 +918,9 @@ class MessageComposerPresenterTest {
|
||||
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_2)))
|
||||
assert(replyMessageLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), any(), any(), value(listOf(Mention.User(A_USER_ID_2))))
|
||||
|
||||
// Check intentional mentions on edit message
|
||||
skipItems(1)
|
||||
@@ -882,7 +936,9 @@ class MessageComposerPresenterTest {
|
||||
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_3)))
|
||||
assert(editMessageLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), any(), any(), any(), value(listOf(Mention.User(A_USER_ID_3))))
|
||||
|
||||
skipItems(1)
|
||||
}
|
||||
@@ -968,6 +1024,7 @@ class MessageComposerPresenterTest {
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
permalinkBuilder = permalinkBuilder,
|
||||
timelineController = TimelineController(room),
|
||||
)
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* 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.features.messages.impl.timeline
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.room.Mention
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class TimelineControllerTest {
|
||||
@Test
|
||||
fun `test switching between live and detached timeline`() = runTest {
|
||||
val liveTimeline = FakeTimeline(name = "live")
|
||||
val detachedTimeline = FakeTimeline(name = "detached")
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline
|
||||
)
|
||||
matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
|
||||
val sut = TimelineController(matrixRoom)
|
||||
|
||||
sut.activeTimelineFlow().test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
assertThat(sut.isLive().first()).isTrue()
|
||||
sut.focusOnEvent(AN_EVENT_ID)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(detachedTimeline)
|
||||
}
|
||||
assertThat(sut.isLive().first()).isFalse()
|
||||
assertThat(detachedTimeline.closeCounter).isEqualTo(0)
|
||||
sut.focusOnLive()
|
||||
assertThat(sut.isLive().first()).isTrue()
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
assertThat(detachedTimeline.closeCounter).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test switching between detached 1 and detached 2 should close detached 1`() = runTest {
|
||||
val liveTimeline = FakeTimeline(name = "live")
|
||||
val detachedTimeline1 = FakeTimeline(name = "detached 1")
|
||||
val detachedTimeline2 = FakeTimeline(name = "detached 2")
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline
|
||||
)
|
||||
val sut = TimelineController(matrixRoom)
|
||||
|
||||
sut.activeTimelineFlow().test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline1))
|
||||
sut.focusOnEvent(AN_EVENT_ID)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(detachedTimeline1)
|
||||
}
|
||||
assertThat(detachedTimeline1.closeCounter).isEqualTo(0)
|
||||
assertThat(detachedTimeline2.closeCounter).isEqualTo(0)
|
||||
// Focus on another event should close the previous detached timeline
|
||||
matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline2))
|
||||
sut.focusOnEvent(AN_EVENT_ID)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(detachedTimeline2)
|
||||
}
|
||||
assertThat(detachedTimeline1.closeCounter).isEqualTo(1)
|
||||
assertThat(detachedTimeline2.closeCounter).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test switching to live when already in live should have no effect`() = runTest {
|
||||
val liveTimeline = FakeTimeline(name = "live")
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline
|
||||
)
|
||||
val sut = TimelineController(matrixRoom)
|
||||
sut.activeTimelineFlow().test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
assertThat(sut.isLive().first()).isTrue()
|
||||
sut.focusOnLive()
|
||||
assertThat(sut.isLive().first()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test closing the TimelineController should close the detached timeline`() = runTest {
|
||||
val liveTimeline = FakeTimeline(name = "live")
|
||||
val detachedTimeline = FakeTimeline(name = "detached")
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline
|
||||
)
|
||||
matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
|
||||
val sut = TimelineController(matrixRoom)
|
||||
|
||||
sut.activeTimelineFlow().test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
sut.focusOnEvent(AN_EVENT_ID)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(detachedTimeline)
|
||||
}
|
||||
assertThat(detachedTimeline.closeCounter).isEqualTo(0)
|
||||
sut.close()
|
||||
assertThat(detachedTimeline.closeCounter).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test getting timeline item`() = runTest {
|
||||
val liveTimeline = FakeTimeline(
|
||||
name = "live",
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem())
|
||||
)
|
||||
)
|
||||
)
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline
|
||||
)
|
||||
val sut = TimelineController(matrixRoom)
|
||||
assertThat(sut.timelineItems().first()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest {
|
||||
val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List<Mention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List<Mention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val liveTimeline = FakeTimeline(name = "live").apply {
|
||||
sendMessageLambda = lambdaForLive
|
||||
}
|
||||
val detachedTimeline = FakeTimeline(name = "detached").apply {
|
||||
sendMessageLambda = lambdaForDetached
|
||||
}
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline
|
||||
)
|
||||
matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
|
||||
val sut = TimelineController(matrixRoom)
|
||||
sut.focusOnEvent(AN_EVENT_ID)
|
||||
sut.activeTimelineFlow().test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(detachedTimeline)
|
||||
}
|
||||
sut.invokeOnCurrentTimeline {
|
||||
sendMessage("body", "htmlBody", emptyList())
|
||||
}
|
||||
lambdaForDetached.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test last forward pagination on a detached timeline should switch to live timeline`() = runTest {
|
||||
val liveTimeline = FakeTimeline(name = "live")
|
||||
val detachedTimeline = FakeTimeline(name = "detached")
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline
|
||||
)
|
||||
matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
|
||||
val sut = TimelineController(matrixRoom)
|
||||
|
||||
sut.activeTimelineFlow().test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
sut.focusOnEvent(AN_EVENT_ID)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(detachedTimeline)
|
||||
}
|
||||
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
|
||||
Result.success(true)
|
||||
}
|
||||
detachedTimeline.apply {
|
||||
this.paginateLambda = paginateLambda
|
||||
}
|
||||
sut.paginate(Timeline.PaginationDirection.FORWARDS)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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.features.messages.impl.timeline
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import org.junit.Test
|
||||
|
||||
class TimelineItemIndexerTest {
|
||||
@Test
|
||||
fun `test TimelineItemIndexer`() {
|
||||
val eventIds = mutableListOf<EventId>()
|
||||
val data = listOf(
|
||||
aTimelineItemEvent().also { eventIds.add(it.eventId!!) },
|
||||
aTimelineItemEvent().also { eventIds.add(it.eventId!!) },
|
||||
aGroupedEvents().also { groupedEvents ->
|
||||
groupedEvents.events.forEach { eventIds.add(it.eventId!!) }
|
||||
},
|
||||
TimelineItem.Virtual(
|
||||
id = "dummy",
|
||||
model = TimelineItemReadMarkerModel
|
||||
),
|
||||
)
|
||||
assertThat(eventIds.size).isEqualTo(4)
|
||||
val sut = TimelineItemIndexer()
|
||||
sut.process(data)
|
||||
eventIds.forEach {
|
||||
assertThat(sut.isKnown(it)).isTrue()
|
||||
}
|
||||
assertThat(sut.indexOf(eventIds[0])).isEqualTo(0)
|
||||
assertThat(sut.indexOf(eventIds[1])).isEqualTo(1)
|
||||
assertThat(sut.indexOf(eventIds[2])).isEqualTo(2)
|
||||
assertThat(sut.indexOf(eventIds[3])).isEqualTo(2)
|
||||
|
||||
// Unknown event
|
||||
assertThat(sut.isKnown(AN_EVENT_ID)).isFalse()
|
||||
assertThat(sut.indexOf(AN_EVENT_ID)).isEqualTo(-1)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import app.cash.turbine.ReceiveTurbine
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.FakeMessagesNavigator
|
||||
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
||||
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
@@ -33,12 +34,12 @@ import io.element.android.features.poll.api.actions.EndPollAction
|
||||
import io.element.android.features.poll.api.actions.SendPollResponseAction
|
||||
import io.element.android.features.poll.test.actions.FakeEndPollAction
|
||||
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
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.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||
@@ -48,20 +49,27 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
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.timeline.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import io.element.android.tests.testutils.awaitWithLatch
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
@@ -72,7 +80,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||
private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID"
|
||||
private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
|
||||
|
||||
class TimelinePresenterTest {
|
||||
@OptIn(ExperimentalCoroutinesApi::class) class TimelinePresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@@ -84,58 +92,49 @@ class TimelinePresenterTest {
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.timelineItems).isEmpty()
|
||||
val loadedNoTimelineState = awaitItem()
|
||||
assertThat(loadedNoTimelineState.timelineItems).isEmpty()
|
||||
assertThat(initialState.isLive).isTrue()
|
||||
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
|
||||
assertThat(initialState.focusedEventId).isNull()
|
||||
assertThat(initialState.focusRequestState).isEqualTo(FocusRequestState.None)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - load more`() = runTest {
|
||||
val presenter = createTimelinePresenter()
|
||||
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
|
||||
Result.success(false)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.paginateLambda = paginateLambda
|
||||
}
|
||||
val presenter = createTimelinePresenter(timeline = timeline)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.paginationState.hasMoreToLoadBackwards).isTrue()
|
||||
assertThat(initialState.paginationState.isBackPaginating).isFalse()
|
||||
initialState.eventSink.invoke(TimelineEvents.LoadMore)
|
||||
val inPaginationState = awaitItem()
|
||||
assertThat(inPaginationState.paginationState.isBackPaginating).isTrue()
|
||||
assertThat(inPaginationState.paginationState.hasMoreToLoadBackwards).isTrue()
|
||||
val postPaginationState = awaitItem()
|
||||
assertThat(postPaginationState.paginationState.hasMoreToLoadBackwards).isTrue()
|
||||
assertThat(postPaginationState.paginationState.isBackPaginating).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - set highlighted event`() = runTest {
|
||||
val presenter = createTimelinePresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
skipItems(1)
|
||||
assertThat(initialState.highlightedEventId).isNull()
|
||||
initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(AN_EVENT_ID))
|
||||
val withHighlightedState = awaitItem()
|
||||
assertThat(withHighlightedState.highlightedEventId).isEqualTo(AN_EVENT_ID)
|
||||
initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(null))
|
||||
val withoutHighlightedState = awaitItem()
|
||||
assertThat(withoutHighlightedState.highlightedEventId).isNull()
|
||||
initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
|
||||
initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.FORWARDS))
|
||||
assert(paginateLambda)
|
||||
.isCalledExactly(2)
|
||||
.withSequence(
|
||||
listOf(value(Timeline.PaginationDirection.BACKWARDS)),
|
||||
listOf(value(Timeline.PaginationDirection.FORWARDS))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - on scroll finished mark a room as read if the first visible index is 0`() = runTest(StandardTestDispatcher()) {
|
||||
val timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
|
||||
)
|
||||
)
|
||||
)
|
||||
val room = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
|
||||
val room = FakeMatrixRoom(matrixTimeline = timeline)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
@@ -144,7 +143,6 @@ class TimelinePresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||
runCurrent()
|
||||
@@ -155,48 +153,62 @@ class TimelinePresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
|
||||
val timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID_2,
|
||||
event = anEventTimelineItem(
|
||||
eventId = AN_EVENT_ID_2,
|
||||
content = aMessageContent("Test message")
|
||||
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID_2,
|
||||
event = anEventTimelineItem(
|
||||
eventId = AN_EVENT_ID_2,
|
||||
content = aMessageContent("Test message")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).apply {
|
||||
this.sendReadReceiptLambda = sendReadReceiptsLambda
|
||||
}
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
||||
val initialState = awaitFirstItem()
|
||||
awaitWithLatch { latch ->
|
||||
timeline.sendReadReceiptLatch = latch
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
skipItems(1)
|
||||
awaitItem().run {
|
||||
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
}
|
||||
assertThat(timeline.sentReadReceipts).isNotEmpty()
|
||||
assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ)
|
||||
advanceUntilIdle()
|
||||
assert(sendReadReceiptsLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), value(ReceiptType.READ))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - on scroll finished send a private read receipt if an event is at an index other than 0 and public read receipts are disabled`() = runTest {
|
||||
val timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID_2,
|
||||
event = anEventTimelineItem(
|
||||
eventId = AN_EVENT_ID_2,
|
||||
content = aMessageContent("Test message")
|
||||
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID_2,
|
||||
event = anEventTimelineItem(
|
||||
eventId = AN_EVENT_ID_2,
|
||||
content = aMessageContent("Test message")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).apply {
|
||||
this.sendReadReceiptLambda = sendReadReceiptsLambda
|
||||
}
|
||||
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
@@ -205,75 +217,86 @@ class TimelinePresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
||||
val initialState = awaitFirstItem()
|
||||
awaitWithLatch { latch ->
|
||||
timeline.sendReadReceiptLatch = latch
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
skipItems(1)
|
||||
awaitItem().run {
|
||||
eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
}
|
||||
assertThat(timeline.sentReadReceipts).isNotEmpty()
|
||||
assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ_PRIVATE)
|
||||
advanceUntilIdle()
|
||||
assert(sendReadReceiptsLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), value(ReceiptType.READ_PRIVATE))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - on scroll finished will not send read receipt the first visible event is the same as before`() = runTest {
|
||||
val timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID_2,
|
||||
event = anEventTimelineItem(
|
||||
eventId = AN_EVENT_ID_2,
|
||||
content = aMessageContent("Test message")
|
||||
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID_2,
|
||||
event = anEventTimelineItem(
|
||||
eventId = AN_EVENT_ID_2,
|
||||
content = aMessageContent("Test message")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).apply {
|
||||
this.sendReadReceiptLambda = sendReadReceiptsLambda
|
||||
}
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
||||
val initialState = awaitFirstItem()
|
||||
awaitWithLatch { latch ->
|
||||
timeline.sendReadReceiptLatch = latch
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
skipItems(1)
|
||||
awaitItem().run {
|
||||
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
}
|
||||
assertThat(timeline.sentReadReceipts).hasSize(1)
|
||||
advanceUntilIdle()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
assert(sendReadReceiptsLambda).isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest {
|
||||
val timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = listOf(
|
||||
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker),
|
||||
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker)
|
||||
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker),
|
||||
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker)
|
||||
)
|
||||
)
|
||||
)
|
||||
).apply {
|
||||
this.sendReadReceiptLambda = sendReadReceiptsLambda
|
||||
}
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
||||
skipItems(1)
|
||||
val initialState = awaitFirstItem()
|
||||
awaitWithLatch { latch ->
|
||||
timeline.sendReadReceiptLatch = latch
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
}
|
||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
assert(sendReadReceiptsLambda).isNeverCalled()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - covers newEventState scenarios`() = runTest {
|
||||
val timeline = FakeMatrixTimeline()
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -281,12 +304,12 @@ class TimelinePresenterTest {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
|
||||
assertThat(initialState.timelineItems.size).isEqualTo(0)
|
||||
timeline.updateTimelineItems {
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event("0", anEventTimelineItem(content = aMessageContent())))
|
||||
}
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||
// Mimics sending a message, and assert newEventState is FromMe
|
||||
timeline.updateTimelineItems { items ->
|
||||
timelineItems.getAndUpdate { items ->
|
||||
val event = anEventTimelineItem(content = aMessageContent(), isOwn = true)
|
||||
items + listOf(MatrixTimelineItem.Event("1", event))
|
||||
}
|
||||
@@ -295,7 +318,7 @@ class TimelinePresenterTest {
|
||||
assertThat(state.newEventState).isEqualTo(NewEventState.FromMe)
|
||||
}
|
||||
// Mimics receiving a message without clearing the previous FromMe
|
||||
timeline.updateTimelineItems { items ->
|
||||
timelineItems.getAndUpdate { items ->
|
||||
val event = anEventTimelineItem(content = aMessageContent())
|
||||
items + listOf(MatrixTimelineItem.Event("2", event))
|
||||
}
|
||||
@@ -307,7 +330,7 @@ class TimelinePresenterTest {
|
||||
assertThat(state.newEventState).isEqualTo(NewEventState.None)
|
||||
}
|
||||
// Mimics receiving a message and assert newEventState is FromOther
|
||||
timeline.updateTimelineItems { items ->
|
||||
timelineItems.getAndUpdate { items ->
|
||||
val event = anEventTimelineItem(content = aMessageContent())
|
||||
items + listOf(MatrixTimelineItem.Event("3", event))
|
||||
}
|
||||
@@ -321,7 +344,10 @@ class TimelinePresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - reaction ordering`() = runTest {
|
||||
val timeline = FakeMatrixTimeline()
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = timelineItems,
|
||||
)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -349,10 +375,9 @@ class TimelinePresenterTest {
|
||||
senders = persistentListOf(charlie)
|
||||
),
|
||||
)
|
||||
timeline.updateTimelineItems {
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem(reactions = oneReaction)))
|
||||
}
|
||||
skipItems(1)
|
||||
)
|
||||
val item = awaitItem().timelineItems.first()
|
||||
assertThat(item).isInstanceOf(TimelineItem.Event::class.java)
|
||||
val event = item as TimelineItem.Event
|
||||
@@ -424,8 +449,10 @@ class TimelinePresenterTest {
|
||||
fun `present - side effect on redacted items is invoked`() = runTest {
|
||||
val redactedVoiceMessageManager = FakeRedactedVoiceMessageManager()
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = aRedactedMatrixTimeline(AN_EVENT_ID),
|
||||
timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
aRedactedMatrixTimeline(AN_EVENT_ID),
|
||||
)
|
||||
),
|
||||
redactedVoiceMessageManager = redactedVoiceMessageManager,
|
||||
)
|
||||
@@ -433,32 +460,141 @@ class TimelinePresenterTest {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(0)
|
||||
awaitFirstItem().let {
|
||||
assertThat(it.timelineItems).isNotEmpty()
|
||||
}
|
||||
skipItems(2)
|
||||
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - focus on event and jump to live make the presenter update the state with the correct Events`() = runTest {
|
||||
val detachedTimeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID,
|
||||
event = anEventTimelineItem(),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val liveTimeline = FakeTimeline(
|
||||
timelineItems = flowOf(emptyList())
|
||||
)
|
||||
val room = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline,
|
||||
).apply {
|
||||
givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
|
||||
}
|
||||
val presenter = createTimelinePresenter(
|
||||
room = room,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
|
||||
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Fetching)
|
||||
}
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Fetched)
|
||||
assertThat(state.timelineItems).isNotEmpty()
|
||||
}
|
||||
initialState.eventSink.invoke(TimelineEvents.JumpToLive)
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
// Event stays focused
|
||||
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
|
||||
assertThat(state.timelineItems).isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - focus on known event retrieves the event from cache`() = runTest {
|
||||
val timelineItemIndexer = TimelineItemIndexer().apply {
|
||||
process(listOf(aMessageEvent(eventId = AN_EVENT_ID)))
|
||||
}
|
||||
val presenter = createTimelinePresenter(
|
||||
room = FakeMatrixRoom(
|
||||
liveTimeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID,
|
||||
event = anEventTimelineItem(eventId = AN_EVENT_ID),
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
timelineItemIndexer = timelineItemIndexer,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
|
||||
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Cached(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - focus on event error case`() = runTest {
|
||||
val presenter = createTimelinePresenter(
|
||||
room = FakeMatrixRoom(
|
||||
liveTimeline = FakeTimeline(
|
||||
timelineItems = flowOf(emptyList()),
|
||||
),
|
||||
).apply {
|
||||
givenTimelineFocusedOnEventResult(Result.failure(Throwable("An error")))
|
||||
},
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
|
||||
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Fetching)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.focusRequestState).isInstanceOf(FocusRequestState.Failure::class.java)
|
||||
state.eventSink(TimelineEvents.ClearFocusRequestState)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when room member info is loaded, read receipts info should be updated`() = runTest {
|
||||
val timeline = FakeMatrixTimeline(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(
|
||||
FAKE_UNIQUE_ID,
|
||||
anEventTimelineItem(
|
||||
sender = A_USER_ID,
|
||||
receipts = persistentListOf(
|
||||
Receipt(
|
||||
userId = A_USER_ID,
|
||||
timestamp = 0L,
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(
|
||||
FAKE_UNIQUE_ID,
|
||||
anEventTimelineItem(
|
||||
sender = A_USER_ID,
|
||||
receipts = persistentListOf(
|
||||
Receipt(
|
||||
userId = A_USER_ID,
|
||||
timestamp = 0L,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val room = FakeMatrixRoom(matrixTimeline = timeline).apply {
|
||||
val room = FakeMatrixRoom(liveTimeline = timeline).apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Unknown)
|
||||
}
|
||||
|
||||
@@ -485,22 +621,19 @@ class TimelinePresenterTest {
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
// Skip 1 item if Mentions feature is enabled
|
||||
if (FeatureFlags.Mentions.defaultValue) {
|
||||
skipItems(1)
|
||||
}
|
||||
return awaitItem()
|
||||
}
|
||||
|
||||
private fun TestScope.createTimelinePresenter(
|
||||
timeline: MatrixTimeline = FakeMatrixTimeline(),
|
||||
room: FakeMatrixRoom = FakeMatrixRoom(matrixTimeline = timeline),
|
||||
timeline: Timeline = FakeTimeline(),
|
||||
room: FakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline),
|
||||
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory(),
|
||||
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
|
||||
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
|
||||
endPollAction: EndPollAction = FakeEndPollAction(),
|
||||
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
|
||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
||||
): TimelinePresenter {
|
||||
return TimelinePresenter(
|
||||
timelineItemsFactory = timelineItemsFactory,
|
||||
@@ -512,6 +645,8 @@ class TimelinePresenterTest {
|
||||
endPollAction = endPollAction,
|
||||
sendPollResponseAction = sendPollResponseAction,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
timelineItemIndexer = timelineItemIndexer,
|
||||
timelineController = TimelineController(room),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,14 +17,25 @@
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -34,55 +45,89 @@ class TimelineViewTest {
|
||||
@Test
|
||||
fun `reaching the end of the timeline with more events to load emits a LoadMore event`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvents>()
|
||||
rule.setContent {
|
||||
TimelineView(
|
||||
aTimelineState(
|
||||
eventSink = eventsRecorder,
|
||||
paginationState = aPaginationState(
|
||||
hasMoreToLoadBackwards = true,
|
||||
)
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf<TimelineItem>(
|
||||
TimelineItem.Virtual(
|
||||
id = "backward_pagination",
|
||||
model = TimelineItemLoadingIndicatorModel(Timeline.PaginationDirection.BACKWARDS, 0)
|
||||
),
|
||||
),
|
||||
typingNotificationState = aTypingNotificationState(),
|
||||
roomName = null,
|
||||
onUserDataClicked = EnsureNeverCalledWithParam(),
|
||||
onLinkClicked = EnsureNeverCalledWithParam(),
|
||||
onMessageClicked = EnsureNeverCalledWithParam(),
|
||||
onMessageLongClicked = EnsureNeverCalledWithParam(),
|
||||
onTimestampClicked = EnsureNeverCalledWithParam(),
|
||||
onSwipeToReply = EnsureNeverCalledWithParam(),
|
||||
onReactionClicked = EnsureNeverCalledWithTwoParams(),
|
||||
onReactionLongClicked = EnsureNeverCalledWithTwoParams(),
|
||||
onMoreReactionsClicked = EnsureNeverCalledWithParam(),
|
||||
onReadReceiptClick = EnsureNeverCalledWithParam(),
|
||||
)
|
||||
}
|
||||
eventsRecorder.assertSingle(TimelineEvents.LoadMore)
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
eventsRecorder.assertSingle(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reaching the end of the timeline does not send a LoadMore event`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvents>(expectEvents = false)
|
||||
rule.setContent {
|
||||
TimelineView(
|
||||
aTimelineState(
|
||||
eventSink = eventsRecorder,
|
||||
paginationState = aPaginationState(
|
||||
hasMoreToLoadBackwards = false,
|
||||
)
|
||||
),
|
||||
typingNotificationState = aTypingNotificationState(),
|
||||
roomName = null,
|
||||
onUserDataClicked = EnsureNeverCalledWithParam(),
|
||||
onLinkClicked = EnsureNeverCalledWithParam(),
|
||||
onMessageClicked = EnsureNeverCalledWithParam(),
|
||||
onMessageLongClicked = EnsureNeverCalledWithParam(),
|
||||
onTimestampClicked = EnsureNeverCalledWithParam(),
|
||||
onSwipeToReply = EnsureNeverCalledWithParam(),
|
||||
onReactionClicked = EnsureNeverCalledWithTwoParams(),
|
||||
onReactionLongClicked = EnsureNeverCalledWithTwoParams(),
|
||||
onMoreReactionsClicked = EnsureNeverCalledWithParam(),
|
||||
onReadReceiptClick = EnsureNeverCalledWithParam(),
|
||||
)
|
||||
}
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scroll to bottom on live timeline does not emit the Event`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvents>(expectEvents = false)
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
isLive = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
forceJumpToBottomVisibility = true,
|
||||
)
|
||||
val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom)
|
||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scroll to bottom on detached timeline emits the expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvents>()
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
isLive = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom)
|
||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvents.JumpToLive)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView(
|
||||
state: TimelineState,
|
||||
typingNotificationState: TypingNotificationState = aTypingNotificationState(),
|
||||
onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkClicked: (String) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onMessageClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onMessageLongClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
onMoreReactionsClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onReadReceiptClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
) {
|
||||
setContent {
|
||||
TimelineView(
|
||||
state = state,
|
||||
typingNotificationState = typingNotificationState,
|
||||
onUserDataClicked = onUserDataClicked,
|
||||
onLinkClicked = onLinkClicked,
|
||||
onMessageClicked = onMessageClicked,
|
||||
onMessageLongClicked = onMessageLongClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
onReactionClicked = onReactionClicked,
|
||||
onReactionLongClicked = onReactionLongClicked,
|
||||
onMoreReactionsClicked = onMoreReactionsClicked,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,22 +32,22 @@ import org.junit.Test
|
||||
|
||||
class InReplyToDetailTest {
|
||||
@Test
|
||||
fun `map - with a not ready InReplyTo does not work`() {
|
||||
fun `map - with a not ready InReplyTo return expected object`() {
|
||||
assertThat(
|
||||
InReplyTo.Pending.map(
|
||||
InReplyTo.Pending(AN_EVENT_ID).map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)
|
||||
).isNull()
|
||||
).isEqualTo(InReplyToDetails.Loading(AN_EVENT_ID))
|
||||
assertThat(
|
||||
InReplyTo.NotLoaded(AN_EVENT_ID).map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)
|
||||
).isNull()
|
||||
).isEqualTo(InReplyToDetails.Loading(AN_EVENT_ID))
|
||||
assertThat(
|
||||
InReplyTo.Error.map(
|
||||
InReplyTo.Error(AN_EVENT_ID, "a message").map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)
|
||||
).isNull()
|
||||
).isEqualTo(InReplyToDetails.Error(AN_EVENT_ID, "a message"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -65,7 +65,7 @@ class InReplyToDetailTest {
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)
|
||||
assertThat(inReplyToDetails).isNotNull()
|
||||
assertThat(inReplyToDetails?.textContent).isNull()
|
||||
assertThat((inReplyToDetails as InReplyToDetails.Ready).textContent).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -89,9 +89,7 @@ class InReplyToDetailTest {
|
||||
)
|
||||
)
|
||||
assertThat(
|
||||
inReplyTo.map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)?.textContent
|
||||
(inReplyTo.map(permalinkParser = FakePermalinkParser()) as InReplyToDetails.Ready).textContent
|
||||
).isEqualTo("Hello!")
|
||||
}
|
||||
|
||||
@@ -113,9 +111,7 @@ class InReplyToDetailTest {
|
||||
)
|
||||
)
|
||||
assertThat(
|
||||
inReplyTo.map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)?.textContent
|
||||
(inReplyTo.map(permalinkParser = FakePermalinkParser()) as InReplyToDetails.Ready).textContent
|
||||
).isEqualTo("**Hello!**")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `any message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(eventContent = aMessageContent()).metadata()
|
||||
anInReplyToDetailsReady(eventContent = aMessageContent()).metadata()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
assertThat(it).isEqualTo(InReplyToMetadata.Text("textContent"))
|
||||
@@ -81,7 +81,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `an image message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = aMessageContent(
|
||||
messageType = ImageMessageType(
|
||||
body = "body",
|
||||
@@ -111,7 +111,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `a sticker message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = StickerContent(
|
||||
body = "body",
|
||||
info = anImageInfo(),
|
||||
@@ -137,7 +137,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `a video message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = aMessageContent(
|
||||
messageType = VideoMessageType(
|
||||
body = "body",
|
||||
@@ -167,7 +167,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `a file message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = aMessageContent(
|
||||
messageType = FileMessageType(
|
||||
body = "body",
|
||||
@@ -200,7 +200,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `a audio message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = aMessageContent(
|
||||
messageType = AudioMessageType(
|
||||
body = "body",
|
||||
@@ -232,7 +232,7 @@ class InReplyToMetadataKtTest {
|
||||
fun `a location message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
testEnv {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = aMessageContent(
|
||||
messageType = LocationMessageType(
|
||||
body = "body",
|
||||
@@ -262,7 +262,7 @@ class InReplyToMetadataKtTest {
|
||||
fun `a voice message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
testEnv {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = aMessageContent(
|
||||
messageType = VoiceMessageType(
|
||||
body = "body",
|
||||
@@ -292,7 +292,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `a poll content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = aPollContent()
|
||||
).metadata()
|
||||
}.test {
|
||||
@@ -314,7 +314,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `redacted content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = RedactedContent
|
||||
).metadata()
|
||||
}.test {
|
||||
@@ -327,7 +327,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `unable to decrypt content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)
|
||||
).metadata()
|
||||
}.test {
|
||||
@@ -340,7 +340,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `failed to parse message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = FailedToParseMessageLikeContent("", "")
|
||||
).metadata()
|
||||
}.test {
|
||||
@@ -353,7 +353,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `failed to parse state content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = FailedToParseStateContent("", "", "")
|
||||
).metadata()
|
||||
}.test {
|
||||
@@ -366,7 +366,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `profile change content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = ProfileChangeContent("", "", "", "")
|
||||
).metadata()
|
||||
}.test {
|
||||
@@ -379,7 +379,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `room membership content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = RoomMembershipContent(A_USER_ID, null)
|
||||
).metadata()
|
||||
}.test {
|
||||
@@ -392,7 +392,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `state content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = StateContent("", OtherState.RoomJoinRules)
|
||||
).metadata()
|
||||
}.test {
|
||||
@@ -405,7 +405,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `unknown content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = UnknownContent
|
||||
).metadata()
|
||||
}.test {
|
||||
@@ -418,7 +418,7 @@ class InReplyToMetadataKtTest {
|
||||
@Test
|
||||
fun `null content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = null
|
||||
).metadata()
|
||||
}.test {
|
||||
@@ -429,13 +429,13 @@ class InReplyToMetadataKtTest {
|
||||
}
|
||||
}
|
||||
|
||||
fun anInReplyToDetails(
|
||||
private fun anInReplyToDetailsReady(
|
||||
eventId: EventId = AN_EVENT_ID,
|
||||
senderId: UserId = A_USER_ID,
|
||||
senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
|
||||
eventContent: EventContent? = aMessageContent(),
|
||||
textContent: String? = "textContent",
|
||||
) = InReplyToDetails(
|
||||
) = InReplyToDetails.Ready(
|
||||
eventId = eventId,
|
||||
senderId = senderId,
|
||||
senderProfile = senderProfile,
|
||||
|
||||
@@ -20,15 +20,19 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
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.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
|
||||
class PollRepository @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val timelineProvider: TimelineProvider,
|
||||
) {
|
||||
suspend fun getPoll(eventId: EventId): Result<PollContent> = runCatching {
|
||||
room.timeline
|
||||
timelineProvider
|
||||
.getActiveTimeline()
|
||||
.timelineItems
|
||||
.first()
|
||||
.asSequence()
|
||||
@@ -51,13 +55,15 @@ class PollRepository @Inject constructor(
|
||||
maxSelections = maxSelections,
|
||||
pollKind = pollKind,
|
||||
)
|
||||
else -> room.editPoll(
|
||||
pollStartId = existingPollId,
|
||||
question = question,
|
||||
answers = answers,
|
||||
maxSelections = maxSelections,
|
||||
pollKind = pollKind,
|
||||
)
|
||||
else -> timelineProvider
|
||||
.getActiveTimeline()
|
||||
.editPoll(
|
||||
pollStartId = existingPollId,
|
||||
question = question,
|
||||
answers = answers,
|
||||
maxSelections = maxSelections,
|
||||
pollKind = pollKind,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun deletePoll(
|
||||
|
||||
@@ -32,25 +32,24 @@ import io.element.android.features.poll.impl.history.model.PollHistoryFilter
|
||||
import io.element.android.features.poll.impl.history.model.PollHistoryItems
|
||||
import io.element.android.features.poll.impl.history.model.PollHistoryItemsFactory
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
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.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class PollHistoryPresenter @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val sendPollResponseAction: SendPollResponseAction,
|
||||
private val endPollAction: EndPollAction,
|
||||
private val pollHistoryItemFactory: PollHistoryItemsFactory,
|
||||
private val timelineProvider: TimelineProvider,
|
||||
) : Presenter<PollHistoryState> {
|
||||
@Composable
|
||||
override fun present(): PollHistoryState {
|
||||
// TODO use room.rememberPollHistory() when working properly?
|
||||
val timeline = room.timeline
|
||||
val paginationState by timeline.paginationState.collectAsState()
|
||||
val timeline by timelineProvider.activeTimelineFlow().collectAsState()
|
||||
val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
|
||||
val pollHistoryItemsFlow = remember {
|
||||
timeline.timelineItems.map { items ->
|
||||
pollHistoryItemFactory.create(items)
|
||||
@@ -61,11 +60,11 @@ class PollHistoryPresenter @Inject constructor(
|
||||
}
|
||||
val pollHistoryItems by pollHistoryItemsFlow.collectAsState(initial = PollHistoryItems())
|
||||
LaunchedEffect(paginationState, pollHistoryItems.size) {
|
||||
if (pollHistoryItems.size == 0 && paginationState.canBackPaginate) loadMore(timeline)
|
||||
if (pollHistoryItems.size == 0 && paginationState.canPaginate) loadMore(timeline)
|
||||
}
|
||||
val isLoading by remember {
|
||||
derivedStateOf {
|
||||
pollHistoryItems.size == 0 || paginationState.isBackPaginating
|
||||
pollHistoryItems.size == 0 || paginationState.isPaginating
|
||||
}
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -88,14 +87,14 @@ class PollHistoryPresenter @Inject constructor(
|
||||
|
||||
return PollHistoryState(
|
||||
isLoading = isLoading,
|
||||
hasMoreToLoad = paginationState.hasMoreToLoadBackwards,
|
||||
hasMoreToLoad = paginationState.hasMoreToLoad,
|
||||
pollHistoryItems = pollHistoryItems,
|
||||
activeFilter = activeFilter,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.loadMore(pollHistory: MatrixTimeline) = launch {
|
||||
pollHistory.paginateBackwards(200)
|
||||
private fun CoroutineScope.loadMore(pollHistory: Timeline) = launch {
|
||||
pollHistory.paginate(Timeline.PaginationDirection.BACKWARDS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,16 +20,17 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.poll.PollAnswer
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.aPollContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
fun aPollTimeline(
|
||||
fun aPollTimelineItems(
|
||||
polls: Map<EventId, PollContent> = emptyMap(),
|
||||
): FakeMatrixTimeline {
|
||||
return FakeMatrixTimeline(
|
||||
initialTimelineItems = polls.map { entry ->
|
||||
): Flow<List<MatrixTimelineItem>> {
|
||||
return flowOf(
|
||||
polls.map { entry ->
|
||||
MatrixTimelineItem.Event(
|
||||
entry.key.value,
|
||||
anEventTimelineItem(
|
||||
|
||||
@@ -25,33 +25,42 @@ import im.vector.app.features.analytics.plan.Composer
|
||||
import im.vector.app.features.analytics.plan.PollCreation
|
||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||
import io.element.android.features.poll.api.create.CreatePollMode
|
||||
import io.element.android.features.poll.impl.aPollTimeline
|
||||
import io.element.android.features.poll.impl.aPollTimelineItems
|
||||
import io.element.android.features.poll.impl.anOngoingPollContent
|
||||
import io.element.android.features.poll.impl.data.PollRepository
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
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.timeline.item.event.PollContent
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.SavePollInvocation
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class CreatePollPresenterTest {
|
||||
@OptIn(ExperimentalCoroutinesApi::class) class CreatePollPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val pollEventId = AN_EVENT_ID
|
||||
private var navUpInvocationsCount = 0
|
||||
private val existingPoll = anOngoingPollContent()
|
||||
private val timeline = FakeTimeline(
|
||||
timelineItems = aPollTimelineItems(mapOf(pollEventId to existingPoll))
|
||||
)
|
||||
private val fakeMatrixRoom = FakeMatrixRoom(
|
||||
matrixTimeline = aPollTimeline(
|
||||
mapOf(pollEventId to existingPoll)
|
||||
)
|
||||
liveTimeline = timeline
|
||||
)
|
||||
private val fakeAnalyticsService = FakeAnalyticsService()
|
||||
private val fakeMessageComposerContext = FakeMessageComposerContext()
|
||||
@@ -80,7 +89,7 @@ class CreatePollPresenterTest {
|
||||
@Test
|
||||
fun `in edit mode, if poll doesn't exist, error is tracked and screen is closed`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
matrixTimeline = aPollTimeline()
|
||||
liveTimeline = FakeTimeline()
|
||||
)
|
||||
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(AN_EVENT_ID), room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
@@ -180,6 +189,12 @@ class CreatePollPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `edit poll sends a poll edit event`() = runTest {
|
||||
val editPollLambda = lambdaRecorder { _: EventId, _: String, _: List<String>, _: Int, _: PollKind ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
timeline.apply {
|
||||
this.editPollLambda = editPollLambda
|
||||
}
|
||||
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -201,16 +216,18 @@ class CreatePollPresenterTest {
|
||||
).apply {
|
||||
eventSink(CreatePollEvents.Save)
|
||||
}
|
||||
delay(1) // Wait for the coroutine to finish
|
||||
assertThat(fakeMatrixRoom.editPollInvocations.size).isEqualTo(1)
|
||||
assertThat(fakeMatrixRoom.editPollInvocations.last()).isEqualTo(
|
||||
SavePollInvocation(
|
||||
question = "Changed question",
|
||||
answers = listOf("Changed answer 1", "Changed answer 2", "Maybe"),
|
||||
maxSelections = 1,
|
||||
pollKind = PollKind.Disclosed
|
||||
advanceUntilIdle() // Wait for the coroutine to finish
|
||||
|
||||
assert(editPollLambda)
|
||||
.isCalledOnce()
|
||||
.with(
|
||||
value(pollEventId),
|
||||
value("Changed question"),
|
||||
value(listOf("Changed answer 1", "Changed answer 2", "Maybe")),
|
||||
value(1),
|
||||
value(PollKind.Disclosed)
|
||||
)
|
||||
)
|
||||
|
||||
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2)
|
||||
assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo(
|
||||
Composer(
|
||||
@@ -233,6 +250,12 @@ class CreatePollPresenterTest {
|
||||
@Test
|
||||
fun `when edit poll fails, error is tracked`() = runTest {
|
||||
val error = Exception("cause")
|
||||
val editPollLambda = lambdaRecorder { _: EventId, _: String, _: List<String>, _: Int, _: PollKind ->
|
||||
Result.failure<Unit>(error)
|
||||
}
|
||||
timeline.apply {
|
||||
this.editPollLambda = editPollLambda
|
||||
}
|
||||
fakeMatrixRoom.givenEditPollResult(Result.failure(error))
|
||||
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
@@ -241,8 +264,8 @@ class CreatePollPresenterTest {
|
||||
awaitDefaultItem()
|
||||
awaitPollLoaded().eventSink(CreatePollEvents.SetAnswer(0, "A"))
|
||||
awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvents.Save)
|
||||
delay(1) // Wait for the coroutine to finish
|
||||
assertThat(fakeMatrixRoom.editPollInvocations).hasSize(1)
|
||||
advanceUntilIdle() // Wait for the coroutine to finish
|
||||
assert(editPollLambda).isCalledOnce()
|
||||
assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
|
||||
assertThat(fakeAnalyticsService.trackedErrors).hasSize(1)
|
||||
assertThat(fakeAnalyticsService.trackedErrors).containsExactly(
|
||||
@@ -497,22 +520,22 @@ class CreatePollPresenterTest {
|
||||
newAnswer1: String? = null,
|
||||
newAnswer2: String? = null,
|
||||
) =
|
||||
awaitItem().apply {
|
||||
assertThat(canSave).isTrue()
|
||||
assertThat(canAddAnswer).isTrue()
|
||||
assertThat(question).isEqualTo(newQuestion ?: existingPoll.question)
|
||||
assertThat(answers).isEqualTo(existingPoll.expectedAnswersState().toMutableList().apply {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.canSave).isTrue()
|
||||
assertThat(state.canAddAnswer).isTrue()
|
||||
assertThat(state.question).isEqualTo(newQuestion ?: existingPoll.question)
|
||||
assertThat(state.answers).isEqualTo(existingPoll.expectedAnswersState().toMutableList().apply {
|
||||
newAnswer1?.let { this[0] = Answer(it, true) }
|
||||
newAnswer2?.let { this[1] = Answer(it, true) }
|
||||
})
|
||||
assertThat(pollKind).isEqualTo(existingPoll.kind)
|
||||
assertThat(state.pollKind).isEqualTo(existingPoll.kind)
|
||||
}
|
||||
|
||||
private fun createCreatePollPresenter(
|
||||
mode: CreatePollMode = CreatePollMode.NewPoll,
|
||||
room: MatrixRoom = fakeMatrixRoom,
|
||||
): CreatePollPresenter = CreatePollPresenter(
|
||||
repository = PollRepository(room),
|
||||
repository = PollRepository(room, LiveTimelineProvider(room)),
|
||||
analyticsService = fakeAnalyticsService,
|
||||
messageComposerContext = fakeMessageComposerContext,
|
||||
navigateUp = { navUpInvocationsCount++ },
|
||||
|
||||
@@ -22,7 +22,7 @@ import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.poll.api.actions.EndPollAction
|
||||
import io.element.android.features.poll.api.actions.SendPollResponseAction
|
||||
import io.element.android.features.poll.impl.aPollTimeline
|
||||
import io.element.android.features.poll.impl.aPollTimelineItems
|
||||
import io.element.android.features.poll.impl.anEndedPollContent
|
||||
import io.element.android.features.poll.impl.anOngoingPollContent
|
||||
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
|
||||
@@ -32,14 +32,21 @@ import io.element.android.features.poll.test.actions.FakeEndPollAction
|
||||
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
|
||||
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -50,14 +57,18 @@ class PollHistoryPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val timeline = aPollTimeline(
|
||||
polls = mapOf(
|
||||
AN_EVENT_ID to anOngoingPollContent(),
|
||||
AN_EVENT_ID_2 to anEndedPollContent()
|
||||
)
|
||||
private val backwardPaginationStatus = MutableStateFlow(Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true))
|
||||
private val timeline = FakeTimeline(
|
||||
timelineItems = aPollTimelineItems(
|
||||
mapOf(
|
||||
AN_EVENT_ID to anOngoingPollContent(),
|
||||
AN_EVENT_ID_2 to anEndedPollContent()
|
||||
)
|
||||
),
|
||||
backwardPaginationStatus = backwardPaginationStatus
|
||||
)
|
||||
private val room = FakeMatrixRoom(
|
||||
matrixTimeline = timeline
|
||||
liveTimeline = timeline
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -66,7 +77,6 @@ class PollHistoryPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING)
|
||||
assertThat(state.pollHistoryItems.size).isEqualTo(0)
|
||||
@@ -127,26 +137,30 @@ class PollHistoryPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - load more scenario`() = runTest {
|
||||
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
|
||||
Result.success(false)
|
||||
}
|
||||
timeline.apply {
|
||||
this.paginateLambda = paginateLambda
|
||||
}
|
||||
val presenter = createPollHistoryPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.pollHistoryItems.size).isEqualTo(2)
|
||||
}
|
||||
timeline.updatePaginationState {
|
||||
copy(isBackPaginating = false)
|
||||
}
|
||||
skipItems(1)
|
||||
val loadedState = awaitItem()
|
||||
assertThat(loadedState.isLoading).isFalse()
|
||||
loadedState.eventSink(PollHistoryEvents.LoadMore)
|
||||
backwardPaginationStatus.getAndUpdate { it.copy(isPaginating = true) }
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isLoading).isTrue()
|
||||
}
|
||||
backwardPaginationStatus.getAndUpdate { it.copy(isPaginating = false) }
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isLoading).isFalse()
|
||||
}
|
||||
// Called once by the initial load and once by the load more event
|
||||
assert(paginateLambda).isCalledExactly(2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,11 +176,11 @@ class PollHistoryPresenterTest {
|
||||
),
|
||||
): PollHistoryPresenter {
|
||||
return PollHistoryPresenter(
|
||||
room = room,
|
||||
appCoroutineScope = appCoroutineScope,
|
||||
sendPollResponseAction = sendPollResponseAction,
|
||||
endPollAction = endPollAction,
|
||||
pollHistoryItemFactory = pollHistoryItemFactory,
|
||||
timelineProvider = LiveTimelineProvider(room),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.room
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.errors.FocusEventException
|
||||
import org.matrix.rustcomponents.sdk.FocusEventException as RustFocusEventException
|
||||
|
||||
fun Throwable.toFocusEventException(): Throwable {
|
||||
return when (this) {
|
||||
is RustFocusEventException -> {
|
||||
when (this) {
|
||||
is RustFocusEventException.InvalidEventId -> {
|
||||
FocusEventException.InvalidEventId(eventId, err)
|
||||
}
|
||||
is RustFocusEventException.EventNotFound -> {
|
||||
FocusEventException.EventNotFound(EventId(eventId))
|
||||
}
|
||||
is RustFocusEventException.Other -> {
|
||||
FocusEventException.Other(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
this
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.impl.room
|
||||
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.coroutine.childScope
|
||||
import io.element.android.libraries.core.extensions.mapFailure
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
@@ -43,21 +44,15 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||
import io.element.android.libraries.matrix.impl.core.toProgressWatcher
|
||||
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
|
||||
import io.element.android.libraries.matrix.impl.media.map
|
||||
import io.element.android.libraries.matrix.impl.media.toMSC3246range
|
||||
import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
|
||||
import io.element.android.libraries.matrix.impl.poll.toInner
|
||||
import io.element.android.libraries.matrix.impl.room.location.toInner
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
||||
import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
|
||||
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
|
||||
import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType
|
||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
|
||||
@@ -80,24 +75,16 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.EventTimelineItem
|
||||
import org.matrix.rustcomponents.sdk.MessageFormat
|
||||
import org.matrix.rustcomponents.sdk.RoomInfo
|
||||
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
||||
import org.matrix.rustcomponents.sdk.RoomListItem
|
||||
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
|
||||
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
|
||||
import org.matrix.rustcomponents.sdk.TypingNotificationsListener
|
||||
import org.matrix.rustcomponents.sdk.UserPowerLevelUpdate
|
||||
import org.matrix.rustcomponents.sdk.WidgetCapabilities
|
||||
import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import uniffi.matrix_sdk.RoomPowerLevelChanges
|
||||
import java.io.File
|
||||
import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody
|
||||
import org.matrix.rustcomponents.sdk.Room as InnerRoom
|
||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||
|
||||
@@ -107,7 +94,7 @@ class RustMatrixRoom(
|
||||
private val isKeyBackupEnabled: Boolean,
|
||||
private val roomListItem: RoomListItem,
|
||||
private val innerRoom: InnerRoom,
|
||||
private val innerTimeline: InnerTimeline,
|
||||
innerTimeline: InnerTimeline,
|
||||
private val roomNotificationSettingsService: RustNotificationSettingsService,
|
||||
sessionCoroutineScope: CoroutineScope,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
@@ -160,7 +147,7 @@ class RustMatrixRoom(
|
||||
private val _roomNotificationSettingsStateFlow = MutableStateFlow<MatrixRoomNotificationSettingsState>(MatrixRoomNotificationSettingsState.Unknown)
|
||||
override val roomNotificationSettingsStateFlow: StateFlow<MatrixRoomNotificationSettingsState> = _roomNotificationSettingsStateFlow
|
||||
|
||||
override val timeline = createMatrixTimeline(innerTimeline) {
|
||||
override val liveTimeline = createTimeline(innerTimeline, isLive = true) {
|
||||
_syncUpdateFlow.value = systemClock.epochMillis()
|
||||
}
|
||||
|
||||
@@ -170,7 +157,7 @@ class RustMatrixRoom(
|
||||
|
||||
init {
|
||||
val powerLevelChanges = roomInfoFlow.map { it.userPowerLevels }.distinctUntilChanged()
|
||||
val membershipChanges = timeline.membershipChangeEventReceived.onStart { emit(Unit) }
|
||||
val membershipChanges = liveTimeline.membershipChangeEventReceived.onStart { emit(Unit) }
|
||||
combine(membershipChanges, powerLevelChanges) { _, _ -> }
|
||||
// Skip initial one
|
||||
.drop(1)
|
||||
@@ -183,12 +170,25 @@ class RustMatrixRoom(
|
||||
|
||||
override suspend fun unsubscribeFromSync() = roomSyncSubscriber.unsubscribe(roomId)
|
||||
|
||||
override suspend fun timelineFocusedOnEvent(eventId: EventId): Result<Timeline> {
|
||||
return runCatching {
|
||||
innerRoom.timelineFocusedOnEvent(
|
||||
eventId = eventId.value,
|
||||
numContextEvents = 50u,
|
||||
internalIdPrefix = "focus_$eventId",
|
||||
).let { inner ->
|
||||
createTimeline(inner, isLive = false)
|
||||
}
|
||||
}.mapFailure {
|
||||
it.toFocusEventException()
|
||||
}
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
roomCoroutineScope.cancel()
|
||||
timeline.close()
|
||||
liveTimeline.close()
|
||||
innerRoom.destroy()
|
||||
roomListItem.destroy()
|
||||
specialModeEventTimelineItem?.destroy()
|
||||
}
|
||||
|
||||
override val name: String?
|
||||
@@ -322,59 +322,8 @@ class RustMatrixRoom(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(roomDispatcher) {
|
||||
messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()).use { content ->
|
||||
runCatching {
|
||||
innerTimeline.send(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun editMessage(
|
||||
originalEventId: EventId?,
|
||||
transactionId: TransactionId?,
|
||||
body: String,
|
||||
htmlBody: String?,
|
||||
mentions: List<Mention>,
|
||||
): Result<Unit> =
|
||||
withContext(roomDispatcher) {
|
||||
if (originalEventId != null) {
|
||||
runCatching {
|
||||
val editedEvent = specialModeEventTimelineItem ?: innerTimeline.getEventTimelineItemByEventId(originalEventId.value)
|
||||
editedEvent.use {
|
||||
innerTimeline.edit(
|
||||
newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()),
|
||||
editItem = it,
|
||||
)
|
||||
}
|
||||
specialModeEventTimelineItem = null
|
||||
}
|
||||
} else {
|
||||
runCatching {
|
||||
transactionId?.let { cancelSend(it) }
|
||||
innerTimeline.send(messageEventContentFromParts(body, htmlBody))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var specialModeEventTimelineItem: EventTimelineItem? = null
|
||||
|
||||
override suspend fun enterSpecialMode(eventId: EventId?): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
specialModeEventTimelineItem?.destroy()
|
||||
specialModeEventTimelineItem = null
|
||||
specialModeEventTimelineItem = eventId?.let { innerTimeline.getEventTimelineItemByEventId(it.value) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
val inReplyTo = specialModeEventTimelineItem ?: innerTimeline.getEventTimelineItemByEventId(eventId.value)
|
||||
inReplyTo.use { eventTimelineItem ->
|
||||
innerTimeline.sendReply(messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), eventTimelineItem)
|
||||
}
|
||||
specialModeEventTimelineItem = null
|
||||
}
|
||||
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> {
|
||||
return liveTimeline.sendMessage(body, htmlBody, mentions)
|
||||
}
|
||||
|
||||
override suspend fun redactEvent(eventId: EventId, reason: String?) = withContext(roomDispatcher) {
|
||||
@@ -457,18 +406,7 @@ class RustMatrixRoom(
|
||||
formattedBody: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
|
||||
innerTimeline.sendImage(
|
||||
url = file.path,
|
||||
thumbnailUrl = thumbnailFile?.path,
|
||||
imageInfo = imageInfo.map(),
|
||||
caption = body,
|
||||
formattedCaption = formattedBody?.let {
|
||||
RustFormattedBody(body = it, format = MessageFormat.Html)
|
||||
},
|
||||
progressWatcher = progressCallback?.toProgressWatcher()
|
||||
)
|
||||
}
|
||||
return liveTimeline.sendImage(file, thumbnailFile, imageInfo, body, formattedBody, progressCallback)
|
||||
}
|
||||
|
||||
override suspend fun sendVideo(
|
||||
@@ -479,63 +417,31 @@ class RustMatrixRoom(
|
||||
formattedBody: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
|
||||
innerTimeline.sendVideo(
|
||||
url = file.path,
|
||||
thumbnailUrl = thumbnailFile?.path,
|
||||
videoInfo = videoInfo.map(),
|
||||
caption = body,
|
||||
formattedCaption = formattedBody?.let {
|
||||
RustFormattedBody(body = it, format = MessageFormat.Html)
|
||||
},
|
||||
progressWatcher = progressCallback?.toProgressWatcher()
|
||||
)
|
||||
}
|
||||
return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, body, formattedBody, progressCallback)
|
||||
}
|
||||
|
||||
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOf(file)) {
|
||||
innerTimeline.sendAudio(
|
||||
url = file.path,
|
||||
audioInfo = audioInfo.map(),
|
||||
// Maybe allow a caption in the future?
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
progressWatcher = progressCallback?.toProgressWatcher()
|
||||
)
|
||||
}
|
||||
return liveTimeline.sendAudio(file, audioInfo, progressCallback)
|
||||
}
|
||||
|
||||
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOf(file)) {
|
||||
innerTimeline.sendFile(file.path, fileInfo.map(), progressCallback?.toProgressWatcher())
|
||||
}
|
||||
return liveTimeline.sendFile(file, fileInfo, progressCallback)
|
||||
}
|
||||
|
||||
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.toggleReaction(key = emoji, eventId = eventId.value)
|
||||
}
|
||||
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> {
|
||||
return liveTimeline.toggleReaction(emoji, eventId)
|
||||
}
|
||||
|
||||
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
roomContentForwarder.forward(fromTimeline = innerTimeline, eventId = eventId, toRoomIds = roomIds)
|
||||
}.onFailure {
|
||||
Timber.e(it)
|
||||
}
|
||||
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> {
|
||||
return liveTimeline.forwardEvent(eventId, roomIds)
|
||||
}
|
||||
|
||||
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.retrySend(transactionId.value)
|
||||
}
|
||||
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> {
|
||||
return liveTimeline.retrySendMessage(transactionId)
|
||||
}
|
||||
|
||||
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.cancelSend(transactionId.value)
|
||||
}
|
||||
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> {
|
||||
return liveTimeline.cancelSend(transactionId)
|
||||
}
|
||||
|
||||
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> = withContext(roomDispatcher) {
|
||||
@@ -613,16 +519,8 @@ class RustMatrixRoom(
|
||||
description: String?,
|
||||
zoomLevel: Int?,
|
||||
assetType: AssetType?,
|
||||
): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.sendLocation(
|
||||
body = body,
|
||||
geoUri = geoUri,
|
||||
description = description,
|
||||
zoomLevel = zoomLevel?.toUByte(),
|
||||
assetType = assetType?.toInner(),
|
||||
)
|
||||
}
|
||||
): Result<Unit> {
|
||||
return liveTimeline.sendLocation(body, geoUri, description, zoomLevel, assetType)
|
||||
}
|
||||
|
||||
override suspend fun createPoll(
|
||||
@@ -630,15 +528,8 @@ class RustMatrixRoom(
|
||||
answers: List<String>,
|
||||
maxSelections: Int,
|
||||
pollKind: PollKind,
|
||||
): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.createPoll(
|
||||
question = question,
|
||||
answers = answers,
|
||||
maxSelections = maxSelections.toUByte(),
|
||||
pollKind = pollKind.toInner(),
|
||||
)
|
||||
}
|
||||
): Result<Unit> {
|
||||
return liveTimeline.createPoll(question, answers, maxSelections, pollKind)
|
||||
}
|
||||
|
||||
override suspend fun editPoll(
|
||||
@@ -647,46 +538,22 @@ class RustMatrixRoom(
|
||||
answers: List<String>,
|
||||
maxSelections: Int,
|
||||
pollKind: PollKind,
|
||||
): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
val pollStartEvent =
|
||||
innerTimeline.getEventTimelineItemByEventId(
|
||||
eventId = pollStartId.value
|
||||
)
|
||||
pollStartEvent.use {
|
||||
innerTimeline.editPoll(
|
||||
question = question,
|
||||
answers = answers,
|
||||
maxSelections = maxSelections.toUByte(),
|
||||
pollKind = pollKind.toInner(),
|
||||
editItem = pollStartEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
): Result<Unit> {
|
||||
return liveTimeline.editPoll(pollStartId, question, answers, maxSelections, pollKind)
|
||||
}
|
||||
|
||||
override suspend fun sendPollResponse(
|
||||
pollStartId: EventId,
|
||||
answers: List<String>
|
||||
): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.sendPollResponse(
|
||||
pollStartId = pollStartId.value,
|
||||
answers = answers,
|
||||
)
|
||||
}
|
||||
): Result<Unit> {
|
||||
return liveTimeline.sendPollResponse(pollStartId, answers)
|
||||
}
|
||||
|
||||
override suspend fun endPoll(
|
||||
pollStartId: EventId,
|
||||
text: String
|
||||
): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.endPoll(
|
||||
pollStartId = pollStartId.value,
|
||||
text = text,
|
||||
)
|
||||
}
|
||||
): Result<Unit> {
|
||||
return liveTimeline.endPoll(pollStartId, text)
|
||||
}
|
||||
|
||||
override suspend fun sendVoiceMessage(
|
||||
@@ -694,16 +561,8 @@ class RustMatrixRoom(
|
||||
audioInfo: AudioInfo,
|
||||
waveform: List<Float>,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> = sendAttachment(listOf(file)) {
|
||||
innerTimeline.sendVoiceMessage(
|
||||
url = file.path,
|
||||
audioInfo = audioInfo.map(),
|
||||
waveform = waveform.toMSC3246range(),
|
||||
// Maybe allow a caption in the future?
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
progressWatcher = progressCallback?.toProgressWatcher(),
|
||||
)
|
||||
): Result<MediaUploadHandler> {
|
||||
return liveTimeline.sendVoiceMessage(file, audioInfo, waveform, progressCallback)
|
||||
}
|
||||
|
||||
override suspend fun typingNotice(isTyping: Boolean) = runCatching {
|
||||
@@ -739,31 +598,22 @@ class RustMatrixRoom(
|
||||
innerRoom.matrixToEventPermalink(eventId.value)
|
||||
}
|
||||
|
||||
private fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> {
|
||||
return runCatching {
|
||||
MediaUploadHandlerImpl(files, handle())
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMatrixTimeline(
|
||||
private fun createTimeline(
|
||||
timeline: InnerTimeline,
|
||||
isLive: Boolean,
|
||||
onNewSyncedEvent: () -> Unit = {},
|
||||
): MatrixTimeline {
|
||||
return RustMatrixTimeline(
|
||||
): Timeline {
|
||||
return RustTimeline(
|
||||
isKeyBackupEnabled = isKeyBackupEnabled,
|
||||
isLive = isLive,
|
||||
matrixRoom = this,
|
||||
systemClock = systemClock,
|
||||
roomCoroutineScope = roomCoroutineScope,
|
||||
dispatcher = roomDispatcher,
|
||||
lastLoginTimestamp = sessionData.loginTimestamp,
|
||||
onNewSyncedEvent = onNewSyncedEvent,
|
||||
innerTimeline = timeline,
|
||||
roomContentForwarder = roomContentForwarder,
|
||||
inner = timeline,
|
||||
)
|
||||
}
|
||||
|
||||
private fun messageEventContentFromParts(body: String, htmlBody: String?): RoomMessageEventContentWithoutRelation =
|
||||
if (htmlBody != null) {
|
||||
messageEventContentFromHtml(body, htmlBody)
|
||||
} else {
|
||||
messageEventContentFromMarkdown(body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* This class is a wrapper around a [MatrixTimeline] that will be created asynchronously.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class AsyncMatrixTimeline(
|
||||
coroutineScope: CoroutineScope,
|
||||
dispatcher: CoroutineDispatcher,
|
||||
private val timelineProvider: suspend () -> MatrixTimeline
|
||||
) : MatrixTimeline {
|
||||
private val _timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
|
||||
MutableStateFlow(emptyList())
|
||||
|
||||
private val _paginationState = MutableStateFlow(
|
||||
MatrixTimeline.PaginationState.Initial
|
||||
)
|
||||
private val timeline = coroutineScope.async(context = dispatcher, start = CoroutineStart.LAZY) {
|
||||
timelineProvider()
|
||||
}
|
||||
private val closeSignal = CompletableDeferred<Unit>()
|
||||
|
||||
override val membershipChangeEventReceived = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
|
||||
init {
|
||||
coroutineScope.launch {
|
||||
val delegateTimeline = timeline.await()
|
||||
delegateTimeline.timelineItems
|
||||
.onEach { _timelineItems.value = it }
|
||||
.launchIn(this)
|
||||
delegateTimeline.paginationState
|
||||
.onEach { _paginationState.value = it }
|
||||
.launchIn(this)
|
||||
delegateTimeline.membershipChangeEventReceived
|
||||
.onEach { membershipChangeEventReceived.emit(it) }
|
||||
.launchIn(this)
|
||||
|
||||
launch {
|
||||
withContext(NonCancellable) {
|
||||
closeSignal.await()
|
||||
Timber.d("Close delegate")
|
||||
delegateTimeline.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val paginationState: StateFlow<MatrixTimeline.PaginationState> = _paginationState
|
||||
override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems
|
||||
|
||||
override suspend fun paginateBackwards(requestSize: Int): Result<Unit> {
|
||||
return timeline.await().paginateBackwards(requestSize)
|
||||
}
|
||||
|
||||
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> {
|
||||
return timeline.await().paginateBackwards(requestSize, untilNumberOfItems)
|
||||
}
|
||||
|
||||
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> {
|
||||
return timeline.await().fetchDetailsForEvent(eventId)
|
||||
}
|
||||
|
||||
override suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit> {
|
||||
return timeline.await().sendReadReceipt(eventId, receiptType)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closeSignal.complete(Unit)
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,8 @@
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline
|
||||
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
|
||||
import io.element.android.libraries.matrix.impl.util.destroyAll
|
||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
@@ -27,13 +25,11 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import org.matrix.rustcomponents.sdk.PaginationStatusListener
|
||||
import org.matrix.rustcomponents.sdk.Timeline
|
||||
import org.matrix.rustcomponents.sdk.TimelineDiff
|
||||
import org.matrix.rustcomponents.sdk.TimelineItem
|
||||
import org.matrix.rustcomponents.sdk.TimelineListener
|
||||
import timber.log.Timber
|
||||
import uniffi.matrix_sdk_ui.PaginationStatus
|
||||
|
||||
internal fun Timeline.timelineDiffFlow(onInitialList: suspend (List<TimelineItem>) -> Unit): Flow<List<TimelineDiff>> =
|
||||
callbackFlow {
|
||||
@@ -58,18 +54,6 @@ internal fun Timeline.timelineDiffFlow(onInitialList: suspend (List<TimelineItem
|
||||
Timber.d(it, "timelineDiffFlow() failed")
|
||||
}.buffer(Channel.UNLIMITED)
|
||||
|
||||
internal fun Timeline.backPaginationStatusFlow(): Flow<PaginationStatus> =
|
||||
mxCallbackFlow {
|
||||
val listener = object : PaginationStatusListener {
|
||||
override fun onUpdate(status: PaginationStatus) {
|
||||
trySendBlocking(status)
|
||||
}
|
||||
}
|
||||
tryOrNull {
|
||||
subscribeToBackPaginationStatus(listener)
|
||||
}
|
||||
}.buffer(Channel.UNLIMITED)
|
||||
|
||||
internal suspend fun Timeline.runWithTimelineListenerRegistered(action: suspend () -> Unit) {
|
||||
val result = addListener(NoOpTimelineListener)
|
||||
try {
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.DmBeginningTimelineProcessor
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.PaginationOptions
|
||||
import org.matrix.rustcomponents.sdk.Timeline
|
||||
import org.matrix.rustcomponents.sdk.TimelineDiff
|
||||
import org.matrix.rustcomponents.sdk.TimelineItem
|
||||
import timber.log.Timber
|
||||
import uniffi.matrix_sdk_ui.EventItemOrigin
|
||||
import uniffi.matrix_sdk_ui.PaginationStatus
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
private const val INITIAL_MAX_SIZE = 50
|
||||
|
||||
class RustMatrixTimeline(
|
||||
roomCoroutineScope: CoroutineScope,
|
||||
isKeyBackupEnabled: Boolean,
|
||||
private val matrixRoom: MatrixRoom,
|
||||
private val innerTimeline: Timeline,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
lastLoginTimestamp: Date?,
|
||||
private val onNewSyncedEvent: () -> Unit,
|
||||
) : MatrixTimeline {
|
||||
private val initLatch = CompletableDeferred<Unit>()
|
||||
private val isInit = AtomicBoolean(false)
|
||||
|
||||
private val _timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
|
||||
MutableStateFlow(emptyList())
|
||||
|
||||
private val _paginationState = MutableStateFlow(
|
||||
MatrixTimeline.PaginationState.Initial
|
||||
)
|
||||
|
||||
private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor(
|
||||
lastLoginTimestamp = lastLoginTimestamp,
|
||||
isRoomEncrypted = matrixRoom.isEncrypted,
|
||||
isKeyBackupEnabled = isKeyBackupEnabled,
|
||||
dispatcher = dispatcher,
|
||||
)
|
||||
|
||||
private val dmBeginningTimelineProcessor = DmBeginningTimelineProcessor()
|
||||
|
||||
private val timelineItemFactory = MatrixTimelineItemMapper(
|
||||
fetchDetailsForEvent = this::fetchDetailsForEvent,
|
||||
roomCoroutineScope = roomCoroutineScope,
|
||||
virtualTimelineItemMapper = VirtualTimelineItemMapper(),
|
||||
eventTimelineItemMapper = EventTimelineItemMapper(
|
||||
contentMapper = TimelineEventContentMapper(
|
||||
eventMessageMapper = EventMessageMapper()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private val timelineDiffProcessor = MatrixTimelineDiffProcessor(
|
||||
timelineItems = _timelineItems,
|
||||
timelineItemFactory = timelineItemFactory,
|
||||
)
|
||||
|
||||
override val paginationState: StateFlow<MatrixTimeline.PaginationState> = _paginationState.asStateFlow()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems
|
||||
.mapLatest { items -> encryptedHistoryPostProcessor.process(items) }
|
||||
.mapLatest { items ->
|
||||
dmBeginningTimelineProcessor.process(
|
||||
items = items,
|
||||
isDm = matrixRoom.isDirect && matrixRoom.isOneToOne,
|
||||
isAtStartOfTimeline = paginationState.value.beginningOfRoomReached
|
||||
)
|
||||
}
|
||||
|
||||
override val membershipChangeEventReceived: Flow<Unit> = timelineDiffProcessor.membershipChangeEventReceived
|
||||
|
||||
init {
|
||||
Timber.d("Initialize timeline for room ${matrixRoom.roomId}")
|
||||
|
||||
roomCoroutineScope.launch(dispatcher) {
|
||||
innerTimeline.timelineDiffFlow { initialList ->
|
||||
postItems(initialList)
|
||||
}.onEach { diffs ->
|
||||
if (diffs.any { diff -> diff.eventOrigin() == EventItemOrigin.SYNC }) {
|
||||
onNewSyncedEvent()
|
||||
}
|
||||
postDiffs(diffs)
|
||||
}.launchIn(this)
|
||||
|
||||
paginationStateFlow()
|
||||
.onEach {
|
||||
_paginationState.value = it
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
fetchMembers()
|
||||
}
|
||||
}
|
||||
|
||||
private fun paginationStateFlow(): Flow<MatrixTimeline.PaginationState> {
|
||||
return combine(
|
||||
innerTimeline.backPaginationStatusFlow(),
|
||||
timelineItems,
|
||||
) { paginationStatus, filteredItems ->
|
||||
if (filteredItems.hasEncryptionHistoryBanner()) {
|
||||
return@combine MatrixTimeline.PaginationState(
|
||||
isBackPaginating = false,
|
||||
hasMoreToLoadBackwards = false,
|
||||
beginningOfRoomReached = false,
|
||||
)
|
||||
}
|
||||
when (paginationStatus) {
|
||||
PaginationStatus.IDLE -> {
|
||||
MatrixTimeline.PaginationState(
|
||||
isBackPaginating = false,
|
||||
hasMoreToLoadBackwards = true,
|
||||
beginningOfRoomReached = false,
|
||||
)
|
||||
}
|
||||
PaginationStatus.PAGINATING -> {
|
||||
MatrixTimeline.PaginationState(
|
||||
isBackPaginating = true,
|
||||
hasMoreToLoadBackwards = true,
|
||||
beginningOfRoomReached = false,
|
||||
)
|
||||
}
|
||||
PaginationStatus.TIMELINE_END_REACHED -> {
|
||||
MatrixTimeline.PaginationState(
|
||||
isBackPaginating = false,
|
||||
hasMoreToLoadBackwards = false,
|
||||
beginningOfRoomReached = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchMembers() = withContext(dispatcher) {
|
||||
initLatch.await()
|
||||
try {
|
||||
innerTimeline.fetchMembers()
|
||||
} catch (exception: Exception) {
|
||||
Timber.e(exception, "Error fetching members for room ${matrixRoom.roomId}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun postItems(items: List<TimelineItem>) = coroutineScope {
|
||||
// Split the initial items in multiple list as there is no pagination in the cached data, so we can post timelineItems asap.
|
||||
items.chunked(INITIAL_MAX_SIZE).reversed().forEach {
|
||||
ensureActive()
|
||||
timelineDiffProcessor.postItems(it)
|
||||
}
|
||||
isInit.set(true)
|
||||
initLatch.complete(Unit)
|
||||
}
|
||||
|
||||
private suspend fun postDiffs(diffs: List<TimelineDiff>) {
|
||||
initLatch.await()
|
||||
timelineDiffProcessor.postDiffs(diffs)
|
||||
}
|
||||
|
||||
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.fetchDetailsForEvent(eventId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun paginateBackwards(requestSize: Int): Result<Unit> {
|
||||
val paginationOptions = PaginationOptions.SimpleRequest(
|
||||
eventLimit = requestSize.toUShort(),
|
||||
waitForToken = true,
|
||||
)
|
||||
return paginateBackwards(paginationOptions)
|
||||
}
|
||||
|
||||
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> {
|
||||
val paginationOptions = PaginationOptions.UntilNumItems(
|
||||
eventLimit = requestSize.toUShort(),
|
||||
items = untilNumberOfItems.toUShort(),
|
||||
waitForToken = true,
|
||||
)
|
||||
return paginateBackwards(paginationOptions)
|
||||
}
|
||||
|
||||
private suspend fun paginateBackwards(paginationOptions: PaginationOptions): Result<Unit> = withContext(dispatcher) {
|
||||
initLatch.await()
|
||||
runCatching {
|
||||
if (!canBackPaginate()) throw TimelineException.CannotPaginate
|
||||
Timber.v("Start back paginating for room ${matrixRoom.roomId} ")
|
||||
innerTimeline.paginateBackwards(
|
||||
when (paginationOptions) {
|
||||
is PaginationOptions.SimpleRequest -> paginationOptions.eventLimit
|
||||
is PaginationOptions.UntilNumItems -> paginationOptions.eventLimit
|
||||
}
|
||||
)
|
||||
}.onFailure { error ->
|
||||
if (error is TimelineException.CannotPaginate) {
|
||||
Timber.d("Can't paginate backwards on room ${matrixRoom.roomId}, we're already at the start")
|
||||
} else {
|
||||
Timber.e(error, "Error paginating backwards on room ${matrixRoom.roomId}")
|
||||
}
|
||||
}.onSuccess {
|
||||
Timber.v("Success back paginating for room ${matrixRoom.roomId}")
|
||||
}.map { }
|
||||
}
|
||||
|
||||
private fun canBackPaginate(): Boolean {
|
||||
return isInit.get() && paginationState.value.canBackPaginate
|
||||
}
|
||||
|
||||
override suspend fun sendReadReceipt(
|
||||
eventId: EventId,
|
||||
receiptType: ReceiptType,
|
||||
) = withContext(dispatcher) {
|
||||
runCatching {
|
||||
innerTimeline.sendReadReceipt(
|
||||
receiptType = receiptType.toRustReceiptType(),
|
||||
eventId = eventId.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
innerTimeline.close()
|
||||
}
|
||||
|
||||
fun getItemById(eventId: EventId): MatrixTimelineItem.Event? {
|
||||
return _timelineItems.value.firstOrNull { (it as? MatrixTimelineItem.Event)?.eventId == eventId } as? MatrixTimelineItem.Event
|
||||
}
|
||||
|
||||
private fun List<MatrixTimelineItem>.hasEncryptionHistoryBanner(): Boolean {
|
||||
val firstItem = firstOrNull()
|
||||
return firstItem is MatrixTimelineItem.Virtual &&
|
||||
firstItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.Mention
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.impl.core.toProgressWatcher
|
||||
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
|
||||
import io.element.android.libraries.matrix.impl.media.map
|
||||
import io.element.android.libraries.matrix.impl.media.toMSC3246range
|
||||
import io.element.android.libraries.matrix.impl.poll.toInner
|
||||
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
|
||||
import io.element.android.libraries.matrix.impl.room.location.toInner
|
||||
import io.element.android.libraries.matrix.impl.room.map
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LastForwardIndicatorsPostProcessor
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIndicatorsPostProcessor
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.EventTimelineItem
|
||||
import org.matrix.rustcomponents.sdk.FormattedBody
|
||||
import org.matrix.rustcomponents.sdk.MessageFormat
|
||||
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
|
||||
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
|
||||
import org.matrix.rustcomponents.sdk.TimelineDiff
|
||||
import org.matrix.rustcomponents.sdk.TimelineItem
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import uniffi.matrix_sdk_ui.EventItemOrigin
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||
|
||||
private const val INITIAL_MAX_SIZE = 50
|
||||
private const val PAGINATION_SIZE = 50
|
||||
|
||||
class RustTimeline(
|
||||
private val inner: InnerTimeline,
|
||||
isLive: Boolean,
|
||||
systemClock: SystemClock,
|
||||
roomCoroutineScope: CoroutineScope,
|
||||
isKeyBackupEnabled: Boolean,
|
||||
private val matrixRoom: MatrixRoom,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
lastLoginTimestamp: Date?,
|
||||
private val roomContentForwarder: RoomContentForwarder,
|
||||
private val onNewSyncedEvent: () -> Unit,
|
||||
) : Timeline {
|
||||
private val initLatch = CompletableDeferred<Unit>()
|
||||
private val isInit = AtomicBoolean(false)
|
||||
|
||||
private val _timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
|
||||
MutableStateFlow(emptyList())
|
||||
|
||||
private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor(
|
||||
lastLoginTimestamp = lastLoginTimestamp,
|
||||
isRoomEncrypted = matrixRoom.isEncrypted,
|
||||
isKeyBackupEnabled = isKeyBackupEnabled,
|
||||
dispatcher = dispatcher,
|
||||
)
|
||||
|
||||
private val roomBeginningPostProcessor = RoomBeginningPostProcessor()
|
||||
private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock)
|
||||
private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(isLive)
|
||||
|
||||
private val timelineItemFactory = MatrixTimelineItemMapper(
|
||||
fetchDetailsForEvent = this::fetchDetailsForEvent,
|
||||
roomCoroutineScope = roomCoroutineScope,
|
||||
virtualTimelineItemMapper = VirtualTimelineItemMapper(),
|
||||
eventTimelineItemMapper = EventTimelineItemMapper(
|
||||
contentMapper = TimelineEventContentMapper(
|
||||
eventMessageMapper = EventMessageMapper()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private val timelineDiffProcessor = MatrixTimelineDiffProcessor(
|
||||
timelineItems = _timelineItems,
|
||||
timelineItemFactory = timelineItemFactory,
|
||||
)
|
||||
|
||||
private val backPaginationStatus = MutableStateFlow(
|
||||
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true)
|
||||
)
|
||||
|
||||
private val forwardPaginationStatus = MutableStateFlow(
|
||||
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = !isLive)
|
||||
)
|
||||
|
||||
init {
|
||||
roomCoroutineScope.launch(dispatcher) {
|
||||
inner.timelineDiffFlow { initialList ->
|
||||
postItems(initialList)
|
||||
}.onEach { diffs ->
|
||||
if (diffs.any { diff -> diff.eventOrigin() == EventItemOrigin.SYNC }) {
|
||||
onNewSyncedEvent()
|
||||
}
|
||||
postDiffs(diffs)
|
||||
}.launchIn(this)
|
||||
|
||||
launch {
|
||||
fetchMembers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val membershipChangeEventReceived: Flow<Unit> = timelineDiffProcessor.membershipChangeEventReceived
|
||||
|
||||
override suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit> {
|
||||
return runCatching {
|
||||
inner.sendReadReceipt(receiptType.toRustReceiptType(), eventId.value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) {
|
||||
when (direction) {
|
||||
Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus.getAndUpdate(update)
|
||||
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.getAndUpdate(update)
|
||||
}
|
||||
}
|
||||
|
||||
// Use NonCancellable to avoid breaking the timeline when the coroutine is cancelled.
|
||||
override suspend fun paginate(direction: Timeline.PaginationDirection): Result<Boolean> = withContext(NonCancellable) {
|
||||
initLatch.await()
|
||||
runCatching {
|
||||
if (!canPaginate(direction)) throw TimelineException.CannotPaginate
|
||||
updatePaginationStatus(direction) { it.copy(isPaginating = true) }
|
||||
when (direction) {
|
||||
Timeline.PaginationDirection.BACKWARDS -> inner.paginateBackwards(PAGINATION_SIZE.toUShort())
|
||||
Timeline.PaginationDirection.FORWARDS -> inner.focusedPaginateForwards(PAGINATION_SIZE.toUShort())
|
||||
}
|
||||
}.onFailure { error ->
|
||||
updatePaginationStatus(direction) { it.copy(isPaginating = false) }
|
||||
if (error is TimelineException.CannotPaginate) {
|
||||
Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backPaginationStatus.value}")
|
||||
} else {
|
||||
Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}")
|
||||
}
|
||||
}.onSuccess { hasReachedEnd ->
|
||||
updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun canPaginate(direction: Timeline.PaginationDirection): Boolean {
|
||||
if (!isInit.get()) return false
|
||||
return when (direction) {
|
||||
Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus.value.canPaginate
|
||||
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.value.canPaginate
|
||||
}
|
||||
}
|
||||
|
||||
override fun paginationStatus(direction: Timeline.PaginationDirection): StateFlow<Timeline.PaginationStatus> {
|
||||
return when (direction) {
|
||||
Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus
|
||||
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus
|
||||
}
|
||||
}
|
||||
|
||||
override val timelineItems: Flow<List<MatrixTimelineItem>> = combine(
|
||||
_timelineItems,
|
||||
backPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(),
|
||||
forwardPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(),
|
||||
) { timelineItems, hasMoreToLoadBackward, hasMoreToLoadForward ->
|
||||
timelineItems
|
||||
.let { items -> encryptedHistoryPostProcessor.process(items) }
|
||||
.let { items ->
|
||||
roomBeginningPostProcessor.process(
|
||||
items = items,
|
||||
isDm = matrixRoom.isDm,
|
||||
hasMoreToLoadBackwards = hasMoreToLoadBackward
|
||||
)
|
||||
}
|
||||
.let { items -> loadingIndicatorsPostProcessor.process(items, hasMoreToLoadBackward, hasMoreToLoadForward) }
|
||||
// Keep lastForwardIndicatorsPostProcessor last
|
||||
.let { items -> lastForwardIndicatorsPostProcessor.process(items) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
inner.close()
|
||||
specialModeEventTimelineItem?.destroy()
|
||||
}
|
||||
|
||||
private suspend fun fetchMembers() = withContext(dispatcher) {
|
||||
initLatch.await()
|
||||
try {
|
||||
inner.fetchMembers()
|
||||
} catch (exception: Exception) {
|
||||
Timber.e(exception, "Error fetching members for room ${matrixRoom.roomId}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun postItems(items: List<TimelineItem>) = coroutineScope {
|
||||
// Split the initial items in multiple list as there is no pagination in the cached data, so we can post timelineItems asap.
|
||||
items.chunked(INITIAL_MAX_SIZE).reversed().forEach {
|
||||
ensureActive()
|
||||
timelineDiffProcessor.postItems(it)
|
||||
}
|
||||
isInit.set(true)
|
||||
initLatch.complete(Unit)
|
||||
}
|
||||
|
||||
private suspend fun postDiffs(diffs: List<TimelineDiff>) {
|
||||
initLatch.await()
|
||||
timelineDiffProcessor.postDiffs(diffs)
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(dispatcher) {
|
||||
messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()).use { content ->
|
||||
runCatching {
|
||||
inner.send(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun editMessage(
|
||||
originalEventId: EventId?,
|
||||
transactionId: TransactionId?,
|
||||
body: String,
|
||||
htmlBody: String?,
|
||||
mentions: List<Mention>,
|
||||
): Result<Unit> =
|
||||
withContext(dispatcher) {
|
||||
if (originalEventId != null) {
|
||||
runCatching {
|
||||
val editedEvent = specialModeEventTimelineItem ?: inner.getEventTimelineItemByEventId(originalEventId.value)
|
||||
editedEvent.use {
|
||||
inner.edit(
|
||||
newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()),
|
||||
editItem = it,
|
||||
)
|
||||
}
|
||||
specialModeEventTimelineItem = null
|
||||
}
|
||||
} else {
|
||||
runCatching {
|
||||
transactionId?.let { cancelSend(it) }
|
||||
inner.send(messageEventContentFromParts(body, htmlBody))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var specialModeEventTimelineItem: EventTimelineItem? = null
|
||||
|
||||
override suspend fun enterSpecialMode(eventId: EventId?): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
specialModeEventTimelineItem?.destroy()
|
||||
specialModeEventTimelineItem = null
|
||||
specialModeEventTimelineItem = eventId?.let { inner.getEventTimelineItemByEventId(it.value) }
|
||||
}.onFailure {
|
||||
Timber.e(it, "Unable to retrieve event for special mode. Are you using the correct timeline?")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
val inReplyTo = specialModeEventTimelineItem ?: inner.getEventTimelineItemByEventId(eventId.value)
|
||||
inReplyTo.use { eventTimelineItem ->
|
||||
inner.sendReply(messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), eventTimelineItem)
|
||||
}
|
||||
specialModeEventTimelineItem = null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendImage(
|
||||
file: File,
|
||||
thumbnailFile: File?,
|
||||
imageInfo: ImageInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
|
||||
inner.sendImage(
|
||||
url = file.path,
|
||||
thumbnailUrl = thumbnailFile?.path,
|
||||
imageInfo = imageInfo.map(),
|
||||
caption = body,
|
||||
formattedCaption = formattedBody?.let {
|
||||
FormattedBody(body = it, format = MessageFormat.Html)
|
||||
},
|
||||
progressWatcher = progressCallback?.toProgressWatcher()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendVideo(
|
||||
file: File,
|
||||
thumbnailFile: File?,
|
||||
videoInfo: VideoInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
|
||||
inner.sendVideo(
|
||||
url = file.path,
|
||||
thumbnailUrl = thumbnailFile?.path,
|
||||
videoInfo = videoInfo.map(),
|
||||
caption = body,
|
||||
formattedCaption = formattedBody?.let {
|
||||
FormattedBody(body = it, format = MessageFormat.Html)
|
||||
},
|
||||
progressWatcher = progressCallback?.toProgressWatcher()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOf(file)) {
|
||||
inner.sendAudio(
|
||||
url = file.path,
|
||||
audioInfo = audioInfo.map(),
|
||||
// Maybe allow a caption in the future?
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
progressWatcher = progressCallback?.toProgressWatcher()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOf(file)) {
|
||||
inner.sendFile(file.path, fileInfo.map(), progressCallback?.toProgressWatcher())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
inner.toggleReaction(key = emoji, eventId = eventId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds)
|
||||
}.onFailure {
|
||||
Timber.e(it)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
inner.retrySend(transactionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
inner.cancelSend(transactionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendLocation(
|
||||
body: String,
|
||||
geoUri: String,
|
||||
description: String?,
|
||||
zoomLevel: Int?,
|
||||
assetType: AssetType?,
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
inner.sendLocation(
|
||||
body = body,
|
||||
geoUri = geoUri,
|
||||
description = description,
|
||||
zoomLevel = zoomLevel?.toUByte(),
|
||||
assetType = assetType?.toInner(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createPoll(
|
||||
question: String,
|
||||
answers: List<String>,
|
||||
maxSelections: Int,
|
||||
pollKind: PollKind,
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
inner.createPoll(
|
||||
question = question,
|
||||
answers = answers,
|
||||
maxSelections = maxSelections.toUByte(),
|
||||
pollKind = pollKind.toInner(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun editPoll(
|
||||
pollStartId: EventId,
|
||||
question: String,
|
||||
answers: List<String>,
|
||||
maxSelections: Int,
|
||||
pollKind: PollKind,
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
val pollStartEvent =
|
||||
inner.getEventTimelineItemByEventId(
|
||||
eventId = pollStartId.value
|
||||
)
|
||||
pollStartEvent.use {
|
||||
inner.editPoll(
|
||||
question = question,
|
||||
answers = answers,
|
||||
maxSelections = maxSelections.toUByte(),
|
||||
pollKind = pollKind.toInner(),
|
||||
editItem = pollStartEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendPollResponse(
|
||||
pollStartId: EventId,
|
||||
answers: List<String>
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
inner.sendPollResponse(
|
||||
pollStartId = pollStartId.value,
|
||||
answers = answers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun endPoll(
|
||||
pollStartId: EventId,
|
||||
text: String
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
inner.endPoll(
|
||||
pollStartId = pollStartId.value,
|
||||
text = text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendVoiceMessage(
|
||||
file: File,
|
||||
audioInfo: AudioInfo,
|
||||
waveform: List<Float>,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> = sendAttachment(listOf(file)) {
|
||||
inner.sendVoiceMessage(
|
||||
url = file.path,
|
||||
audioInfo = audioInfo.map(),
|
||||
waveform = waveform.toMSC3246range(),
|
||||
// Maybe allow a caption in the future?
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
progressWatcher = progressCallback?.toProgressWatcher(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun messageEventContentFromParts(body: String, htmlBody: String?): RoomMessageEventContentWithoutRelation =
|
||||
if (htmlBody != null) {
|
||||
messageEventContentFromHtml(body, htmlBody)
|
||||
} else {
|
||||
messageEventContentFromMarkdown(body)
|
||||
}
|
||||
|
||||
private fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> {
|
||||
return runCatching {
|
||||
MediaUploadHandlerImpl(files, handle())
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchDetailsForEvent(eventId: EventId): Result<Unit> {
|
||||
return runCatching {
|
||||
inner.fetchDetailsForEvent(eventId.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,9 +57,16 @@ class EventMessageMapper {
|
||||
senderProfile = event.senderProfile.map(),
|
||||
)
|
||||
}
|
||||
is RepliedToEventDetails.Error -> InReplyTo.Error
|
||||
is RepliedToEventDetails.Pending -> InReplyTo.Pending
|
||||
is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(inReplyToId)
|
||||
is RepliedToEventDetails.Error -> InReplyTo.Error(
|
||||
eventId = inReplyToId,
|
||||
message = event.message,
|
||||
)
|
||||
RepliedToEventDetails.Pending -> InReplyTo.Pending(
|
||||
eventId = inReplyToId,
|
||||
)
|
||||
is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(
|
||||
eventId = inReplyToId
|
||||
)
|
||||
}
|
||||
}
|
||||
MessageContent(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
|
||||
internal fun List<MatrixTimelineItem>.hasEncryptionHistoryBanner(): Boolean {
|
||||
val firstItem = firstOrNull()
|
||||
return firstItem is MatrixTimelineItem.Virtual &&
|
||||
firstItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
|
||||
/**
|
||||
* This post processor is responsible for adding virtual items to indicate all the previous last forward item.
|
||||
*/
|
||||
class LastForwardIndicatorsPostProcessor(
|
||||
private val isTimelineLive: Boolean,
|
||||
) {
|
||||
private val lastForwardIdentifiers = LinkedHashSet<String>()
|
||||
|
||||
fun process(
|
||||
items: List<MatrixTimelineItem>,
|
||||
): List<MatrixTimelineItem> {
|
||||
// If the timeline is live, we don't have any last forward indicator to display
|
||||
if (isTimelineLive) {
|
||||
return items
|
||||
} else {
|
||||
return buildList {
|
||||
val latestEventIdentifier = items.latestEventIdentifier()
|
||||
// Remove if it always exists (this should happen only when no new events are added)
|
||||
lastForwardIdentifiers.remove(latestEventIdentifier)
|
||||
|
||||
items.forEach { item ->
|
||||
add(item)
|
||||
|
||||
if (item is MatrixTimelineItem.Event) {
|
||||
if (lastForwardIdentifiers.contains(item.uniqueId)) {
|
||||
add(createLastForwardIndicator(item.uniqueId))
|
||||
}
|
||||
}
|
||||
}
|
||||
// This is important to always add this one at the end of the list so it's used to keep the scroll position.
|
||||
add(createLastForwardIndicator(latestEventIdentifier))
|
||||
lastForwardIdentifiers.add(latestEventIdentifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLastForwardIndicator(identifier: String): MatrixTimelineItem {
|
||||
return MatrixTimelineItem.Virtual(
|
||||
uniqueId = "last_forward_indicator_$identifier",
|
||||
virtual = VirtualTimelineItem.LastForwardIndicator
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<MatrixTimelineItem>.latestEventIdentifier(): String {
|
||||
return findLast {
|
||||
it is MatrixTimelineItem.Event
|
||||
}?.let {
|
||||
(it as MatrixTimelineItem.Event).uniqueId
|
||||
} ?: "fake_id"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
|
||||
class LoadingIndicatorsPostProcessor(private val systemClock: SystemClock) {
|
||||
fun process(
|
||||
items: List<MatrixTimelineItem>,
|
||||
hasMoreToLoadBackward: Boolean,
|
||||
hasMoreToLoadForward: Boolean,
|
||||
): List<MatrixTimelineItem> {
|
||||
val shouldAddBackwardLoadingIndicator = hasMoreToLoadBackward && !items.hasEncryptionHistoryBanner()
|
||||
val shouldAddForwardLoadingIndicator = hasMoreToLoadForward && items.isNotEmpty()
|
||||
val currentTimestamp = systemClock.epochMillis()
|
||||
return buildList {
|
||||
if (shouldAddBackwardLoadingIndicator) {
|
||||
val backwardLoadingIndicator = MatrixTimelineItem.Virtual(
|
||||
uniqueId = "BackwardLoadingIndicator",
|
||||
virtual = VirtualTimelineItem.LoadingIndicator(
|
||||
direction = Timeline.PaginationDirection.BACKWARDS,
|
||||
timestamp = currentTimestamp
|
||||
)
|
||||
)
|
||||
add(backwardLoadingIndicator)
|
||||
}
|
||||
addAll(items)
|
||||
if (shouldAddForwardLoadingIndicator) {
|
||||
val forwardLoadingIndicator = MatrixTimelineItem.Virtual(
|
||||
uniqueId = "ForwardLoadingIndicator",
|
||||
virtual = VirtualTimelineItem.LoadingIndicator(
|
||||
direction = Timeline.PaginationDirection.FORWARDS,
|
||||
timestamp = currentTimestamp
|
||||
)
|
||||
)
|
||||
add(forwardLoadingIndicator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,23 +16,38 @@
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
|
||||
/**
|
||||
* This timeline post-processor removes the room creation event and the self-join event from the timeline for DMs.
|
||||
* This timeline post-processor removes the room creation event and the self-join event from the timeline for DMs
|
||||
* or add the RoomBeginning item for non DM room.
|
||||
*/
|
||||
class DmBeginningTimelineProcessor {
|
||||
class RoomBeginningPostProcessor {
|
||||
fun process(
|
||||
items: List<MatrixTimelineItem>,
|
||||
isDm: Boolean,
|
||||
isAtStartOfTimeline: Boolean
|
||||
hasMoreToLoadBackwards: Boolean
|
||||
): List<MatrixTimelineItem> {
|
||||
if (!isDm || !isAtStartOfTimeline) return items
|
||||
return when {
|
||||
hasMoreToLoadBackwards -> items
|
||||
isDm -> processForDM(items)
|
||||
else -> processForRoom(items)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processForRoom(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
||||
if (items.hasEncryptionHistoryBanner()) return items
|
||||
val roomBeginningItem = createRoomBeginningItem()
|
||||
return listOf(roomBeginningItem) + items
|
||||
}
|
||||
|
||||
private fun processForDM(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
||||
// Find room creation event. This is usually index 0
|
||||
val roomCreationEventIndex = items.indexOfFirst {
|
||||
val stateEventContent = (it as? MatrixTimelineItem.Event)?.event?.content as? StateContent
|
||||
@@ -58,4 +73,12 @@ class DmBeginningTimelineProcessor {
|
||||
}
|
||||
return newItems
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun createRoomBeginningItem(): MatrixTimelineItem.Virtual {
|
||||
return MatrixTimelineItem.Virtual(
|
||||
uniqueId = VirtualTimelineItem.RoomBeginning.toString(),
|
||||
virtual = VirtualTimelineItem.RoomBeginning
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -22,21 +22,22 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MembershipCha
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import org.junit.Test
|
||||
|
||||
class DmBeginningTimelineProcessorTest {
|
||||
class RoomBeginningPostProcessorTest {
|
||||
@Test
|
||||
fun `processor removes room creation event and self-join event from DM timeline`() {
|
||||
val timelineItems = listOf(
|
||||
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
|
||||
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
|
||||
)
|
||||
val processor = DmBeginningTimelineProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = true)
|
||||
val processor = RoomBeginningPostProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false)
|
||||
assertThat(processedItems).isEmpty()
|
||||
}
|
||||
|
||||
@@ -52,19 +53,31 @@ class DmBeginningTimelineProcessorTest {
|
||||
MatrixTimelineItem.Event("m.room.member_other", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))),
|
||||
MatrixTimelineItem.Event("m.room.message", anEventTimelineItem(content = aMessageContent("hi"))),
|
||||
)
|
||||
val processor = DmBeginningTimelineProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = true)
|
||||
val processor = RoomBeginningPostProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false)
|
||||
assertThat(processedItems).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processor won't remove items if it's not a DM`() {
|
||||
fun `processor will add beginning of room item if it's not a DM`() {
|
||||
val timelineItems = listOf(
|
||||
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
|
||||
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
|
||||
)
|
||||
val processor = DmBeginningTimelineProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = false, isAtStartOfTimeline = true)
|
||||
val processor = RoomBeginningPostProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = false)
|
||||
assertThat(processedItems).isEqualTo(
|
||||
listOf(processor.createRoomBeginningItem()) + timelineItems
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processor will not add beginning of room item if it's not a DM and EncryptedHistoryBanner item is found`() {
|
||||
val timelineItems = listOf(
|
||||
MatrixTimelineItem.Virtual("EncryptedHistoryBanner", VirtualTimelineItem.EncryptedHistoryBanner),
|
||||
)
|
||||
val processor = RoomBeginningPostProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = false)
|
||||
assertThat(processedItems).isEqualTo(timelineItems)
|
||||
}
|
||||
|
||||
@@ -74,8 +87,8 @@ class DmBeginningTimelineProcessorTest {
|
||||
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
|
||||
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
|
||||
)
|
||||
val processor = DmBeginningTimelineProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = false)
|
||||
val processor = RoomBeginningPostProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
|
||||
assertThat(processedItems).isEqualTo(timelineItems)
|
||||
}
|
||||
|
||||
@@ -84,8 +97,8 @@ class DmBeginningTimelineProcessorTest {
|
||||
val timelineItems = listOf(
|
||||
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
|
||||
)
|
||||
val processor = DmBeginningTimelineProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = false)
|
||||
val processor = RoomBeginningPostProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
|
||||
assertThat(processedItems).isEqualTo(timelineItems)
|
||||
}
|
||||
|
||||
@@ -95,8 +108,8 @@ class DmBeginningTimelineProcessorTest {
|
||||
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
|
||||
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))),
|
||||
)
|
||||
val processor = DmBeginningTimelineProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = false)
|
||||
val processor = RoomBeginningPostProcessor()
|
||||
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
|
||||
assertThat(processedItems).isEqualTo(timelineItems)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import io.element.android.libraries.indicator.impl.DefaultIndicatorService
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.preferences.impl.store.DefaultSessionPreferencesStore
|
||||
import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
|
||||
import io.element.android.services.analytics.noop.NoopAnalyticsService
|
||||
@@ -145,7 +146,7 @@ class RoomListScreen(
|
||||
Singleton.appScope.launch {
|
||||
withContext(coroutineDispatchers.io) {
|
||||
matrixClient.getRoom(roomId)!!.use { room ->
|
||||
room.timeline.paginateBackwards(20, 50)
|
||||
room.liveTimeline.paginate(Timeline.PaginationDirection.BACKWARDS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,13 @@ inline fun <reified T1, reified T2, reified T3, reified T4, reified R> lambdaRec
|
||||
return LambdaFourParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2, T3, T4, T5) -> R
|
||||
): LambdaFiveParamsRecorder<T1, T2, T3, T4, T5, R> {
|
||||
return LambdaFiveParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
class LambdaNoParamRecorder<out R>(ensureNeverCalled: Boolean, val block: () -> R) : LambdaRecorder(ensureNeverCalled), () -> R {
|
||||
override fun invoke(): R {
|
||||
onInvoke()
|
||||
@@ -109,3 +116,12 @@ class LambdaFourParamsRecorder<in T1, in T2, in T3, in T4, out R>(ensureNeverCal
|
||||
return block(p1, p2, p3, p4)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaFiveParamsRecorder<in T1, in T2, in T3, in T4, in T5, out R>(ensureNeverCalled: Boolean, val block: (T1, T2, T3, T4, T5) -> R) : LambdaRecorder(
|
||||
ensureNeverCalled
|
||||
), (T1, T2, T3, T4, T5) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5): R {
|
||||
onInvoke(p1, p2, p3, p4, p5)
|
||||
return block(p1, p2, p3, p4, p5)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user