diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 634aca1733..f3c3462cd8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -35,6 +35,7 @@ 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 @@ -71,6 +72,7 @@ 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().firstOrNull() @@ -101,6 +103,7 @@ class MessagesNode @AssistedInject constructor( analyticsService.capture(room.toAnalyticsViewRoom()) }, onDestroy = { + timelineController.close() mediaPlayer.close() } ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 6531c0f8d5..db46bc0c86 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -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 { private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator) @@ -286,8 +288,10 @@ class MessagesPresenter @AssistedInject constructor( emoji: String, eventId: EventId, ) = launch(dispatchers.io) { - room.toggleReaction(emoji, eventId) - .onFailure { Timber.e(it) } + timelineController.invokeOnTimeline { + toggleReaction(emoji, eventId) + .onFailure { Timber.e(it) } + } } private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState>) = launch(dispatchers.io) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 04c182a566..f7072922c7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -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 { 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.invokeOnTimeline { + 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.invokeOnTimeline { + 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.invokeOnTimeline { + replyMessage(capturedMode.eventId, message.markdown, message.html, mentions) + } + } } analyticsService.capture( Composer( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt index 9f98614fac..811ad18075 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt @@ -17,11 +17,14 @@ package io.element.android.features.messages.impl.timeline import androidx.compose.runtime.MutableState +import com.squareup.anvil.annotations.ContributesBinding 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 io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.LiveTimelineProvider +import io.element.android.libraries.matrix.api.timeline.TimelineProvider 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 @@ -30,23 +33,25 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.map +import java.io.Closeable import java.util.Optional import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException /** - * This controller is responsible of using the right timeline to display messages. + * 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). + * This controller will replace the [LiveTimelineProvider] in the DI. */ @SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class, boundType = TimelineProvider::class, replaces = [LiveTimelineProvider::class]) class TimelineController @Inject constructor( private val room: MatrixRoom, -) { +) : Closeable, TimelineProvider { private val liveTimeline = MutableStateFlow(room.liveTimeline) private val detachedTimeline = MutableStateFlow>(Optional.empty()) @@ -60,6 +65,12 @@ class TimelineController @Inject constructor( return detachedTimeline.map { !it.isPresent } } + suspend fun invokeOnTimeline(block: suspend (Timeline.() -> Any)) { + currentTimelineFlow().first().run { + block(this) + } + } + suspend fun focusOnEvent(eventId: EventId): Result { return try { val newDetachedTimeline = room.timelineFocusedOnEvent(eventId) @@ -93,6 +104,10 @@ class TimelineController @Inject constructor( } } + override fun close() { + focusOnLive() + } + suspend fun paginate(direction: Timeline.PaginationDirection): Result { return currentTimelineFlow().first().paginate(direction) .onSuccess { hasReachedEnd -> @@ -124,7 +139,6 @@ class TimelineController @Inject constructor( if (eventId != null && eventId != lastReadReceiptId.value) { lastReadReceiptId.value = eventId currentTimelineFlow() - .filterIsInstance(Timeline::class) .first() .sendReadReceipt(eventId = eventId, receiptType = readReceiptType) } @@ -140,4 +154,8 @@ class TimelineController @Inject constructor( } return null } + + override suspend fun getActiveTimeline(): Timeline { + return currentTimelineFlow().first() + } } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt index 952fe97a5a..55d2bd8099 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt @@ -20,15 +20,18 @@ 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.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 = runCatching { - room.liveTimeline + timelineProvider + .getActiveTimeline() .timelineItems .first() .asSequence() @@ -51,13 +54,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( diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index a2ca077bb8..1e9b926aaf 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange +import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineProvider.kt new file mode 100644 index 0000000000..242859fede --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineProvider.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.timeline + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.MatrixRoom +import javax.inject.Inject + +/** + * This interface defines a way to get the active timeline. + * It could be the current room timeline, or a timeline for a specific event. + */ +interface TimelineProvider { + suspend fun getActiveTimeline(): Timeline +} + +/** + * Default implementation of [TimelineProvider] that provides the live timeline of a room. + */ +@ContributesBinding(RoomScope::class) +class LiveTimelineProvider @Inject constructor( + private val room: MatrixRoom, +) : TimelineProvider { + override suspend fun getActiveTimeline(): Timeline = room.liveTimeline +} +