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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.
*/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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."),
)
}

View file

@ -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"),

View file

@ -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 = {},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = {},
)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.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"
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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!**")
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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