Merge pull request #2759 from element-hq/feature/fga/permalink_timeline
Permalink timeline
This commit is contained in:
commit
ae8ee8704f
383 changed files with 3579 additions and 1506 deletions
|
|
@ -32,7 +32,7 @@ class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
|
|||
|
||||
return object : MessagesEntryPoint.NodeBuilder {
|
||||
override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
|
||||
plugins += MessagesNode.Inputs(focusedEventId = params.focusedEventId)
|
||||
plugins += MessagesFlowNode.Inputs(focusedEventId = params.focusedEventId)
|
||||
return this
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
|||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.architecture.overlay.Overlay
|
||||
import io.element.android.libraries.architecture.overlay.operation.show
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
|
|
@ -81,7 +82,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Messages(plugins.filterIsInstance<Inputs>().firstOrNull()?.focusedEventId),
|
||||
initialElement = NavTarget.Messages,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
overlay = Overlay(
|
||||
|
|
@ -91,15 +92,14 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
plugins = plugins
|
||||
) {
|
||||
data class Inputs(val focusedEventId: EventId?) : NodeInputs
|
||||
private val inputs = inputs<Inputs>()
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Empty : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class Messages(
|
||||
val focusedEventId: EventId? = null,
|
||||
) : NavTarget
|
||||
data object Messages : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class MediaViewer(
|
||||
|
|
@ -191,10 +191,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
ElementCallActivity.start(context, inputs)
|
||||
}
|
||||
}
|
||||
val params = MessagesNode.Inputs(
|
||||
focusedEventId = navTarget.focusedEventId,
|
||||
val inputs = MessagesNode.Inputs(
|
||||
focusedEventId = inputs.focusedEventId,
|
||||
)
|
||||
createNode<MessagesNode>(buildContext, listOf(callback, params))
|
||||
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
|
||||
}
|
||||
is NavTarget.MediaViewer -> {
|
||||
val inputs = MediaViewerNode.Inputs(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ package io.element.android.features.messages.impl
|
|||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
|
|
@ -30,12 +35,15 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
||||
import io.element.android.libraries.androidutils.system.toast
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
|
|
@ -64,13 +72,15 @@ class MessagesNode @AssistedInject constructor(
|
|||
private val permalinkParser: PermalinkParser,
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
private val timelineController: TimelineController,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
private val presenter = presenterFactory.create(this)
|
||||
private val callback = plugins<Callback>().firstOrNull()
|
||||
|
||||
// TODO Handle navigation to the Event
|
||||
data class Inputs(val focusedEventId: EventId?) : NodeInputs
|
||||
|
||||
private val inputs = inputs<Inputs>()
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onRoomDetailsClicked()
|
||||
fun onEventClicked(event: TimelineItem.Event): Boolean
|
||||
|
|
@ -86,12 +96,14 @@ class MessagesNode @AssistedInject constructor(
|
|||
fun onJoinCallClicked(roomId: RoomId)
|
||||
}
|
||||
|
||||
init {
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
lifecycle.subscribe(
|
||||
onCreate = {
|
||||
analyticsService.capture(room.toAnalyticsViewRoom())
|
||||
},
|
||||
onDestroy = {
|
||||
timelineController.close()
|
||||
mediaPlayer.close()
|
||||
}
|
||||
)
|
||||
|
|
@ -116,6 +128,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
private fun onLinkClicked(
|
||||
context: Context,
|
||||
url: String,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
) {
|
||||
when (val permalink = permalinkParser.parse(url)) {
|
||||
is PermalinkData.UserLink -> {
|
||||
|
|
@ -124,7 +137,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
callback?.onUserDataClicked(permalink.userId)
|
||||
}
|
||||
is PermalinkData.RoomLink -> {
|
||||
handleRoomLinkClicked(permalink)
|
||||
handleRoomLinkClicked(permalink, eventSink)
|
||||
}
|
||||
is PermalinkData.FallbackLink,
|
||||
is PermalinkData.RoomEmailInviteLink -> {
|
||||
|
|
@ -133,11 +146,11 @@ class MessagesNode @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleRoomLinkClicked(roomLink: PermalinkData.RoomLink) {
|
||||
private fun handleRoomLinkClicked(roomLink: PermalinkData.RoomLink, eventSink: (TimelineEvents) -> Unit) {
|
||||
if (room.matches(roomLink.roomIdOrAlias)) {
|
||||
if (roomLink.eventId != null) {
|
||||
// TODO Handle navigation to the Event
|
||||
context.toast("TODO Handle navigation to the Event ${roomLink.eventId}")
|
||||
val eventId = roomLink.eventId
|
||||
if (eventId != null) {
|
||||
eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||
} else {
|
||||
// Click on the same room, ignore
|
||||
context.toast("Already viewing this room!")
|
||||
|
|
@ -189,12 +202,23 @@ class MessagesNode @AssistedInject constructor(
|
|||
onEventClicked = this::onEventClicked,
|
||||
onPreviewAttachments = this::onPreviewAttachments,
|
||||
onUserDataClicked = this::onUserDataClicked,
|
||||
onLinkClicked = { onLinkClicked(context, it) },
|
||||
onLinkClicked = { onLinkClicked(context, it, state.timelineState.eventSink) },
|
||||
onSendLocationClicked = this::onSendLocationClicked,
|
||||
onCreatePollClicked = this::onCreatePollClicked,
|
||||
onJoinCallClicked = this::onJoinCallClicked,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
var focusedEventId by rememberSaveable {
|
||||
mutableStateOf(inputs.focusedEventId)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
focusedEventId?.also { eventId ->
|
||||
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||
}
|
||||
// Reset the focused event id to null to avoid refocusing when restoring node.
|
||||
focusedEventId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
|
|||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||
|
|
@ -116,6 +117,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
private val htmlConverterProvider: HtmlConverterProvider,
|
||||
@Assisted private val navigator: MessagesNavigator,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val timelineController: TimelineController,
|
||||
) : Presenter<MessagesState> {
|
||||
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
|
||||
|
||||
|
|
@ -185,10 +187,6 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
|
||||
LaunchedEffect(composerState.mode.relatedEventId) {
|
||||
timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId))
|
||||
}
|
||||
|
||||
val enableTextFormatting by appPreferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
||||
|
||||
var enableVoiceMessages by remember { mutableStateOf(false) }
|
||||
|
|
@ -290,8 +288,10 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
emoji: String,
|
||||
eventId: EventId,
|
||||
) = launch(dispatchers.io) {
|
||||
room.toggleReaction(emoji, eventId)
|
||||
.onFailure { Timber.e(it) }
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
toggleReaction(emoji, eventId)
|
||||
.onFailure { Timber.e(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<AsyncData<Unit>>) = launch(dispatchers.io) {
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ fun aMessagesState(
|
|||
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
|
||||
timelineState: TimelineState = aTimelineState(
|
||||
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||
// Render a focused event for an event with sender information displayed
|
||||
focusedEventIndex = 2,
|
||||
),
|
||||
retrySendMenuState: RetrySendMenuState = aRetrySendMenuState(),
|
||||
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
|
||||
|
|
|
|||
|
|
@ -382,20 +382,19 @@ private fun MessagesViewContent(
|
|||
},
|
||||
content = { paddingValues ->
|
||||
TimelineView(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
state = state.timelineState,
|
||||
roomName = state.roomName.dataOrNull(),
|
||||
typingNotificationState = state.typingNotificationState,
|
||||
onMessageClicked = onMessageClicked,
|
||||
onMessageLongClicked = onMessageLongClicked,
|
||||
onUserDataClicked = onUserDataClicked,
|
||||
onLinkClicked = onLinkClicked,
|
||||
onMessageClicked = onMessageClicked,
|
||||
onMessageLongClicked = onMessageLongClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
onReactionClicked = onReactionClicked,
|
||||
onReactionLongClicked = onReactionLongClicked,
|
||||
onMoreReactionsClicked = onMoreReactionsClicked,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ import io.element.android.libraries.architecture.AsyncData
|
|||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -37,8 +38,8 @@ import kotlinx.coroutines.launch
|
|||
|
||||
class ForwardMessagesPresenter @AssistedInject constructor(
|
||||
@Assisted eventId: String,
|
||||
private val room: MatrixRoom,
|
||||
private val matrixCoroutineScope: CoroutineScope,
|
||||
private val timelineProvider: TimelineProvider,
|
||||
) : Presenter<ForwardMessagesState> {
|
||||
private val eventId: EventId = EventId(eventId)
|
||||
|
||||
|
|
@ -79,7 +80,7 @@ class ForwardMessagesPresenter @AssistedInject constructor(
|
|||
isForwardMessagesState: MutableState<AsyncData<ImmutableList<RoomId>>>,
|
||||
) = launch {
|
||||
isForwardMessagesState.value = AsyncData.Loading()
|
||||
room.forwardEvent(eventId, roomIds).fold(
|
||||
timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds).fold(
|
||||
{ isForwardMessagesState.value = AsyncData.Success(roomIds) },
|
||||
{ isForwardMessagesState.value = AsyncData.Failure(it) }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.attachments.Attachment
|
|||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||
import io.element.android.features.messages.impl.mentions.MentionSuggestion
|
||||
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
|
|
@ -100,6 +101,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
private val permalinkParser: PermalinkParser,
|
||||
private val permalinkBuilder: PermalinkBuilder,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
private val timelineController: TimelineController,
|
||||
) : Presenter<MessageComposerState> {
|
||||
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
|
||||
private var pendingEvent: MessageComposerEvents? = null
|
||||
|
|
@ -264,7 +266,9 @@ class MessageComposerPresenter @Inject constructor(
|
|||
is MessageComposerMode.Quote -> null
|
||||
}.let { relatedEventId ->
|
||||
appCoroutineScope.launch {
|
||||
room.enterSpecialMode(relatedEventId)
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
enterSpecialMode(relatedEventId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -386,16 +390,17 @@ class MessageComposerPresenter @Inject constructor(
|
|||
is MessageComposerMode.Edit -> {
|
||||
val eventId = capturedMode.eventId
|
||||
val transactionId = capturedMode.transactionId
|
||||
room.editMessage(eventId, transactionId, message.markdown, message.html, mentions)
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
editMessage(eventId, transactionId, message.markdown, message.html, mentions)
|
||||
}
|
||||
}
|
||||
|
||||
is MessageComposerMode.Quote -> TODO()
|
||||
is MessageComposerMode.Reply -> room.replyMessage(
|
||||
capturedMode.eventId,
|
||||
message.markdown,
|
||||
message.html,
|
||||
mentions
|
||||
)
|
||||
is MessageComposerMode.Reply -> {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
replyMessage(capturedMode.eventId, message.markdown, message.html, mentions)
|
||||
}
|
||||
}
|
||||
}
|
||||
analyticsService.capture(
|
||||
Composer(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.io.Closeable
|
||||
import java.util.Optional
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* This controller is responsible of using the right timeline to display messages and make associated actions.
|
||||
* It can be focused on the live timeline or on a detached timeline (focusing an unknown event).
|
||||
*/
|
||||
@SingleIn(RoomScope::class)
|
||||
@ContributesBinding(RoomScope::class, boundType = TimelineProvider::class)
|
||||
class TimelineController @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
) : Closeable, TimelineProvider {
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob())
|
||||
|
||||
private val liveTimeline = flowOf(room.liveTimeline)
|
||||
private val detachedTimeline = MutableStateFlow<Optional<Timeline>>(Optional.empty())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun timelineItems(): Flow<List<MatrixTimelineItem>> {
|
||||
return currentTimelineFlow.flatMapLatest { it.timelineItems }
|
||||
}
|
||||
|
||||
fun isLive(): Flow<Boolean> {
|
||||
return detachedTimeline.map { !it.isPresent }
|
||||
}
|
||||
|
||||
suspend fun invokeOnCurrentTimeline(block: suspend (Timeline.() -> Any)) {
|
||||
currentTimelineFlow.value.run {
|
||||
block(this)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun focusOnEvent(eventId: EventId): Result<Unit> {
|
||||
return room.timelineFocusedOnEvent(eventId)
|
||||
.onFailure {
|
||||
if (it is CancellationException) {
|
||||
throw it
|
||||
}
|
||||
}
|
||||
.map { newDetachedTimeline ->
|
||||
detachedTimeline.getAndUpdate { current ->
|
||||
if (current.isPresent) {
|
||||
current.get().close()
|
||||
}
|
||||
Optional.of(newDetachedTimeline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure the controller is focused on the live timeline.
|
||||
* This does close the detached timeline if any.
|
||||
*/
|
||||
fun focusOnLive() {
|
||||
closeDetachedTimeline()
|
||||
}
|
||||
|
||||
private fun closeDetachedTimeline() {
|
||||
detachedTimeline.getAndUpdate {
|
||||
when {
|
||||
it.isPresent -> {
|
||||
it.get().close()
|
||||
Optional.empty()
|
||||
}
|
||||
else -> Optional.empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
coroutineScope.cancel()
|
||||
closeDetachedTimeline()
|
||||
}
|
||||
|
||||
suspend fun paginate(direction: Timeline.PaginationDirection): Result<Boolean> {
|
||||
return currentTimelineFlow.value.paginate(direction)
|
||||
.onSuccess { hasReachedEnd ->
|
||||
if (direction == Timeline.PaginationDirection.FORWARDS && hasReachedEnd) {
|
||||
focusOnLive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val currentTimelineFlow = combine(liveTimeline, detachedTimeline) { live, detached ->
|
||||
when {
|
||||
detached.isPresent -> detached.get()
|
||||
else -> live
|
||||
}
|
||||
}.stateIn(coroutineScope, SharingStarted.Eagerly, room.liveTimeline)
|
||||
|
||||
override fun activeTimelineFlow(): StateFlow<Timeline> {
|
||||
return currentTimelineFlow
|
||||
}
|
||||
}
|
||||
|
|
@ -17,17 +17,21 @@
|
|||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
sealed interface TimelineEvents {
|
||||
data object LoadMore : TimelineEvents
|
||||
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
|
||||
data class OnScrollFinished(val firstIndex: Int) : TimelineEvents
|
||||
data class FocusOnEvent(val eventId: EventId) : TimelineEvents
|
||||
data object ClearFocusRequestState : TimelineEvents
|
||||
data object JumpToLive : TimelineEvents
|
||||
|
||||
/**
|
||||
* Events coming from a timeline item.
|
||||
*/
|
||||
sealed interface EventFromTimelineItem : TimelineEvents
|
||||
|
||||
data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem
|
||||
|
||||
/**
|
||||
* Events coming from a poll item.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
class TimelineItemIndexer @Inject constructor() {
|
||||
private val timelineEventsIndexes = mutableMapOf<EventId, Int>()
|
||||
|
||||
fun isKnown(eventId: EventId): Boolean {
|
||||
return timelineEventsIndexes.containsKey(eventId).also {
|
||||
Timber.d("$eventId isKnown = $it")
|
||||
}
|
||||
}
|
||||
|
||||
fun indexOf(eventId: EventId): Int {
|
||||
return (timelineEventsIndexes[eventId] ?: -1).also {
|
||||
Timber.d("indexOf $eventId= $it")
|
||||
}
|
||||
}
|
||||
|
||||
fun process(timelineItems: List<TimelineItem>) {
|
||||
Timber.d("process ${timelineItems.size} items")
|
||||
timelineEventsIndexes.clear()
|
||||
timelineItems.forEachIndexed { index, timelineItem ->
|
||||
when (timelineItem) {
|
||||
is TimelineItem.Event -> {
|
||||
processEvent(timelineItem, index)
|
||||
}
|
||||
is TimelineItem.GroupedEvents -> {
|
||||
timelineItem.events.forEach { event ->
|
||||
processEvent(event, index)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processEvent(event: TimelineItem.Event, index: Int) {
|
||||
if (event.eventId == null) return
|
||||
timelineEventsIndexes[event.eventId] = index
|
||||
}
|
||||
}
|
||||
|
|
@ -54,11 +54,9 @@ import kotlinx.coroutines.flow.onEach
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val BACK_PAGINATION_EVENT_LIMIT = 20
|
||||
private const val BACK_PAGINATION_PAGE_SIZE = 50
|
||||
|
||||
class TimelinePresenter @AssistedInject constructor(
|
||||
private val timelineItemsFactory: TimelineItemsFactory,
|
||||
private val timelineItemIndexer: TimelineItemIndexer,
|
||||
private val room: MatrixRoom,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val appScope: CoroutineScope,
|
||||
|
|
@ -67,50 +65,62 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
private val sendPollResponseAction: SendPollResponseAction,
|
||||
private val endPollAction: EndPollAction,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
private val timelineController: TimelineController,
|
||||
) : Presenter<TimelineState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(navigator: MessagesNavigator): TimelinePresenter
|
||||
}
|
||||
|
||||
private val timeline = room.timeline
|
||||
|
||||
@Composable
|
||||
override fun present(): TimelineState {
|
||||
val localScope = rememberCoroutineScope()
|
||||
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
|
||||
val focusedEventId: MutableState<EventId?> = rememberSaveable {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
val focusRequestState: MutableState<FocusRequestState> = remember {
|
||||
mutableStateOf(FocusRequestState.None)
|
||||
}
|
||||
|
||||
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
|
||||
|
||||
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
||||
val paginationState by timeline.paginationState.collectAsState()
|
||||
|
||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
|
||||
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
||||
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value)
|
||||
|
||||
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val newItemState = remember { mutableStateOf(NewEventState.None) }
|
||||
|
||||
val newEventState = remember { mutableStateOf(NewEventState.None) }
|
||||
|
||||
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
|
||||
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
|
||||
val isLive by timelineController.isLive().collectAsState(initial = true)
|
||||
|
||||
fun handleEvents(event: TimelineEvents) {
|
||||
when (event) {
|
||||
TimelineEvents.LoadMore -> localScope.paginateBackwards()
|
||||
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
||||
is TimelineEvents.OnScrollFinished -> {
|
||||
if (event.firstIndex == 0) {
|
||||
newItemState.value = NewEventState.None
|
||||
is TimelineEvents.LoadMore -> {
|
||||
localScope.launch {
|
||||
timelineController.paginate(direction = event.direction)
|
||||
}
|
||||
}
|
||||
is TimelineEvents.OnScrollFinished -> {
|
||||
if (isLive) {
|
||||
if (event.firstIndex == 0) {
|
||||
newEventState.value = NewEventState.None
|
||||
}
|
||||
println("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
|
||||
appScope.sendReadReceiptIfNeeded(
|
||||
firstVisibleIndex = event.firstIndex,
|
||||
timelineItems = timelineItems,
|
||||
lastReadReceiptId = lastReadReceiptId,
|
||||
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
|
||||
)
|
||||
} else {
|
||||
newEventState.value = NewEventState.None
|
||||
}
|
||||
appScope.sendReadReceiptIfNeeded(
|
||||
firstVisibleIndex = event.firstIndex,
|
||||
timelineItems = timelineItems,
|
||||
lastReadReceiptId = lastReadReceiptId,
|
||||
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
|
||||
)
|
||||
}
|
||||
is TimelineEvents.PollAnswerSelected -> appScope.launch {
|
||||
sendPollResponseAction.execute(
|
||||
|
|
@ -123,28 +133,58 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
pollStartId = event.pollStartId,
|
||||
)
|
||||
}
|
||||
is TimelineEvents.PollEditClicked ->
|
||||
is TimelineEvents.PollEditClicked -> {
|
||||
navigator.onEditPollClicked(event.pollStartId)
|
||||
}
|
||||
is TimelineEvents.FocusOnEvent -> localScope.launch {
|
||||
focusedEventId.value = event.eventId
|
||||
if (timelineItemIndexer.isKnown(event.eventId)) {
|
||||
val index = timelineItemIndexer.indexOf(event.eventId)
|
||||
focusRequestState.value = FocusRequestState.Cached(index)
|
||||
} else {
|
||||
focusRequestState.value = FocusRequestState.Fetching
|
||||
timelineController.focusOnEvent(event.eventId)
|
||||
.fold(
|
||||
onSuccess = {
|
||||
focusRequestState.value = FocusRequestState.Fetched
|
||||
},
|
||||
onFailure = {
|
||||
focusRequestState.value = FocusRequestState.Failure(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
is TimelineEvents.ClearFocusRequestState -> {
|
||||
focusRequestState.value = FocusRequestState.None
|
||||
}
|
||||
is TimelineEvents.JumpToLive -> {
|
||||
timelineController.focusOnLive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(timelineItems.size) {
|
||||
computeNewItemState(timelineItems, prevMostRecentItemId, newItemState)
|
||||
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
|
||||
}
|
||||
|
||||
LaunchedEffect(timelineItems.size, focusRequestState.value, focusedEventId.value) {
|
||||
val currentFocusedEventId = focusedEventId.value
|
||||
if (focusRequestState.value is FocusRequestState.Fetched && currentFocusedEventId != null) {
|
||||
if (timelineItemIndexer.isKnown(currentFocusedEventId)) {
|
||||
val index = timelineItemIndexer.indexOf(currentFocusedEventId)
|
||||
focusRequestState.value = FocusRequestState.Cached(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
combine(timeline.timelineItems, room.membersStateFlow) { items, membersState ->
|
||||
combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState ->
|
||||
timelineItemsFactory.replaceWith(
|
||||
timelineItems = items,
|
||||
roomMembers = membersState.roomMembers().orEmpty()
|
||||
)
|
||||
items
|
||||
}
|
||||
.onEach { timelineItems ->
|
||||
if (timelineItems.isEmpty()) {
|
||||
paginateBackwards()
|
||||
}
|
||||
}
|
||||
.onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem)
|
||||
.launchIn(this)
|
||||
}
|
||||
|
|
@ -152,6 +192,7 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
val timelineRoomInfo by remember {
|
||||
derivedStateOf {
|
||||
TimelineRoomInfo(
|
||||
name = room.displayName,
|
||||
isDm = room.isDm,
|
||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
|
||||
|
|
@ -160,11 +201,12 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
}
|
||||
return TimelineState(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
highlightedEventId = highlightedEventId.value,
|
||||
paginationState = paginationState,
|
||||
timelineItems = timelineItems,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
newEventState = newItemState.value,
|
||||
newEventState = newEventState.value,
|
||||
isLive = isLive,
|
||||
focusedEventId = focusedEventId.value,
|
||||
focusRequestState = focusRequestState.value,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
|
@ -190,6 +232,7 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
newMostRecentItem is TimelineItem.Event &&
|
||||
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
|
||||
newMostRecentItemId != prevMostRecentItemIdValue
|
||||
|
||||
if (hasNewEvent) {
|
||||
val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event
|
||||
// Scroll to bottom if the new event is from me, even if sent from another device
|
||||
|
|
@ -217,7 +260,7 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
|
||||
if (eventId != null && eventId != lastReadReceiptId.value) {
|
||||
lastReadReceiptId.value = eventId
|
||||
timeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
|
||||
room.liveTimeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -231,8 +274,4 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun CoroutineScope.paginateBackwards() = launch {
|
||||
timeline.paginateBackwards(BACK_PAGINATION_EVENT_LIMIT, BACK_PAGINATION_PAGE_SIZE)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import androidx.compose.runtime.Immutable
|
|||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
|
|
@ -28,15 +27,28 @@ data class TimelineState(
|
|||
val timelineItems: ImmutableList<TimelineItem>,
|
||||
val timelineRoomInfo: TimelineRoomInfo,
|
||||
val renderReadReceipts: Boolean,
|
||||
val highlightedEventId: EventId?,
|
||||
val paginationState: MatrixTimeline.PaginationState,
|
||||
val newEventState: NewEventState,
|
||||
val eventSink: (TimelineEvents) -> Unit
|
||||
)
|
||||
val isLive: Boolean,
|
||||
val focusedEventId: EventId?,
|
||||
val focusRequestState: FocusRequestState,
|
||||
val eventSink: (TimelineEvents) -> Unit,
|
||||
) {
|
||||
val hasAnyEvent = timelineItems.any { it is TimelineItem.Event }
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface FocusRequestState {
|
||||
data object None : FocusRequestState
|
||||
data class Cached(val index: Int) : FocusRequestState
|
||||
data object Fetching : FocusRequestState
|
||||
data object Fetched : FocusRequestState
|
||||
data class Failure(val throwable: Throwable) : FocusRequestState
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class TimelineRoomInfo(
|
||||
val isDm: Boolean,
|
||||
val name: String?,
|
||||
val userHasPermissionToSendMessage: Boolean,
|
||||
val userHasPermissionToSendReaction: Boolean,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
|||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
|
@ -47,32 +46,22 @@ import kotlin.random.Random
|
|||
|
||||
fun aTimelineState(
|
||||
timelineItems: ImmutableList<TimelineItem> = persistentListOf(),
|
||||
paginationState: MatrixTimeline.PaginationState = aPaginationState(),
|
||||
renderReadReceipts: Boolean = false,
|
||||
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
|
||||
focusedEventIndex: Int = -1,
|
||||
isLive: Boolean = true,
|
||||
eventSink: (TimelineEvents) -> Unit = {},
|
||||
) = TimelineState(
|
||||
timelineItems = timelineItems,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
paginationState = paginationState,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
highlightedEventId = null,
|
||||
newEventState = NewEventState.None,
|
||||
isLive = isLive,
|
||||
focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId,
|
||||
focusRequestState = FocusRequestState.None,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aPaginationState(
|
||||
isBackPaginating: Boolean = false,
|
||||
hasMoreToLoadBackwards: Boolean = true,
|
||||
beginningOfRoomReached: Boolean = false,
|
||||
): MatrixTimeline.PaginationState {
|
||||
return MatrixTimeline.PaginationState(
|
||||
isBackPaginating = isBackPaginating,
|
||||
hasMoreToLoadBackwards = hasMoreToLoadBackwards,
|
||||
beginningOfRoomReached = beginningOfRoomReached,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
|
||||
return persistentListOf(
|
||||
// 3 items (First Middle Last) with isMine = false
|
||||
|
|
@ -235,10 +224,12 @@ internal fun aGroupedEvents(
|
|||
}
|
||||
|
||||
internal fun aTimelineRoomInfo(
|
||||
name: String = "Room name",
|
||||
isDm: Boolean = false,
|
||||
userHasPermissionToSendMessage: Boolean = true,
|
||||
) = TimelineRoomInfo(
|
||||
isDm = isDm,
|
||||
name = name,
|
||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||
userHasPermissionToSendReaction = true,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -55,10 +55,9 @@ import androidx.compose.ui.unit.dp
|
|||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
|
|
@ -74,12 +73,12 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
@Composable
|
||||
fun TimelineView(
|
||||
state: TimelineState,
|
||||
typingNotificationState: TypingNotificationState,
|
||||
roomName: String?,
|
||||
onUserDataClicked: (UserId) -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
onMessageClicked: (TimelineItem.Event) -> Unit,
|
||||
|
|
@ -93,8 +92,8 @@ fun TimelineView(
|
|||
modifier: Modifier = Modifier,
|
||||
forceJumpToBottomVisibility: Boolean = false
|
||||
) {
|
||||
fun onReachedLoadMore() {
|
||||
state.eventSink(TimelineEvents.LoadMore)
|
||||
fun clearFocusRequestState() {
|
||||
state.eventSink(TimelineEvents.ClearFocusRequestState)
|
||||
}
|
||||
|
||||
fun onScrollFinishedAt(firstVisibleIndex: Int) {
|
||||
|
|
@ -109,9 +108,8 @@ fun TimelineView(
|
|||
accessibilityManager.isTouchExplorationEnabled.not()
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun inReplyToClicked(eventId: EventId) {
|
||||
// TODO implement this logic once we have support to 'jump to event X' in sliding sync
|
||||
state.eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||
}
|
||||
|
||||
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
|
||||
|
|
@ -123,8 +121,10 @@ fun TimelineView(
|
|||
reverseLayout = useReverseLayout,
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
item {
|
||||
TypingNotificationView(state = typingNotificationState)
|
||||
if (state.isLive) {
|
||||
item {
|
||||
TypingNotificationView(state = typingNotificationState)
|
||||
}
|
||||
}
|
||||
items(
|
||||
items = state.timelineItems,
|
||||
|
|
@ -137,7 +137,7 @@ fun TimelineView(
|
|||
renderReadReceipts = state.renderReadReceipts,
|
||||
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true &&
|
||||
state.timelineItems.first().identifier() == timelineItem.identifier(),
|
||||
highlightedItem = state.highlightedEventId?.value,
|
||||
focusedEventId = state.focusedEventId,
|
||||
onClick = onMessageClicked,
|
||||
onLongClick = onMessageLongClicked,
|
||||
onUserDataClick = onUserDataClicked,
|
||||
|
|
@ -152,28 +152,23 @@ fun TimelineView(
|
|||
onSwipeToReply = onSwipeToReply,
|
||||
)
|
||||
}
|
||||
if (state.paginationState.hasMoreToLoadBackwards) {
|
||||
// Do not use key parameter to avoid wrong positioning
|
||||
item(contentType = "TimelineLoadingMoreIndicator") {
|
||||
TimelineLoadingMoreIndicator()
|
||||
LaunchedEffect(Unit) {
|
||||
onReachedLoadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.paginationState.beginningOfRoomReached && !state.timelineRoomInfo.isDm) {
|
||||
item(contentType = "BeginningOfRoomReached") {
|
||||
TimelineItemRoomBeginningView(roomName = roomName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FocusRequestStateView(
|
||||
focusRequestState = state.focusRequestState,
|
||||
onClearFocusRequestState = ::clearFocusRequestState
|
||||
)
|
||||
|
||||
TimelineScrollHelper(
|
||||
isTimelineEmpty = state.timelineItems.isEmpty(),
|
||||
hasAnyEvent = state.hasAnyEvent,
|
||||
lazyListState = lazyListState,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
newEventState = state.newEventState,
|
||||
onScrollFinishedAt = ::onScrollFinishedAt
|
||||
isLive = state.isLive,
|
||||
focusRequestState = state.focusRequestState,
|
||||
onScrollFinishedAt = ::onScrollFinishedAt,
|
||||
onClearFocusRequestState = ::clearFocusRequestState,
|
||||
onJumpToLive = { state.eventSink(TimelineEvents.JumpToLive) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -181,17 +176,21 @@ fun TimelineView(
|
|||
|
||||
@Composable
|
||||
private fun BoxScope.TimelineScrollHelper(
|
||||
isTimelineEmpty: Boolean,
|
||||
hasAnyEvent: Boolean,
|
||||
lazyListState: LazyListState,
|
||||
newEventState: NewEventState,
|
||||
isLive: Boolean,
|
||||
forceJumpToBottomVisibility: Boolean,
|
||||
focusRequestState: FocusRequestState,
|
||||
onClearFocusRequestState: () -> Unit,
|
||||
onScrollFinishedAt: (Int) -> Unit,
|
||||
onJumpToLive: () -> Unit,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
||||
val canAutoScroll by remember {
|
||||
derivedStateOf {
|
||||
lazyListState.firstVisibleItemIndex < 3
|
||||
lazyListState.firstVisibleItemIndex < 3 && isLive
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -205,16 +204,36 @@ private fun BoxScope.TimelineScrollHelper(
|
|||
}
|
||||
}
|
||||
|
||||
fun jumpToBottom() {
|
||||
if (isLive) {
|
||||
scrollToBottom()
|
||||
} else {
|
||||
onJumpToLive()
|
||||
}
|
||||
}
|
||||
|
||||
val latestOnClearFocusRequestState by rememberUpdatedState(onClearFocusRequestState)
|
||||
LaunchedEffect(focusRequestState) {
|
||||
if (focusRequestState is FocusRequestState.Cached) {
|
||||
if (abs(lazyListState.firstVisibleItemIndex - focusRequestState.index) < 10) {
|
||||
lazyListState.animateScrollToItem(focusRequestState.index)
|
||||
} else {
|
||||
lazyListState.scrollToItem(focusRequestState.index)
|
||||
}
|
||||
latestOnClearFocusRequestState()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(canAutoScroll, newEventState) {
|
||||
val shouldAutoScroll = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
|
||||
if (shouldAutoScroll) {
|
||||
val shouldScrollToBottom = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
|
||||
if (shouldScrollToBottom) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
val latestOnScrollFinishedAt by rememberUpdatedState(onScrollFinishedAt)
|
||||
LaunchedEffect(isScrollFinished, isTimelineEmpty) {
|
||||
if (isScrollFinished && !isTimelineEmpty) {
|
||||
LaunchedEffect(isScrollFinished, hasAnyEvent) {
|
||||
if (isScrollFinished && hasAnyEvent) {
|
||||
// Notify the parent composable about the first visible item index when scrolling finishes
|
||||
latestOnScrollFinishedAt(lazyListState.firstVisibleItemIndex)
|
||||
}
|
||||
|
|
@ -222,11 +241,11 @@ private fun BoxScope.TimelineScrollHelper(
|
|||
|
||||
JumpToBottomButton(
|
||||
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
|
||||
isVisible = !canAutoScroll || forceJumpToBottomVisibility,
|
||||
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 24.dp, bottom = 12.dp),
|
||||
onClick = ::scrollToBottom,
|
||||
onClick = { jumpToBottom() },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -271,18 +290,20 @@ internal fun TimelineViewPreview(
|
|||
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
|
||||
) {
|
||||
TimelineView(
|
||||
state = aTimelineState(timelineItems),
|
||||
roomName = null,
|
||||
state = aTimelineState(
|
||||
timelineItems = timelineItems,
|
||||
focusedEventIndex = 0,
|
||||
),
|
||||
typingNotificationState = aTypingNotificationState(),
|
||||
onMessageClicked = {},
|
||||
onTimestampClicked = {},
|
||||
onUserDataClicked = {},
|
||||
onLinkClicked = {},
|
||||
onMessageClicked = {},
|
||||
onMessageLongClicked = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
onReactionClicked = { _, _ -> },
|
||||
onReactionLongClicked = { _, _ -> },
|
||||
onMoreReactionsClicked = {},
|
||||
onSwipeToReply = {},
|
||||
onReadReceiptClick = {},
|
||||
forceJumpToBottomVisibility = true,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import androidx.compose.foundation.gestures.Orientation
|
|||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
|
|
@ -93,7 +92,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
|
||||
import io.element.android.features.messages.impl.timeline.model.eventId
|
||||
import io.element.android.features.messages.impl.timeline.model.metadata
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
|
||||
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
|
||||
import io.element.android.libraries.designsystem.components.EqualWidthColumn
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
|
|
@ -146,7 +147,7 @@ fun TimelineItemEventRow(
|
|||
}
|
||||
|
||||
fun inReplyToClicked() {
|
||||
val inReplyToEventId = event.inReplyTo?.eventId ?: return
|
||||
val inReplyToEventId = event.inReplyTo?.eventId() ?: return
|
||||
inReplyToClick(inReplyToEventId)
|
||||
}
|
||||
|
||||
|
|
@ -417,7 +418,6 @@ private fun MessageSenderInformation(
|
|||
private fun MessageEventBubbleContent(
|
||||
event: TimelineItem.Event,
|
||||
onMessageLongClick: () -> Unit,
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
inReplyToClick: () -> Unit,
|
||||
onTimestampClicked: () -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
|
|
@ -437,7 +437,7 @@ private fun MessageEventBubbleContent(
|
|||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = spacedBy(4.dp, Alignment.Start),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.Start),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
|
|
@ -565,16 +565,30 @@ private fun MessageEventBubbleContent(
|
|||
}
|
||||
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
|
||||
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
|
||||
ReplyToContent(
|
||||
senderId = inReplyTo.senderId,
|
||||
senderProfile = inReplyTo.senderProfile,
|
||||
metadata = inReplyTo.metadata(),
|
||||
modifier = Modifier
|
||||
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
val inReplyToModifier = Modifier
|
||||
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
// FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent
|
||||
// .clickable(enabled = true, onClick = inReplyToClick)
|
||||
)
|
||||
.clickable(onClick = inReplyToClick)
|
||||
when (inReplyTo) {
|
||||
is InReplyToDetails.Ready -> {
|
||||
ReplyToContent(
|
||||
senderId = inReplyTo.senderId,
|
||||
senderProfile = inReplyTo.senderProfile,
|
||||
metadata = inReplyTo.metadata(),
|
||||
modifier = inReplyToModifier,
|
||||
)
|
||||
}
|
||||
is InReplyToDetails.Error ->
|
||||
ReplyToErrorContent(
|
||||
data = inReplyTo,
|
||||
modifier = inReplyToModifier,
|
||||
)
|
||||
is InReplyToDetails.Loading ->
|
||||
ReplyToLoadingContent(
|
||||
modifier = inReplyToModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (inReplyToDetails != null) {
|
||||
// Use SubComposeLayout only if necessary as it can have consequences on the performance.
|
||||
|
|
@ -584,7 +598,7 @@ private fun MessageEventBubbleContent(
|
|||
contentWithTimestamp()
|
||||
}
|
||||
} else {
|
||||
Column(modifier = modifier, verticalArrangement = spacedBy(8.dp)) {
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
threadDecoration()
|
||||
contentWithTimestamp()
|
||||
}
|
||||
|
|
@ -652,6 +666,44 @@ private fun ReplyToContent(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyToLoadingContent(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
||||
Row(
|
||||
modifier
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(paddings)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
PlaceholderAtom(width = 80.dp, height = 12.dp)
|
||||
PlaceholderAtom(width = 140.dp, height = 14.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyToErrorContent(
|
||||
data: InReplyToDetails.Error,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
||||
Row(
|
||||
modifier
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(paddings)
|
||||
) {
|
||||
Text(
|
||||
text = data.message,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyToContentText(metadata: InReplyToMetadata?) {
|
||||
val text = when (metadata) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowWithReplyOtherPreview(
|
||||
@PreviewParameter(InReplyToDetailsOtherProvider::class) inReplyToDetails: InReplyToDetails,
|
||||
) = ElementPreview {
|
||||
TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
|
||||
}
|
||||
|
||||
class InReplyToDetailsOtherProvider : InReplyToDetailsProvider() {
|
||||
override val values: Sequence<InReplyToDetails>
|
||||
get() = sequenceOf(
|
||||
InReplyToDetails.Loading(eventId = EventId("\$anEventId")),
|
||||
InReplyToDetails.Error(eventId = EventId("\$anEventId"), message = "An error message."),
|
||||
)
|
||||
}
|
||||
|
|
@ -170,7 +170,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
|
|||
protected fun aInReplyToDetails(
|
||||
eventContent: EventContent,
|
||||
displayNameAmbiguous: Boolean = false,
|
||||
) = InReplyToDetails(
|
||||
) = InReplyToDetails.Ready(
|
||||
eventId = EventId("\$event"),
|
||||
eventContent = eventContent,
|
||||
senderId = UserId("@Sender:domain"),
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ fun TimelineItemGroupedEventsRow(
|
|||
timelineRoomInfo: TimelineRoomInfo,
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
highlightedItem: String?,
|
||||
focusedEventId: EventId?,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
|
|
@ -68,7 +68,7 @@ fun TimelineItemGroupedEventsRow(
|
|||
onExpandGroupClick = ::onExpandGroupClick,
|
||||
timelineItem = timelineItem,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
highlightedItem = highlightedItem,
|
||||
focusedEventId = focusedEventId,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
onClick = onClick,
|
||||
|
|
@ -92,7 +92,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
|||
onExpandGroupClick: () -> Unit,
|
||||
timelineItem: TimelineItem.GroupedEvents,
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
highlightedItem: String?,
|
||||
focusedEventId: EventId?,
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
|
|
@ -116,7 +116,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
|||
timelineItem.events.size
|
||||
),
|
||||
isExpanded = isExpanded,
|
||||
isHighlighted = !isExpanded && timelineItem.events.any { it.identifier() == highlightedItem },
|
||||
isHighlighted = !isExpanded && timelineItem.events.any { it.isEvent(focusedEventId) },
|
||||
onClick = onExpandGroupClick,
|
||||
)
|
||||
if (isExpanded) {
|
||||
|
|
@ -127,7 +127,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
|||
timelineRoomInfo = timelineRoomInfo,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
highlightedItem = highlightedItem,
|
||||
focusedEventId = focusedEventId,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
|
|
@ -160,12 +160,13 @@ private fun TimelineItemGroupedEventsRowContent(
|
|||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPreview {
|
||||
val events = aGroupedEvents(withReadReceipts = true)
|
||||
TimelineItemGroupedEventsRowContent(
|
||||
isExpanded = true,
|
||||
onExpandGroupClick = {},
|
||||
timelineItem = aGroupedEvents(withReadReceipts = true),
|
||||
timelineItem = events,
|
||||
timelineRoomInfo = aTimelineRoomInfo(),
|
||||
highlightedItem = null,
|
||||
focusedEventId = events.events.first().eventId,
|
||||
renderReadReceipts = true,
|
||||
isLastOutgoingMessage = false,
|
||||
onClick = {},
|
||||
|
|
@ -190,7 +191,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
|
|||
onExpandGroupClick = {},
|
||||
timelineItem = aGroupedEvents(withReadReceipts = true),
|
||||
timelineRoomInfo = aTimelineRoomInfo(),
|
||||
highlightedItem = null,
|
||||
focusedEventId = null,
|
||||
renderReadReceipts = true,
|
||||
isLastOutgoingMessage = false,
|
||||
onClick = {},
|
||||
|
|
|
|||
|
|
@ -16,13 +16,24 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||
import io.element.android.libraries.designsystem.text.toPx
|
||||
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
|
|
@ -32,7 +43,7 @@ internal fun TimelineItemRow(
|
|||
timelineRoomInfo: TimelineRoomInfo,
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
highlightedItem: String?,
|
||||
focusedEventId: EventId?,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
|
|
@ -47,69 +58,111 @@ internal fun TimelineItemRow(
|
|||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when (timelineItem) {
|
||||
is TimelineItem.Virtual -> {
|
||||
TimelineItemVirtualRow(
|
||||
virtual = timelineItem,
|
||||
modifier = modifier,
|
||||
)
|
||||
val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) {
|
||||
val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
|
||||
14.dp
|
||||
} else {
|
||||
2.dp
|
||||
}
|
||||
is TimelineItem.Event -> {
|
||||
if (timelineItem.content is TimelineItemStateContent || timelineItem.content is TimelineItemLegacyCallInviteContent) {
|
||||
TimelineItemStateEventRow(
|
||||
event = timelineItem,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
||||
onClick = { onClick(timelineItem) },
|
||||
onReadReceiptsClick = onReadReceiptClick,
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
Modifier.focusedEvent(focusedEventOffset)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(modifier = modifier.then(backgroundModifier)) {
|
||||
when (timelineItem) {
|
||||
is TimelineItem.Virtual -> {
|
||||
TimelineItemVirtualRow(
|
||||
virtual = timelineItem,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
eventSink = eventSink,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
TimelineItemEventRow(
|
||||
event = timelineItem,
|
||||
}
|
||||
is TimelineItem.Event -> {
|
||||
if (timelineItem.content is TimelineItemStateContent || timelineItem.content is TimelineItemLegacyCallInviteContent) {
|
||||
TimelineItemStateEventRow(
|
||||
event = timelineItem,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
isHighlighted = timelineItem.isEvent(focusedEventId),
|
||||
onClick = { onClick(timelineItem) },
|
||||
onReadReceiptsClick = onReadReceiptClick,
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
eventSink = eventSink,
|
||||
)
|
||||
} else {
|
||||
TimelineItemEventRow(
|
||||
event = timelineItem,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
isHighlighted = timelineItem.isEvent(focusedEventId),
|
||||
onClick = { onClick(timelineItem) },
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClicked = onLinkClicked,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
}
|
||||
is TimelineItem.GroupedEvents -> {
|
||||
TimelineItemGroupedEventsRow(
|
||||
timelineItem = timelineItem,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
||||
onClick = { onClick(timelineItem) },
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
focusedEventId = focusedEventId,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClicked = onLinkClicked,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||
eventSink = eventSink,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
is TimelineItem.GroupedEvents -> {
|
||||
TimelineItemGroupedEventsRow(
|
||||
timelineItem = timelineItem,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
highlightedItem = highlightedItem,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClicked = onLinkClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
eventSink = eventSink,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ModifierComposable")
|
||||
@Composable
|
||||
private fun Modifier.focusedEvent(
|
||||
focusedEventOffset: Dp
|
||||
): Modifier {
|
||||
val highlightedLineColor = ElementTheme.colors.textActionAccent
|
||||
val gradientColors = listOf(
|
||||
ElementTheme.colors.highlightedMessageBackgroundColor,
|
||||
ElementTheme.materialColors.background
|
||||
)
|
||||
val verticalOffset = focusedEventOffset.toPx()
|
||||
val verticalRatio = 0.7f
|
||||
return drawWithCache {
|
||||
val brush = Brush.verticalGradient(
|
||||
colors = gradientColors,
|
||||
endY = size.height * verticalRatio,
|
||||
)
|
||||
onDrawBehind {
|
||||
drawRect(
|
||||
brush,
|
||||
topLeft = Offset(0f, verticalOffset),
|
||||
size = Size(size.width, size.height * verticalRatio)
|
||||
)
|
||||
drawLine(
|
||||
highlightedLineColor,
|
||||
start = Offset(0f, verticalOffset),
|
||||
end = Offset(size.width, verticalOffset)
|
||||
)
|
||||
}
|
||||
}.padding(top = 4.dp)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,24 +16,51 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemReadMarkerView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLastForwardIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel
|
||||
|
||||
@Composable
|
||||
fun TimelineItemVirtualRow(
|
||||
virtual: TimelineItem.Virtual,
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when (virtual.model) {
|
||||
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
|
||||
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
|
||||
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
|
||||
Box(modifier = modifier) {
|
||||
when (virtual.model) {
|
||||
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model)
|
||||
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
|
||||
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView()
|
||||
TimelineItemRoomBeginningModel -> TimelineItemRoomBeginningView(roomName = timelineRoomInfo.name)
|
||||
is TimelineItemLoadingIndicatorModel -> {
|
||||
TimelineLoadingMoreIndicator(virtual.model.direction)
|
||||
val latestEventSink by rememberUpdatedState(eventSink)
|
||||
LaunchedEffect(virtual.model.timestamp) {
|
||||
latestEventSink(TimelineEvents.LoadMore(virtual.model.direction))
|
||||
}
|
||||
}
|
||||
is TimelineItemLastForwardIndicatorModel -> {
|
||||
Spacer(modifier = Modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components.virtual
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -27,24 +29,45 @@ import androidx.compose.ui.unit.dp
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
@Composable
|
||||
internal fun TimelineLoadingMoreIndicator(modifier: Modifier = Modifier) {
|
||||
internal fun TimelineLoadingMoreIndicator(
|
||||
direction: Timeline.PaginationDirection,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.padding(8.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
when (direction) {
|
||||
Timeline.PaginationDirection.FORWARDS -> {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp)
|
||||
.height(1.dp)
|
||||
)
|
||||
}
|
||||
Timeline.PaginationDirection.BACKWARDS -> {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineLoadingMoreIndicatorPreview() = ElementPreview {
|
||||
TimelineLoadingMoreIndicator()
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TimelineLoadingMoreIndicator(Timeline.PaginationDirection.BACKWARDS)
|
||||
TimelineLoadingMoreIndicator(Timeline.PaginationDirection.FORWARDS)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.factories
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
|
||||
import io.element.android.features.messages.impl.timeline.diff.TimelineItemsCacheInvalidator
|
||||
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
|
||||
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
||||
|
|
@ -43,9 +44,9 @@ class TimelineItemsFactory @Inject constructor(
|
|||
private val eventItemFactory: TimelineItemEventFactory,
|
||||
private val virtualItemFactory: TimelineItemVirtualFactory,
|
||||
private val timelineItemGrouper: TimelineItemGrouper,
|
||||
private val timelineItemIndexer: TimelineItemIndexer,
|
||||
) {
|
||||
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
|
||||
|
||||
private val lock = Mutex()
|
||||
private val diffCache = MutableListDiffCache<TimelineItem>()
|
||||
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, TimelineItem>(
|
||||
|
|
@ -100,6 +101,7 @@ class TimelineItemsFactory @Inject constructor(
|
|||
}
|
||||
}
|
||||
val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList()
|
||||
timelineItemIndexer.process(result)
|
||||
this.timelineItems.emit(result)
|
||||
}
|
||||
|
||||
|
|
@ -108,13 +110,13 @@ class TimelineItemsFactory @Inject constructor(
|
|||
index: Int,
|
||||
roomMembers: List<RoomMember>,
|
||||
): TimelineItem? {
|
||||
val timelineItemState =
|
||||
val timelineItem =
|
||||
when (val currentTimelineItem = timelineItems[index]) {
|
||||
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers)
|
||||
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
|
||||
MatrixTimelineItem.Other -> null
|
||||
}
|
||||
diffCache[index] = timelineItemState
|
||||
return timelineItemState
|
||||
diffCache[index] = timelineItem
|
||||
return timelineItem
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ package io.element.android.features.messages.impl.timeline.factories.virtual
|
|||
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLastForwardIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
|
|
@ -41,6 +44,12 @@ class TimelineItemVirtualFactory @Inject constructor(
|
|||
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
|
||||
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
|
||||
is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
is VirtualTimelineItem.RoomBeginning -> TimelineItemRoomBeginningModel
|
||||
is VirtualTimelineItem.LoadingIndicator -> TimelineItemLoadingIndicatorModel(
|
||||
direction = inner.direction,
|
||||
timestamp = inner.timestamp
|
||||
)
|
||||
is VirtualTimelineItem.LastForwardIndicator -> TimelineItemLastForwardIndicatorModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.focus
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.timeline.FocusRequestState
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.errors.FocusEventException
|
||||
|
||||
open class FocusRequestStateProvider : PreviewParameterProvider<FocusRequestState> {
|
||||
override val values: Sequence<FocusRequestState>
|
||||
get() = sequenceOf(
|
||||
FocusRequestState.Fetching,
|
||||
FocusRequestState.Failure(
|
||||
FocusEventException.EventNotFound(
|
||||
eventId = EventId("\$anEventId"),
|
||||
)
|
||||
),
|
||||
FocusRequestState.Failure(
|
||||
FocusEventException.InvalidEventId(
|
||||
eventId = "invalid",
|
||||
err = "An error"
|
||||
)
|
||||
),
|
||||
FocusRequestState.Failure(
|
||||
FocusEventException.Other(
|
||||
msg = "An error"
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.focus
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.messages.impl.timeline.FocusRequestState
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.room.errors.FocusEventException
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun FocusRequestStateView(
|
||||
focusRequestState: FocusRequestState,
|
||||
onClearFocusRequestState: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (focusRequestState) {
|
||||
is FocusRequestState.Failure -> {
|
||||
val errorMessage = when (focusRequestState.throwable) {
|
||||
is FocusEventException.EventNotFound,
|
||||
is FocusEventException.InvalidEventId -> stringResource(id = CommonStrings.error_message_not_found)
|
||||
is FocusEventException.Other -> stringResource(id = CommonStrings.error_unknown)
|
||||
else -> stringResource(id = CommonStrings.error_unknown)
|
||||
}
|
||||
ErrorDialog(
|
||||
content = errorMessage,
|
||||
onDismiss = onClearFocusRequestState,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
FocusRequestState.Fetching -> {
|
||||
ProgressDialog(modifier = modifier, onDismissRequest = onClearFocusRequestState)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun FocusRequestStateViewPreview(
|
||||
@PreviewParameter(FocusRequestStateProvider::class) state: FocusRequestState,
|
||||
) = ElementPreview {
|
||||
FocusRequestStateView(
|
||||
focusRequestState = state,
|
||||
onClearFocusRequestState = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
|
|
@ -27,18 +28,30 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||
|
||||
data class InReplyToDetails(
|
||||
val eventId: EventId,
|
||||
val senderId: UserId,
|
||||
val senderProfile: ProfileTimelineDetails,
|
||||
val eventContent: EventContent?,
|
||||
val textContent: String?,
|
||||
)
|
||||
@Immutable
|
||||
sealed interface InReplyToDetails {
|
||||
data class Ready(
|
||||
val eventId: EventId,
|
||||
val senderId: UserId,
|
||||
val senderProfile: ProfileTimelineDetails,
|
||||
val eventContent: EventContent?,
|
||||
val textContent: String?,
|
||||
) : InReplyToDetails
|
||||
|
||||
data class Loading(val eventId: EventId) : InReplyToDetails
|
||||
data class Error(val eventId: EventId, val message: String) : InReplyToDetails
|
||||
}
|
||||
|
||||
fun InReplyToDetails.eventId() = when (this) {
|
||||
is InReplyToDetails.Ready -> eventId
|
||||
is InReplyToDetails.Loading -> eventId
|
||||
is InReplyToDetails.Error -> eventId
|
||||
}
|
||||
|
||||
fun InReplyTo.map(
|
||||
permalinkParser: PermalinkParser,
|
||||
) = when (this) {
|
||||
is InReplyTo.Ready -> InReplyToDetails(
|
||||
is InReplyTo.Ready -> InReplyToDetails.Ready(
|
||||
eventId = eventId,
|
||||
senderId = senderId,
|
||||
senderProfile = senderProfile,
|
||||
|
|
@ -55,5 +68,7 @@ fun InReplyTo.map(
|
|||
else -> null
|
||||
}
|
||||
)
|
||||
else -> null
|
||||
is InReplyTo.Error -> InReplyToDetails.Error(eventId, message)
|
||||
is InReplyTo.NotLoaded -> InReplyToDetails.Loading(eventId)
|
||||
is InReplyTo.Pending -> InReplyToDetails.Loading(eventId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ internal sealed interface InReplyToMetadata {
|
|||
* Metadata can be either a thumbnail with a text OR just a text.
|
||||
*/
|
||||
@Composable
|
||||
internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventContent) {
|
||||
internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (eventContent) {
|
||||
is MessageContent -> when (val type = eventContent.type) {
|
||||
is ImageMessageType -> InReplyToMetadata.Thumbnail(
|
||||
AttachmentThumbnailInfo(
|
||||
|
|
|
|||
|
|
@ -40,6 +40,14 @@ sealed interface TimelineItem {
|
|||
is GroupedEvents -> id
|
||||
}
|
||||
|
||||
fun isEvent(eventId: EventId?): Boolean {
|
||||
if (eventId == null) return false
|
||||
return when (this) {
|
||||
is Event -> this.eventId == eventId
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun contentType(): String = when (this) {
|
||||
is Event -> content.type
|
||||
is Virtual -> model.type
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model.virtual
|
||||
|
||||
data object TimelineItemLastForwardIndicatorModel : TimelineItemVirtualModel {
|
||||
override val type: String = "TimelineItemLastForwardIndicatorModel"
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model.virtual
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
data class TimelineItemLoadingIndicatorModel(
|
||||
val direction: Timeline.PaginationDirection,
|
||||
val timestamp: Long,
|
||||
) : TimelineItemVirtualModel {
|
||||
override val type: String = "TimelineItemLoadingIndicatorModel"
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model.virtual
|
||||
|
||||
data object TimelineItemRoomBeginningModel : TimelineItemVirtualModel {
|
||||
override val type: String = "TimelineItemRoomBeginningModel"
|
||||
}
|
||||
|
|
@ -31,6 +31,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
|||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter
|
||||
import io.element.android.features.messages.impl.textcomposer.TestRichTextEditorStateFactory
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.FakeEmojibaseProvider
|
||||
|
|
@ -63,6 +65,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
|
|||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
|
||||
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
|
|
@ -81,6 +84,7 @@ import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
|||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
|
|
@ -95,6 +99,9 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
|
|||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -167,7 +174,13 @@ class MessagesPresenterTest {
|
|||
@Test
|
||||
fun `present - handle toggling a reaction`() = runTest {
|
||||
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
||||
val room = FakeMatrixRoom()
|
||||
val toggleReactionSuccess = lambdaRecorder { _: String, _: EventId -> Result.success(Unit) }
|
||||
val toggleReactionFailure = lambdaRecorder { _: String, _: EventId -> Result.failure<Unit>(IllegalStateException("Failed to send reaction")) }
|
||||
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.toggleReactionLambda = toggleReactionSuccess
|
||||
}
|
||||
val room = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -175,29 +188,42 @@ class MessagesPresenterTest {
|
|||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||
assertThat(room.myReactions.count()).isEqualTo(1)
|
||||
// No crashes when sending a reaction failed
|
||||
room.givenToggleReactionResult(Result.failure(IllegalStateException("Failed to send reaction")))
|
||||
timeline.apply { toggleReactionLambda = toggleReactionFailure }
|
||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||
assertThat(room.myReactions.count()).isEqualTo(1)
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
||||
assert(toggleReactionSuccess)
|
||||
.isCalledOnce()
|
||||
.with(value("👍"), value(AN_EVENT_ID))
|
||||
assert(toggleReactionFailure)
|
||||
.isCalledOnce()
|
||||
.with(value("👍"), value(AN_EVENT_ID))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle toggling a reaction twice`() = runTest {
|
||||
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
||||
val room = FakeMatrixRoom()
|
||||
val toggleReactionSuccess = lambdaRecorder { _: String, _: EventId -> Result.success(Unit) }
|
||||
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.toggleReactionLambda = toggleReactionSuccess
|
||||
}
|
||||
val room = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||
assertThat(room.myReactions.count()).isEqualTo(1)
|
||||
|
||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||
assertThat(room.myReactions.count()).isEqualTo(0)
|
||||
assert(toggleReactionSuccess)
|
||||
.isCalledExactly(2)
|
||||
.withSequence(
|
||||
listOf(value("👍"), value(AN_EVENT_ID)),
|
||||
listOf(value("👍"), value(AN_EVENT_ID)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -272,7 +298,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(3)
|
||||
skipItems(2)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
|
@ -428,7 +454,6 @@ class MessagesPresenterTest {
|
|||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
|
||||
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
skipItems(1) // back paginating
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -748,6 +773,7 @@ class MessagesPresenterTest {
|
|||
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
permalinkBuilder = FakePermalinkBuilder(),
|
||||
timelineController = TimelineController(matrixRoom),
|
||||
)
|
||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
|
||||
this,
|
||||
|
|
@ -768,6 +794,8 @@ class MessagesPresenterTest {
|
|||
endPollAction = endPollAction,
|
||||
sendPollResponseAction = FakeSendPollResponseAction(),
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
timelineItemIndexer = TimelineItemIndexer(),
|
||||
timelineController = TimelineController(matrixRoom),
|
||||
)
|
||||
val timelinePresenterFactory = object : TimelinePresenter.Factory {
|
||||
override fun create(navigator: MessagesNavigator): TimelinePresenter {
|
||||
|
|
@ -804,6 +832,7 @@ class MessagesPresenterTest {
|
|||
buildMeta = aBuildMeta(),
|
||||
dispatchers = coroutineDispatchers,
|
||||
htmlConverterProvider = FakeHtmlConverterProvider(),
|
||||
timelineController = TimelineController(matrixRoom),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package io.element.android.features.messages.impl.fixtures
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory
|
||||
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseMessageFactory
|
||||
|
|
@ -46,7 +47,9 @@ import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorW
|
|||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
|
||||
internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
|
||||
internal fun TestScope.aTimelineItemsFactory(
|
||||
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer()
|
||||
): TimelineItemsFactory {
|
||||
val timelineEventFormatter = aTimelineEventFormatter()
|
||||
val matrixClient = FakeMatrixClient()
|
||||
return TimelineItemsFactory(
|
||||
|
|
@ -83,6 +86,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
|
|||
),
|
||||
),
|
||||
timelineItemGrouper = TimelineItemGrouper(),
|
||||
timelineItemIndexer = timelineItemIndexer,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,14 +21,20 @@ import app.cash.molecule.moleculeFlow
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.lang.IllegalStateException
|
||||
|
||||
class ForwardMessagesPresenterTests {
|
||||
@get:Rule
|
||||
|
|
@ -36,7 +42,7 @@ class ForwardMessagesPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = aPresenter()
|
||||
val presenter = aForwardMessagesPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -49,7 +55,14 @@ class ForwardMessagesPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - forward successful`() = runTest {
|
||||
val presenter = aPresenter()
|
||||
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.forwardEventLambda = forwardEventLambda
|
||||
}
|
||||
val room = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = aForwardMessagesPresenter(fakeMatrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -61,18 +74,23 @@ class ForwardMessagesPresenterTests {
|
|||
val successfulForwardState = awaitItem()
|
||||
assertThat(successfulForwardState.isForwarding).isFalse()
|
||||
assertThat(successfulForwardState.forwardingSucceeded).isNotNull()
|
||||
assert(forwardEventLambda).isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - select a room and forward failed, then clear`() = runTest {
|
||||
val room = FakeMatrixRoom()
|
||||
val presenter = aPresenter(fakeMatrixRoom = room)
|
||||
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
|
||||
Result.failure<Unit>(IllegalStateException("error"))
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.forwardEventLambda = forwardEventLambda
|
||||
}
|
||||
val room = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = aForwardMessagesPresenter(fakeMatrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
// Test failed forwarding
|
||||
room.givenForwardEventResult(Result.failure(Throwable("error")))
|
||||
skipItems(1)
|
||||
val summary = aRoomSummaryDetails()
|
||||
presenter.onRoomSelected(listOf(summary.roomId))
|
||||
|
|
@ -82,16 +100,17 @@ class ForwardMessagesPresenterTests {
|
|||
// Then clear error
|
||||
failedForwardState.eventSink(ForwardMessagesEvents.ClearError)
|
||||
assertThat(awaitItem().error).isNull()
|
||||
assert(forwardEventLambda).isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.aPresenter(
|
||||
private fun CoroutineScope.aForwardMessagesPresenter(
|
||||
eventId: EventId = AN_EVENT_ID,
|
||||
fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
|
||||
coroutineScope: CoroutineScope = this,
|
||||
) = ForwardMessagesPresenter(
|
||||
eventId = eventId.value,
|
||||
room = fakeMatrixRoom,
|
||||
timelineProvider = LiveTimelineProvider(fakeMatrixRoom),
|
||||
matrixCoroutineScope = coroutineScope,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
|||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
|
|
@ -65,6 +66,7 @@ import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
|||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
|
|
@ -81,6 +83,10 @@ import io.element.android.libraries.textcomposer.model.Suggestion
|
|||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.waitForPredicate
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -259,7 +265,13 @@ class MessageComposerPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - edit sent message`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List<Mention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.editMessageLambda = editMessageLambda
|
||||
}
|
||||
val fakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = createPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
|
|
@ -283,7 +295,13 @@ class MessageComposerPresenterTest {
|
|||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assert(editMessageLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), any(), value(ANOTHER_MESSAGE), value(ANOTHER_MESSAGE), any())
|
||||
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||
Composer(
|
||||
inThread = false,
|
||||
|
|
@ -297,7 +315,13 @@ class MessageComposerPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - edit not sent message`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List<Mention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.editMessageLambda = editMessageLambda
|
||||
}
|
||||
val fakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = createPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
|
|
@ -321,7 +345,13 @@ class MessageComposerPresenterTest {
|
|||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assert(editMessageLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), any(), value(ANOTHER_MESSAGE), value(ANOTHER_MESSAGE), any())
|
||||
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||
Composer(
|
||||
inThread = false,
|
||||
|
|
@ -335,7 +365,13 @@ class MessageComposerPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - reply message`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<Mention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.replyMessageLambda = replyMessageLambda
|
||||
}
|
||||
val fakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = createPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
|
|
@ -355,7 +391,13 @@ class MessageComposerPresenterTest {
|
|||
state.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY to A_REPLY)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assert(replyMessageLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), value(A_REPLY), value(A_REPLY), any())
|
||||
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||
Composer(
|
||||
inThread = false,
|
||||
|
|
@ -831,7 +873,17 @@ class MessageComposerPresenterTest {
|
|||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - send messages with intentional mentions`() = runTest {
|
||||
val room = FakeMatrixRoom()
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<Mention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List<Mention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.replyMessageLambda = replyMessageLambda
|
||||
this.editMessageLambda = editMessageLambda
|
||||
}
|
||||
val room = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val presenter = createPresenter(room = room, coroutineScope = this)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -866,7 +918,9 @@ class MessageComposerPresenterTest {
|
|||
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_2)))
|
||||
assert(replyMessageLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), any(), any(), value(listOf(Mention.User(A_USER_ID_2))))
|
||||
|
||||
// Check intentional mentions on edit message
|
||||
skipItems(1)
|
||||
|
|
@ -882,7 +936,9 @@ class MessageComposerPresenterTest {
|
|||
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_3)))
|
||||
assert(editMessageLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), any(), any(), any(), value(listOf(Mention.User(A_USER_ID_3))))
|
||||
|
||||
skipItems(1)
|
||||
}
|
||||
|
|
@ -968,6 +1024,7 @@ class MessageComposerPresenterTest {
|
|||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
permalinkBuilder = permalinkBuilder,
|
||||
timelineController = TimelineController(room),
|
||||
)
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.room.Mention
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class TimelineControllerTest {
|
||||
@Test
|
||||
fun `test switching between live and detached timeline`() = runTest {
|
||||
val liveTimeline = FakeTimeline(name = "live")
|
||||
val detachedTimeline = FakeTimeline(name = "detached")
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline
|
||||
)
|
||||
matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
|
||||
val sut = TimelineController(matrixRoom)
|
||||
|
||||
sut.activeTimelineFlow().test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
assertThat(sut.isLive().first()).isTrue()
|
||||
sut.focusOnEvent(AN_EVENT_ID)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(detachedTimeline)
|
||||
}
|
||||
assertThat(sut.isLive().first()).isFalse()
|
||||
assertThat(detachedTimeline.closeCounter).isEqualTo(0)
|
||||
sut.focusOnLive()
|
||||
assertThat(sut.isLive().first()).isTrue()
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
assertThat(detachedTimeline.closeCounter).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test switching between detached 1 and detached 2 should close detached 1`() = runTest {
|
||||
val liveTimeline = FakeTimeline(name = "live")
|
||||
val detachedTimeline1 = FakeTimeline(name = "detached 1")
|
||||
val detachedTimeline2 = FakeTimeline(name = "detached 2")
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline
|
||||
)
|
||||
val sut = TimelineController(matrixRoom)
|
||||
|
||||
sut.activeTimelineFlow().test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline1))
|
||||
sut.focusOnEvent(AN_EVENT_ID)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(detachedTimeline1)
|
||||
}
|
||||
assertThat(detachedTimeline1.closeCounter).isEqualTo(0)
|
||||
assertThat(detachedTimeline2.closeCounter).isEqualTo(0)
|
||||
// Focus on another event should close the previous detached timeline
|
||||
matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline2))
|
||||
sut.focusOnEvent(AN_EVENT_ID)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(detachedTimeline2)
|
||||
}
|
||||
assertThat(detachedTimeline1.closeCounter).isEqualTo(1)
|
||||
assertThat(detachedTimeline2.closeCounter).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test switching to live when already in live should have no effect`() = runTest {
|
||||
val liveTimeline = FakeTimeline(name = "live")
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline
|
||||
)
|
||||
val sut = TimelineController(matrixRoom)
|
||||
sut.activeTimelineFlow().test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
assertThat(sut.isLive().first()).isTrue()
|
||||
sut.focusOnLive()
|
||||
assertThat(sut.isLive().first()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test closing the TimelineController should close the detached timeline`() = runTest {
|
||||
val liveTimeline = FakeTimeline(name = "live")
|
||||
val detachedTimeline = FakeTimeline(name = "detached")
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline
|
||||
)
|
||||
matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
|
||||
val sut = TimelineController(matrixRoom)
|
||||
|
||||
sut.activeTimelineFlow().test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
sut.focusOnEvent(AN_EVENT_ID)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(detachedTimeline)
|
||||
}
|
||||
assertThat(detachedTimeline.closeCounter).isEqualTo(0)
|
||||
sut.close()
|
||||
assertThat(detachedTimeline.closeCounter).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test getting timeline item`() = runTest {
|
||||
val liveTimeline = FakeTimeline(
|
||||
name = "live",
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem())
|
||||
)
|
||||
)
|
||||
)
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline
|
||||
)
|
||||
val sut = TimelineController(matrixRoom)
|
||||
assertThat(sut.timelineItems().first()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest {
|
||||
val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List<Mention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List<Mention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val liveTimeline = FakeTimeline(name = "live").apply {
|
||||
sendMessageLambda = lambdaForLive
|
||||
}
|
||||
val detachedTimeline = FakeTimeline(name = "detached").apply {
|
||||
sendMessageLambda = lambdaForDetached
|
||||
}
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline
|
||||
)
|
||||
matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
|
||||
val sut = TimelineController(matrixRoom)
|
||||
sut.focusOnEvent(AN_EVENT_ID)
|
||||
sut.activeTimelineFlow().test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(detachedTimeline)
|
||||
}
|
||||
sut.invokeOnCurrentTimeline {
|
||||
sendMessage("body", "htmlBody", emptyList())
|
||||
}
|
||||
lambdaForDetached.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test last forward pagination on a detached timeline should switch to live timeline`() = runTest {
|
||||
val liveTimeline = FakeTimeline(name = "live")
|
||||
val detachedTimeline = FakeTimeline(name = "detached")
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline
|
||||
)
|
||||
matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
|
||||
val sut = TimelineController(matrixRoom)
|
||||
|
||||
sut.activeTimelineFlow().test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
sut.focusOnEvent(AN_EVENT_ID)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(detachedTimeline)
|
||||
}
|
||||
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
|
||||
Result.success(true)
|
||||
}
|
||||
detachedTimeline.apply {
|
||||
this.paginateLambda = paginateLambda
|
||||
}
|
||||
sut.paginate(Timeline.PaginationDirection.FORWARDS)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import org.junit.Test
|
||||
|
||||
class TimelineItemIndexerTest {
|
||||
@Test
|
||||
fun `test TimelineItemIndexer`() {
|
||||
val eventIds = mutableListOf<EventId>()
|
||||
val data = listOf(
|
||||
aTimelineItemEvent().also { eventIds.add(it.eventId!!) },
|
||||
aTimelineItemEvent().also { eventIds.add(it.eventId!!) },
|
||||
aGroupedEvents().also { groupedEvents ->
|
||||
groupedEvents.events.forEach { eventIds.add(it.eventId!!) }
|
||||
},
|
||||
TimelineItem.Virtual(
|
||||
id = "dummy",
|
||||
model = TimelineItemReadMarkerModel
|
||||
),
|
||||
)
|
||||
assertThat(eventIds.size).isEqualTo(4)
|
||||
val sut = TimelineItemIndexer()
|
||||
sut.process(data)
|
||||
eventIds.forEach {
|
||||
assertThat(sut.isKnown(it)).isTrue()
|
||||
}
|
||||
assertThat(sut.indexOf(eventIds[0])).isEqualTo(0)
|
||||
assertThat(sut.indexOf(eventIds[1])).isEqualTo(1)
|
||||
assertThat(sut.indexOf(eventIds[2])).isEqualTo(2)
|
||||
assertThat(sut.indexOf(eventIds[3])).isEqualTo(2)
|
||||
|
||||
// Unknown event
|
||||
assertThat(sut.isKnown(AN_EVENT_ID)).isFalse()
|
||||
assertThat(sut.indexOf(AN_EVENT_ID)).isEqualTo(-1)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import app.cash.turbine.ReceiveTurbine
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.FakeMessagesNavigator
|
||||
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
||||
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
|
|
@ -33,12 +34,12 @@ import io.element.android.features.poll.api.actions.EndPollAction
|
|||
import io.element.android.features.poll.api.actions.SendPollResponseAction
|
||||
import io.element.android.features.poll.test.actions.FakeEndPollAction
|
||||
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||
|
|
@ -48,20 +49,27 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
|||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import io.element.android.tests.testutils.awaitWithLatch
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
|
|
@ -72,7 +80,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||
private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID"
|
||||
private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
|
||||
|
||||
class TimelinePresenterTest {
|
||||
@OptIn(ExperimentalCoroutinesApi::class) class TimelinePresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
|
|
@ -84,58 +92,49 @@ class TimelinePresenterTest {
|
|||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.timelineItems).isEmpty()
|
||||
val loadedNoTimelineState = awaitItem()
|
||||
assertThat(loadedNoTimelineState.timelineItems).isEmpty()
|
||||
assertThat(initialState.isLive).isTrue()
|
||||
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
|
||||
assertThat(initialState.focusedEventId).isNull()
|
||||
assertThat(initialState.focusRequestState).isEqualTo(FocusRequestState.None)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - load more`() = runTest {
|
||||
val presenter = createTimelinePresenter()
|
||||
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
|
||||
Result.success(false)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.paginateLambda = paginateLambda
|
||||
}
|
||||
val presenter = createTimelinePresenter(timeline = timeline)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.paginationState.hasMoreToLoadBackwards).isTrue()
|
||||
assertThat(initialState.paginationState.isBackPaginating).isFalse()
|
||||
initialState.eventSink.invoke(TimelineEvents.LoadMore)
|
||||
val inPaginationState = awaitItem()
|
||||
assertThat(inPaginationState.paginationState.isBackPaginating).isTrue()
|
||||
assertThat(inPaginationState.paginationState.hasMoreToLoadBackwards).isTrue()
|
||||
val postPaginationState = awaitItem()
|
||||
assertThat(postPaginationState.paginationState.hasMoreToLoadBackwards).isTrue()
|
||||
assertThat(postPaginationState.paginationState.isBackPaginating).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - set highlighted event`() = runTest {
|
||||
val presenter = createTimelinePresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
skipItems(1)
|
||||
assertThat(initialState.highlightedEventId).isNull()
|
||||
initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(AN_EVENT_ID))
|
||||
val withHighlightedState = awaitItem()
|
||||
assertThat(withHighlightedState.highlightedEventId).isEqualTo(AN_EVENT_ID)
|
||||
initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(null))
|
||||
val withoutHighlightedState = awaitItem()
|
||||
assertThat(withoutHighlightedState.highlightedEventId).isNull()
|
||||
initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
|
||||
initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.FORWARDS))
|
||||
assert(paginateLambda)
|
||||
.isCalledExactly(2)
|
||||
.withSequence(
|
||||
listOf(value(Timeline.PaginationDirection.BACKWARDS)),
|
||||
listOf(value(Timeline.PaginationDirection.FORWARDS))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - on scroll finished mark a room as read if the first visible index is 0`() = runTest(StandardTestDispatcher()) {
|
||||
val timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
|
||||
)
|
||||
)
|
||||
)
|
||||
val room = FakeMatrixRoom(liveTimeline = timeline)
|
||||
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
|
||||
val room = FakeMatrixRoom(matrixTimeline = timeline)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
|
|
@ -144,7 +143,6 @@ class TimelinePresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||
runCurrent()
|
||||
|
|
@ -155,48 +153,62 @@ class TimelinePresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
|
||||
val timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID_2,
|
||||
event = anEventTimelineItem(
|
||||
eventId = AN_EVENT_ID_2,
|
||||
content = aMessageContent("Test message")
|
||||
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID_2,
|
||||
event = anEventTimelineItem(
|
||||
eventId = AN_EVENT_ID_2,
|
||||
content = aMessageContent("Test message")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).apply {
|
||||
this.sendReadReceiptLambda = sendReadReceiptsLambda
|
||||
}
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
||||
val initialState = awaitFirstItem()
|
||||
awaitWithLatch { latch ->
|
||||
timeline.sendReadReceiptLatch = latch
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
skipItems(1)
|
||||
awaitItem().run {
|
||||
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
}
|
||||
assertThat(timeline.sentReadReceipts).isNotEmpty()
|
||||
assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ)
|
||||
advanceUntilIdle()
|
||||
assert(sendReadReceiptsLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), value(ReceiptType.READ))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - on scroll finished send a private read receipt if an event is at an index other than 0 and public read receipts are disabled`() = runTest {
|
||||
val timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID_2,
|
||||
event = anEventTimelineItem(
|
||||
eventId = AN_EVENT_ID_2,
|
||||
content = aMessageContent("Test message")
|
||||
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID_2,
|
||||
event = anEventTimelineItem(
|
||||
eventId = AN_EVENT_ID_2,
|
||||
content = aMessageContent("Test message")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).apply {
|
||||
this.sendReadReceiptLambda = sendReadReceiptsLambda
|
||||
}
|
||||
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
|
|
@ -205,75 +217,86 @@ class TimelinePresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
||||
val initialState = awaitFirstItem()
|
||||
awaitWithLatch { latch ->
|
||||
timeline.sendReadReceiptLatch = latch
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
skipItems(1)
|
||||
awaitItem().run {
|
||||
eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
}
|
||||
assertThat(timeline.sentReadReceipts).isNotEmpty()
|
||||
assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ_PRIVATE)
|
||||
advanceUntilIdle()
|
||||
assert(sendReadReceiptsLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), value(ReceiptType.READ_PRIVATE))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - on scroll finished will not send read receipt the first visible event is the same as before`() = runTest {
|
||||
val timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID_2,
|
||||
event = anEventTimelineItem(
|
||||
eventId = AN_EVENT_ID_2,
|
||||
content = aMessageContent("Test message")
|
||||
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID_2,
|
||||
event = anEventTimelineItem(
|
||||
eventId = AN_EVENT_ID_2,
|
||||
content = aMessageContent("Test message")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).apply {
|
||||
this.sendReadReceiptLambda = sendReadReceiptsLambda
|
||||
}
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
||||
val initialState = awaitFirstItem()
|
||||
awaitWithLatch { latch ->
|
||||
timeline.sendReadReceiptLatch = latch
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
skipItems(1)
|
||||
awaitItem().run {
|
||||
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
}
|
||||
assertThat(timeline.sentReadReceipts).hasSize(1)
|
||||
advanceUntilIdle()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
assert(sendReadReceiptsLambda).isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest {
|
||||
val timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = listOf(
|
||||
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker),
|
||||
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker)
|
||||
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker),
|
||||
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker)
|
||||
)
|
||||
)
|
||||
)
|
||||
).apply {
|
||||
this.sendReadReceiptLambda = sendReadReceiptsLambda
|
||||
}
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
||||
skipItems(1)
|
||||
val initialState = awaitFirstItem()
|
||||
awaitWithLatch { latch ->
|
||||
timeline.sendReadReceiptLatch = latch
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
}
|
||||
assertThat(timeline.sentReadReceipts).isEmpty()
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
assert(sendReadReceiptsLambda).isNeverCalled()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - covers newEventState scenarios`() = runTest {
|
||||
val timeline = FakeMatrixTimeline()
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -281,12 +304,12 @@ class TimelinePresenterTest {
|
|||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
|
||||
assertThat(initialState.timelineItems.size).isEqualTo(0)
|
||||
timeline.updateTimelineItems {
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event("0", anEventTimelineItem(content = aMessageContent())))
|
||||
}
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||
// Mimics sending a message, and assert newEventState is FromMe
|
||||
timeline.updateTimelineItems { items ->
|
||||
timelineItems.getAndUpdate { items ->
|
||||
val event = anEventTimelineItem(content = aMessageContent(), isOwn = true)
|
||||
items + listOf(MatrixTimelineItem.Event("1", event))
|
||||
}
|
||||
|
|
@ -295,7 +318,7 @@ class TimelinePresenterTest {
|
|||
assertThat(state.newEventState).isEqualTo(NewEventState.FromMe)
|
||||
}
|
||||
// Mimics receiving a message without clearing the previous FromMe
|
||||
timeline.updateTimelineItems { items ->
|
||||
timelineItems.getAndUpdate { items ->
|
||||
val event = anEventTimelineItem(content = aMessageContent())
|
||||
items + listOf(MatrixTimelineItem.Event("2", event))
|
||||
}
|
||||
|
|
@ -307,7 +330,7 @@ class TimelinePresenterTest {
|
|||
assertThat(state.newEventState).isEqualTo(NewEventState.None)
|
||||
}
|
||||
// Mimics receiving a message and assert newEventState is FromOther
|
||||
timeline.updateTimelineItems { items ->
|
||||
timelineItems.getAndUpdate { items ->
|
||||
val event = anEventTimelineItem(content = aMessageContent())
|
||||
items + listOf(MatrixTimelineItem.Event("3", event))
|
||||
}
|
||||
|
|
@ -321,7 +344,10 @@ class TimelinePresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - reaction ordering`() = runTest {
|
||||
val timeline = FakeMatrixTimeline()
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = timelineItems,
|
||||
)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -349,10 +375,9 @@ class TimelinePresenterTest {
|
|||
senders = persistentListOf(charlie)
|
||||
),
|
||||
)
|
||||
timeline.updateTimelineItems {
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem(reactions = oneReaction)))
|
||||
}
|
||||
skipItems(1)
|
||||
)
|
||||
val item = awaitItem().timelineItems.first()
|
||||
assertThat(item).isInstanceOf(TimelineItem.Event::class.java)
|
||||
val event = item as TimelineItem.Event
|
||||
|
|
@ -424,8 +449,10 @@ class TimelinePresenterTest {
|
|||
fun `present - side effect on redacted items is invoked`() = runTest {
|
||||
val redactedVoiceMessageManager = FakeRedactedVoiceMessageManager()
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = aRedactedMatrixTimeline(AN_EVENT_ID),
|
||||
timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
aRedactedMatrixTimeline(AN_EVENT_ID),
|
||||
)
|
||||
),
|
||||
redactedVoiceMessageManager = redactedVoiceMessageManager,
|
||||
)
|
||||
|
|
@ -433,32 +460,141 @@ class TimelinePresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(0)
|
||||
awaitFirstItem().let {
|
||||
assertThat(it.timelineItems).isNotEmpty()
|
||||
}
|
||||
skipItems(2)
|
||||
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - focus on event and jump to live make the presenter update the state with the correct Events`() = runTest {
|
||||
val detachedTimeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID,
|
||||
event = anEventTimelineItem(),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val liveTimeline = FakeTimeline(
|
||||
timelineItems = flowOf(emptyList())
|
||||
)
|
||||
val room = FakeMatrixRoom(
|
||||
liveTimeline = liveTimeline,
|
||||
).apply {
|
||||
givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
|
||||
}
|
||||
val presenter = createTimelinePresenter(
|
||||
room = room,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
|
||||
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Fetching)
|
||||
}
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Fetched)
|
||||
assertThat(state.timelineItems).isNotEmpty()
|
||||
}
|
||||
initialState.eventSink.invoke(TimelineEvents.JumpToLive)
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
// Event stays focused
|
||||
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
|
||||
assertThat(state.timelineItems).isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - focus on known event retrieves the event from cache`() = runTest {
|
||||
val timelineItemIndexer = TimelineItemIndexer().apply {
|
||||
process(listOf(aMessageEvent(eventId = AN_EVENT_ID)))
|
||||
}
|
||||
val presenter = createTimelinePresenter(
|
||||
room = FakeMatrixRoom(
|
||||
liveTimeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = FAKE_UNIQUE_ID,
|
||||
event = anEventTimelineItem(eventId = AN_EVENT_ID),
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
timelineItemIndexer = timelineItemIndexer,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
|
||||
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Cached(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - focus on event error case`() = runTest {
|
||||
val presenter = createTimelinePresenter(
|
||||
room = FakeMatrixRoom(
|
||||
liveTimeline = FakeTimeline(
|
||||
timelineItems = flowOf(emptyList()),
|
||||
),
|
||||
).apply {
|
||||
givenTimelineFocusedOnEventResult(Result.failure(Throwable("An error")))
|
||||
},
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
|
||||
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Fetching)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.focusRequestState).isInstanceOf(FocusRequestState.Failure::class.java)
|
||||
state.eventSink(TimelineEvents.ClearFocusRequestState)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when room member info is loaded, read receipts info should be updated`() = runTest {
|
||||
val timeline = FakeMatrixTimeline(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(
|
||||
FAKE_UNIQUE_ID,
|
||||
anEventTimelineItem(
|
||||
sender = A_USER_ID,
|
||||
receipts = persistentListOf(
|
||||
Receipt(
|
||||
userId = A_USER_ID,
|
||||
timestamp = 0L,
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(
|
||||
FAKE_UNIQUE_ID,
|
||||
anEventTimelineItem(
|
||||
sender = A_USER_ID,
|
||||
receipts = persistentListOf(
|
||||
Receipt(
|
||||
userId = A_USER_ID,
|
||||
timestamp = 0L,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val room = FakeMatrixRoom(matrixTimeline = timeline).apply {
|
||||
val room = FakeMatrixRoom(liveTimeline = timeline).apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Unknown)
|
||||
}
|
||||
|
||||
|
|
@ -485,22 +621,19 @@ class TimelinePresenterTest {
|
|||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
// Skip 1 item if Mentions feature is enabled
|
||||
if (FeatureFlags.Mentions.defaultValue) {
|
||||
skipItems(1)
|
||||
}
|
||||
return awaitItem()
|
||||
}
|
||||
|
||||
private fun TestScope.createTimelinePresenter(
|
||||
timeline: MatrixTimeline = FakeMatrixTimeline(),
|
||||
room: FakeMatrixRoom = FakeMatrixRoom(matrixTimeline = timeline),
|
||||
timeline: Timeline = FakeTimeline(),
|
||||
room: FakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline),
|
||||
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory(),
|
||||
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
|
||||
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
|
||||
endPollAction: EndPollAction = FakeEndPollAction(),
|
||||
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
|
||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
||||
): TimelinePresenter {
|
||||
return TimelinePresenter(
|
||||
timelineItemsFactory = timelineItemsFactory,
|
||||
|
|
@ -512,6 +645,8 @@ class TimelinePresenterTest {
|
|||
endPollAction = endPollAction,
|
||||
sendPollResponseAction = sendPollResponseAction,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
timelineItemIndexer = timelineItemIndexer,
|
||||
timelineController = TimelineController(room),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,14 +17,25 @@
|
|||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
|
|
@ -34,55 +45,89 @@ class TimelineViewTest {
|
|||
@Test
|
||||
fun `reaching the end of the timeline with more events to load emits a LoadMore event`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvents>()
|
||||
rule.setContent {
|
||||
TimelineView(
|
||||
aTimelineState(
|
||||
eventSink = eventsRecorder,
|
||||
paginationState = aPaginationState(
|
||||
hasMoreToLoadBackwards = true,
|
||||
)
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf<TimelineItem>(
|
||||
TimelineItem.Virtual(
|
||||
id = "backward_pagination",
|
||||
model = TimelineItemLoadingIndicatorModel(Timeline.PaginationDirection.BACKWARDS, 0)
|
||||
),
|
||||
),
|
||||
typingNotificationState = aTypingNotificationState(),
|
||||
roomName = null,
|
||||
onUserDataClicked = EnsureNeverCalledWithParam(),
|
||||
onLinkClicked = EnsureNeverCalledWithParam(),
|
||||
onMessageClicked = EnsureNeverCalledWithParam(),
|
||||
onMessageLongClicked = EnsureNeverCalledWithParam(),
|
||||
onTimestampClicked = EnsureNeverCalledWithParam(),
|
||||
onSwipeToReply = EnsureNeverCalledWithParam(),
|
||||
onReactionClicked = EnsureNeverCalledWithTwoParams(),
|
||||
onReactionLongClicked = EnsureNeverCalledWithTwoParams(),
|
||||
onMoreReactionsClicked = EnsureNeverCalledWithParam(),
|
||||
onReadReceiptClick = EnsureNeverCalledWithParam(),
|
||||
)
|
||||
}
|
||||
eventsRecorder.assertSingle(TimelineEvents.LoadMore)
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
eventsRecorder.assertSingle(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reaching the end of the timeline does not send a LoadMore event`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvents>(expectEvents = false)
|
||||
rule.setContent {
|
||||
TimelineView(
|
||||
aTimelineState(
|
||||
eventSink = eventsRecorder,
|
||||
paginationState = aPaginationState(
|
||||
hasMoreToLoadBackwards = false,
|
||||
)
|
||||
),
|
||||
typingNotificationState = aTypingNotificationState(),
|
||||
roomName = null,
|
||||
onUserDataClicked = EnsureNeverCalledWithParam(),
|
||||
onLinkClicked = EnsureNeverCalledWithParam(),
|
||||
onMessageClicked = EnsureNeverCalledWithParam(),
|
||||
onMessageLongClicked = EnsureNeverCalledWithParam(),
|
||||
onTimestampClicked = EnsureNeverCalledWithParam(),
|
||||
onSwipeToReply = EnsureNeverCalledWithParam(),
|
||||
onReactionClicked = EnsureNeverCalledWithTwoParams(),
|
||||
onReactionLongClicked = EnsureNeverCalledWithTwoParams(),
|
||||
onMoreReactionsClicked = EnsureNeverCalledWithParam(),
|
||||
onReadReceiptClick = EnsureNeverCalledWithParam(),
|
||||
)
|
||||
}
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scroll to bottom on live timeline does not emit the Event`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvents>(expectEvents = false)
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
isLive = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
forceJumpToBottomVisibility = true,
|
||||
)
|
||||
val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom)
|
||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scroll to bottom on detached timeline emits the expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvents>()
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
isLive = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom)
|
||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvents.JumpToLive)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView(
|
||||
state: TimelineState,
|
||||
typingNotificationState: TypingNotificationState = aTypingNotificationState(),
|
||||
onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkClicked: (String) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onMessageClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onMessageLongClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
onMoreReactionsClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onReadReceiptClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
) {
|
||||
setContent {
|
||||
TimelineView(
|
||||
state = state,
|
||||
typingNotificationState = typingNotificationState,
|
||||
onUserDataClicked = onUserDataClicked,
|
||||
onLinkClicked = onLinkClicked,
|
||||
onMessageClicked = onMessageClicked,
|
||||
onMessageLongClicked = onMessageLongClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
onReactionClicked = onReactionClicked,
|
||||
onReactionLongClicked = onReactionLongClicked,
|
||||
onMoreReactionsClicked = onMoreReactionsClicked,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,22 +32,22 @@ import org.junit.Test
|
|||
|
||||
class InReplyToDetailTest {
|
||||
@Test
|
||||
fun `map - with a not ready InReplyTo does not work`() {
|
||||
fun `map - with a not ready InReplyTo return expected object`() {
|
||||
assertThat(
|
||||
InReplyTo.Pending.map(
|
||||
InReplyTo.Pending(AN_EVENT_ID).map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)
|
||||
).isNull()
|
||||
).isEqualTo(InReplyToDetails.Loading(AN_EVENT_ID))
|
||||
assertThat(
|
||||
InReplyTo.NotLoaded(AN_EVENT_ID).map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)
|
||||
).isNull()
|
||||
).isEqualTo(InReplyToDetails.Loading(AN_EVENT_ID))
|
||||
assertThat(
|
||||
InReplyTo.Error.map(
|
||||
InReplyTo.Error(AN_EVENT_ID, "a message").map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)
|
||||
).isNull()
|
||||
).isEqualTo(InReplyToDetails.Error(AN_EVENT_ID, "a message"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -65,7 +65,7 @@ class InReplyToDetailTest {
|
|||
permalinkParser = FakePermalinkParser()
|
||||
)
|
||||
assertThat(inReplyToDetails).isNotNull()
|
||||
assertThat(inReplyToDetails?.textContent).isNull()
|
||||
assertThat((inReplyToDetails as InReplyToDetails.Ready).textContent).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -89,9 +89,7 @@ class InReplyToDetailTest {
|
|||
)
|
||||
)
|
||||
assertThat(
|
||||
inReplyTo.map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)?.textContent
|
||||
(inReplyTo.map(permalinkParser = FakePermalinkParser()) as InReplyToDetails.Ready).textContent
|
||||
).isEqualTo("Hello!")
|
||||
}
|
||||
|
||||
|
|
@ -113,9 +111,7 @@ class InReplyToDetailTest {
|
|||
)
|
||||
)
|
||||
assertThat(
|
||||
inReplyTo.map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)?.textContent
|
||||
(inReplyTo.map(permalinkParser = FakePermalinkParser()) as InReplyToDetails.Ready).textContent
|
||||
).isEqualTo("**Hello!**")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `any message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(eventContent = aMessageContent()).metadata()
|
||||
anInReplyToDetailsReady(eventContent = aMessageContent()).metadata()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
assertThat(it).isEqualTo(InReplyToMetadata.Text("textContent"))
|
||||
|
|
@ -81,7 +81,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `an image message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = aMessageContent(
|
||||
messageType = ImageMessageType(
|
||||
body = "body",
|
||||
|
|
@ -111,7 +111,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `a sticker message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = StickerContent(
|
||||
body = "body",
|
||||
info = anImageInfo(),
|
||||
|
|
@ -137,7 +137,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `a video message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = aMessageContent(
|
||||
messageType = VideoMessageType(
|
||||
body = "body",
|
||||
|
|
@ -167,7 +167,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `a file message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = aMessageContent(
|
||||
messageType = FileMessageType(
|
||||
body = "body",
|
||||
|
|
@ -200,7 +200,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `a audio message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = aMessageContent(
|
||||
messageType = AudioMessageType(
|
||||
body = "body",
|
||||
|
|
@ -232,7 +232,7 @@ class InReplyToMetadataKtTest {
|
|||
fun `a location message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
testEnv {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = aMessageContent(
|
||||
messageType = LocationMessageType(
|
||||
body = "body",
|
||||
|
|
@ -262,7 +262,7 @@ class InReplyToMetadataKtTest {
|
|||
fun `a voice message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
testEnv {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = aMessageContent(
|
||||
messageType = VoiceMessageType(
|
||||
body = "body",
|
||||
|
|
@ -292,7 +292,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `a poll content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = aPollContent()
|
||||
).metadata()
|
||||
}.test {
|
||||
|
|
@ -314,7 +314,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `redacted content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = RedactedContent
|
||||
).metadata()
|
||||
}.test {
|
||||
|
|
@ -327,7 +327,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `unable to decrypt content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)
|
||||
).metadata()
|
||||
}.test {
|
||||
|
|
@ -340,7 +340,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `failed to parse message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = FailedToParseMessageLikeContent("", "")
|
||||
).metadata()
|
||||
}.test {
|
||||
|
|
@ -353,7 +353,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `failed to parse state content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = FailedToParseStateContent("", "", "")
|
||||
).metadata()
|
||||
}.test {
|
||||
|
|
@ -366,7 +366,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `profile change content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = ProfileChangeContent("", "", "", "")
|
||||
).metadata()
|
||||
}.test {
|
||||
|
|
@ -379,7 +379,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `room membership content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = RoomMembershipContent(A_USER_ID, null)
|
||||
).metadata()
|
||||
}.test {
|
||||
|
|
@ -392,7 +392,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `state content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = StateContent("", OtherState.RoomJoinRules)
|
||||
).metadata()
|
||||
}.test {
|
||||
|
|
@ -405,7 +405,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `unknown content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = UnknownContent
|
||||
).metadata()
|
||||
}.test {
|
||||
|
|
@ -418,7 +418,7 @@ class InReplyToMetadataKtTest {
|
|||
@Test
|
||||
fun `null content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = null
|
||||
).metadata()
|
||||
}.test {
|
||||
|
|
@ -429,13 +429,13 @@ class InReplyToMetadataKtTest {
|
|||
}
|
||||
}
|
||||
|
||||
fun anInReplyToDetails(
|
||||
private fun anInReplyToDetailsReady(
|
||||
eventId: EventId = AN_EVENT_ID,
|
||||
senderId: UserId = A_USER_ID,
|
||||
senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
|
||||
eventContent: EventContent? = aMessageContent(),
|
||||
textContent: String? = "textContent",
|
||||
) = InReplyToDetails(
|
||||
) = InReplyToDetails.Ready(
|
||||
eventId = eventId,
|
||||
senderId = senderId,
|
||||
senderProfile = senderProfile,
|
||||
|
|
|
|||
|
|
@ -20,15 +20,19 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
|
||||
class PollRepository @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val timelineProvider: TimelineProvider,
|
||||
) {
|
||||
suspend fun getPoll(eventId: EventId): Result<PollContent> = runCatching {
|
||||
room.timeline
|
||||
timelineProvider
|
||||
.getActiveTimeline()
|
||||
.timelineItems
|
||||
.first()
|
||||
.asSequence()
|
||||
|
|
@ -51,13 +55,15 @@ class PollRepository @Inject constructor(
|
|||
maxSelections = maxSelections,
|
||||
pollKind = pollKind,
|
||||
)
|
||||
else -> room.editPoll(
|
||||
pollStartId = existingPollId,
|
||||
question = question,
|
||||
answers = answers,
|
||||
maxSelections = maxSelections,
|
||||
pollKind = pollKind,
|
||||
)
|
||||
else -> timelineProvider
|
||||
.getActiveTimeline()
|
||||
.editPoll(
|
||||
pollStartId = existingPollId,
|
||||
question = question,
|
||||
answers = answers,
|
||||
maxSelections = maxSelections,
|
||||
pollKind = pollKind,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun deletePoll(
|
||||
|
|
|
|||
|
|
@ -32,25 +32,24 @@ import io.element.android.features.poll.impl.history.model.PollHistoryFilter
|
|||
import io.element.android.features.poll.impl.history.model.PollHistoryItems
|
||||
import io.element.android.features.poll.impl.history.model.PollHistoryItemsFactory
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class PollHistoryPresenter @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val sendPollResponseAction: SendPollResponseAction,
|
||||
private val endPollAction: EndPollAction,
|
||||
private val pollHistoryItemFactory: PollHistoryItemsFactory,
|
||||
private val timelineProvider: TimelineProvider,
|
||||
) : Presenter<PollHistoryState> {
|
||||
@Composable
|
||||
override fun present(): PollHistoryState {
|
||||
// TODO use room.rememberPollHistory() when working properly?
|
||||
val timeline = room.timeline
|
||||
val paginationState by timeline.paginationState.collectAsState()
|
||||
val timeline by timelineProvider.activeTimelineFlow().collectAsState()
|
||||
val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
|
||||
val pollHistoryItemsFlow = remember {
|
||||
timeline.timelineItems.map { items ->
|
||||
pollHistoryItemFactory.create(items)
|
||||
|
|
@ -61,11 +60,11 @@ class PollHistoryPresenter @Inject constructor(
|
|||
}
|
||||
val pollHistoryItems by pollHistoryItemsFlow.collectAsState(initial = PollHistoryItems())
|
||||
LaunchedEffect(paginationState, pollHistoryItems.size) {
|
||||
if (pollHistoryItems.size == 0 && paginationState.canBackPaginate) loadMore(timeline)
|
||||
if (pollHistoryItems.size == 0 && paginationState.canPaginate) loadMore(timeline)
|
||||
}
|
||||
val isLoading by remember {
|
||||
derivedStateOf {
|
||||
pollHistoryItems.size == 0 || paginationState.isBackPaginating
|
||||
pollHistoryItems.size == 0 || paginationState.isPaginating
|
||||
}
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
|
@ -88,14 +87,14 @@ class PollHistoryPresenter @Inject constructor(
|
|||
|
||||
return PollHistoryState(
|
||||
isLoading = isLoading,
|
||||
hasMoreToLoad = paginationState.hasMoreToLoadBackwards,
|
||||
hasMoreToLoad = paginationState.hasMoreToLoad,
|
||||
pollHistoryItems = pollHistoryItems,
|
||||
activeFilter = activeFilter,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.loadMore(pollHistory: MatrixTimeline) = launch {
|
||||
pollHistory.paginateBackwards(200)
|
||||
private fun CoroutineScope.loadMore(pollHistory: Timeline) = launch {
|
||||
pollHistory.paginate(Timeline.PaginationDirection.BACKWARDS)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,16 +20,17 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.poll.PollAnswer
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.aPollContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
fun aPollTimeline(
|
||||
fun aPollTimelineItems(
|
||||
polls: Map<EventId, PollContent> = emptyMap(),
|
||||
): FakeMatrixTimeline {
|
||||
return FakeMatrixTimeline(
|
||||
initialTimelineItems = polls.map { entry ->
|
||||
): Flow<List<MatrixTimelineItem>> {
|
||||
return flowOf(
|
||||
polls.map { entry ->
|
||||
MatrixTimelineItem.Event(
|
||||
entry.key.value,
|
||||
anEventTimelineItem(
|
||||
|
|
|
|||
|
|
@ -25,33 +25,42 @@ import im.vector.app.features.analytics.plan.Composer
|
|||
import im.vector.app.features.analytics.plan.PollCreation
|
||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||
import io.element.android.features.poll.api.create.CreatePollMode
|
||||
import io.element.android.features.poll.impl.aPollTimeline
|
||||
import io.element.android.features.poll.impl.aPollTimelineItems
|
||||
import io.element.android.features.poll.impl.anOngoingPollContent
|
||||
import io.element.android.features.poll.impl.data.PollRepository
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.SavePollInvocation
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class CreatePollPresenterTest {
|
||||
@OptIn(ExperimentalCoroutinesApi::class) class CreatePollPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val pollEventId = AN_EVENT_ID
|
||||
private var navUpInvocationsCount = 0
|
||||
private val existingPoll = anOngoingPollContent()
|
||||
private val timeline = FakeTimeline(
|
||||
timelineItems = aPollTimelineItems(mapOf(pollEventId to existingPoll))
|
||||
)
|
||||
private val fakeMatrixRoom = FakeMatrixRoom(
|
||||
matrixTimeline = aPollTimeline(
|
||||
mapOf(pollEventId to existingPoll)
|
||||
)
|
||||
liveTimeline = timeline
|
||||
)
|
||||
private val fakeAnalyticsService = FakeAnalyticsService()
|
||||
private val fakeMessageComposerContext = FakeMessageComposerContext()
|
||||
|
|
@ -80,7 +89,7 @@ class CreatePollPresenterTest {
|
|||
@Test
|
||||
fun `in edit mode, if poll doesn't exist, error is tracked and screen is closed`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
matrixTimeline = aPollTimeline()
|
||||
liveTimeline = FakeTimeline()
|
||||
)
|
||||
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(AN_EVENT_ID), room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
|
|
@ -180,6 +189,12 @@ class CreatePollPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `edit poll sends a poll edit event`() = runTest {
|
||||
val editPollLambda = lambdaRecorder { _: EventId, _: String, _: List<String>, _: Int, _: PollKind ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
timeline.apply {
|
||||
this.editPollLambda = editPollLambda
|
||||
}
|
||||
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -201,16 +216,18 @@ class CreatePollPresenterTest {
|
|||
).apply {
|
||||
eventSink(CreatePollEvents.Save)
|
||||
}
|
||||
delay(1) // Wait for the coroutine to finish
|
||||
assertThat(fakeMatrixRoom.editPollInvocations.size).isEqualTo(1)
|
||||
assertThat(fakeMatrixRoom.editPollInvocations.last()).isEqualTo(
|
||||
SavePollInvocation(
|
||||
question = "Changed question",
|
||||
answers = listOf("Changed answer 1", "Changed answer 2", "Maybe"),
|
||||
maxSelections = 1,
|
||||
pollKind = PollKind.Disclosed
|
||||
advanceUntilIdle() // Wait for the coroutine to finish
|
||||
|
||||
assert(editPollLambda)
|
||||
.isCalledOnce()
|
||||
.with(
|
||||
value(pollEventId),
|
||||
value("Changed question"),
|
||||
value(listOf("Changed answer 1", "Changed answer 2", "Maybe")),
|
||||
value(1),
|
||||
value(PollKind.Disclosed)
|
||||
)
|
||||
)
|
||||
|
||||
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2)
|
||||
assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo(
|
||||
Composer(
|
||||
|
|
@ -233,6 +250,12 @@ class CreatePollPresenterTest {
|
|||
@Test
|
||||
fun `when edit poll fails, error is tracked`() = runTest {
|
||||
val error = Exception("cause")
|
||||
val editPollLambda = lambdaRecorder { _: EventId, _: String, _: List<String>, _: Int, _: PollKind ->
|
||||
Result.failure<Unit>(error)
|
||||
}
|
||||
timeline.apply {
|
||||
this.editPollLambda = editPollLambda
|
||||
}
|
||||
fakeMatrixRoom.givenEditPollResult(Result.failure(error))
|
||||
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
|
|
@ -241,8 +264,8 @@ class CreatePollPresenterTest {
|
|||
awaitDefaultItem()
|
||||
awaitPollLoaded().eventSink(CreatePollEvents.SetAnswer(0, "A"))
|
||||
awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvents.Save)
|
||||
delay(1) // Wait for the coroutine to finish
|
||||
assertThat(fakeMatrixRoom.editPollInvocations).hasSize(1)
|
||||
advanceUntilIdle() // Wait for the coroutine to finish
|
||||
assert(editPollLambda).isCalledOnce()
|
||||
assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
|
||||
assertThat(fakeAnalyticsService.trackedErrors).hasSize(1)
|
||||
assertThat(fakeAnalyticsService.trackedErrors).containsExactly(
|
||||
|
|
@ -497,22 +520,22 @@ class CreatePollPresenterTest {
|
|||
newAnswer1: String? = null,
|
||||
newAnswer2: String? = null,
|
||||
) =
|
||||
awaitItem().apply {
|
||||
assertThat(canSave).isTrue()
|
||||
assertThat(canAddAnswer).isTrue()
|
||||
assertThat(question).isEqualTo(newQuestion ?: existingPoll.question)
|
||||
assertThat(answers).isEqualTo(existingPoll.expectedAnswersState().toMutableList().apply {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.canSave).isTrue()
|
||||
assertThat(state.canAddAnswer).isTrue()
|
||||
assertThat(state.question).isEqualTo(newQuestion ?: existingPoll.question)
|
||||
assertThat(state.answers).isEqualTo(existingPoll.expectedAnswersState().toMutableList().apply {
|
||||
newAnswer1?.let { this[0] = Answer(it, true) }
|
||||
newAnswer2?.let { this[1] = Answer(it, true) }
|
||||
})
|
||||
assertThat(pollKind).isEqualTo(existingPoll.kind)
|
||||
assertThat(state.pollKind).isEqualTo(existingPoll.kind)
|
||||
}
|
||||
|
||||
private fun createCreatePollPresenter(
|
||||
mode: CreatePollMode = CreatePollMode.NewPoll,
|
||||
room: MatrixRoom = fakeMatrixRoom,
|
||||
): CreatePollPresenter = CreatePollPresenter(
|
||||
repository = PollRepository(room),
|
||||
repository = PollRepository(room, LiveTimelineProvider(room)),
|
||||
analyticsService = fakeAnalyticsService,
|
||||
messageComposerContext = fakeMessageComposerContext,
|
||||
navigateUp = { navUpInvocationsCount++ },
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import app.cash.turbine.test
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.poll.api.actions.EndPollAction
|
||||
import io.element.android.features.poll.api.actions.SendPollResponseAction
|
||||
import io.element.android.features.poll.impl.aPollTimeline
|
||||
import io.element.android.features.poll.impl.aPollTimelineItems
|
||||
import io.element.android.features.poll.impl.anEndedPollContent
|
||||
import io.element.android.features.poll.impl.anOngoingPollContent
|
||||
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
|
||||
|
|
@ -32,14 +32,21 @@ import io.element.android.features.poll.test.actions.FakeEndPollAction
|
|||
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
|
||||
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -50,14 +57,18 @@ class PollHistoryPresenterTest {
|
|||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val timeline = aPollTimeline(
|
||||
polls = mapOf(
|
||||
AN_EVENT_ID to anOngoingPollContent(),
|
||||
AN_EVENT_ID_2 to anEndedPollContent()
|
||||
)
|
||||
private val backwardPaginationStatus = MutableStateFlow(Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true))
|
||||
private val timeline = FakeTimeline(
|
||||
timelineItems = aPollTimelineItems(
|
||||
mapOf(
|
||||
AN_EVENT_ID to anOngoingPollContent(),
|
||||
AN_EVENT_ID_2 to anEndedPollContent()
|
||||
)
|
||||
),
|
||||
backwardPaginationStatus = backwardPaginationStatus
|
||||
)
|
||||
private val room = FakeMatrixRoom(
|
||||
matrixTimeline = timeline
|
||||
liveTimeline = timeline
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
@ -66,7 +77,6 @@ class PollHistoryPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING)
|
||||
assertThat(state.pollHistoryItems.size).isEqualTo(0)
|
||||
|
|
@ -127,26 +137,30 @@ class PollHistoryPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - load more scenario`() = runTest {
|
||||
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
|
||||
Result.success(false)
|
||||
}
|
||||
timeline.apply {
|
||||
this.paginateLambda = paginateLambda
|
||||
}
|
||||
val presenter = createPollHistoryPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.pollHistoryItems.size).isEqualTo(2)
|
||||
}
|
||||
timeline.updatePaginationState {
|
||||
copy(isBackPaginating = false)
|
||||
}
|
||||
skipItems(1)
|
||||
val loadedState = awaitItem()
|
||||
assertThat(loadedState.isLoading).isFalse()
|
||||
loadedState.eventSink(PollHistoryEvents.LoadMore)
|
||||
backwardPaginationStatus.getAndUpdate { it.copy(isPaginating = true) }
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isLoading).isTrue()
|
||||
}
|
||||
backwardPaginationStatus.getAndUpdate { it.copy(isPaginating = false) }
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isLoading).isFalse()
|
||||
}
|
||||
// Called once by the initial load and once by the load more event
|
||||
assert(paginateLambda).isCalledExactly(2)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -162,11 +176,11 @@ class PollHistoryPresenterTest {
|
|||
),
|
||||
): PollHistoryPresenter {
|
||||
return PollHistoryPresenter(
|
||||
room = room,
|
||||
appCoroutineScope = appCoroutineScope,
|
||||
sendPollResponseAction = sendPollResponseAction,
|
||||
endPollAction = endPollAction,
|
||||
pollHistoryItemFactory = pollHistoryItemFactory,
|
||||
timelineProvider = LiveTimelineProvider(room),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue