Merge pull request #2759 from element-hq/feature/fga/permalink_timeline
Permalink timeline
This commit is contained in:
commit
51d87a8309
383 changed files with 3579 additions and 1506 deletions
|
|
@ -282,7 +282,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
is PermalinkData.RoomLink -> {
|
is PermalinkData.RoomLink -> {
|
||||||
backstack.push(
|
backstack.push(
|
||||||
NavTarget.Room(
|
NavTarget.Room(
|
||||||
data.roomIdOrAlias,
|
roomIdOrAlias = data.roomIdOrAlias,
|
||||||
initialElement = RoomNavigationTarget.Messages(data.eventId),
|
initialElement = RoomNavigationTarget.Messages(data.eventId),
|
||||||
// TODO Use the viaParameters
|
// TODO Use the viaParameters
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
data class Inputs(
|
data class Inputs(
|
||||||
val roomIdOrAlias: RoomIdOrAlias,
|
val roomIdOrAlias: RoomIdOrAlias,
|
||||||
val roomDescription: Optional<RoomDescription>,
|
val roomDescription: Optional<RoomDescription>,
|
||||||
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(),
|
val initialElement: RoomNavigationTarget,
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
private val inputs: Inputs = inputs()
|
||||||
|
|
@ -177,7 +177,10 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
is NavTarget.JoinedRoom -> {
|
is NavTarget.JoinedRoom -> {
|
||||||
val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
|
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)
|
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
|
||||||
) {
|
) {
|
||||||
data class Inputs(
|
data class Inputs(
|
||||||
val roomId: RoomId,
|
val roomId: RoomId,
|
||||||
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(),
|
val initialElement: RoomNavigationTarget,
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
private val inputs: Inputs = inputs()
|
||||||
|
|
@ -106,7 +106,10 @@ class JoinedRoomFlowNode @AssistedInject constructor(
|
||||||
val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
|
val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
|
||||||
val awaitRoomState = loadingRoomStateStateFlow.value
|
val awaitRoomState = loadingRoomStateStateFlow.value
|
||||||
if (awaitRoomState is LoadingRoomState.Loaded) {
|
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)
|
createNode<JoinedRoomLoadedFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
|
||||||
} else {
|
} else {
|
||||||
loadingNode(buildContext, this::navigateUp)
|
loadingNode(buildContext, this::navigateUp)
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
data class Inputs(
|
data class Inputs(
|
||||||
val room: MatrixRoom,
|
val room: MatrixRoom,
|
||||||
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(),
|
val initialElement: RoomNavigationTarget,
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
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.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.appnav.di.RoomComponentFactory
|
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.appnav.room.joined.JoinedRoomLoadedFlowNode
|
||||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||||
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||||
|
|
@ -124,7 +125,7 @@ class JoinRoomLoadedFlowNodeTest {
|
||||||
// GIVEN
|
// GIVEN
|
||||||
val room = FakeMatrixRoom()
|
val room = FakeMatrixRoom()
|
||||||
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
|
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
|
||||||
val inputs = JoinedRoomLoadedFlowNode.Inputs(room)
|
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
|
||||||
val roomFlowNode = createJoinedRoomLoadedFlowNode(
|
val roomFlowNode = createJoinedRoomLoadedFlowNode(
|
||||||
plugins = listOf(inputs),
|
plugins = listOf(inputs),
|
||||||
messagesEntryPoint = fakeMessagesEntryPoint,
|
messagesEntryPoint = fakeMessagesEntryPoint,
|
||||||
|
|
@ -146,7 +147,7 @@ class JoinRoomLoadedFlowNodeTest {
|
||||||
val room = FakeMatrixRoom()
|
val room = FakeMatrixRoom()
|
||||||
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
|
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
|
||||||
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
|
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
|
||||||
val inputs = JoinedRoomLoadedFlowNode.Inputs(room)
|
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
|
||||||
val roomFlowNode = createJoinedRoomLoadedFlowNode(
|
val roomFlowNode = createJoinedRoomLoadedFlowNode(
|
||||||
plugins = listOf(inputs),
|
plugins = listOf(inputs),
|
||||||
messagesEntryPoint = fakeMessagesEntryPoint,
|
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 {
|
return object : MessagesEntryPoint.NodeBuilder {
|
||||||
override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
|
override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
|
||||||
plugins += MessagesNode.Inputs(focusedEventId = params.focusedEventId)
|
plugins += MessagesFlowNode.Inputs(focusedEventId = params.focusedEventId)
|
||||||
return this
|
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.BaseFlowNode
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.architecture.createNode
|
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.Overlay
|
||||||
import io.element.android.libraries.architecture.overlay.operation.show
|
import io.element.android.libraries.architecture.overlay.operation.show
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
|
@ -81,7 +82,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
private val createPollEntryPoint: CreatePollEntryPoint,
|
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||||
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
|
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.Messages(plugins.filterIsInstance<Inputs>().firstOrNull()?.focusedEventId),
|
initialElement = NavTarget.Messages,
|
||||||
savedStateMap = buildContext.savedStateMap,
|
savedStateMap = buildContext.savedStateMap,
|
||||||
),
|
),
|
||||||
overlay = Overlay(
|
overlay = Overlay(
|
||||||
|
|
@ -91,15 +92,14 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
plugins = plugins
|
plugins = plugins
|
||||||
) {
|
) {
|
||||||
data class Inputs(val focusedEventId: EventId?) : NodeInputs
|
data class Inputs(val focusedEventId: EventId?) : NodeInputs
|
||||||
|
private val inputs = inputs<Inputs>()
|
||||||
|
|
||||||
sealed interface NavTarget : Parcelable {
|
sealed interface NavTarget : Parcelable {
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object Empty : NavTarget
|
data object Empty : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class Messages(
|
data object Messages : NavTarget
|
||||||
val focusedEventId: EventId? = null,
|
|
||||||
) : NavTarget
|
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class MediaViewer(
|
data class MediaViewer(
|
||||||
|
|
@ -191,10 +191,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
ElementCallActivity.start(context, inputs)
|
ElementCallActivity.start(context, inputs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val params = MessagesNode.Inputs(
|
val inputs = MessagesNode.Inputs(
|
||||||
focusedEventId = navTarget.focusedEventId,
|
focusedEventId = inputs.focusedEventId,
|
||||||
)
|
)
|
||||||
createNode<MessagesNode>(buildContext, listOf(callback, params))
|
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
|
||||||
}
|
}
|
||||||
is NavTarget.MediaViewer -> {
|
is NavTarget.MediaViewer -> {
|
||||||
val inputs = MediaViewerNode.Inputs(
|
val inputs = MediaViewerNode.Inputs(
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ package io.element.android.features.messages.impl
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
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.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.bumble.appyx.core.lifecycle.subscribe
|
import com.bumble.appyx.core.lifecycle.subscribe
|
||||||
|
|
@ -30,12 +35,15 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
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.LocalTimelineItemPresenterFactories
|
||||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
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.openUrlInExternalApp
|
||||||
import io.element.android.libraries.androidutils.system.toast
|
import io.element.android.libraries.androidutils.system.toast
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
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.core.bool.orFalse
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
|
@ -64,13 +72,15 @@ class MessagesNode @AssistedInject constructor(
|
||||||
private val permalinkParser: PermalinkParser,
|
private val permalinkParser: PermalinkParser,
|
||||||
@ApplicationContext
|
@ApplicationContext
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
|
private val timelineController: TimelineController,
|
||||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||||
private val presenter = presenterFactory.create(this)
|
private val presenter = presenterFactory.create(this)
|
||||||
private val callback = plugins<Callback>().firstOrNull()
|
private val callback = plugins<Callback>().firstOrNull()
|
||||||
|
|
||||||
// TODO Handle navigation to the Event
|
|
||||||
data class Inputs(val focusedEventId: EventId?) : NodeInputs
|
data class Inputs(val focusedEventId: EventId?) : NodeInputs
|
||||||
|
|
||||||
|
private val inputs = inputs<Inputs>()
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onRoomDetailsClicked()
|
fun onRoomDetailsClicked()
|
||||||
fun onEventClicked(event: TimelineItem.Event): Boolean
|
fun onEventClicked(event: TimelineItem.Event): Boolean
|
||||||
|
|
@ -86,12 +96,14 @@ class MessagesNode @AssistedInject constructor(
|
||||||
fun onJoinCallClicked(roomId: RoomId)
|
fun onJoinCallClicked(roomId: RoomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
override fun onBuilt() {
|
||||||
|
super.onBuilt()
|
||||||
lifecycle.subscribe(
|
lifecycle.subscribe(
|
||||||
onCreate = {
|
onCreate = {
|
||||||
analyticsService.capture(room.toAnalyticsViewRoom())
|
analyticsService.capture(room.toAnalyticsViewRoom())
|
||||||
},
|
},
|
||||||
onDestroy = {
|
onDestroy = {
|
||||||
|
timelineController.close()
|
||||||
mediaPlayer.close()
|
mediaPlayer.close()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -116,6 +128,7 @@ class MessagesNode @AssistedInject constructor(
|
||||||
private fun onLinkClicked(
|
private fun onLinkClicked(
|
||||||
context: Context,
|
context: Context,
|
||||||
url: String,
|
url: String,
|
||||||
|
eventSink: (TimelineEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
when (val permalink = permalinkParser.parse(url)) {
|
when (val permalink = permalinkParser.parse(url)) {
|
||||||
is PermalinkData.UserLink -> {
|
is PermalinkData.UserLink -> {
|
||||||
|
|
@ -124,7 +137,7 @@ class MessagesNode @AssistedInject constructor(
|
||||||
callback?.onUserDataClicked(permalink.userId)
|
callback?.onUserDataClicked(permalink.userId)
|
||||||
}
|
}
|
||||||
is PermalinkData.RoomLink -> {
|
is PermalinkData.RoomLink -> {
|
||||||
handleRoomLinkClicked(permalink)
|
handleRoomLinkClicked(permalink, eventSink)
|
||||||
}
|
}
|
||||||
is PermalinkData.FallbackLink,
|
is PermalinkData.FallbackLink,
|
||||||
is PermalinkData.RoomEmailInviteLink -> {
|
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 (room.matches(roomLink.roomIdOrAlias)) {
|
||||||
if (roomLink.eventId != null) {
|
val eventId = roomLink.eventId
|
||||||
// TODO Handle navigation to the Event
|
if (eventId != null) {
|
||||||
context.toast("TODO Handle navigation to the Event ${roomLink.eventId}")
|
eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||||
} else {
|
} else {
|
||||||
// Click on the same room, ignore
|
// Click on the same room, ignore
|
||||||
context.toast("Already viewing this room!")
|
context.toast("Already viewing this room!")
|
||||||
|
|
@ -189,12 +202,23 @@ class MessagesNode @AssistedInject constructor(
|
||||||
onEventClicked = this::onEventClicked,
|
onEventClicked = this::onEventClicked,
|
||||||
onPreviewAttachments = this::onPreviewAttachments,
|
onPreviewAttachments = this::onPreviewAttachments,
|
||||||
onUserDataClicked = this::onUserDataClicked,
|
onUserDataClicked = this::onUserDataClicked,
|
||||||
onLinkClicked = { onLinkClicked(context, it) },
|
onLinkClicked = { onLinkClicked(context, it, state.timelineState.eventSink) },
|
||||||
onSendLocationClicked = this::onSendLocationClicked,
|
onSendLocationClicked = this::onSendLocationClicked,
|
||||||
onCreatePollClicked = this::onCreatePollClicked,
|
onCreatePollClicked = this::onCreatePollClicked,
|
||||||
onJoinCallClicked = this::onJoinCallClicked,
|
onJoinCallClicked = this::onJoinCallClicked,
|
||||||
modifier = modifier,
|
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.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
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.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.TimelineEvents
|
||||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||||
|
|
@ -116,6 +117,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
private val htmlConverterProvider: HtmlConverterProvider,
|
private val htmlConverterProvider: HtmlConverterProvider,
|
||||||
@Assisted private val navigator: MessagesNavigator,
|
@Assisted private val navigator: MessagesNavigator,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
|
private val timelineController: TimelineController,
|
||||||
) : Presenter<MessagesState> {
|
) : Presenter<MessagesState> {
|
||||||
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
|
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
|
||||||
|
|
||||||
|
|
@ -185,10 +187,6 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||||
|
|
||||||
LaunchedEffect(composerState.mode.relatedEventId) {
|
|
||||||
timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId))
|
|
||||||
}
|
|
||||||
|
|
||||||
val enableTextFormatting by appPreferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
val enableTextFormatting by appPreferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
||||||
|
|
||||||
var enableVoiceMessages by remember { mutableStateOf(false) }
|
var enableVoiceMessages by remember { mutableStateOf(false) }
|
||||||
|
|
@ -290,8 +288,10 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
emoji: String,
|
emoji: String,
|
||||||
eventId: EventId,
|
eventId: EventId,
|
||||||
) = launch(dispatchers.io) {
|
) = launch(dispatchers.io) {
|
||||||
room.toggleReaction(emoji, eventId)
|
timelineController.invokeOnCurrentTimeline {
|
||||||
.onFailure { Timber.e(it) }
|
toggleReaction(emoji, eventId)
|
||||||
|
.onFailure { Timber.e(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<AsyncData<Unit>>) = launch(dispatchers.io) {
|
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<AsyncData<Unit>>) = launch(dispatchers.io) {
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@ fun aMessagesState(
|
||||||
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
|
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
|
||||||
timelineState: TimelineState = aTimelineState(
|
timelineState: TimelineState = aTimelineState(
|
||||||
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||||
|
// Render a focused event for an event with sender information displayed
|
||||||
|
focusedEventIndex = 2,
|
||||||
),
|
),
|
||||||
retrySendMenuState: RetrySendMenuState = aRetrySendMenuState(),
|
retrySendMenuState: RetrySendMenuState = aRetrySendMenuState(),
|
||||||
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
|
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
|
||||||
|
|
|
||||||
|
|
@ -382,20 +382,19 @@ private fun MessagesViewContent(
|
||||||
},
|
},
|
||||||
content = { paddingValues ->
|
content = { paddingValues ->
|
||||||
TimelineView(
|
TimelineView(
|
||||||
modifier = Modifier.padding(paddingValues),
|
|
||||||
state = state.timelineState,
|
state = state.timelineState,
|
||||||
roomName = state.roomName.dataOrNull(),
|
|
||||||
typingNotificationState = state.typingNotificationState,
|
typingNotificationState = state.typingNotificationState,
|
||||||
onMessageClicked = onMessageClicked,
|
|
||||||
onMessageLongClicked = onMessageLongClicked,
|
|
||||||
onUserDataClicked = onUserDataClicked,
|
onUserDataClicked = onUserDataClicked,
|
||||||
onLinkClicked = onLinkClicked,
|
onLinkClicked = onLinkClicked,
|
||||||
|
onMessageClicked = onMessageClicked,
|
||||||
|
onMessageLongClicked = onMessageLongClicked,
|
||||||
onTimestampClicked = onTimestampClicked,
|
onTimestampClicked = onTimestampClicked,
|
||||||
|
onSwipeToReply = onSwipeToReply,
|
||||||
onReactionClicked = onReactionClicked,
|
onReactionClicked = onReactionClicked,
|
||||||
onReactionLongClicked = onReactionLongClicked,
|
onReactionLongClicked = onReactionLongClicked,
|
||||||
onMoreReactionsClicked = onMoreReactionsClicked,
|
onMoreReactionsClicked = onMoreReactionsClicked,
|
||||||
onReadReceiptClick = onReadReceiptClick,
|
onReadReceiptClick = onReadReceiptClick,
|
||||||
onSwipeToReply = onSwipeToReply,
|
modifier = Modifier.padding(paddingValues),
|
||||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
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.architecture.Presenter
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
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.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.ImmutableList
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -37,8 +38,8 @@ import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class ForwardMessagesPresenter @AssistedInject constructor(
|
class ForwardMessagesPresenter @AssistedInject constructor(
|
||||||
@Assisted eventId: String,
|
@Assisted eventId: String,
|
||||||
private val room: MatrixRoom,
|
|
||||||
private val matrixCoroutineScope: CoroutineScope,
|
private val matrixCoroutineScope: CoroutineScope,
|
||||||
|
private val timelineProvider: TimelineProvider,
|
||||||
) : Presenter<ForwardMessagesState> {
|
) : Presenter<ForwardMessagesState> {
|
||||||
private val eventId: EventId = EventId(eventId)
|
private val eventId: EventId = EventId(eventId)
|
||||||
|
|
||||||
|
|
@ -79,7 +80,7 @@ class ForwardMessagesPresenter @AssistedInject constructor(
|
||||||
isForwardMessagesState: MutableState<AsyncData<ImmutableList<RoomId>>>,
|
isForwardMessagesState: MutableState<AsyncData<ImmutableList<RoomId>>>,
|
||||||
) = launch {
|
) = launch {
|
||||||
isForwardMessagesState.value = AsyncData.Loading()
|
isForwardMessagesState.value = AsyncData.Loading()
|
||||||
room.forwardEvent(eventId, roomIds).fold(
|
timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds).fold(
|
||||||
{ isForwardMessagesState.value = AsyncData.Success(roomIds) },
|
{ isForwardMessagesState.value = AsyncData.Success(roomIds) },
|
||||||
{ isForwardMessagesState.value = AsyncData.Failure(it) }
|
{ 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.attachments.preview.error.sendAttachmentError
|
||||||
import io.element.android.features.messages.impl.mentions.MentionSuggestion
|
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.mentions.MentionSuggestionsProcessor
|
||||||
|
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||||
import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
|
|
@ -100,6 +101,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
private val permalinkParser: PermalinkParser,
|
private val permalinkParser: PermalinkParser,
|
||||||
private val permalinkBuilder: PermalinkBuilder,
|
private val permalinkBuilder: PermalinkBuilder,
|
||||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||||
|
private val timelineController: TimelineController,
|
||||||
) : Presenter<MessageComposerState> {
|
) : Presenter<MessageComposerState> {
|
||||||
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
|
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
|
||||||
private var pendingEvent: MessageComposerEvents? = null
|
private var pendingEvent: MessageComposerEvents? = null
|
||||||
|
|
@ -264,7 +266,9 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
is MessageComposerMode.Quote -> null
|
is MessageComposerMode.Quote -> null
|
||||||
}.let { relatedEventId ->
|
}.let { relatedEventId ->
|
||||||
appCoroutineScope.launch {
|
appCoroutineScope.launch {
|
||||||
room.enterSpecialMode(relatedEventId)
|
timelineController.invokeOnCurrentTimeline {
|
||||||
|
enterSpecialMode(relatedEventId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -386,16 +390,17 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
is MessageComposerMode.Edit -> {
|
is MessageComposerMode.Edit -> {
|
||||||
val eventId = capturedMode.eventId
|
val eventId = capturedMode.eventId
|
||||||
val transactionId = capturedMode.transactionId
|
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.Quote -> TODO()
|
||||||
is MessageComposerMode.Reply -> room.replyMessage(
|
is MessageComposerMode.Reply -> {
|
||||||
capturedMode.eventId,
|
timelineController.invokeOnCurrentTimeline {
|
||||||
message.markdown,
|
replyMessage(capturedMode.eventId, message.markdown, message.html, mentions)
|
||||||
message.html,
|
}
|
||||||
mentions
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
analyticsService.capture(
|
analyticsService.capture(
|
||||||
Composer(
|
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
|
package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
|
||||||
sealed interface TimelineEvents {
|
sealed interface TimelineEvents {
|
||||||
data object LoadMore : TimelineEvents
|
|
||||||
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
|
|
||||||
data class OnScrollFinished(val firstIndex: Int) : 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.
|
* Events coming from a timeline item.
|
||||||
*/
|
*/
|
||||||
sealed interface EventFromTimelineItem : TimelineEvents
|
sealed interface EventFromTimelineItem : TimelineEvents
|
||||||
|
|
||||||
|
data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Events coming from a poll item.
|
* 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.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private const val BACK_PAGINATION_EVENT_LIMIT = 20
|
|
||||||
private const val BACK_PAGINATION_PAGE_SIZE = 50
|
|
||||||
|
|
||||||
class TimelinePresenter @AssistedInject constructor(
|
class TimelinePresenter @AssistedInject constructor(
|
||||||
private val timelineItemsFactory: TimelineItemsFactory,
|
private val timelineItemsFactory: TimelineItemsFactory,
|
||||||
|
private val timelineItemIndexer: TimelineItemIndexer,
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
private val appScope: CoroutineScope,
|
private val appScope: CoroutineScope,
|
||||||
|
|
@ -67,50 +65,62 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
private val sendPollResponseAction: SendPollResponseAction,
|
private val sendPollResponseAction: SendPollResponseAction,
|
||||||
private val endPollAction: EndPollAction,
|
private val endPollAction: EndPollAction,
|
||||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||||
|
private val timelineController: TimelineController,
|
||||||
) : Presenter<TimelineState> {
|
) : Presenter<TimelineState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(navigator: MessagesNavigator): TimelinePresenter
|
fun create(navigator: MessagesNavigator): TimelinePresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
private val timeline = room.timeline
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): TimelineState {
|
override fun present(): TimelineState {
|
||||||
val localScope = rememberCoroutineScope()
|
val localScope = rememberCoroutineScope()
|
||||||
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
|
val focusedEventId: MutableState<EventId?> = rememberSaveable {
|
||||||
mutableStateOf(null)
|
mutableStateOf(null)
|
||||||
}
|
}
|
||||||
|
val focusRequestState: MutableState<FocusRequestState> = remember {
|
||||||
|
mutableStateOf(FocusRequestState.None)
|
||||||
|
}
|
||||||
|
|
||||||
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
|
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
|
||||||
|
|
||||||
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
||||||
val paginationState by timeline.paginationState.collectAsState()
|
|
||||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||||
|
|
||||||
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
||||||
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value)
|
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value)
|
||||||
|
|
||||||
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
|
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 isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
|
||||||
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
|
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
|
||||||
|
val isLive by timelineController.isLive().collectAsState(initial = true)
|
||||||
|
|
||||||
fun handleEvents(event: TimelineEvents) {
|
fun handleEvents(event: TimelineEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
TimelineEvents.LoadMore -> localScope.paginateBackwards()
|
is TimelineEvents.LoadMore -> {
|
||||||
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
localScope.launch {
|
||||||
is TimelineEvents.OnScrollFinished -> {
|
timelineController.paginate(direction = event.direction)
|
||||||
if (event.firstIndex == 0) {
|
}
|
||||||
newItemState.value = NewEventState.None
|
}
|
||||||
|
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 {
|
is TimelineEvents.PollAnswerSelected -> appScope.launch {
|
||||||
sendPollResponseAction.execute(
|
sendPollResponseAction.execute(
|
||||||
|
|
@ -123,28 +133,58 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
pollStartId = event.pollStartId,
|
pollStartId = event.pollStartId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is TimelineEvents.PollEditClicked ->
|
is TimelineEvents.PollEditClicked -> {
|
||||||
navigator.onEditPollClicked(event.pollStartId)
|
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) {
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
combine(timeline.timelineItems, room.membersStateFlow) { items, membersState ->
|
combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState ->
|
||||||
timelineItemsFactory.replaceWith(
|
timelineItemsFactory.replaceWith(
|
||||||
timelineItems = items,
|
timelineItems = items,
|
||||||
roomMembers = membersState.roomMembers().orEmpty()
|
roomMembers = membersState.roomMembers().orEmpty()
|
||||||
)
|
)
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
.onEach { timelineItems ->
|
|
||||||
if (timelineItems.isEmpty()) {
|
|
||||||
paginateBackwards()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem)
|
.onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem)
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +192,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
val timelineRoomInfo by remember {
|
val timelineRoomInfo by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
TimelineRoomInfo(
|
TimelineRoomInfo(
|
||||||
|
name = room.displayName,
|
||||||
isDm = room.isDm,
|
isDm = room.isDm,
|
||||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||||
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
|
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
|
||||||
|
|
@ -160,11 +201,12 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
return TimelineState(
|
return TimelineState(
|
||||||
timelineRoomInfo = timelineRoomInfo,
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
highlightedEventId = highlightedEventId.value,
|
|
||||||
paginationState = paginationState,
|
|
||||||
timelineItems = timelineItems,
|
timelineItems = timelineItems,
|
||||||
renderReadReceipts = renderReadReceipts,
|
renderReadReceipts = renderReadReceipts,
|
||||||
newEventState = newItemState.value,
|
newEventState = newEventState.value,
|
||||||
|
isLive = isLive,
|
||||||
|
focusedEventId = focusedEventId.value,
|
||||||
|
focusRequestState = focusRequestState.value,
|
||||||
eventSink = { handleEvents(it) }
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -190,6 +232,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
newMostRecentItem is TimelineItem.Event &&
|
newMostRecentItem is TimelineItem.Event &&
|
||||||
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
|
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
|
||||||
newMostRecentItemId != prevMostRecentItemIdValue
|
newMostRecentItemId != prevMostRecentItemIdValue
|
||||||
|
|
||||||
if (hasNewEvent) {
|
if (hasNewEvent) {
|
||||||
val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event
|
val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event
|
||||||
// Scroll to bottom if the new event is from me, even if sent from another device
|
// 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)
|
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
|
||||||
if (eventId != null && eventId != lastReadReceiptId.value) {
|
if (eventId != null && eventId != lastReadReceiptId.value) {
|
||||||
lastReadReceiptId.value = eventId
|
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
|
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.NewEventState
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
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.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
@ -28,15 +27,28 @@ data class TimelineState(
|
||||||
val timelineItems: ImmutableList<TimelineItem>,
|
val timelineItems: ImmutableList<TimelineItem>,
|
||||||
val timelineRoomInfo: TimelineRoomInfo,
|
val timelineRoomInfo: TimelineRoomInfo,
|
||||||
val renderReadReceipts: Boolean,
|
val renderReadReceipts: Boolean,
|
||||||
val highlightedEventId: EventId?,
|
|
||||||
val paginationState: MatrixTimeline.PaginationState,
|
|
||||||
val newEventState: NewEventState,
|
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
|
@Immutable
|
||||||
data class TimelineRoomInfo(
|
data class TimelineRoomInfo(
|
||||||
val isDm: Boolean,
|
val isDm: Boolean,
|
||||||
|
val name: String?,
|
||||||
val userHasPermissionToSendMessage: Boolean,
|
val userHasPermissionToSendMessage: Boolean,
|
||||||
val userHasPermissionToSendReaction: 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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
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.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.TimelineItemDebugInfo
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
@ -47,32 +46,22 @@ import kotlin.random.Random
|
||||||
|
|
||||||
fun aTimelineState(
|
fun aTimelineState(
|
||||||
timelineItems: ImmutableList<TimelineItem> = persistentListOf(),
|
timelineItems: ImmutableList<TimelineItem> = persistentListOf(),
|
||||||
paginationState: MatrixTimeline.PaginationState = aPaginationState(),
|
|
||||||
renderReadReceipts: Boolean = false,
|
renderReadReceipts: Boolean = false,
|
||||||
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
|
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
|
||||||
|
focusedEventIndex: Int = -1,
|
||||||
|
isLive: Boolean = true,
|
||||||
eventSink: (TimelineEvents) -> Unit = {},
|
eventSink: (TimelineEvents) -> Unit = {},
|
||||||
) = TimelineState(
|
) = TimelineState(
|
||||||
timelineItems = timelineItems,
|
timelineItems = timelineItems,
|
||||||
timelineRoomInfo = timelineRoomInfo,
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
paginationState = paginationState,
|
|
||||||
renderReadReceipts = renderReadReceipts,
|
renderReadReceipts = renderReadReceipts,
|
||||||
highlightedEventId = null,
|
|
||||||
newEventState = NewEventState.None,
|
newEventState = NewEventState.None,
|
||||||
|
isLive = isLive,
|
||||||
|
focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId,
|
||||||
|
focusRequestState = FocusRequestState.None,
|
||||||
eventSink = eventSink,
|
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> {
|
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
|
||||||
return persistentListOf(
|
return persistentListOf(
|
||||||
// 3 items (First Middle Last) with isMine = false
|
// 3 items (First Middle Last) with isMine = false
|
||||||
|
|
@ -235,10 +224,12 @@ internal fun aGroupedEvents(
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun aTimelineRoomInfo(
|
internal fun aTimelineRoomInfo(
|
||||||
|
name: String = "Room name",
|
||||||
isDm: Boolean = false,
|
isDm: Boolean = false,
|
||||||
userHasPermissionToSendMessage: Boolean = true,
|
userHasPermissionToSendMessage: Boolean = true,
|
||||||
) = TimelineRoomInfo(
|
) = TimelineRoomInfo(
|
||||||
isDm = isDm,
|
isDm = isDm,
|
||||||
|
name = name,
|
||||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||||
userHasPermissionToSendReaction = true,
|
userHasPermissionToSendReaction = true,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,9 @@ import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
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.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.LocalTimelineItemPresenterFactories
|
||||||
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
|
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.NewEventState
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
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.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineView(
|
fun TimelineView(
|
||||||
state: TimelineState,
|
state: TimelineState,
|
||||||
typingNotificationState: TypingNotificationState,
|
typingNotificationState: TypingNotificationState,
|
||||||
roomName: String?,
|
|
||||||
onUserDataClicked: (UserId) -> Unit,
|
onUserDataClicked: (UserId) -> Unit,
|
||||||
onLinkClicked: (String) -> Unit,
|
onLinkClicked: (String) -> Unit,
|
||||||
onMessageClicked: (TimelineItem.Event) -> Unit,
|
onMessageClicked: (TimelineItem.Event) -> Unit,
|
||||||
|
|
@ -93,8 +92,8 @@ fun TimelineView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
forceJumpToBottomVisibility: Boolean = false
|
forceJumpToBottomVisibility: Boolean = false
|
||||||
) {
|
) {
|
||||||
fun onReachedLoadMore() {
|
fun clearFocusRequestState() {
|
||||||
state.eventSink(TimelineEvents.LoadMore)
|
state.eventSink(TimelineEvents.ClearFocusRequestState)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onScrollFinishedAt(firstVisibleIndex: Int) {
|
fun onScrollFinishedAt(firstVisibleIndex: Int) {
|
||||||
|
|
@ -109,9 +108,8 @@ fun TimelineView(
|
||||||
accessibilityManager.isTouchExplorationEnabled.not()
|
accessibilityManager.isTouchExplorationEnabled.not()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNUSED_PARAMETER")
|
|
||||||
fun inReplyToClicked(eventId: EventId) {
|
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
|
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
|
||||||
|
|
@ -123,8 +121,10 @@ fun TimelineView(
|
||||||
reverseLayout = useReverseLayout,
|
reverseLayout = useReverseLayout,
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
if (state.isLive) {
|
||||||
TypingNotificationView(state = typingNotificationState)
|
item {
|
||||||
|
TypingNotificationView(state = typingNotificationState)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
items(
|
items(
|
||||||
items = state.timelineItems,
|
items = state.timelineItems,
|
||||||
|
|
@ -137,7 +137,7 @@ fun TimelineView(
|
||||||
renderReadReceipts = state.renderReadReceipts,
|
renderReadReceipts = state.renderReadReceipts,
|
||||||
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true &&
|
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true &&
|
||||||
state.timelineItems.first().identifier() == timelineItem.identifier(),
|
state.timelineItems.first().identifier() == timelineItem.identifier(),
|
||||||
highlightedItem = state.highlightedEventId?.value,
|
focusedEventId = state.focusedEventId,
|
||||||
onClick = onMessageClicked,
|
onClick = onMessageClicked,
|
||||||
onLongClick = onMessageLongClicked,
|
onLongClick = onMessageLongClicked,
|
||||||
onUserDataClick = onUserDataClicked,
|
onUserDataClick = onUserDataClicked,
|
||||||
|
|
@ -152,28 +152,23 @@ fun TimelineView(
|
||||||
onSwipeToReply = onSwipeToReply,
|
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(
|
TimelineScrollHelper(
|
||||||
isTimelineEmpty = state.timelineItems.isEmpty(),
|
hasAnyEvent = state.hasAnyEvent,
|
||||||
lazyListState = lazyListState,
|
lazyListState = lazyListState,
|
||||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||||
newEventState = state.newEventState,
|
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
|
@Composable
|
||||||
private fun BoxScope.TimelineScrollHelper(
|
private fun BoxScope.TimelineScrollHelper(
|
||||||
isTimelineEmpty: Boolean,
|
hasAnyEvent: Boolean,
|
||||||
lazyListState: LazyListState,
|
lazyListState: LazyListState,
|
||||||
newEventState: NewEventState,
|
newEventState: NewEventState,
|
||||||
|
isLive: Boolean,
|
||||||
forceJumpToBottomVisibility: Boolean,
|
forceJumpToBottomVisibility: Boolean,
|
||||||
|
focusRequestState: FocusRequestState,
|
||||||
|
onClearFocusRequestState: () -> Unit,
|
||||||
onScrollFinishedAt: (Int) -> Unit,
|
onScrollFinishedAt: (Int) -> Unit,
|
||||||
|
onJumpToLive: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
||||||
val canAutoScroll by remember {
|
val canAutoScroll by remember {
|
||||||
derivedStateOf {
|
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) {
|
LaunchedEffect(canAutoScroll, newEventState) {
|
||||||
val shouldAutoScroll = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
|
val shouldScrollToBottom = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
|
||||||
if (shouldAutoScroll) {
|
if (shouldScrollToBottom) {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val latestOnScrollFinishedAt by rememberUpdatedState(onScrollFinishedAt)
|
val latestOnScrollFinishedAt by rememberUpdatedState(onScrollFinishedAt)
|
||||||
LaunchedEffect(isScrollFinished, isTimelineEmpty) {
|
LaunchedEffect(isScrollFinished, hasAnyEvent) {
|
||||||
if (isScrollFinished && !isTimelineEmpty) {
|
if (isScrollFinished && hasAnyEvent) {
|
||||||
// Notify the parent composable about the first visible item index when scrolling finishes
|
// Notify the parent composable about the first visible item index when scrolling finishes
|
||||||
latestOnScrollFinishedAt(lazyListState.firstVisibleItemIndex)
|
latestOnScrollFinishedAt(lazyListState.firstVisibleItemIndex)
|
||||||
}
|
}
|
||||||
|
|
@ -222,11 +241,11 @@ private fun BoxScope.TimelineScrollHelper(
|
||||||
|
|
||||||
JumpToBottomButton(
|
JumpToBottomButton(
|
||||||
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
|
// 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
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(end = 24.dp, bottom = 12.dp),
|
.padding(end = 24.dp, bottom = 12.dp),
|
||||||
onClick = ::scrollToBottom,
|
onClick = { jumpToBottom() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,18 +290,20 @@ internal fun TimelineViewPreview(
|
||||||
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
|
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
|
||||||
) {
|
) {
|
||||||
TimelineView(
|
TimelineView(
|
||||||
state = aTimelineState(timelineItems),
|
state = aTimelineState(
|
||||||
roomName = null,
|
timelineItems = timelineItems,
|
||||||
|
focusedEventIndex = 0,
|
||||||
|
),
|
||||||
typingNotificationState = aTypingNotificationState(),
|
typingNotificationState = aTypingNotificationState(),
|
||||||
onMessageClicked = {},
|
|
||||||
onTimestampClicked = {},
|
|
||||||
onUserDataClicked = {},
|
onUserDataClicked = {},
|
||||||
onLinkClicked = {},
|
onLinkClicked = {},
|
||||||
|
onMessageClicked = {},
|
||||||
onMessageLongClicked = {},
|
onMessageLongClicked = {},
|
||||||
|
onTimestampClicked = {},
|
||||||
|
onSwipeToReply = {},
|
||||||
onReactionClicked = { _, _ -> },
|
onReactionClicked = { _, _ -> },
|
||||||
onReactionLongClicked = { _, _ -> },
|
onReactionLongClicked = { _, _ -> },
|
||||||
onMoreReactionsClicked = {},
|
onMoreReactionsClicked = {},
|
||||||
onSwipeToReply = {},
|
|
||||||
onReadReceiptClick = {},
|
onReadReceiptClick = {},
|
||||||
forceJumpToBottomVisibility = true,
|
forceJumpToBottomVisibility = true,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import androidx.compose.foundation.gestures.Orientation
|
||||||
import androidx.compose.foundation.gestures.draggable
|
import androidx.compose.foundation.gestures.draggable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
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.aTimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
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.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.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.colors.AvatarColorsProvider
|
||||||
import io.element.android.libraries.designsystem.components.EqualWidthColumn
|
import io.element.android.libraries.designsystem.components.EqualWidthColumn
|
||||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
|
@ -146,7 +147,7 @@ fun TimelineItemEventRow(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun inReplyToClicked() {
|
fun inReplyToClicked() {
|
||||||
val inReplyToEventId = event.inReplyTo?.eventId ?: return
|
val inReplyToEventId = event.inReplyTo?.eventId() ?: return
|
||||||
inReplyToClick(inReplyToEventId)
|
inReplyToClick(inReplyToEventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -417,7 +418,6 @@ private fun MessageSenderInformation(
|
||||||
private fun MessageEventBubbleContent(
|
private fun MessageEventBubbleContent(
|
||||||
event: TimelineItem.Event,
|
event: TimelineItem.Event,
|
||||||
onMessageLongClick: () -> Unit,
|
onMessageLongClick: () -> Unit,
|
||||||
@Suppress("UNUSED_PARAMETER")
|
|
||||||
inReplyToClick: () -> Unit,
|
inReplyToClick: () -> Unit,
|
||||||
onTimestampClicked: () -> Unit,
|
onTimestampClicked: () -> Unit,
|
||||||
onLinkClicked: (String) -> Unit,
|
onLinkClicked: (String) -> Unit,
|
||||||
|
|
@ -437,7 +437,7 @@ private fun MessageEventBubbleContent(
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
horizontalArrangement = spacedBy(4.dp, Alignment.Start),
|
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.Start),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -565,16 +565,30 @@ private fun MessageEventBubbleContent(
|
||||||
}
|
}
|
||||||
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
|
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
|
||||||
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
|
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
|
||||||
ReplyToContent(
|
val inReplyToModifier = Modifier
|
||||||
senderId = inReplyTo.senderId,
|
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||||
senderProfile = inReplyTo.senderProfile,
|
.clip(RoundedCornerShape(6.dp))
|
||||||
metadata = inReplyTo.metadata(),
|
|
||||||
modifier = 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
|
// 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) {
|
if (inReplyToDetails != null) {
|
||||||
// Use SubComposeLayout only if necessary as it can have consequences on the performance.
|
// Use SubComposeLayout only if necessary as it can have consequences on the performance.
|
||||||
|
|
@ -584,7 +598,7 @@ private fun MessageEventBubbleContent(
|
||||||
contentWithTimestamp()
|
contentWithTimestamp()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Column(modifier = modifier, verticalArrangement = spacedBy(8.dp)) {
|
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
threadDecoration()
|
threadDecoration()
|
||||||
contentWithTimestamp()
|
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
|
@Composable
|
||||||
private fun ReplyToContentText(metadata: InReplyToMetadata?) {
|
private fun ReplyToContentText(metadata: InReplyToMetadata?) {
|
||||||
val text = when (metadata) {
|
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(
|
protected fun aInReplyToDetails(
|
||||||
eventContent: EventContent,
|
eventContent: EventContent,
|
||||||
displayNameAmbiguous: Boolean = false,
|
displayNameAmbiguous: Boolean = false,
|
||||||
) = InReplyToDetails(
|
) = InReplyToDetails.Ready(
|
||||||
eventId = EventId("\$event"),
|
eventId = EventId("\$event"),
|
||||||
eventContent = eventContent,
|
eventContent = eventContent,
|
||||||
senderId = UserId("@Sender:domain"),
|
senderId = UserId("@Sender:domain"),
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ fun TimelineItemGroupedEventsRow(
|
||||||
timelineRoomInfo: TimelineRoomInfo,
|
timelineRoomInfo: TimelineRoomInfo,
|
||||||
renderReadReceipts: Boolean,
|
renderReadReceipts: Boolean,
|
||||||
isLastOutgoingMessage: Boolean,
|
isLastOutgoingMessage: Boolean,
|
||||||
highlightedItem: String?,
|
focusedEventId: EventId?,
|
||||||
onClick: (TimelineItem.Event) -> Unit,
|
onClick: (TimelineItem.Event) -> Unit,
|
||||||
onLongClick: (TimelineItem.Event) -> Unit,
|
onLongClick: (TimelineItem.Event) -> Unit,
|
||||||
inReplyToClick: (EventId) -> Unit,
|
inReplyToClick: (EventId) -> Unit,
|
||||||
|
|
@ -68,7 +68,7 @@ fun TimelineItemGroupedEventsRow(
|
||||||
onExpandGroupClick = ::onExpandGroupClick,
|
onExpandGroupClick = ::onExpandGroupClick,
|
||||||
timelineItem = timelineItem,
|
timelineItem = timelineItem,
|
||||||
timelineRoomInfo = timelineRoomInfo,
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
highlightedItem = highlightedItem,
|
focusedEventId = focusedEventId,
|
||||||
renderReadReceipts = renderReadReceipts,
|
renderReadReceipts = renderReadReceipts,
|
||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
@ -92,7 +92,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||||
onExpandGroupClick: () -> Unit,
|
onExpandGroupClick: () -> Unit,
|
||||||
timelineItem: TimelineItem.GroupedEvents,
|
timelineItem: TimelineItem.GroupedEvents,
|
||||||
timelineRoomInfo: TimelineRoomInfo,
|
timelineRoomInfo: TimelineRoomInfo,
|
||||||
highlightedItem: String?,
|
focusedEventId: EventId?,
|
||||||
renderReadReceipts: Boolean,
|
renderReadReceipts: Boolean,
|
||||||
isLastOutgoingMessage: Boolean,
|
isLastOutgoingMessage: Boolean,
|
||||||
onClick: (TimelineItem.Event) -> Unit,
|
onClick: (TimelineItem.Event) -> Unit,
|
||||||
|
|
@ -116,7 +116,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||||
timelineItem.events.size
|
timelineItem.events.size
|
||||||
),
|
),
|
||||||
isExpanded = isExpanded,
|
isExpanded = isExpanded,
|
||||||
isHighlighted = !isExpanded && timelineItem.events.any { it.identifier() == highlightedItem },
|
isHighlighted = !isExpanded && timelineItem.events.any { it.isEvent(focusedEventId) },
|
||||||
onClick = onExpandGroupClick,
|
onClick = onExpandGroupClick,
|
||||||
)
|
)
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
|
|
@ -127,7 +127,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||||
timelineRoomInfo = timelineRoomInfo,
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
renderReadReceipts = renderReadReceipts,
|
renderReadReceipts = renderReadReceipts,
|
||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
highlightedItem = highlightedItem,
|
focusedEventId = focusedEventId,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
inReplyToClick = inReplyToClick,
|
inReplyToClick = inReplyToClick,
|
||||||
|
|
@ -160,12 +160,13 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPreview {
|
internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPreview {
|
||||||
|
val events = aGroupedEvents(withReadReceipts = true)
|
||||||
TimelineItemGroupedEventsRowContent(
|
TimelineItemGroupedEventsRowContent(
|
||||||
isExpanded = true,
|
isExpanded = true,
|
||||||
onExpandGroupClick = {},
|
onExpandGroupClick = {},
|
||||||
timelineItem = aGroupedEvents(withReadReceipts = true),
|
timelineItem = events,
|
||||||
timelineRoomInfo = aTimelineRoomInfo(),
|
timelineRoomInfo = aTimelineRoomInfo(),
|
||||||
highlightedItem = null,
|
focusedEventId = events.events.first().eventId,
|
||||||
renderReadReceipts = true,
|
renderReadReceipts = true,
|
||||||
isLastOutgoingMessage = false,
|
isLastOutgoingMessage = false,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
|
|
@ -190,7 +191,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
|
||||||
onExpandGroupClick = {},
|
onExpandGroupClick = {},
|
||||||
timelineItem = aGroupedEvents(withReadReceipts = true),
|
timelineItem = aGroupedEvents(withReadReceipts = true),
|
||||||
timelineRoomInfo = aTimelineRoomInfo(),
|
timelineRoomInfo = aTimelineRoomInfo(),
|
||||||
highlightedItem = null,
|
focusedEventId = null,
|
||||||
renderReadReceipts = true,
|
renderReadReceipts = true,
|
||||||
isLastOutgoingMessage = false,
|
isLastOutgoingMessage = false,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,24 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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.TimelineEvents
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
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.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
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.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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
|
|
@ -32,7 +43,7 @@ internal fun TimelineItemRow(
|
||||||
timelineRoomInfo: TimelineRoomInfo,
|
timelineRoomInfo: TimelineRoomInfo,
|
||||||
renderReadReceipts: Boolean,
|
renderReadReceipts: Boolean,
|
||||||
isLastOutgoingMessage: Boolean,
|
isLastOutgoingMessage: Boolean,
|
||||||
highlightedItem: String?,
|
focusedEventId: EventId?,
|
||||||
onUserDataClick: (UserId) -> Unit,
|
onUserDataClick: (UserId) -> Unit,
|
||||||
onLinkClicked: (String) -> Unit,
|
onLinkClicked: (String) -> Unit,
|
||||||
onClick: (TimelineItem.Event) -> Unit,
|
onClick: (TimelineItem.Event) -> Unit,
|
||||||
|
|
@ -47,69 +58,111 @@ internal fun TimelineItemRow(
|
||||||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
when (timelineItem) {
|
val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) {
|
||||||
is TimelineItem.Virtual -> {
|
val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
|
||||||
TimelineItemVirtualRow(
|
14.dp
|
||||||
virtual = timelineItem,
|
} else {
|
||||||
modifier = modifier,
|
2.dp
|
||||||
)
|
|
||||||
}
|
}
|
||||||
is TimelineItem.Event -> {
|
Modifier.focusedEvent(focusedEventOffset)
|
||||||
if (timelineItem.content is TimelineItemStateContent || timelineItem.content is TimelineItemLegacyCallInviteContent) {
|
} else {
|
||||||
TimelineItemStateEventRow(
|
Modifier
|
||||||
event = timelineItem,
|
}
|
||||||
renderReadReceipts = renderReadReceipts,
|
Box(modifier = modifier.then(backgroundModifier)) {
|
||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
when (timelineItem) {
|
||||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
is TimelineItem.Virtual -> {
|
||||||
onClick = { onClick(timelineItem) },
|
TimelineItemVirtualRow(
|
||||||
onReadReceiptsClick = onReadReceiptClick,
|
virtual = timelineItem,
|
||||||
onLongClick = { onLongClick(timelineItem) },
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
modifier = modifier,
|
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
TimelineItemEventRow(
|
is TimelineItem.Event -> {
|
||||||
event = timelineItem,
|
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,
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
renderReadReceipts = renderReadReceipts,
|
renderReadReceipts = renderReadReceipts,
|
||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
focusedEventId = focusedEventId,
|
||||||
onClick = { onClick(timelineItem) },
|
onClick = onClick,
|
||||||
onLongClick = { onLongClick(timelineItem) },
|
onLongClick = onLongClick,
|
||||||
|
inReplyToClick = inReplyToClick,
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
onLinkClicked = onLinkClicked,
|
onLinkClicked = onLinkClicked,
|
||||||
inReplyToClick = inReplyToClick,
|
onTimestampClicked = onTimestampClicked,
|
||||||
onReactionClick = onReactionClick,
|
onReactionClick = onReactionClick,
|
||||||
onReactionLongClick = onReactionLongClick,
|
onReactionLongClick = onReactionLongClick,
|
||||||
onMoreReactionsClick = onMoreReactionsClick,
|
onMoreReactionsClick = onMoreReactionsClick,
|
||||||
onReadReceiptClick = onReadReceiptClick,
|
onReadReceiptClick = onReadReceiptClick,
|
||||||
onTimestampClicked = onTimestampClicked,
|
|
||||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
|
||||||
eventSink = eventSink,
|
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
|
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.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Modifier
|
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.TimelineEncryptedHistoryBannerView
|
||||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
|
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.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.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
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.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.TimelineItemReadMarkerModel
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemVirtualRow(
|
fun TimelineItemVirtualRow(
|
||||||
virtual: TimelineItem.Virtual,
|
virtual: TimelineItem.Virtual,
|
||||||
|
timelineRoomInfo: TimelineRoomInfo,
|
||||||
|
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
when (virtual.model) {
|
Box(modifier = modifier) {
|
||||||
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
|
when (virtual.model) {
|
||||||
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
|
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model)
|
||||||
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
|
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
|
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.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.CircularProgressIndicator
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineLoadingMoreIndicator(modifier: Modifier = Modifier) {
|
internal fun TimelineLoadingMoreIndicator(
|
||||||
|
direction: Timeline.PaginationDirection,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier = modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.wrapContentHeight()
|
|
||||||
.padding(8.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator(
|
when (direction) {
|
||||||
strokeWidth = 2.dp,
|
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
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineLoadingMoreIndicatorPreview() = ElementPreview {
|
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.Composable
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.collectAsState
|
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.diff.TimelineItemsCacheInvalidator
|
||||||
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
|
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
|
||||||
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
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 eventItemFactory: TimelineItemEventFactory,
|
||||||
private val virtualItemFactory: TimelineItemVirtualFactory,
|
private val virtualItemFactory: TimelineItemVirtualFactory,
|
||||||
private val timelineItemGrouper: TimelineItemGrouper,
|
private val timelineItemGrouper: TimelineItemGrouper,
|
||||||
|
private val timelineItemIndexer: TimelineItemIndexer,
|
||||||
) {
|
) {
|
||||||
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
|
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
|
||||||
|
|
||||||
private val lock = Mutex()
|
private val lock = Mutex()
|
||||||
private val diffCache = MutableListDiffCache<TimelineItem>()
|
private val diffCache = MutableListDiffCache<TimelineItem>()
|
||||||
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, TimelineItem>(
|
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, TimelineItem>(
|
||||||
|
|
@ -100,6 +101,7 @@ class TimelineItemsFactory @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList()
|
val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList()
|
||||||
|
timelineItemIndexer.process(result)
|
||||||
this.timelineItems.emit(result)
|
this.timelineItems.emit(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,13 +110,13 @@ class TimelineItemsFactory @Inject constructor(
|
||||||
index: Int,
|
index: Int,
|
||||||
roomMembers: List<RoomMember>,
|
roomMembers: List<RoomMember>,
|
||||||
): TimelineItem? {
|
): TimelineItem? {
|
||||||
val timelineItemState =
|
val timelineItem =
|
||||||
when (val currentTimelineItem = timelineItems[index]) {
|
when (val currentTimelineItem = timelineItems[index]) {
|
||||||
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers)
|
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers)
|
||||||
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
|
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
|
||||||
MatrixTimelineItem.Other -> null
|
MatrixTimelineItem.Other -> null
|
||||||
}
|
}
|
||||||
diffCache[index] = timelineItemState
|
diffCache[index] = timelineItem
|
||||||
return timelineItemState
|
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.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
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.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.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
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.DayDivider -> daySeparatorFactory.create(inner)
|
||||||
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
|
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
|
||||||
is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel
|
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
|
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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
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.api.timeline.item.event.TextMessageType
|
||||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||||
|
|
||||||
data class InReplyToDetails(
|
@Immutable
|
||||||
val eventId: EventId,
|
sealed interface InReplyToDetails {
|
||||||
val senderId: UserId,
|
data class Ready(
|
||||||
val senderProfile: ProfileTimelineDetails,
|
val eventId: EventId,
|
||||||
val eventContent: EventContent?,
|
val senderId: UserId,
|
||||||
val textContent: String?,
|
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(
|
fun InReplyTo.map(
|
||||||
permalinkParser: PermalinkParser,
|
permalinkParser: PermalinkParser,
|
||||||
) = when (this) {
|
) = when (this) {
|
||||||
is InReplyTo.Ready -> InReplyToDetails(
|
is InReplyTo.Ready -> InReplyToDetails.Ready(
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
senderId = senderId,
|
senderId = senderId,
|
||||||
senderProfile = senderProfile,
|
senderProfile = senderProfile,
|
||||||
|
|
@ -55,5 +68,7 @@ fun InReplyTo.map(
|
||||||
else -> null
|
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.
|
* Metadata can be either a thumbnail with a text OR just a text.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventContent) {
|
internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (eventContent) {
|
||||||
is MessageContent -> when (val type = eventContent.type) {
|
is MessageContent -> when (val type = eventContent.type) {
|
||||||
is ImageMessageType -> InReplyToMetadata.Thumbnail(
|
is ImageMessageType -> InReplyToMetadata.Thumbnail(
|
||||||
AttachmentThumbnailInfo(
|
AttachmentThumbnailInfo(
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,14 @@ sealed interface TimelineItem {
|
||||||
is GroupedEvents -> id
|
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) {
|
fun contentType(): String = when (this) {
|
||||||
is Event -> content.type
|
is Event -> content.type
|
||||||
is Virtual -> model.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.messagecomposer.MessageComposerPresenter
|
||||||
import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter
|
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.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.TimelinePresenter
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.FakeEmojibaseProvider
|
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.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
|
import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
|
||||||
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
|
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.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
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.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
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.room.aRoomMember
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
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.WarmUpRule
|
||||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||||
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
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.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
@ -167,7 +174,13 @@ class MessagesPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - handle toggling a reaction`() = runTest {
|
fun `present - handle toggling a reaction`() = runTest {
|
||||||
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
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)
|
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -175,29 +188,42 @@ class MessagesPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||||
assertThat(room.myReactions.count()).isEqualTo(1)
|
|
||||||
// No crashes when sending a reaction failed
|
// 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))
|
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||||
assertThat(room.myReactions.count()).isEqualTo(1)
|
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
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
|
@Test
|
||||||
fun `present - handle toggling a reaction twice`() = runTest {
|
fun `present - handle toggling a reaction twice`() = runTest {
|
||||||
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
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)
|
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||||
assertThat(room.myReactions.count()).isEqualTo(1)
|
|
||||||
|
|
||||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
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) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(3)
|
skipItems(2)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
|
|
@ -428,7 +454,6 @@ class MessagesPresenterTest {
|
||||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
|
||||||
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
|
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
skipItems(1) // back paginating
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -748,6 +773,7 @@ class MessagesPresenterTest {
|
||||||
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
|
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
|
||||||
permalinkParser = FakePermalinkParser(),
|
permalinkParser = FakePermalinkParser(),
|
||||||
permalinkBuilder = FakePermalinkBuilder(),
|
permalinkBuilder = FakePermalinkBuilder(),
|
||||||
|
timelineController = TimelineController(matrixRoom),
|
||||||
)
|
)
|
||||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
|
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
|
||||||
this,
|
this,
|
||||||
|
|
@ -768,6 +794,8 @@ class MessagesPresenterTest {
|
||||||
endPollAction = endPollAction,
|
endPollAction = endPollAction,
|
||||||
sendPollResponseAction = FakeSendPollResponseAction(),
|
sendPollResponseAction = FakeSendPollResponseAction(),
|
||||||
sessionPreferencesStore = sessionPreferencesStore,
|
sessionPreferencesStore = sessionPreferencesStore,
|
||||||
|
timelineItemIndexer = TimelineItemIndexer(),
|
||||||
|
timelineController = TimelineController(matrixRoom),
|
||||||
)
|
)
|
||||||
val timelinePresenterFactory = object : TimelinePresenter.Factory {
|
val timelinePresenterFactory = object : TimelinePresenter.Factory {
|
||||||
override fun create(navigator: MessagesNavigator): TimelinePresenter {
|
override fun create(navigator: MessagesNavigator): TimelinePresenter {
|
||||||
|
|
@ -804,6 +832,7 @@ class MessagesPresenterTest {
|
||||||
buildMeta = aBuildMeta(),
|
buildMeta = aBuildMeta(),
|
||||||
dispatchers = coroutineDispatchers,
|
dispatchers = coroutineDispatchers,
|
||||||
htmlConverterProvider = FakeHtmlConverterProvider(),
|
htmlConverterProvider = FakeHtmlConverterProvider(),
|
||||||
|
timelineController = TimelineController(matrixRoom),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.fixtures
|
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.TimelineItemsFactory
|
||||||
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory
|
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory
|
||||||
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseMessageFactory
|
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 io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
|
||||||
internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
|
internal fun TestScope.aTimelineItemsFactory(
|
||||||
|
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer()
|
||||||
|
): TimelineItemsFactory {
|
||||||
val timelineEventFormatter = aTimelineEventFormatter()
|
val timelineEventFormatter = aTimelineEventFormatter()
|
||||||
val matrixClient = FakeMatrixClient()
|
val matrixClient = FakeMatrixClient()
|
||||||
return TimelineItemsFactory(
|
return TimelineItemsFactory(
|
||||||
|
|
@ -83,6 +86,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
timelineItemGrouper = TimelineItemGrouper(),
|
timelineItemGrouper = TimelineItemGrouper(),
|
||||||
|
timelineItemIndexer = timelineItemIndexer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,20 @@ import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
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.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
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.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.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.lang.IllegalStateException
|
||||||
|
|
||||||
class ForwardMessagesPresenterTests {
|
class ForwardMessagesPresenterTests {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
|
|
@ -36,7 +42,7 @@ class ForwardMessagesPresenterTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val presenter = aPresenter()
|
val presenter = aForwardMessagesPresenter()
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -49,7 +55,14 @@ class ForwardMessagesPresenterTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - forward successful`() = runTest {
|
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) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -61,18 +74,23 @@ class ForwardMessagesPresenterTests {
|
||||||
val successfulForwardState = awaitItem()
|
val successfulForwardState = awaitItem()
|
||||||
assertThat(successfulForwardState.isForwarding).isFalse()
|
assertThat(successfulForwardState.isForwarding).isFalse()
|
||||||
assertThat(successfulForwardState.forwardingSucceeded).isNotNull()
|
assertThat(successfulForwardState.forwardingSucceeded).isNotNull()
|
||||||
|
assert(forwardEventLambda).isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - select a room and forward failed, then clear`() = runTest {
|
fun `present - select a room and forward failed, then clear`() = runTest {
|
||||||
val room = FakeMatrixRoom()
|
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
|
||||||
val presenter = aPresenter(fakeMatrixRoom = room)
|
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) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
// Test failed forwarding
|
|
||||||
room.givenForwardEventResult(Result.failure(Throwable("error")))
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val summary = aRoomSummaryDetails()
|
val summary = aRoomSummaryDetails()
|
||||||
presenter.onRoomSelected(listOf(summary.roomId))
|
presenter.onRoomSelected(listOf(summary.roomId))
|
||||||
|
|
@ -82,16 +100,17 @@ class ForwardMessagesPresenterTests {
|
||||||
// Then clear error
|
// Then clear error
|
||||||
failedForwardState.eventSink(ForwardMessagesEvents.ClearError)
|
failedForwardState.eventSink(ForwardMessagesEvents.ClearError)
|
||||||
assertThat(awaitItem().error).isNull()
|
assertThat(awaitItem().error).isNull()
|
||||||
|
assert(forwardEventLambda).isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.aPresenter(
|
private fun CoroutineScope.aForwardMessagesPresenter(
|
||||||
eventId: EventId = AN_EVENT_ID,
|
eventId: EventId = AN_EVENT_ID,
|
||||||
fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
|
fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
|
||||||
coroutineScope: CoroutineScope = this,
|
coroutineScope: CoroutineScope = this,
|
||||||
) = ForwardMessagesPresenter(
|
) = ForwardMessagesPresenter(
|
||||||
eventId = eventId.value,
|
eventId = eventId.value,
|
||||||
room = fakeMatrixRoom,
|
timelineProvider = LiveTimelineProvider(fakeMatrixRoom),
|
||||||
matrixCoroutineScope = coroutineScope,
|
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.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
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.messagecomposer.MessageComposerState
|
||||||
|
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||||
import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
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.permalink.FakePermalinkParser
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
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.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.api.PickerProvider
|
||||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
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.libraries.textcomposer.model.SuggestionType
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
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.element.android.tests.testutils.waitForPredicate
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
@ -259,7 +265,13 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - edit sent message`() = runTest {
|
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(
|
val presenter = createPresenter(
|
||||||
this,
|
this,
|
||||||
fakeMatrixRoom,
|
fakeMatrixRoom,
|
||||||
|
|
@ -283,7 +295,13 @@ class MessageComposerPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
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(
|
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||||
Composer(
|
Composer(
|
||||||
inThread = false,
|
inThread = false,
|
||||||
|
|
@ -297,7 +315,13 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - edit not sent message`() = runTest {
|
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(
|
val presenter = createPresenter(
|
||||||
this,
|
this,
|
||||||
fakeMatrixRoom,
|
fakeMatrixRoom,
|
||||||
|
|
@ -321,7 +345,13 @@ class MessageComposerPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
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(
|
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||||
Composer(
|
Composer(
|
||||||
inThread = false,
|
inThread = false,
|
||||||
|
|
@ -335,7 +365,13 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - reply message`() = runTest {
|
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(
|
val presenter = createPresenter(
|
||||||
this,
|
this,
|
||||||
fakeMatrixRoom,
|
fakeMatrixRoom,
|
||||||
|
|
@ -355,7 +391,13 @@ class MessageComposerPresenterTest {
|
||||||
state.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
|
state.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
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(
|
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||||
Composer(
|
Composer(
|
||||||
inThread = false,
|
inThread = false,
|
||||||
|
|
@ -831,7 +873,17 @@ class MessageComposerPresenterTest {
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@Test
|
@Test
|
||||||
fun `present - send messages with intentional mentions`() = runTest {
|
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)
|
val presenter = createPresenter(room = room, coroutineScope = this)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -866,7 +918,9 @@ class MessageComposerPresenterTest {
|
||||||
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||||
advanceUntilIdle()
|
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
|
// Check intentional mentions on edit message
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
@ -882,7 +936,9 @@ class MessageComposerPresenterTest {
|
||||||
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||||
advanceUntilIdle()
|
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)
|
skipItems(1)
|
||||||
}
|
}
|
||||||
|
|
@ -968,6 +1024,7 @@ class MessageComposerPresenterTest {
|
||||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
|
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
|
||||||
permalinkParser = FakePermalinkParser(),
|
permalinkParser = FakePermalinkParser(),
|
||||||
permalinkBuilder = permalinkBuilder,
|
permalinkBuilder = permalinkBuilder,
|
||||||
|
timelineController = TimelineController(room),
|
||||||
)
|
)
|
||||||
|
|
||||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
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 app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.impl.FakeMessagesNavigator
|
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.fixtures.aTimelineItemsFactory
|
||||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
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.api.actions.SendPollResponseAction
|
||||||
import io.element.android.features.poll.test.actions.FakeEndPollAction
|
import io.element.android.features.poll.test.actions.FakeEndPollAction
|
||||||
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
|
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.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.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.MatrixTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
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.EventReaction
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
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.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
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.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.aMessageContent
|
||||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
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.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 io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.delay
|
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.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runCurrent
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
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 = "FAKE_UNIQUE_ID"
|
||||||
private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
|
private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
|
||||||
|
|
||||||
class TimelinePresenterTest {
|
@OptIn(ExperimentalCoroutinesApi::class) class TimelinePresenterTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
|
@ -84,58 +92,49 @@ class TimelinePresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
assertThat(initialState.timelineItems).isEmpty()
|
assertThat(initialState.timelineItems).isEmpty()
|
||||||
val loadedNoTimelineState = awaitItem()
|
assertThat(initialState.isLive).isTrue()
|
||||||
assertThat(loadedNoTimelineState.timelineItems).isEmpty()
|
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
|
||||||
|
assertThat(initialState.focusedEventId).isNull()
|
||||||
|
assertThat(initialState.focusRequestState).isEqualTo(FocusRequestState.None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - load more`() = runTest {
|
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) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.paginationState.hasMoreToLoadBackwards).isTrue()
|
initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
|
||||||
assertThat(initialState.paginationState.isBackPaginating).isFalse()
|
initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.FORWARDS))
|
||||||
initialState.eventSink.invoke(TimelineEvents.LoadMore)
|
assert(paginateLambda)
|
||||||
val inPaginationState = awaitItem()
|
.isCalledExactly(2)
|
||||||
assertThat(inPaginationState.paginationState.isBackPaginating).isTrue()
|
.withSequence(
|
||||||
assertThat(inPaginationState.paginationState.hasMoreToLoadBackwards).isTrue()
|
listOf(value(Timeline.PaginationDirection.BACKWARDS)),
|
||||||
val postPaginationState = awaitItem()
|
listOf(value(Timeline.PaginationDirection.FORWARDS))
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@Test
|
@Test
|
||||||
fun `present - on scroll finished mark a room as read if the first visible index is 0`() = runTest(StandardTestDispatcher()) {
|
fun `present - on scroll finished mark a room as read if the first visible index is 0`() = runTest(StandardTestDispatcher()) {
|
||||||
val timeline = FakeMatrixTimeline(
|
val timeline = FakeTimeline(
|
||||||
initialTimelineItems = listOf(
|
timelineItems = flowOf(
|
||||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
|
listOf(
|
||||||
|
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
val room = FakeMatrixRoom(liveTimeline = timeline)
|
||||||
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
|
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
|
||||||
val room = FakeMatrixRoom(matrixTimeline = timeline)
|
|
||||||
val presenter = createTimelinePresenter(
|
val presenter = createTimelinePresenter(
|
||||||
timeline = timeline,
|
timeline = timeline,
|
||||||
room = room,
|
room = room,
|
||||||
|
|
@ -144,7 +143,6 @@ class TimelinePresenterTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
@ -155,48 +153,62 @@ class TimelinePresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
|
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
|
||||||
val timeline = FakeMatrixTimeline(
|
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
||||||
initialTimelineItems = listOf(
|
Result.success(Unit)
|
||||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
}
|
||||||
MatrixTimelineItem.Event(
|
val timeline = FakeTimeline(
|
||||||
uniqueId = FAKE_UNIQUE_ID_2,
|
timelineItems = flowOf(
|
||||||
event = anEventTimelineItem(
|
listOf(
|
||||||
eventId = AN_EVENT_ID_2,
|
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||||
content = aMessageContent("Test message")
|
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)
|
val presenter = createTimelinePresenter(timeline)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
skipItems(1)
|
||||||
val initialState = awaitFirstItem()
|
awaitItem().run {
|
||||||
awaitWithLatch { latch ->
|
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||||
timeline.sendReadReceiptLatch = latch
|
|
||||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
|
||||||
}
|
}
|
||||||
assertThat(timeline.sentReadReceipts).isNotEmpty()
|
advanceUntilIdle()
|
||||||
assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ)
|
assert(sendReadReceiptsLambda)
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(any(), value(ReceiptType.READ))
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 {
|
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(
|
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
||||||
initialTimelineItems = listOf(
|
Result.success(Unit)
|
||||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
}
|
||||||
MatrixTimelineItem.Event(
|
val timeline = FakeTimeline(
|
||||||
uniqueId = FAKE_UNIQUE_ID_2,
|
timelineItems = flowOf(
|
||||||
event = anEventTimelineItem(
|
listOf(
|
||||||
eventId = AN_EVENT_ID_2,
|
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||||
content = aMessageContent("Test message")
|
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 sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
|
||||||
val presenter = createTimelinePresenter(
|
val presenter = createTimelinePresenter(
|
||||||
timeline = timeline,
|
timeline = timeline,
|
||||||
|
|
@ -205,75 +217,86 @@ class TimelinePresenterTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
skipItems(1)
|
||||||
val initialState = awaitFirstItem()
|
awaitItem().run {
|
||||||
awaitWithLatch { latch ->
|
eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||||
timeline.sendReadReceiptLatch = latch
|
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
|
||||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
|
||||||
}
|
}
|
||||||
assertThat(timeline.sentReadReceipts).isNotEmpty()
|
advanceUntilIdle()
|
||||||
assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ_PRIVATE)
|
assert(sendReadReceiptsLambda)
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(any(), value(ReceiptType.READ_PRIVATE))
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - on scroll finished will not send read receipt the first visible event is the same as before`() = runTest {
|
fun `present - on scroll finished will not send read receipt the first visible event is the same as before`() = runTest {
|
||||||
val timeline = FakeMatrixTimeline(
|
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
||||||
initialTimelineItems = listOf(
|
Result.success(Unit)
|
||||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
}
|
||||||
MatrixTimelineItem.Event(
|
val timeline = FakeTimeline(
|
||||||
uniqueId = FAKE_UNIQUE_ID_2,
|
timelineItems = flowOf(
|
||||||
event = anEventTimelineItem(
|
listOf(
|
||||||
eventId = AN_EVENT_ID_2,
|
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||||
content = aMessageContent("Test message")
|
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)
|
val presenter = createTimelinePresenter(timeline)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
skipItems(1)
|
||||||
val initialState = awaitFirstItem()
|
awaitItem().run {
|
||||||
awaitWithLatch { latch ->
|
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||||
timeline.sendReadReceiptLatch = latch
|
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
|
||||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
|
||||||
}
|
}
|
||||||
assertThat(timeline.sentReadReceipts).hasSize(1)
|
advanceUntilIdle()
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
assert(sendReadReceiptsLambda).isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest {
|
fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest {
|
||||||
val timeline = FakeMatrixTimeline(
|
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
||||||
initialTimelineItems = listOf(
|
Result.success(Unit)
|
||||||
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker),
|
}
|
||||||
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker)
|
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)
|
val presenter = createTimelinePresenter(timeline)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
skipItems(1)
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
awaitWithLatch { latch ->
|
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||||
timeline.sendReadReceiptLatch = latch
|
|
||||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
|
||||||
}
|
|
||||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
assert(sendReadReceiptsLambda).isNeverCalled()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - covers newEventState scenarios`() = runTest {
|
fun `present - covers newEventState scenarios`() = runTest {
|
||||||
val timeline = FakeMatrixTimeline()
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
val presenter = createTimelinePresenter(timeline)
|
val presenter = createTimelinePresenter(timeline)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -281,12 +304,12 @@ class TimelinePresenterTest {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
|
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
|
||||||
assertThat(initialState.timelineItems.size).isEqualTo(0)
|
assertThat(initialState.timelineItems.size).isEqualTo(0)
|
||||||
timeline.updateTimelineItems {
|
timelineItems.emit(
|
||||||
listOf(MatrixTimelineItem.Event("0", anEventTimelineItem(content = aMessageContent())))
|
listOf(MatrixTimelineItem.Event("0", anEventTimelineItem(content = aMessageContent())))
|
||||||
}
|
)
|
||||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||||
// Mimics sending a message, and assert newEventState is FromMe
|
// Mimics sending a message, and assert newEventState is FromMe
|
||||||
timeline.updateTimelineItems { items ->
|
timelineItems.getAndUpdate { items ->
|
||||||
val event = anEventTimelineItem(content = aMessageContent(), isOwn = true)
|
val event = anEventTimelineItem(content = aMessageContent(), isOwn = true)
|
||||||
items + listOf(MatrixTimelineItem.Event("1", event))
|
items + listOf(MatrixTimelineItem.Event("1", event))
|
||||||
}
|
}
|
||||||
|
|
@ -295,7 +318,7 @@ class TimelinePresenterTest {
|
||||||
assertThat(state.newEventState).isEqualTo(NewEventState.FromMe)
|
assertThat(state.newEventState).isEqualTo(NewEventState.FromMe)
|
||||||
}
|
}
|
||||||
// Mimics receiving a message without clearing the previous FromMe
|
// Mimics receiving a message without clearing the previous FromMe
|
||||||
timeline.updateTimelineItems { items ->
|
timelineItems.getAndUpdate { items ->
|
||||||
val event = anEventTimelineItem(content = aMessageContent())
|
val event = anEventTimelineItem(content = aMessageContent())
|
||||||
items + listOf(MatrixTimelineItem.Event("2", event))
|
items + listOf(MatrixTimelineItem.Event("2", event))
|
||||||
}
|
}
|
||||||
|
|
@ -307,7 +330,7 @@ class TimelinePresenterTest {
|
||||||
assertThat(state.newEventState).isEqualTo(NewEventState.None)
|
assertThat(state.newEventState).isEqualTo(NewEventState.None)
|
||||||
}
|
}
|
||||||
// Mimics receiving a message and assert newEventState is FromOther
|
// Mimics receiving a message and assert newEventState is FromOther
|
||||||
timeline.updateTimelineItems { items ->
|
timelineItems.getAndUpdate { items ->
|
||||||
val event = anEventTimelineItem(content = aMessageContent())
|
val event = anEventTimelineItem(content = aMessageContent())
|
||||||
items + listOf(MatrixTimelineItem.Event("3", event))
|
items + listOf(MatrixTimelineItem.Event("3", event))
|
||||||
}
|
}
|
||||||
|
|
@ -321,7 +344,10 @@ class TimelinePresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - reaction ordering`() = runTest {
|
fun `present - reaction ordering`() = runTest {
|
||||||
val timeline = FakeMatrixTimeline()
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
val timeline = FakeTimeline(
|
||||||
|
timelineItems = timelineItems,
|
||||||
|
)
|
||||||
val presenter = createTimelinePresenter(timeline)
|
val presenter = createTimelinePresenter(timeline)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -349,10 +375,9 @@ class TimelinePresenterTest {
|
||||||
senders = persistentListOf(charlie)
|
senders = persistentListOf(charlie)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
timeline.updateTimelineItems {
|
timelineItems.emit(
|
||||||
listOf(MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem(reactions = oneReaction)))
|
listOf(MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem(reactions = oneReaction)))
|
||||||
}
|
)
|
||||||
skipItems(1)
|
|
||||||
val item = awaitItem().timelineItems.first()
|
val item = awaitItem().timelineItems.first()
|
||||||
assertThat(item).isInstanceOf(TimelineItem.Event::class.java)
|
assertThat(item).isInstanceOf(TimelineItem.Event::class.java)
|
||||||
val event = item as TimelineItem.Event
|
val event = item as TimelineItem.Event
|
||||||
|
|
@ -424,8 +449,10 @@ class TimelinePresenterTest {
|
||||||
fun `present - side effect on redacted items is invoked`() = runTest {
|
fun `present - side effect on redacted items is invoked`() = runTest {
|
||||||
val redactedVoiceMessageManager = FakeRedactedVoiceMessageManager()
|
val redactedVoiceMessageManager = FakeRedactedVoiceMessageManager()
|
||||||
val presenter = createTimelinePresenter(
|
val presenter = createTimelinePresenter(
|
||||||
timeline = FakeMatrixTimeline(
|
timeline = FakeTimeline(
|
||||||
initialTimelineItems = aRedactedMatrixTimeline(AN_EVENT_ID),
|
timelineItems = flowOf(
|
||||||
|
aRedactedMatrixTimeline(AN_EVENT_ID),
|
||||||
|
)
|
||||||
),
|
),
|
||||||
redactedVoiceMessageManager = redactedVoiceMessageManager,
|
redactedVoiceMessageManager = redactedVoiceMessageManager,
|
||||||
)
|
)
|
||||||
|
|
@ -433,32 +460,141 @@ class TimelinePresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(0)
|
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(0)
|
||||||
awaitFirstItem().let {
|
skipItems(2)
|
||||||
assertThat(it.timelineItems).isNotEmpty()
|
|
||||||
}
|
|
||||||
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(1)
|
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
|
@Test
|
||||||
fun `present - when room member info is loaded, read receipts info should be updated`() = runTest {
|
fun `present - when room member info is loaded, read receipts info should be updated`() = runTest {
|
||||||
val timeline = FakeMatrixTimeline(
|
val timeline = FakeTimeline(
|
||||||
listOf(
|
timelineItems = flowOf(
|
||||||
MatrixTimelineItem.Event(
|
listOf(
|
||||||
FAKE_UNIQUE_ID,
|
MatrixTimelineItem.Event(
|
||||||
anEventTimelineItem(
|
FAKE_UNIQUE_ID,
|
||||||
sender = A_USER_ID,
|
anEventTimelineItem(
|
||||||
receipts = persistentListOf(
|
sender = A_USER_ID,
|
||||||
Receipt(
|
receipts = persistentListOf(
|
||||||
userId = A_USER_ID,
|
Receipt(
|
||||||
timestamp = 0L,
|
userId = A_USER_ID,
|
||||||
|
timestamp = 0L,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val room = FakeMatrixRoom(matrixTimeline = timeline).apply {
|
val room = FakeMatrixRoom(liveTimeline = timeline).apply {
|
||||||
givenRoomMembersState(MatrixRoomMembersState.Unknown)
|
givenRoomMembersState(MatrixRoomMembersState.Unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -485,22 +621,19 @@ class TimelinePresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||||
// Skip 1 item if Mentions feature is enabled
|
|
||||||
if (FeatureFlags.Mentions.defaultValue) {
|
|
||||||
skipItems(1)
|
|
||||||
}
|
|
||||||
return awaitItem()
|
return awaitItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TestScope.createTimelinePresenter(
|
private fun TestScope.createTimelinePresenter(
|
||||||
timeline: MatrixTimeline = FakeMatrixTimeline(),
|
timeline: Timeline = FakeTimeline(),
|
||||||
room: FakeMatrixRoom = FakeMatrixRoom(matrixTimeline = timeline),
|
room: FakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline),
|
||||||
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory(),
|
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory(),
|
||||||
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
|
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
|
||||||
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
|
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
|
||||||
endPollAction: EndPollAction = FakeEndPollAction(),
|
endPollAction: EndPollAction = FakeEndPollAction(),
|
||||||
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
|
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
|
||||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||||
|
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
||||||
): TimelinePresenter {
|
): TimelinePresenter {
|
||||||
return TimelinePresenter(
|
return TimelinePresenter(
|
||||||
timelineItemsFactory = timelineItemsFactory,
|
timelineItemsFactory = timelineItemsFactory,
|
||||||
|
|
@ -512,6 +645,8 @@ class TimelinePresenterTest {
|
||||||
endPollAction = endPollAction,
|
endPollAction = endPollAction,
|
||||||
sendPollResponseAction = sendPollResponseAction,
|
sendPollResponseAction = sendPollResponseAction,
|
||||||
sessionPreferencesStore = sessionPreferencesStore,
|
sessionPreferencesStore = sessionPreferencesStore,
|
||||||
|
timelineItemIndexer = timelineItemIndexer,
|
||||||
|
timelineController = TimelineController(room),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,25 @@
|
||||||
package io.element.android.features.messages.impl.timeline
|
package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
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 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.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.EnsureNeverCalledWithParam
|
||||||
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||||
import io.element.android.tests.testutils.EventsRecorder
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TestRule
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
|
@ -34,55 +45,89 @@ class TimelineViewTest {
|
||||||
@Test
|
@Test
|
||||||
fun `reaching the end of the timeline with more events to load emits a LoadMore event`() {
|
fun `reaching the end of the timeline with more events to load emits a LoadMore event`() {
|
||||||
val eventsRecorder = EventsRecorder<TimelineEvents>()
|
val eventsRecorder = EventsRecorder<TimelineEvents>()
|
||||||
rule.setContent {
|
rule.setTimelineView(
|
||||||
TimelineView(
|
state = aTimelineState(
|
||||||
aTimelineState(
|
timelineItems = persistentListOf<TimelineItem>(
|
||||||
eventSink = eventsRecorder,
|
TimelineItem.Virtual(
|
||||||
paginationState = aPaginationState(
|
id = "backward_pagination",
|
||||||
hasMoreToLoadBackwards = true,
|
model = TimelineItemLoadingIndicatorModel(Timeline.PaginationDirection.BACKWARDS, 0)
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
typingNotificationState = aTypingNotificationState(),
|
eventSink = eventsRecorder,
|
||||||
roomName = null,
|
),
|
||||||
onUserDataClicked = EnsureNeverCalledWithParam(),
|
)
|
||||||
onLinkClicked = EnsureNeverCalledWithParam(),
|
eventsRecorder.assertSingle(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
|
||||||
onMessageClicked = EnsureNeverCalledWithParam(),
|
|
||||||
onMessageLongClicked = EnsureNeverCalledWithParam(),
|
|
||||||
onTimestampClicked = EnsureNeverCalledWithParam(),
|
|
||||||
onSwipeToReply = EnsureNeverCalledWithParam(),
|
|
||||||
onReactionClicked = EnsureNeverCalledWithTwoParams(),
|
|
||||||
onReactionLongClicked = EnsureNeverCalledWithTwoParams(),
|
|
||||||
onMoreReactionsClicked = EnsureNeverCalledWithParam(),
|
|
||||||
onReadReceiptClick = EnsureNeverCalledWithParam(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
eventsRecorder.assertSingle(TimelineEvents.LoadMore)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `reaching the end of the timeline does not send a LoadMore event`() {
|
fun `reaching the end of the timeline does not send a LoadMore event`() {
|
||||||
val eventsRecorder = EventsRecorder<TimelineEvents>(expectEvents = false)
|
val eventsRecorder = EventsRecorder<TimelineEvents>(expectEvents = false)
|
||||||
rule.setContent {
|
rule.setTimelineView(
|
||||||
TimelineView(
|
state = aTimelineState(
|
||||||
aTimelineState(
|
eventSink = eventsRecorder,
|
||||||
eventSink = eventsRecorder,
|
),
|
||||||
paginationState = aPaginationState(
|
)
|
||||||
hasMoreToLoadBackwards = false,
|
}
|
||||||
)
|
|
||||||
),
|
@Test
|
||||||
typingNotificationState = aTypingNotificationState(),
|
fun `scroll to bottom on live timeline does not emit the Event`() {
|
||||||
roomName = null,
|
val eventsRecorder = EventsRecorder<TimelineEvents>(expectEvents = false)
|
||||||
onUserDataClicked = EnsureNeverCalledWithParam(),
|
rule.setTimelineView(
|
||||||
onLinkClicked = EnsureNeverCalledWithParam(),
|
state = aTimelineState(
|
||||||
onMessageClicked = EnsureNeverCalledWithParam(),
|
isLive = true,
|
||||||
onMessageLongClicked = EnsureNeverCalledWithParam(),
|
eventSink = eventsRecorder,
|
||||||
onTimestampClicked = EnsureNeverCalledWithParam(),
|
),
|
||||||
onSwipeToReply = EnsureNeverCalledWithParam(),
|
forceJumpToBottomVisibility = true,
|
||||||
onReactionClicked = EnsureNeverCalledWithTwoParams(),
|
)
|
||||||
onReactionLongClicked = EnsureNeverCalledWithTwoParams(),
|
val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom)
|
||||||
onMoreReactionsClicked = EnsureNeverCalledWithParam(),
|
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||||
onReadReceiptClick = EnsureNeverCalledWithParam(),
|
}
|
||||||
)
|
|
||||||
}
|
@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 {
|
class InReplyToDetailTest {
|
||||||
@Test
|
@Test
|
||||||
fun `map - with a not ready InReplyTo does not work`() {
|
fun `map - with a not ready InReplyTo return expected object`() {
|
||||||
assertThat(
|
assertThat(
|
||||||
InReplyTo.Pending.map(
|
InReplyTo.Pending(AN_EVENT_ID).map(
|
||||||
permalinkParser = FakePermalinkParser()
|
permalinkParser = FakePermalinkParser()
|
||||||
)
|
)
|
||||||
).isNull()
|
).isEqualTo(InReplyToDetails.Loading(AN_EVENT_ID))
|
||||||
assertThat(
|
assertThat(
|
||||||
InReplyTo.NotLoaded(AN_EVENT_ID).map(
|
InReplyTo.NotLoaded(AN_EVENT_ID).map(
|
||||||
permalinkParser = FakePermalinkParser()
|
permalinkParser = FakePermalinkParser()
|
||||||
)
|
)
|
||||||
).isNull()
|
).isEqualTo(InReplyToDetails.Loading(AN_EVENT_ID))
|
||||||
assertThat(
|
assertThat(
|
||||||
InReplyTo.Error.map(
|
InReplyTo.Error(AN_EVENT_ID, "a message").map(
|
||||||
permalinkParser = FakePermalinkParser()
|
permalinkParser = FakePermalinkParser()
|
||||||
)
|
)
|
||||||
).isNull()
|
).isEqualTo(InReplyToDetails.Error(AN_EVENT_ID, "a message"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -65,7 +65,7 @@ class InReplyToDetailTest {
|
||||||
permalinkParser = FakePermalinkParser()
|
permalinkParser = FakePermalinkParser()
|
||||||
)
|
)
|
||||||
assertThat(inReplyToDetails).isNotNull()
|
assertThat(inReplyToDetails).isNotNull()
|
||||||
assertThat(inReplyToDetails?.textContent).isNull()
|
assertThat((inReplyToDetails as InReplyToDetails.Ready).textContent).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -89,9 +89,7 @@ class InReplyToDetailTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assertThat(
|
assertThat(
|
||||||
inReplyTo.map(
|
(inReplyTo.map(permalinkParser = FakePermalinkParser()) as InReplyToDetails.Ready).textContent
|
||||||
permalinkParser = FakePermalinkParser()
|
|
||||||
)?.textContent
|
|
||||||
).isEqualTo("Hello!")
|
).isEqualTo("Hello!")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,9 +111,7 @@ class InReplyToDetailTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assertThat(
|
assertThat(
|
||||||
inReplyTo.map(
|
(inReplyTo.map(permalinkParser = FakePermalinkParser()) as InReplyToDetails.Ready).textContent
|
||||||
permalinkParser = FakePermalinkParser()
|
|
||||||
)?.textContent
|
|
||||||
).isEqualTo("**Hello!**")
|
).isEqualTo("**Hello!**")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `any message content`() = runTest {
|
fun `any message content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(eventContent = aMessageContent()).metadata()
|
anInReplyToDetailsReady(eventContent = aMessageContent()).metadata()
|
||||||
}.test {
|
}.test {
|
||||||
awaitItem().let {
|
awaitItem().let {
|
||||||
assertThat(it).isEqualTo(InReplyToMetadata.Text("textContent"))
|
assertThat(it).isEqualTo(InReplyToMetadata.Text("textContent"))
|
||||||
|
|
@ -81,7 +81,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `an image message content`() = runTest {
|
fun `an image message content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = aMessageContent(
|
eventContent = aMessageContent(
|
||||||
messageType = ImageMessageType(
|
messageType = ImageMessageType(
|
||||||
body = "body",
|
body = "body",
|
||||||
|
|
@ -111,7 +111,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `a sticker message content`() = runTest {
|
fun `a sticker message content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = StickerContent(
|
eventContent = StickerContent(
|
||||||
body = "body",
|
body = "body",
|
||||||
info = anImageInfo(),
|
info = anImageInfo(),
|
||||||
|
|
@ -137,7 +137,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `a video message content`() = runTest {
|
fun `a video message content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = aMessageContent(
|
eventContent = aMessageContent(
|
||||||
messageType = VideoMessageType(
|
messageType = VideoMessageType(
|
||||||
body = "body",
|
body = "body",
|
||||||
|
|
@ -167,7 +167,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `a file message content`() = runTest {
|
fun `a file message content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = aMessageContent(
|
eventContent = aMessageContent(
|
||||||
messageType = FileMessageType(
|
messageType = FileMessageType(
|
||||||
body = "body",
|
body = "body",
|
||||||
|
|
@ -200,7 +200,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `a audio message content`() = runTest {
|
fun `a audio message content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = aMessageContent(
|
eventContent = aMessageContent(
|
||||||
messageType = AudioMessageType(
|
messageType = AudioMessageType(
|
||||||
body = "body",
|
body = "body",
|
||||||
|
|
@ -232,7 +232,7 @@ class InReplyToMetadataKtTest {
|
||||||
fun `a location message content`() = runTest {
|
fun `a location message content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
testEnv {
|
testEnv {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = aMessageContent(
|
eventContent = aMessageContent(
|
||||||
messageType = LocationMessageType(
|
messageType = LocationMessageType(
|
||||||
body = "body",
|
body = "body",
|
||||||
|
|
@ -262,7 +262,7 @@ class InReplyToMetadataKtTest {
|
||||||
fun `a voice message content`() = runTest {
|
fun `a voice message content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
testEnv {
|
testEnv {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = aMessageContent(
|
eventContent = aMessageContent(
|
||||||
messageType = VoiceMessageType(
|
messageType = VoiceMessageType(
|
||||||
body = "body",
|
body = "body",
|
||||||
|
|
@ -292,7 +292,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `a poll content`() = runTest {
|
fun `a poll content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = aPollContent()
|
eventContent = aPollContent()
|
||||||
).metadata()
|
).metadata()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -314,7 +314,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `redacted content`() = runTest {
|
fun `redacted content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = RedactedContent
|
eventContent = RedactedContent
|
||||||
).metadata()
|
).metadata()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -327,7 +327,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `unable to decrypt content`() = runTest {
|
fun `unable to decrypt content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)
|
eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)
|
||||||
).metadata()
|
).metadata()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -340,7 +340,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `failed to parse message content`() = runTest {
|
fun `failed to parse message content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = FailedToParseMessageLikeContent("", "")
|
eventContent = FailedToParseMessageLikeContent("", "")
|
||||||
).metadata()
|
).metadata()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -353,7 +353,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `failed to parse state content`() = runTest {
|
fun `failed to parse state content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = FailedToParseStateContent("", "", "")
|
eventContent = FailedToParseStateContent("", "", "")
|
||||||
).metadata()
|
).metadata()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -366,7 +366,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `profile change content`() = runTest {
|
fun `profile change content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = ProfileChangeContent("", "", "", "")
|
eventContent = ProfileChangeContent("", "", "", "")
|
||||||
).metadata()
|
).metadata()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -379,7 +379,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `room membership content`() = runTest {
|
fun `room membership content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = RoomMembershipContent(A_USER_ID, null)
|
eventContent = RoomMembershipContent(A_USER_ID, null)
|
||||||
).metadata()
|
).metadata()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -392,7 +392,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `state content`() = runTest {
|
fun `state content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = StateContent("", OtherState.RoomJoinRules)
|
eventContent = StateContent("", OtherState.RoomJoinRules)
|
||||||
).metadata()
|
).metadata()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -405,7 +405,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `unknown content`() = runTest {
|
fun `unknown content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = UnknownContent
|
eventContent = UnknownContent
|
||||||
).metadata()
|
).metadata()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -418,7 +418,7 @@ class InReplyToMetadataKtTest {
|
||||||
@Test
|
@Test
|
||||||
fun `null content`() = runTest {
|
fun `null content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetails(
|
anInReplyToDetailsReady(
|
||||||
eventContent = null
|
eventContent = null
|
||||||
).metadata()
|
).metadata()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -429,13 +429,13 @@ class InReplyToMetadataKtTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun anInReplyToDetails(
|
private fun anInReplyToDetailsReady(
|
||||||
eventId: EventId = AN_EVENT_ID,
|
eventId: EventId = AN_EVENT_ID,
|
||||||
senderId: UserId = A_USER_ID,
|
senderId: UserId = A_USER_ID,
|
||||||
senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
|
senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
|
||||||
eventContent: EventContent? = aMessageContent(),
|
eventContent: EventContent? = aMessageContent(),
|
||||||
textContent: String? = "textContent",
|
textContent: String? = "textContent",
|
||||||
) = InReplyToDetails(
|
) = InReplyToDetails.Ready(
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
senderId = senderId,
|
senderId = senderId,
|
||||||
senderProfile = senderProfile,
|
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.poll.PollKind
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
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.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 io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class PollRepository @Inject constructor(
|
class PollRepository @Inject constructor(
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
|
private val timelineProvider: TimelineProvider,
|
||||||
) {
|
) {
|
||||||
suspend fun getPoll(eventId: EventId): Result<PollContent> = runCatching {
|
suspend fun getPoll(eventId: EventId): Result<PollContent> = runCatching {
|
||||||
room.timeline
|
timelineProvider
|
||||||
|
.getActiveTimeline()
|
||||||
.timelineItems
|
.timelineItems
|
||||||
.first()
|
.first()
|
||||||
.asSequence()
|
.asSequence()
|
||||||
|
|
@ -51,13 +55,15 @@ class PollRepository @Inject constructor(
|
||||||
maxSelections = maxSelections,
|
maxSelections = maxSelections,
|
||||||
pollKind = pollKind,
|
pollKind = pollKind,
|
||||||
)
|
)
|
||||||
else -> room.editPoll(
|
else -> timelineProvider
|
||||||
pollStartId = existingPollId,
|
.getActiveTimeline()
|
||||||
question = question,
|
.editPoll(
|
||||||
answers = answers,
|
pollStartId = existingPollId,
|
||||||
maxSelections = maxSelections,
|
question = question,
|
||||||
pollKind = pollKind,
|
answers = answers,
|
||||||
)
|
maxSelections = maxSelections,
|
||||||
|
pollKind = pollKind,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deletePoll(
|
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.PollHistoryItems
|
||||||
import io.element.android.features.poll.impl.history.model.PollHistoryItemsFactory
|
import io.element.android.features.poll.impl.history.model.PollHistoryItemsFactory
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
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.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class PollHistoryPresenter @Inject constructor(
|
class PollHistoryPresenter @Inject constructor(
|
||||||
private val room: MatrixRoom,
|
|
||||||
private val appCoroutineScope: CoroutineScope,
|
private val appCoroutineScope: CoroutineScope,
|
||||||
private val sendPollResponseAction: SendPollResponseAction,
|
private val sendPollResponseAction: SendPollResponseAction,
|
||||||
private val endPollAction: EndPollAction,
|
private val endPollAction: EndPollAction,
|
||||||
private val pollHistoryItemFactory: PollHistoryItemsFactory,
|
private val pollHistoryItemFactory: PollHistoryItemsFactory,
|
||||||
|
private val timelineProvider: TimelineProvider,
|
||||||
) : Presenter<PollHistoryState> {
|
) : Presenter<PollHistoryState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): PollHistoryState {
|
override fun present(): PollHistoryState {
|
||||||
// TODO use room.rememberPollHistory() when working properly?
|
val timeline by timelineProvider.activeTimelineFlow().collectAsState()
|
||||||
val timeline = room.timeline
|
val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
|
||||||
val paginationState by timeline.paginationState.collectAsState()
|
|
||||||
val pollHistoryItemsFlow = remember {
|
val pollHistoryItemsFlow = remember {
|
||||||
timeline.timelineItems.map { items ->
|
timeline.timelineItems.map { items ->
|
||||||
pollHistoryItemFactory.create(items)
|
pollHistoryItemFactory.create(items)
|
||||||
|
|
@ -61,11 +60,11 @@ class PollHistoryPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
val pollHistoryItems by pollHistoryItemsFlow.collectAsState(initial = PollHistoryItems())
|
val pollHistoryItems by pollHistoryItemsFlow.collectAsState(initial = PollHistoryItems())
|
||||||
LaunchedEffect(paginationState, pollHistoryItems.size) {
|
LaunchedEffect(paginationState, pollHistoryItems.size) {
|
||||||
if (pollHistoryItems.size == 0 && paginationState.canBackPaginate) loadMore(timeline)
|
if (pollHistoryItems.size == 0 && paginationState.canPaginate) loadMore(timeline)
|
||||||
}
|
}
|
||||||
val isLoading by remember {
|
val isLoading by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
pollHistoryItems.size == 0 || paginationState.isBackPaginating
|
pollHistoryItems.size == 0 || paginationState.isPaginating
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
@ -88,14 +87,14 @@ class PollHistoryPresenter @Inject constructor(
|
||||||
|
|
||||||
return PollHistoryState(
|
return PollHistoryState(
|
||||||
isLoading = isLoading,
|
isLoading = isLoading,
|
||||||
hasMoreToLoad = paginationState.hasMoreToLoadBackwards,
|
hasMoreToLoad = paginationState.hasMoreToLoad,
|
||||||
pollHistoryItems = pollHistoryItems,
|
pollHistoryItems = pollHistoryItems,
|
||||||
activeFilter = activeFilter,
|
activeFilter = activeFilter,
|
||||||
eventSink = ::handleEvents,
|
eventSink = ::handleEvents,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.loadMore(pollHistory: MatrixTimeline) = launch {
|
private fun CoroutineScope.loadMore(pollHistory: Timeline) = launch {
|
||||||
pollHistory.paginateBackwards(200)
|
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.poll.PollAnswer
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
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.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.aPollContent
|
||||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
||||||
fun aPollTimeline(
|
fun aPollTimelineItems(
|
||||||
polls: Map<EventId, PollContent> = emptyMap(),
|
polls: Map<EventId, PollContent> = emptyMap(),
|
||||||
): FakeMatrixTimeline {
|
): Flow<List<MatrixTimelineItem>> {
|
||||||
return FakeMatrixTimeline(
|
return flowOf(
|
||||||
initialTimelineItems = polls.map { entry ->
|
polls.map { entry ->
|
||||||
MatrixTimelineItem.Event(
|
MatrixTimelineItem.Event(
|
||||||
entry.key.value,
|
entry.key.value,
|
||||||
anEventTimelineItem(
|
anEventTimelineItem(
|
||||||
|
|
|
||||||
|
|
@ -25,33 +25,42 @@ import im.vector.app.features.analytics.plan.Composer
|
||||||
import im.vector.app.features.analytics.plan.PollCreation
|
import im.vector.app.features.analytics.plan.PollCreation
|
||||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||||
import io.element.android.features.poll.api.create.CreatePollMode
|
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.anOngoingPollContent
|
||||||
import io.element.android.features.poll.impl.data.PollRepository
|
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.poll.PollKind
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
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.api.timeline.item.event.PollContent
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
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.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.room.SavePollInvocation
|
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.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
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.delay
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class CreatePollPresenterTest {
|
@OptIn(ExperimentalCoroutinesApi::class) class CreatePollPresenterTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
private val pollEventId = AN_EVENT_ID
|
private val pollEventId = AN_EVENT_ID
|
||||||
private var navUpInvocationsCount = 0
|
private var navUpInvocationsCount = 0
|
||||||
private val existingPoll = anOngoingPollContent()
|
private val existingPoll = anOngoingPollContent()
|
||||||
|
private val timeline = FakeTimeline(
|
||||||
|
timelineItems = aPollTimelineItems(mapOf(pollEventId to existingPoll))
|
||||||
|
)
|
||||||
private val fakeMatrixRoom = FakeMatrixRoom(
|
private val fakeMatrixRoom = FakeMatrixRoom(
|
||||||
matrixTimeline = aPollTimeline(
|
liveTimeline = timeline
|
||||||
mapOf(pollEventId to existingPoll)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
private val fakeAnalyticsService = FakeAnalyticsService()
|
private val fakeAnalyticsService = FakeAnalyticsService()
|
||||||
private val fakeMessageComposerContext = FakeMessageComposerContext()
|
private val fakeMessageComposerContext = FakeMessageComposerContext()
|
||||||
|
|
@ -80,7 +89,7 @@ class CreatePollPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `in edit mode, if poll doesn't exist, error is tracked and screen is closed`() = runTest {
|
fun `in edit mode, if poll doesn't exist, error is tracked and screen is closed`() = runTest {
|
||||||
val room = FakeMatrixRoom(
|
val room = FakeMatrixRoom(
|
||||||
matrixTimeline = aPollTimeline()
|
liveTimeline = FakeTimeline()
|
||||||
)
|
)
|
||||||
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(AN_EVENT_ID), room = room)
|
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(AN_EVENT_ID), room = room)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
|
@ -180,6 +189,12 @@ class CreatePollPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `edit poll sends a poll edit event`() = runTest {
|
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))
|
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -201,16 +216,18 @@ class CreatePollPresenterTest {
|
||||||
).apply {
|
).apply {
|
||||||
eventSink(CreatePollEvents.Save)
|
eventSink(CreatePollEvents.Save)
|
||||||
}
|
}
|
||||||
delay(1) // Wait for the coroutine to finish
|
advanceUntilIdle() // Wait for the coroutine to finish
|
||||||
assertThat(fakeMatrixRoom.editPollInvocations.size).isEqualTo(1)
|
|
||||||
assertThat(fakeMatrixRoom.editPollInvocations.last()).isEqualTo(
|
assert(editPollLambda)
|
||||||
SavePollInvocation(
|
.isCalledOnce()
|
||||||
question = "Changed question",
|
.with(
|
||||||
answers = listOf("Changed answer 1", "Changed answer 2", "Maybe"),
|
value(pollEventId),
|
||||||
maxSelections = 1,
|
value("Changed question"),
|
||||||
pollKind = PollKind.Disclosed
|
value(listOf("Changed answer 1", "Changed answer 2", "Maybe")),
|
||||||
|
value(1),
|
||||||
|
value(PollKind.Disclosed)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2)
|
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2)
|
||||||
assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo(
|
assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo(
|
||||||
Composer(
|
Composer(
|
||||||
|
|
@ -233,6 +250,12 @@ class CreatePollPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `when edit poll fails, error is tracked`() = runTest {
|
fun `when edit poll fails, error is tracked`() = runTest {
|
||||||
val error = Exception("cause")
|
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))
|
fakeMatrixRoom.givenEditPollResult(Result.failure(error))
|
||||||
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
|
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
|
@ -241,8 +264,8 @@ class CreatePollPresenterTest {
|
||||||
awaitDefaultItem()
|
awaitDefaultItem()
|
||||||
awaitPollLoaded().eventSink(CreatePollEvents.SetAnswer(0, "A"))
|
awaitPollLoaded().eventSink(CreatePollEvents.SetAnswer(0, "A"))
|
||||||
awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvents.Save)
|
awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvents.Save)
|
||||||
delay(1) // Wait for the coroutine to finish
|
advanceUntilIdle() // Wait for the coroutine to finish
|
||||||
assertThat(fakeMatrixRoom.editPollInvocations).hasSize(1)
|
assert(editPollLambda).isCalledOnce()
|
||||||
assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
|
assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
|
||||||
assertThat(fakeAnalyticsService.trackedErrors).hasSize(1)
|
assertThat(fakeAnalyticsService.trackedErrors).hasSize(1)
|
||||||
assertThat(fakeAnalyticsService.trackedErrors).containsExactly(
|
assertThat(fakeAnalyticsService.trackedErrors).containsExactly(
|
||||||
|
|
@ -497,22 +520,22 @@ class CreatePollPresenterTest {
|
||||||
newAnswer1: String? = null,
|
newAnswer1: String? = null,
|
||||||
newAnswer2: String? = null,
|
newAnswer2: String? = null,
|
||||||
) =
|
) =
|
||||||
awaitItem().apply {
|
awaitItem().also { state ->
|
||||||
assertThat(canSave).isTrue()
|
assertThat(state.canSave).isTrue()
|
||||||
assertThat(canAddAnswer).isTrue()
|
assertThat(state.canAddAnswer).isTrue()
|
||||||
assertThat(question).isEqualTo(newQuestion ?: existingPoll.question)
|
assertThat(state.question).isEqualTo(newQuestion ?: existingPoll.question)
|
||||||
assertThat(answers).isEqualTo(existingPoll.expectedAnswersState().toMutableList().apply {
|
assertThat(state.answers).isEqualTo(existingPoll.expectedAnswersState().toMutableList().apply {
|
||||||
newAnswer1?.let { this[0] = Answer(it, true) }
|
newAnswer1?.let { this[0] = Answer(it, true) }
|
||||||
newAnswer2?.let { this[1] = Answer(it, true) }
|
newAnswer2?.let { this[1] = Answer(it, true) }
|
||||||
})
|
})
|
||||||
assertThat(pollKind).isEqualTo(existingPoll.kind)
|
assertThat(state.pollKind).isEqualTo(existingPoll.kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createCreatePollPresenter(
|
private fun createCreatePollPresenter(
|
||||||
mode: CreatePollMode = CreatePollMode.NewPoll,
|
mode: CreatePollMode = CreatePollMode.NewPoll,
|
||||||
room: MatrixRoom = fakeMatrixRoom,
|
room: MatrixRoom = fakeMatrixRoom,
|
||||||
): CreatePollPresenter = CreatePollPresenter(
|
): CreatePollPresenter = CreatePollPresenter(
|
||||||
repository = PollRepository(room),
|
repository = PollRepository(room, LiveTimelineProvider(room)),
|
||||||
analyticsService = fakeAnalyticsService,
|
analyticsService = fakeAnalyticsService,
|
||||||
messageComposerContext = fakeMessageComposerContext,
|
messageComposerContext = fakeMessageComposerContext,
|
||||||
navigateUp = { navUpInvocationsCount++ },
|
navigateUp = { navUpInvocationsCount++ },
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.poll.api.actions.EndPollAction
|
import io.element.android.features.poll.api.actions.EndPollAction
|
||||||
import io.element.android.features.poll.api.actions.SendPollResponseAction
|
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.anEndedPollContent
|
||||||
import io.element.android.features.poll.impl.anOngoingPollContent
|
import io.element.android.features.poll.impl.anOngoingPollContent
|
||||||
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
|
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.features.poll.test.actions.FakeSendPollResponseAction
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
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
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
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.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
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.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 io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.getAndUpdate
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runCurrent
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -50,14 +57,18 @@ class PollHistoryPresenterTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
private val timeline = aPollTimeline(
|
private val backwardPaginationStatus = MutableStateFlow(Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true))
|
||||||
polls = mapOf(
|
private val timeline = FakeTimeline(
|
||||||
AN_EVENT_ID to anOngoingPollContent(),
|
timelineItems = aPollTimelineItems(
|
||||||
AN_EVENT_ID_2 to anEndedPollContent()
|
mapOf(
|
||||||
)
|
AN_EVENT_ID to anOngoingPollContent(),
|
||||||
|
AN_EVENT_ID_2 to anEndedPollContent()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
backwardPaginationStatus = backwardPaginationStatus
|
||||||
)
|
)
|
||||||
private val room = FakeMatrixRoom(
|
private val room = FakeMatrixRoom(
|
||||||
matrixTimeline = timeline
|
liveTimeline = timeline
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -66,7 +77,6 @@ class PollHistoryPresenterTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING)
|
assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING)
|
||||||
assertThat(state.pollHistoryItems.size).isEqualTo(0)
|
assertThat(state.pollHistoryItems.size).isEqualTo(0)
|
||||||
|
|
@ -127,26 +137,30 @@ class PollHistoryPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - load more scenario`() = runTest {
|
fun `present - load more scenario`() = runTest {
|
||||||
|
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
|
||||||
|
Result.success(false)
|
||||||
|
}
|
||||||
|
timeline.apply {
|
||||||
|
this.paginateLambda = paginateLambda
|
||||||
|
}
|
||||||
val presenter = createPollHistoryPresenter(room = room)
|
val presenter = createPollHistoryPresenter(room = room)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(2)
|
skipItems(1)
|
||||||
awaitItem().also { state ->
|
|
||||||
assertThat(state.pollHistoryItems.size).isEqualTo(2)
|
|
||||||
}
|
|
||||||
timeline.updatePaginationState {
|
|
||||||
copy(isBackPaginating = false)
|
|
||||||
}
|
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
assertThat(loadedState.isLoading).isFalse()
|
assertThat(loadedState.isLoading).isFalse()
|
||||||
loadedState.eventSink(PollHistoryEvents.LoadMore)
|
loadedState.eventSink(PollHistoryEvents.LoadMore)
|
||||||
|
backwardPaginationStatus.getAndUpdate { it.copy(isPaginating = true) }
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.isLoading).isTrue()
|
assertThat(state.isLoading).isTrue()
|
||||||
}
|
}
|
||||||
|
backwardPaginationStatus.getAndUpdate { it.copy(isPaginating = false) }
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.isLoading).isFalse()
|
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 {
|
): PollHistoryPresenter {
|
||||||
return PollHistoryPresenter(
|
return PollHistoryPresenter(
|
||||||
room = room,
|
|
||||||
appCoroutineScope = appCoroutineScope,
|
appCoroutineScope = appCoroutineScope,
|
||||||
sendPollResponseAction = sendPollResponseAction,
|
sendPollResponseAction = sendPollResponseAction,
|
||||||
endPollAction = endPollAction,
|
endPollAction = endPollAction,
|
||||||
pollHistoryItemFactory = pollHistoryItemFactory,
|
pollHistoryItemFactory = pollHistoryItemFactory,
|
||||||
|
timelineProvider = LiveTimelineProvider(room),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,10 @@ val SemanticColors.bigIconDefaultBackgroundColor
|
||||||
val SemanticColors.bigCheckmarkBorderColor
|
val SemanticColors.bigCheckmarkBorderColor
|
||||||
get() = if (isLight) LightColorTokens.colorGray400 else DarkColorTokens.colorGray400
|
get() = if (isLight) LightColorTokens.colorGray400 else DarkColorTokens.colorGray400
|
||||||
|
|
||||||
|
@OptIn(CoreColorToken::class)
|
||||||
|
val SemanticColors.highlightedMessageBackgroundColor
|
||||||
|
get() = if (isLight) LightColorTokens.colorGreen300 else DarkColorTokens.colorGreen300
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ColorAliasesPreview() = ElementPreview {
|
internal fun ColorAliasesPreview() = ElementPreview {
|
||||||
|
|
@ -167,6 +171,8 @@ internal fun ColorAliasesPreview() = ElementPreview {
|
||||||
"temporaryColorBgSpecial" to ElementTheme.colors.temporaryColorBgSpecial,
|
"temporaryColorBgSpecial" to ElementTheme.colors.temporaryColorBgSpecial,
|
||||||
"iconSuccessPrimaryBackground" to ElementTheme.colors.iconSuccessPrimaryBackground,
|
"iconSuccessPrimaryBackground" to ElementTheme.colors.iconSuccessPrimaryBackground,
|
||||||
"bigIconBackgroundColor" to ElementTheme.colors.bigIconDefaultBackgroundColor,
|
"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.location.AssetType
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
|
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.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.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.MatrixWidgetDriver
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
@ -98,7 +98,16 @@ interface MatrixRoom : Closeable {
|
||||||
|
|
||||||
val syncUpdateFlow: StateFlow<Long>
|
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()
|
fun destroy()
|
||||||
|
|
||||||
|
|
@ -122,12 +131,6 @@ interface MatrixRoom : Closeable {
|
||||||
|
|
||||||
suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
|
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 redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
|
||||||
|
|
||||||
suspend fun sendImage(
|
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
|
data class NotLoaded(val eventId: EventId) : InReplyTo
|
||||||
|
|
||||||
/** The event details are pending to be fetched. We should **not** fetch them again. */
|
/** 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. */
|
/** The event details are available. */
|
||||||
data class Ready(
|
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
|
* 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.
|
* 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
|
package io.element.android.libraries.matrix.api.timeline.item.virtual
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
|
||||||
sealed interface VirtualTimelineItem {
|
sealed interface VirtualTimelineItem {
|
||||||
data class DayDivider(
|
data class DayDivider(
|
||||||
val timestamp: Long
|
val timestamp: Long
|
||||||
|
|
@ -24,4 +26,13 @@ sealed interface VirtualTimelineItem {
|
||||||
data object ReadMarker : VirtualTimelineItem
|
data object ReadMarker : VirtualTimelineItem
|
||||||
|
|
||||||
data object EncryptedHistoryBanner : 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.CoroutineDispatchers
|
||||||
import io.element.android.libraries.core.coroutine.childScope
|
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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
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.MatrixRoomPowerLevels
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
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.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.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.MatrixWidgetDriver
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
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.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.RoomMemberListFetcher
|
||||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
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.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.timeline.toRustReceiptType
|
||||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||||
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
|
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.flow.onStart
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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.RoomInfo
|
||||||
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
||||||
import org.matrix.rustcomponents.sdk.RoomListItem
|
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.TypingNotificationsListener
|
||||||
import org.matrix.rustcomponents.sdk.UserPowerLevelUpdate
|
import org.matrix.rustcomponents.sdk.UserPowerLevelUpdate
|
||||||
import org.matrix.rustcomponents.sdk.WidgetCapabilities
|
import org.matrix.rustcomponents.sdk.WidgetCapabilities
|
||||||
import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider
|
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 org.matrix.rustcomponents.sdk.use
|
||||||
import timber.log.Timber
|
|
||||||
import uniffi.matrix_sdk.RoomPowerLevelChanges
|
import uniffi.matrix_sdk.RoomPowerLevelChanges
|
||||||
import java.io.File
|
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.Room as InnerRoom
|
||||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||||
|
|
||||||
|
|
@ -107,7 +94,7 @@ class RustMatrixRoom(
|
||||||
private val isKeyBackupEnabled: Boolean,
|
private val isKeyBackupEnabled: Boolean,
|
||||||
private val roomListItem: RoomListItem,
|
private val roomListItem: RoomListItem,
|
||||||
private val innerRoom: InnerRoom,
|
private val innerRoom: InnerRoom,
|
||||||
private val innerTimeline: InnerTimeline,
|
innerTimeline: InnerTimeline,
|
||||||
private val roomNotificationSettingsService: RustNotificationSettingsService,
|
private val roomNotificationSettingsService: RustNotificationSettingsService,
|
||||||
sessionCoroutineScope: CoroutineScope,
|
sessionCoroutineScope: CoroutineScope,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
|
|
@ -160,7 +147,7 @@ class RustMatrixRoom(
|
||||||
private val _roomNotificationSettingsStateFlow = MutableStateFlow<MatrixRoomNotificationSettingsState>(MatrixRoomNotificationSettingsState.Unknown)
|
private val _roomNotificationSettingsStateFlow = MutableStateFlow<MatrixRoomNotificationSettingsState>(MatrixRoomNotificationSettingsState.Unknown)
|
||||||
override val roomNotificationSettingsStateFlow: StateFlow<MatrixRoomNotificationSettingsState> = _roomNotificationSettingsStateFlow
|
override val roomNotificationSettingsStateFlow: StateFlow<MatrixRoomNotificationSettingsState> = _roomNotificationSettingsStateFlow
|
||||||
|
|
||||||
override val timeline = createMatrixTimeline(innerTimeline) {
|
override val liveTimeline = createTimeline(innerTimeline, isLive = true) {
|
||||||
_syncUpdateFlow.value = systemClock.epochMillis()
|
_syncUpdateFlow.value = systemClock.epochMillis()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,7 +157,7 @@ class RustMatrixRoom(
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val powerLevelChanges = roomInfoFlow.map { it.userPowerLevels }.distinctUntilChanged()
|
val powerLevelChanges = roomInfoFlow.map { it.userPowerLevels }.distinctUntilChanged()
|
||||||
val membershipChanges = timeline.membershipChangeEventReceived.onStart { emit(Unit) }
|
val membershipChanges = liveTimeline.membershipChangeEventReceived.onStart { emit(Unit) }
|
||||||
combine(membershipChanges, powerLevelChanges) { _, _ -> }
|
combine(membershipChanges, powerLevelChanges) { _, _ -> }
|
||||||
// Skip initial one
|
// Skip initial one
|
||||||
.drop(1)
|
.drop(1)
|
||||||
|
|
@ -183,12 +170,25 @@ class RustMatrixRoom(
|
||||||
|
|
||||||
override suspend fun unsubscribeFromSync() = roomSyncSubscriber.unsubscribe(roomId)
|
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() {
|
override fun destroy() {
|
||||||
roomCoroutineScope.cancel()
|
roomCoroutineScope.cancel()
|
||||||
timeline.close()
|
liveTimeline.close()
|
||||||
innerRoom.destroy()
|
innerRoom.destroy()
|
||||||
roomListItem.destroy()
|
roomListItem.destroy()
|
||||||
specialModeEventTimelineItem?.destroy()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override val name: String?
|
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) {
|
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> {
|
||||||
messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()).use { content ->
|
return liveTimeline.sendMessage(body, htmlBody, mentions)
|
||||||
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 redactEvent(eventId: EventId, reason: String?) = withContext(roomDispatcher) {
|
override suspend fun redactEvent(eventId: EventId, reason: String?) = withContext(roomDispatcher) {
|
||||||
|
|
@ -457,18 +406,7 @@ class RustMatrixRoom(
|
||||||
formattedBody: String?,
|
formattedBody: String?,
|
||||||
progressCallback: ProgressCallback?,
|
progressCallback: ProgressCallback?,
|
||||||
): Result<MediaUploadHandler> {
|
): Result<MediaUploadHandler> {
|
||||||
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
|
return liveTimeline.sendImage(file, thumbnailFile, imageInfo, body, formattedBody, progressCallback)
|
||||||
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()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendVideo(
|
override suspend fun sendVideo(
|
||||||
|
|
@ -479,63 +417,31 @@ class RustMatrixRoom(
|
||||||
formattedBody: String?,
|
formattedBody: String?,
|
||||||
progressCallback: ProgressCallback?,
|
progressCallback: ProgressCallback?,
|
||||||
): Result<MediaUploadHandler> {
|
): Result<MediaUploadHandler> {
|
||||||
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
|
return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, body, formattedBody, progressCallback)
|
||||||
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()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||||
return sendAttachment(listOf(file)) {
|
return liveTimeline.sendAudio(file, audioInfo, progressCallback)
|
||||||
innerTimeline.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> {
|
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||||
return sendAttachment(listOf(file)) {
|
return liveTimeline.sendFile(file, fileInfo, progressCallback)
|
||||||
innerTimeline.sendFile(file.path, fileInfo.map(), progressCallback?.toProgressWatcher())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> = withContext(roomDispatcher) {
|
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> {
|
||||||
runCatching {
|
return liveTimeline.toggleReaction(emoji, eventId)
|
||||||
innerTimeline.toggleReaction(key = emoji, eventId = eventId.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(roomDispatcher) {
|
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> {
|
||||||
runCatching {
|
return liveTimeline.forwardEvent(eventId, roomIds)
|
||||||
roomContentForwarder.forward(fromTimeline = innerTimeline, eventId = eventId, toRoomIds = roomIds)
|
|
||||||
}.onFailure {
|
|
||||||
Timber.e(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> = withContext(roomDispatcher) {
|
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> {
|
||||||
runCatching {
|
return liveTimeline.retrySendMessage(transactionId)
|
||||||
innerTimeline.retrySend(transactionId.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> = withContext(roomDispatcher) {
|
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> {
|
||||||
runCatching {
|
return liveTimeline.cancelSend(transactionId)
|
||||||
innerTimeline.cancelSend(transactionId.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> = withContext(roomDispatcher) {
|
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> = withContext(roomDispatcher) {
|
||||||
|
|
@ -613,16 +519,8 @@ class RustMatrixRoom(
|
||||||
description: String?,
|
description: String?,
|
||||||
zoomLevel: Int?,
|
zoomLevel: Int?,
|
||||||
assetType: AssetType?,
|
assetType: AssetType?,
|
||||||
): Result<Unit> = withContext(roomDispatcher) {
|
): Result<Unit> {
|
||||||
runCatching {
|
return liveTimeline.sendLocation(body, geoUri, description, zoomLevel, assetType)
|
||||||
innerTimeline.sendLocation(
|
|
||||||
body = body,
|
|
||||||
geoUri = geoUri,
|
|
||||||
description = description,
|
|
||||||
zoomLevel = zoomLevel?.toUByte(),
|
|
||||||
assetType = assetType?.toInner(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createPoll(
|
override suspend fun createPoll(
|
||||||
|
|
@ -630,15 +528,8 @@ class RustMatrixRoom(
|
||||||
answers: List<String>,
|
answers: List<String>,
|
||||||
maxSelections: Int,
|
maxSelections: Int,
|
||||||
pollKind: PollKind,
|
pollKind: PollKind,
|
||||||
): Result<Unit> = withContext(roomDispatcher) {
|
): Result<Unit> {
|
||||||
runCatching {
|
return liveTimeline.createPoll(question, answers, maxSelections, pollKind)
|
||||||
innerTimeline.createPoll(
|
|
||||||
question = question,
|
|
||||||
answers = answers,
|
|
||||||
maxSelections = maxSelections.toUByte(),
|
|
||||||
pollKind = pollKind.toInner(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun editPoll(
|
override suspend fun editPoll(
|
||||||
|
|
@ -647,46 +538,22 @@ class RustMatrixRoom(
|
||||||
answers: List<String>,
|
answers: List<String>,
|
||||||
maxSelections: Int,
|
maxSelections: Int,
|
||||||
pollKind: PollKind,
|
pollKind: PollKind,
|
||||||
): Result<Unit> = withContext(roomDispatcher) {
|
): Result<Unit> {
|
||||||
runCatching {
|
return liveTimeline.editPoll(pollStartId, question, answers, maxSelections, pollKind)
|
||||||
val pollStartEvent =
|
|
||||||
innerTimeline.getEventTimelineItemByEventId(
|
|
||||||
eventId = pollStartId.value
|
|
||||||
)
|
|
||||||
pollStartEvent.use {
|
|
||||||
innerTimeline.editPoll(
|
|
||||||
question = question,
|
|
||||||
answers = answers,
|
|
||||||
maxSelections = maxSelections.toUByte(),
|
|
||||||
pollKind = pollKind.toInner(),
|
|
||||||
editItem = pollStartEvent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendPollResponse(
|
override suspend fun sendPollResponse(
|
||||||
pollStartId: EventId,
|
pollStartId: EventId,
|
||||||
answers: List<String>
|
answers: List<String>
|
||||||
): Result<Unit> = withContext(roomDispatcher) {
|
): Result<Unit> {
|
||||||
runCatching {
|
return liveTimeline.sendPollResponse(pollStartId, answers)
|
||||||
innerTimeline.sendPollResponse(
|
|
||||||
pollStartId = pollStartId.value,
|
|
||||||
answers = answers,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun endPoll(
|
override suspend fun endPoll(
|
||||||
pollStartId: EventId,
|
pollStartId: EventId,
|
||||||
text: String
|
text: String
|
||||||
): Result<Unit> = withContext(roomDispatcher) {
|
): Result<Unit> {
|
||||||
runCatching {
|
return liveTimeline.endPoll(pollStartId, text)
|
||||||
innerTimeline.endPoll(
|
|
||||||
pollStartId = pollStartId.value,
|
|
||||||
text = text,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendVoiceMessage(
|
override suspend fun sendVoiceMessage(
|
||||||
|
|
@ -694,16 +561,8 @@ class RustMatrixRoom(
|
||||||
audioInfo: AudioInfo,
|
audioInfo: AudioInfo,
|
||||||
waveform: List<Float>,
|
waveform: List<Float>,
|
||||||
progressCallback: ProgressCallback?,
|
progressCallback: ProgressCallback?,
|
||||||
): Result<MediaUploadHandler> = sendAttachment(listOf(file)) {
|
): Result<MediaUploadHandler> {
|
||||||
innerTimeline.sendVoiceMessage(
|
return liveTimeline.sendVoiceMessage(file, audioInfo, waveform, progressCallback)
|
||||||
url = file.path,
|
|
||||||
audioInfo = audioInfo.map(),
|
|
||||||
waveform = waveform.toMSC3246range(),
|
|
||||||
// Maybe allow a caption in the future?
|
|
||||||
caption = null,
|
|
||||||
formattedCaption = null,
|
|
||||||
progressWatcher = progressCallback?.toProgressWatcher(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun typingNotice(isTyping: Boolean) = runCatching {
|
override suspend fun typingNotice(isTyping: Boolean) = runCatching {
|
||||||
|
|
@ -739,31 +598,22 @@ class RustMatrixRoom(
|
||||||
innerRoom.matrixToEventPermalink(eventId.value)
|
innerRoom.matrixToEventPermalink(eventId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> {
|
private fun createTimeline(
|
||||||
return runCatching {
|
|
||||||
MediaUploadHandlerImpl(files, handle())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createMatrixTimeline(
|
|
||||||
timeline: InnerTimeline,
|
timeline: InnerTimeline,
|
||||||
|
isLive: Boolean,
|
||||||
onNewSyncedEvent: () -> Unit = {},
|
onNewSyncedEvent: () -> Unit = {},
|
||||||
): MatrixTimeline {
|
): Timeline {
|
||||||
return RustMatrixTimeline(
|
return RustTimeline(
|
||||||
isKeyBackupEnabled = isKeyBackupEnabled,
|
isKeyBackupEnabled = isKeyBackupEnabled,
|
||||||
|
isLive = isLive,
|
||||||
matrixRoom = this,
|
matrixRoom = this,
|
||||||
|
systemClock = systemClock,
|
||||||
roomCoroutineScope = roomCoroutineScope,
|
roomCoroutineScope = roomCoroutineScope,
|
||||||
dispatcher = roomDispatcher,
|
dispatcher = roomDispatcher,
|
||||||
lastLoginTimestamp = sessionData.loginTimestamp,
|
lastLoginTimestamp = sessionData.loginTimestamp,
|
||||||
onNewSyncedEvent = onNewSyncedEvent,
|
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
|
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.cancelAndDestroy
|
||||||
import io.element.android.libraries.matrix.impl.util.destroyAll
|
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.Channel
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.channels.trySendBlocking
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
|
|
@ -27,13 +25,11 @@ import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.buffer
|
import kotlinx.coroutines.flow.buffer
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import org.matrix.rustcomponents.sdk.PaginationStatusListener
|
|
||||||
import org.matrix.rustcomponents.sdk.Timeline
|
import org.matrix.rustcomponents.sdk.Timeline
|
||||||
import org.matrix.rustcomponents.sdk.TimelineDiff
|
import org.matrix.rustcomponents.sdk.TimelineDiff
|
||||||
import org.matrix.rustcomponents.sdk.TimelineItem
|
import org.matrix.rustcomponents.sdk.TimelineItem
|
||||||
import org.matrix.rustcomponents.sdk.TimelineListener
|
import org.matrix.rustcomponents.sdk.TimelineListener
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import uniffi.matrix_sdk_ui.PaginationStatus
|
|
||||||
|
|
||||||
internal fun Timeline.timelineDiffFlow(onInitialList: suspend (List<TimelineItem>) -> Unit): Flow<List<TimelineDiff>> =
|
internal fun Timeline.timelineDiffFlow(onInitialList: suspend (List<TimelineItem>) -> Unit): Flow<List<TimelineDiff>> =
|
||||||
callbackFlow {
|
callbackFlow {
|
||||||
|
|
@ -58,18 +54,6 @@ internal fun Timeline.timelineDiffFlow(onInitialList: suspend (List<TimelineItem
|
||||||
Timber.d(it, "timelineDiffFlow() failed")
|
Timber.d(it, "timelineDiffFlow() failed")
|
||||||
}.buffer(Channel.UNLIMITED)
|
}.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) {
|
internal suspend fun Timeline.runWithTimelineListenerRegistered(action: suspend () -> Unit) {
|
||||||
val result = addListener(NoOpTimelineListener)
|
val result = addListener(NoOpTimelineListener)
|
||||||
try {
|
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(),
|
senderProfile = event.senderProfile.map(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is RepliedToEventDetails.Error -> InReplyTo.Error
|
is RepliedToEventDetails.Error -> InReplyTo.Error(
|
||||||
is RepliedToEventDetails.Pending -> InReplyTo.Pending
|
eventId = inReplyToId,
|
||||||
is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(inReplyToId)
|
message = event.message,
|
||||||
|
)
|
||||||
|
RepliedToEventDetails.Pending -> InReplyTo.Pending(
|
||||||
|
eventId = inReplyToId,
|
||||||
|
)
|
||||||
|
is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(
|
||||||
|
eventId = inReplyToId
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageContent(
|
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
|
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.MatrixTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
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.OtherState
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
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.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(
|
fun process(
|
||||||
items: List<MatrixTimelineItem>,
|
items: List<MatrixTimelineItem>,
|
||||||
isDm: Boolean,
|
isDm: Boolean,
|
||||||
isAtStartOfTimeline: Boolean
|
hasMoreToLoadBackwards: Boolean
|
||||||
): List<MatrixTimelineItem> {
|
): 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
|
// Find room creation event. This is usually index 0
|
||||||
val roomCreationEventIndex = items.indexOfFirst {
|
val roomCreationEventIndex = items.indexOfFirst {
|
||||||
val stateEventContent = (it as? MatrixTimelineItem.Event)?.event?.content as? StateContent
|
val stateEventContent = (it as? MatrixTimelineItem.Event)?.event?.content as? StateContent
|
||||||
|
|
@ -58,4 +73,12 @@ class DmBeginningTimelineProcessor {
|
||||||
}
|
}
|
||||||
return newItems
|
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.OtherState
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
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.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
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
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.aMessageContent
|
||||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class DmBeginningTimelineProcessorTest {
|
class RoomBeginningPostProcessorTest {
|
||||||
@Test
|
@Test
|
||||||
fun `processor removes room creation event and self-join event from DM timeline`() {
|
fun `processor removes room creation event and self-join event from DM timeline`() {
|
||||||
val timelineItems = listOf(
|
val timelineItems = listOf(
|
||||||
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
|
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))),
|
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
|
||||||
)
|
)
|
||||||
val processor = DmBeginningTimelineProcessor()
|
val processor = RoomBeginningPostProcessor()
|
||||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = true)
|
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false)
|
||||||
assertThat(processedItems).isEmpty()
|
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.member_other", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))),
|
||||||
MatrixTimelineItem.Event("m.room.message", anEventTimelineItem(content = aMessageContent("hi"))),
|
MatrixTimelineItem.Event("m.room.message", anEventTimelineItem(content = aMessageContent("hi"))),
|
||||||
)
|
)
|
||||||
val processor = DmBeginningTimelineProcessor()
|
val processor = RoomBeginningPostProcessor()
|
||||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = true)
|
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false)
|
||||||
assertThat(processedItems).isEqualTo(expected)
|
assertThat(processedItems).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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(
|
val timelineItems = listOf(
|
||||||
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
|
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))),
|
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
|
||||||
)
|
)
|
||||||
val processor = DmBeginningTimelineProcessor()
|
val processor = RoomBeginningPostProcessor()
|
||||||
val processedItems = processor.process(timelineItems, isDm = false, isAtStartOfTimeline = true)
|
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)
|
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.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
|
||||||
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
|
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
|
||||||
)
|
)
|
||||||
val processor = DmBeginningTimelineProcessor()
|
val processor = RoomBeginningPostProcessor()
|
||||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = false)
|
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
|
||||||
assertThat(processedItems).isEqualTo(timelineItems)
|
assertThat(processedItems).isEqualTo(timelineItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,8 +97,8 @@ class DmBeginningTimelineProcessorTest {
|
||||||
val timelineItems = listOf(
|
val timelineItems = listOf(
|
||||||
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
|
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
|
||||||
)
|
)
|
||||||
val processor = DmBeginningTimelineProcessor()
|
val processor = RoomBeginningPostProcessor()
|
||||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = false)
|
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
|
||||||
assertThat(processedItems).isEqualTo(timelineItems)
|
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.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
|
||||||
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))),
|
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))),
|
||||||
)
|
)
|
||||||
val processor = DmBeginningTimelineProcessor()
|
val processor = RoomBeginningPostProcessor()
|
||||||
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = false)
|
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
|
||||||
assertThat(processedItems).isEqualTo(timelineItems)
|
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.location.AssetType
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
|
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.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.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.timeline.item.event.EventTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
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.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
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.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.libraries.matrix.test.widget.FakeWidgetDriver
|
||||||
import io.element.android.tests.testutils.simulateLongTask
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
import kotlinx.collections.immutable.ImmutableMap
|
import kotlinx.collections.immutable.ImmutableMap
|
||||||
|
|
@ -84,7 +84,7 @@ class FakeMatrixRoom(
|
||||||
override val joinedMemberCount: Long = 123L,
|
override val joinedMemberCount: Long = 123L,
|
||||||
override val activeMemberCount: Long = 234L,
|
override val activeMemberCount: Long = 234L,
|
||||||
val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
|
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 roomPermalinkResult: () -> Result<String> = { Result.success("room link") },
|
||||||
private var eventPermalinkResult: (EventId) -> Result<String> = { Result.success("event link") },
|
private var eventPermalinkResult: (EventId) -> Result<String> = { Result.success("event link") },
|
||||||
canRedactOwn: Boolean = false,
|
canRedactOwn: Boolean = false,
|
||||||
|
|
@ -134,7 +134,6 @@ class FakeMatrixRoom(
|
||||||
private var updatePowerLevelsResult = Result.success(Unit)
|
private var updatePowerLevelsResult = Result.success(Unit)
|
||||||
private var resetPowerLevelsResult = Result.success(defaultRoomPowerLevels())
|
private var resetPowerLevelsResult = Result.success(defaultRoomPowerLevels())
|
||||||
var sendMessageMentions = emptyList<Mention>()
|
var sendMessageMentions = emptyList<Mention>()
|
||||||
val editMessageCalls = mutableListOf<Pair<String, String?>>()
|
|
||||||
private val _typingRecord = mutableListOf<Boolean>()
|
private val _typingRecord = mutableListOf<Boolean>()
|
||||||
val typingRecord: List<Boolean>
|
val typingRecord: List<Boolean>
|
||||||
get() = _typingRecord
|
get() = _typingRecord
|
||||||
|
|
@ -215,7 +214,15 @@ class FakeMatrixRoom(
|
||||||
|
|
||||||
override val syncUpdateFlow: StateFlow<Long> = MutableStateFlow(0L)
|
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
|
override suspend fun subscribeToSync() = Unit
|
||||||
|
|
||||||
|
|
@ -288,31 +295,6 @@ class FakeMatrixRoom(
|
||||||
return eventPermalinkResult(eventId)
|
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
|
var redactEventEventIdParam: EventId? = null
|
||||||
private set
|
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.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.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.preferences.impl.store.DefaultSessionPreferencesStore
|
||||||
import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
|
import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
|
||||||
import io.element.android.services.analytics.noop.NoopAnalyticsService
|
import io.element.android.services.analytics.noop.NoopAnalyticsService
|
||||||
|
|
@ -145,7 +146,7 @@ class RoomListScreen(
|
||||||
Singleton.appScope.launch {
|
Singleton.appScope.launch {
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
matrixClient.getRoom(roomId)!!.use { room ->
|
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)
|
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 {
|
class LambdaNoParamRecorder<out R>(ensureNeverCalled: Boolean, val block: () -> R) : LambdaRecorder(ensureNeverCalled), () -> R {
|
||||||
override fun invoke(): R {
|
override fun invoke(): R {
|
||||||
onInvoke()
|
onInvoke()
|
||||||
|
|
@ -109,3 +116,12 @@ class LambdaFourParamsRecorder<in T1, in T2, in T3, in T4, out R>(ensureNeverCal
|
||||||
return block(p1, p2, p3, p4)
|
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
Loading…
Add table
Add a link
Reference in a new issue