From 3cc749dd0ad1600d937bc56d631d8fce7df1ab61 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 28 Aug 2024 21:59:10 +0200 Subject: [PATCH] Pinned events : start displaying actions in timeline --- .../messages/impl/MessagesFlowNode.kt | 50 ++++++-- .../features/messages/impl/MessagesNode.kt | 5 +- .../messages/impl/MessagesPresenter.kt | 5 +- .../impl/actionlist/ActionListPresenter.kt | 113 ++++++++++-------- .../actionlist/model/TimelineItemAction.kt | 1 + .../model/TimelineItemActionPostProcessor.kt | 28 +++++ .../impl/forward/ForwardMessagesNode.kt | 10 +- .../impl/forward/ForwardMessagesPresenter.kt | 9 +- .../pinned/PinnedEventsTimelineProvider.kt | 17 ++- .../pinned/list/PinnedMessagesListEvents.kt | 8 +- .../list/PinnedMessagesListNavigator.kt | 26 ++++ .../pinned/list/PinnedMessagesListNode.kt | 28 ++++- .../list/PinnedMessagesListPresenter.kt | 100 +++++++++++++++- .../pinned/list/PinnedMessagesListState.kt | 4 + ...MessagesListTimelineActionPostProcessor.kt | 41 +++++++ .../pinned/list/PinnedMessagesListView.kt | 32 ++++- .../PinnedMessagesTimelineListProvider.kt | 7 ++ .../impl/timeline/TimelinePresenter.kt | 2 + .../messages/impl/timeline/TimelineState.kt | 2 + .../impl/timeline/TimelineStateProvider.kt | 3 + .../poll/impl/history/PollHistoryPresenter.kt | 5 +- .../core/coroutine/DerivedStateFlow.kt | 55 +++++++++ libraries/matrix/api/build.gradle.kts | 1 + .../matrix/api/timeline/TimelineProvider.kt | 8 +- 24 files changed, 474 insertions(+), 86 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionPostProcessor.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNavigator.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListTimelineActionPostProcessor.kt create mode 100644 libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/DerivedStateFlow.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index c244f73d35..5472741e02 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -21,12 +21,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -44,6 +46,7 @@ import io.element.android.features.messages.impl.forward.ForwardMessagesNode import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode import io.element.android.features.messages.impl.report.ReportMessageNode +import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent @@ -69,6 +72,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.room.joinedRoomMembers import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache @@ -98,9 +102,10 @@ class MessagesFlowNode @AssistedInject constructor( private val roomMemberProfilesCache: RoomMemberProfilesCache, private val mentionSpanTheme: MentionSpanTheme, private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider, + private val timelineController: TimelineController, ) : BaseFlowNode( backstack = BackStack( - initialElement = NavTarget.Messages, + initialElement = NavTarget.Messages(overriddenFocusedEventId = null), savedStateMap = buildContext.savedStateMap, ), overlay = Overlay( @@ -118,7 +123,7 @@ class MessagesFlowNode @AssistedInject constructor( data object Empty : NavTarget @Parcelize - data object Messages : NavTarget + data class Messages(val overriddenFocusedEventId: EventId?) : NavTarget @Parcelize data class MediaViewer( @@ -137,7 +142,7 @@ class MessagesFlowNode @AssistedInject constructor( data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget @Parcelize - data class ForwardEvent(val eventId: EventId) : NavTarget + data class ForwardEvent(val eventId: EventId, val fromPinnedEvents: Boolean) : NavTarget @Parcelize data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget @@ -159,7 +164,11 @@ class MessagesFlowNode @AssistedInject constructor( override fun onBuilt() { super.onBuilt() - + lifecycle.subscribe( + onDestroy = { + timelineController.close() + } + ) room.membersStateFlow .onEach { membersState -> roomMemberProfilesCache.replace(membersState.joinedRoomMembers()) @@ -167,7 +176,6 @@ class MessagesFlowNode @AssistedInject constructor( .launchIn(lifecycleScope) pinnedEventsTimelineProvider.launchIn(lifecycleScope) - } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -199,7 +207,7 @@ class MessagesFlowNode @AssistedInject constructor( } override fun onForwardEventClick(eventId: EventId) { - backstack.push(NavTarget.ForwardEvent(eventId)) + backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false)) } override fun onReportMessage(eventId: EventId, senderId: UserId) { @@ -232,7 +240,7 @@ class MessagesFlowNode @AssistedInject constructor( } } val inputs = MessagesNode.Inputs( - focusedEventId = inputs.focusedEventId, + focusedEventId = navTarget.overriddenFocusedEventId ?: inputs.focusedEventId, ) createNode(buildContext, listOf(callback, inputs)) } @@ -259,7 +267,12 @@ class MessagesFlowNode @AssistedInject constructor( createNode(buildContext, listOf(inputs)) } is NavTarget.ForwardEvent -> { - val inputs = ForwardMessagesNode.Inputs(navTarget.eventId) + val timelineProvider = if (navTarget.fromPinnedEvents) { + pinnedEventsTimelineProvider + } else { + timelineController + } + val inputs = ForwardMessagesNode.Inputs(navTarget.eventId, timelineProvider) val callback = object : ForwardMessagesNode.Callback { override fun onForwardedToSingleRoom(roomId: RoomId) { callbacks.forEach { it.onForwardedToSingleRoom(roomId) } @@ -294,8 +307,25 @@ class MessagesFlowNode @AssistedInject constructor( callbacks.forEach { it.onUserDataClick(userId) } } - override fun onPermalinkClick(data: PermalinkData) { - callbacks.forEach { it.onPermalinkClick(data) } + override fun onViewInTimelineClick(eventId: EventId) { + backstack.newRoot(NavTarget.Messages(overriddenFocusedEventId = eventId)) + } + + override fun onRoomPermalinkClick(data: PermalinkData.RoomLink) { + if (room.matches(data.roomIdOrAlias)) { + val eventId = data.eventId + backstack.newRoot(NavTarget.Messages(overriddenFocusedEventId = eventId)) + } else { + callbacks.forEach { it.onPermalinkClick(data) } + } + } + + override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) + } + + override fun onForwardEventClick(eventId: EventId) { + backstack.push(NavTarget.ForwardEvent(eventId = eventId, fromPinnedEvents = true)) } } createNode(buildContext, plugins = listOf(callback)) 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 d722a5b7a0..96cd9edfa9 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 @@ -37,7 +37,6 @@ 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.messagecomposer.MessageComposerEvents -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 @@ -75,7 +74,6 @@ 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 callbacks = plugins() @@ -107,7 +105,6 @@ class MessagesNode @AssistedInject constructor( analyticsService.capture(room.toAnalyticsViewRoom()) }, onDestroy = { - timelineController.close() mediaPlayer.close() } ) @@ -202,6 +199,8 @@ class MessagesNode @AssistedInject constructor( callbacks.forEach { it.onJoinCallClick(room.roomId) } } + + @Composable override fun View(modifier: Modifier) { val context = LocalContext.current 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 c17ac1035b..946617c1ca 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.api.timeline.HtmlConverterProvider import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor 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 @@ -98,7 +99,7 @@ class MessagesPresenter @AssistedInject constructor( private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter, timelinePresenterFactory: TimelinePresenter.Factory, private val typingNotificationPresenter: TypingNotificationPresenter, - private val actionListPresenter: ActionListPresenter, + private val actionListPresenterFactory: ActionListPresenter.Factory, private val customReactionPresenter: CustomReactionPresenter, private val reactionSummaryPresenter: ReactionSummaryPresenter, private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter, @@ -114,6 +115,7 @@ class MessagesPresenter @AssistedInject constructor( private val permalinkParser: PermalinkParser, ) : Presenter { private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator) + private val actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default) @AssistedFactory interface Factory { @@ -286,6 +288,7 @@ class MessagesPresenter @AssistedInject constructor( TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState) TimelineItemAction.Pin -> handlePinAction(targetEvent) TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) + TimelineItemAction.ViewInTimeline -> Unit } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index 124f4e911d..1f94d2a847 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -23,8 +23,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent @@ -47,13 +51,19 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import javax.inject.Inject -class ActionListPresenter @Inject constructor( +class ActionListPresenter @AssistedInject constructor( + @Assisted + private val postProcessor: TimelineItemActionPostProcessor, private val appPreferencesStore: AppPreferencesStore, private val featureFlagsService: FeatureFlagService, private val room: MatrixRoom, ) : Presenter { + @AssistedFactory + interface Factory { + fun create(postProcessor: TimelineItemActionPostProcessor): ActionListPresenter + } + @Composable override fun present(): ActionListState { val localCoroutineScope = rememberCoroutineScope() @@ -105,6 +115,7 @@ class ActionListPresenter @Inject constructor( isPinnedEventsEnabled = isPinnedEventsEnabled, isEventPinned = pinnedEventIds.contains(timelineItem.eventId), ) + val displayEmojiReactions = usersEventPermissions.canSendReaction && timelineItem.isRemote && timelineItem.content.canReact() @@ -118,57 +129,59 @@ class ActionListPresenter @Inject constructor( target.value = ActionListState.Target.None } } -} -private fun buildActions( - timelineItem: TimelineItem.Event, - usersEventPermissions: UserEventPermissions, - isDeveloperModeEnabled: Boolean, - isPinnedEventsEnabled: Boolean, - isEventPinned: Boolean, -): List { - val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther - return buildList { - if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) { - if (timelineItem.isThreaded) { - add(TimelineItemAction.ReplyInThread) - } else { - add(TimelineItemAction.Reply) + private fun buildActions( + timelineItem: TimelineItem.Event, + usersEventPermissions: UserEventPermissions, + isDeveloperModeEnabled: Boolean, + isPinnedEventsEnabled: Boolean, + isEventPinned: Boolean, + ): List { + val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther + return buildList { + if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) { + if (timelineItem.isThreaded) { + add(TimelineItemAction.ReplyInThread) + } else { + add(TimelineItemAction.Reply) + } + } + if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) { + add(TimelineItemAction.Forward) + } + if (timelineItem.isEditable) { + add(TimelineItemAction.Edit) + } + if (canRedact && timelineItem.content is TimelineItemPollContent && !timelineItem.content.isEnded) { + add(TimelineItemAction.EndPoll) + } + val canPinUnpin = isPinnedEventsEnabled && usersEventPermissions.canPinUnpin && timelineItem.isRemote + if (canPinUnpin) { + if (isEventPinned) { + add(TimelineItemAction.Unpin) + } else { + add(TimelineItemAction.Pin) + } + } + if (timelineItem.content.canBeCopied()) { + add(TimelineItemAction.Copy) + } + if (timelineItem.isRemote) { + add(TimelineItemAction.CopyLink) + } + if (isDeveloperModeEnabled) { + add(TimelineItemAction.ViewSource) + } + if (!timelineItem.isMine) { + add(TimelineItemAction.ReportContent) + } + if (canRedact) { + add(TimelineItemAction.Redact) } } - if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) { - add(TimelineItemAction.Forward) - } - if (timelineItem.isEditable) { - add(TimelineItemAction.Edit) - } - if (canRedact && timelineItem.content is TimelineItemPollContent && !timelineItem.content.isEnded) { - add(TimelineItemAction.EndPoll) - } - val canPinUnpin = isPinnedEventsEnabled && usersEventPermissions.canPinUnpin && timelineItem.isRemote - if (canPinUnpin) { - if (isEventPinned) { - add(TimelineItemAction.Unpin) - } else { - add(TimelineItemAction.Pin) - } - } - if (timelineItem.content.canBeCopied()) { - add(TimelineItemAction.Copy) - } - if (timelineItem.isRemote) { - add(TimelineItemAction.CopyLink) - } - if (isDeveloperModeEnabled) { - add(TimelineItemAction.ViewSource) - } - if (!timelineItem.isMine) { - add(TimelineItemAction.ReportContent) - } - if (canRedact) { - add(TimelineItemAction.Redact) - } - }.postFilter(timelineItem.content) + .postFilter(timelineItem.content) + .let(postProcessor::process) + } } /** diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt index a650dc88eb..0805a74655 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt @@ -29,6 +29,7 @@ sealed class TimelineItemAction( @DrawableRes val icon: Int, val destructive: Boolean = false ) { + data object ViewInTimeline: TimelineItemAction(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on) data object Forward : TimelineItemAction(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward) data object Copy : TimelineItemAction(CommonStrings.action_copy, CompoundDrawables.ic_compound_copy) data object CopyLink : TimelineItemAction(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionPostProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionPostProcessor.kt new file mode 100644 index 0000000000..0b05829f55 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionPostProcessor.kt @@ -0,0 +1,28 @@ +/* + * 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 + * + * https://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.actionlist.model + +fun interface TimelineItemActionPostProcessor { + fun process(actions: List): List + + object Default : TimelineItemActionPostProcessor { + override fun process(actions: List): List { + return actions + } + } + +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt index 37aabfd4b8..b574f1b6de 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt @@ -34,8 +34,11 @@ import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.RoomScope 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.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode +import kotlinx.coroutines.flow.StateFlow import kotlinx.parcelize.Parcelize @ContributesNode(RoomScope::class) @@ -59,10 +62,13 @@ class ForwardMessagesNode @AssistedInject constructor( fun onForwardedToSingleRoom(roomId: RoomId) } - data class Inputs(val eventId: EventId) : NodeInputs + data class Inputs( + val eventId: EventId, + val timelineProvider: TimelineProvider, + ) : NodeInputs private val inputs = inputs() - private val presenter = presenterFactory.create(inputs.eventId.value) + private val presenter = presenterFactory.create(inputs.eventId.value, inputs.timelineProvider) private val callbacks = plugins.filterIsInstance() override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt index 3b311c7ced..c6e02fdc18 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt @@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.forward import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -27,23 +28,27 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState 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.timeline.Timeline 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 +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import java.sql.Time class ForwardMessagesPresenter @AssistedInject constructor( @Assisted eventId: String, + @Assisted private val timelineProvider: TimelineProvider, private val appCoroutineScope: CoroutineScope, - private val timelineProvider: TimelineProvider, ) : Presenter { private val eventId: EventId = EventId(eventId) @AssistedFactory interface Factory { - fun create(eventId: String): ForwardMessagesPresenter + fun create(eventId: String, timelineProvider: TimelineProvider): ForwardMessagesPresenter } private val forwardingActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/PinnedEventsTimelineProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/PinnedEventsTimelineProvider.kt index 24060a6a80..1e26e03600 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/PinnedEventsTimelineProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/PinnedEventsTimelineProvider.kt @@ -18,15 +18,17 @@ package io.element.android.features.messages.impl.pinned import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.SingleIn import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.TimelineProvider import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onCompletion @@ -38,9 +40,16 @@ class PinnedEventsTimelineProvider @Inject constructor( private val room: MatrixRoom, private val networkMonitor: NetworkMonitor, private val featureFlagService: FeatureFlagService, -) { +) : TimelineProvider { private val _timelineStateFlow: MutableStateFlow> = MutableStateFlow(AsyncData.Uninitialized) + override fun activeTimelineFlow(): StateFlow { + return _timelineStateFlow + .mapState { value -> + value.dataOrNull() + } + } + val timelineStateFlow = _timelineStateFlow fun launchIn(scope: CoroutineScope) { @@ -56,12 +65,12 @@ class PinnedEventsTimelineProvider @Inject constructor( } } .onCompletion { - invokeOnTimeline { it.close() } + invokeOnTimeline { close() } } .launchIn(scope) } - suspend fun invokeOnTimeline(action: suspend (Timeline) -> Unit) { + suspend fun invokeOnTimeline(action: suspend Timeline.() -> Unit) { when (val asyncTimeline = timelineStateFlow.value) { is AsyncData.Success -> action(asyncTimeline.data) else -> Unit diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvents.kt index e259df4d62..54149fd260 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvents.kt @@ -16,4 +16,10 @@ package io.element.android.features.messages.impl.pinned.list -sealed interface PinnedMessagesListEvents +import io.element.android.features.messages.impl.MessagesEvents +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +sealed interface PinnedMessagesListEvents { + data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNavigator.kt new file mode 100644 index 0000000000..0320ec8c01 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNavigator.kt @@ -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 + * + * https://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.pinned.list + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo + +interface PinnedMessagesListNavigator { + fun onViewInTimelineClick(eventId: EventId) + fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) + fun onForwardEventClick(eventId: EventId) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt index ff53cf0781..6d902ec332 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt @@ -28,31 +28,37 @@ import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode -import io.element.android.features.messages.impl.MessagesNode.Callback import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactory import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.androidutils.system.openUrlInExternalApp import io.element.android.libraries.di.RoomScope +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.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo @ContributesNode(RoomScope::class) class PinnedMessagesListNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: PinnedMessagesListPresenter, + presenterFactory: PinnedMessagesListPresenter.Factory, private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val permalinkParser: PermalinkParser, -) : Node(buildContext, plugins = plugins) { +) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator { interface Callback : Plugin { fun onEventClick(event: TimelineItem.Event) fun onUserDataClick(userId: UserId) - fun onPermalinkClick(data: PermalinkData) + fun onViewInTimelineClick(eventId: EventId) + fun onRoomPermalinkClick(data: PermalinkData.RoomLink) + fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) + fun onForwardEventClick(eventId: EventId) } + private val presenter = presenterFactory.create(this) private val callbacks = plugins() private fun onEventClick(event: TimelineItem.Event) { @@ -71,7 +77,7 @@ class PinnedMessagesListNode @AssistedInject constructor( callbacks.forEach { it.onUserDataClick(permalink.userId) } } is PermalinkData.RoomLink -> { - callbacks.forEach { it.onPermalinkClick(permalink) } + callbacks.forEach { it.onRoomPermalinkClick(permalink) } } is PermalinkData.FallbackLink, is PermalinkData.RoomEmailInviteLink -> { @@ -80,6 +86,18 @@ class PinnedMessagesListNode @AssistedInject constructor( } } + override fun onViewInTimelineClick(eventId: EventId) { + callbacks.forEach { it.onViewInTimelineClick(eventId) } + } + + override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + callbacks.forEach { it.onShowEventDebugInfoClick(eventId, debugInfo) } + } + + override fun onForwardEventClick(eventId: EventId) { + callbacks.forEach { it.onForwardEventClick(eventId) } + } + @Composable override fun View(modifier: Modifier) { CompositionLocalProvider( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index 94a7a32cc1..5e61b4903b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -18,41 +18,69 @@ package io.element.android.features.messages.impl.pinned.list import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.features.messages.impl.UserEventPermissions +import io.element.android.features.messages.impl.actionlist.ActionListPresenter +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import javax.inject.Inject +import kotlinx.coroutines.launch +import timber.log.Timber -class PinnedMessagesListPresenter @Inject constructor( +class PinnedMessagesListPresenter @AssistedInject constructor( + @Assisted private val navigator: PinnedMessagesListNavigator, private val room: MatrixRoom, private val timelineItemsFactory: TimelineItemsFactory, private val timelineProvider: PinnedEventsTimelineProvider, + private val snackbarDispatcher: SnackbarDispatcher, + actionListPresenterFactory: ActionListPresenter.Factory, ) : Presenter { + @AssistedFactory + interface Factory { + fun create(navigator: PinnedMessagesListNavigator): PinnedMessagesListPresenter + } + + private val actionListPresenter = actionListPresenterFactory.create(PinnedMessagesListTimelineActionPostProcessor) + @Composable override fun present(): PinnedMessagesListState { val timelineRoomInfo = remember { TimelineRoomInfo( isDm = room.isDm, name = room.displayName, + isMainTimeline = false, // We don't need to compute those values userHasPermissionToSendMessage = false, userHasPermissionToSendReaction = false, @@ -60,6 +88,9 @@ class PinnedMessagesListPresenter @Inject constructor( ) } + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val userEventPermissions by userEventPermissions(syncUpdateFlow.value) + var pinnedMessageItems by remember { mutableStateOf>>(AsyncData.Uninitialized) } @@ -70,16 +101,76 @@ class PinnedMessagesListPresenter @Inject constructor( } ) + val coroutineScope = rememberCoroutineScope() fun handleEvents(event: PinnedMessagesListEvents) { + when (event) { + is PinnedMessagesListEvents.HandleAction -> coroutineScope.handleTimelineAction(event.action, event.event) + } } return pinnedMessagesListState( timelineRoomInfo = timelineRoomInfo, + userEventPermissions = userEventPermissions, timelineItems = pinnedMessageItems, eventSink = ::handleEvents ) } + private fun CoroutineScope.handleTimelineAction( + action: TimelineItemAction, + targetEvent: TimelineItem.Event, + ) = launch { + when (action) { + TimelineItemAction.Redact -> handleActionRedact(targetEvent) + TimelineItemAction.ViewSource -> { + navigator.onShowEventDebugInfoClick(targetEvent.eventId, targetEvent.debugInfo) + } + TimelineItemAction.Forward -> { + targetEvent.eventId?.let { eventId -> + navigator.onForwardEventClick(eventId) + } + } + TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) + TimelineItemAction.ViewInTimeline -> { + targetEvent.eventId?.let { eventId -> + navigator.onViewInTimelineClick(eventId) + } + } + else -> Unit + } + } + + private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) { + if (targetEvent.eventId == null) return + timelineProvider.invokeOnTimeline { + unpinEvent(targetEvent.eventId) + .onFailure { + Timber.e(it, "Failed to unpin event ${targetEvent.eventId}") + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error)) + } + } + } + + private suspend fun handleActionRedact(event: TimelineItem.Event) { + timelineProvider.invokeOnTimeline { + redactEvent(eventId = event.eventId, transactionId = event.transactionId, reason = null) + .onFailure { Timber.e(it) } + } + } + + @Composable + private fun userEventPermissions(updateKey: Long): State { + return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) { + value = UserEventPermissions( + canSendMessage = false, + canSendReaction = false, + canRedactOwn = room.canRedactOwn().getOrElse { false }, + canRedactOther = room.canRedactOther().getOrElse { false }, + canPinUnpin = room.canPinUnpin().getOrElse { false }, + ) + } + } + @Composable private fun PinnedMessagesListEffect(onItemsChange: (AsyncData>) -> Unit) { val updatedOnItemsChange by rememberUpdatedState(onItemsChange) @@ -111,8 +202,10 @@ class PinnedMessagesListPresenter @Inject constructor( } } + @Composable private fun pinnedMessagesListState( timelineRoomInfo: TimelineRoomInfo, + userEventPermissions: UserEventPermissions, timelineItems: AsyncData>, eventSink: (PinnedMessagesListEvents) -> Unit ): PinnedMessagesListState { @@ -123,9 +216,12 @@ class PinnedMessagesListPresenter @Inject constructor( if (timelineItems.data.isEmpty()) { PinnedMessagesListState.Empty } else { + val actionListState = actionListPresenter.present() PinnedMessagesListState.Filled( timelineRoomInfo = timelineRoomInfo, + userEventPermissions = userEventPermissions, timelineItems = timelineItems.data, + actionListState = actionListState, eventSink = eventSink ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt index 2e0083842f..78e6304512 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt @@ -20,6 +20,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import io.element.android.features.messages.impl.UserEventPermissions +import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.ui.strings.CommonPlurals @@ -33,7 +35,9 @@ sealed interface PinnedMessagesListState { data object Empty : PinnedMessagesListState data class Filled( val timelineRoomInfo: TimelineRoomInfo, + val userEventPermissions: UserEventPermissions, val timelineItems: ImmutableList, + val actionListState: ActionListState, val eventSink: (PinnedMessagesListEvents) -> Unit, ) : PinnedMessagesListState { val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListTimelineActionPostProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListTimelineActionPostProcessor.kt new file mode 100644 index 0000000000..da6e13aac2 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListTimelineActionPostProcessor.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 + * + * https://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.pinned.list + +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor + +object PinnedMessagesListTimelineActionPostProcessor : TimelineItemActionPostProcessor { + + override fun process(actions: List): List { + return buildList { + add(TimelineItemAction.ViewInTimeline) + addAll(actions.filter(::predicate)) + } + } + + private fun predicate(action: TimelineItemAction): Boolean { + return when (action) { + is TimelineItemAction.Pin, + is TimelineItemAction.Unpin, + is TimelineItemAction.Redact, + is TimelineItemAction.Forward, + is TimelineItemAction.ViewSource -> true + else -> false + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index e9c5bd40a6..5518e2b36a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -32,6 +32,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.actionlist.ActionListEvents +import io.element.android.features.messages.impl.actionlist.ActionListView +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.timeline.components.TimelineItemRow import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule @@ -148,6 +151,33 @@ private fun PinnedMessagesListLoaded( onLinkClick: (String) -> Unit, modifier: Modifier = Modifier, ) { + fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) { + state.actionListState.eventSink( + ActionListEvents.Clear + ) + state.eventSink( + PinnedMessagesListEvents.HandleAction( + action = timelineItemAction, + event = event, + ) + ) + } + + fun onMessageLongClick(event: TimelineItem.Event) { + state.actionListState.eventSink( + ActionListEvents.ComputeForMessage( + event = event, + userEventPermissions = state.userEventPermissions, + ) + ) + } + + ActionListView( + state = state.actionListState, + onSelectAction = ::onActionSelected, + onCustomReactionClick = {}, + onEmojiReactionClick = { _, _ -> }, + ) LazyColumn( modifier = modifier.fillMaxSize(), state = rememberLazyListState(), @@ -166,7 +196,7 @@ private fun PinnedMessagesListLoaded( isLastOutgoingMessage = false, focusedEventId = null, onClick = onEventClick, - onLongClick = {}, + onLongClick = ::onMessageLongClick, onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, inReplyToClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesTimelineListProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesTimelineListProvider.kt index bd35407a93..fe336d8e37 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesTimelineListProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesTimelineListProvider.kt @@ -17,6 +17,9 @@ package io.element.android.features.messages.impl.pinned.list import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.UserEventPermissions +import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -41,8 +44,12 @@ fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty fun aLoadedPinnedMessagesListState( timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), timelineItems: List = emptyList(), + actionListState: ActionListState = anActionListState(), + aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT, ) = PinnedMessagesListState.Filled( timelineRoomInfo = timelineRoomInfo, timelineItems = timelineItems.toImmutableList(), + actionListState = actionListState, + userEventPermissions = aUserEventPermissions, eventSink = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 5fcf24cdbc..5324ef44fa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.api.timeline.getActiveTimeline import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.ui.room.canSendMessageAsState @@ -221,6 +222,7 @@ class TimelinePresenter @AssistedInject constructor( userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToSendReaction = userHasPermissionToSendReaction, isCallOngoing = roomInfo?.hasRoomCall.orFalse(), + isMainTimeline = true ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index 74f8fda0b4..114dc08a98 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -20,6 +20,7 @@ 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.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import kotlinx.collections.immutable.ImmutableList import kotlin.time.Duration @@ -74,4 +75,5 @@ data class TimelineRoomInfo( val userHasPermissionToSendMessage: Boolean, val userHasPermissionToSendReaction: Boolean, val isCallOngoing: Boolean, + val isMainTimeline: Boolean, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index f1abc52725..d697eb5554 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -33,6 +33,7 @@ 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.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield @@ -241,10 +242,12 @@ internal fun aTimelineRoomInfo( name: String = "Room name", isDm: Boolean = false, userHasPermissionToSendMessage: Boolean = true, + isMainTimeline: Boolean = true, ) = TimelineRoomInfo( isDm = isDm, name = name, userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToSendReaction = true, isCallOngoing = false, + isMainTimeline = isMainTimeline, ) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt index b1579d6cdb..2a76196376 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt @@ -32,6 +32,7 @@ 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.Timeline import io.element.android.libraries.matrix.api.timeline.TimelineProvider import kotlinx.coroutines.CoroutineScope @@ -44,11 +45,11 @@ class PollHistoryPresenter @Inject constructor( private val sendPollResponseAction: SendPollResponseAction, private val endPollAction: EndPollAction, private val pollHistoryItemFactory: PollHistoryItemsFactory, - private val timelineProvider: TimelineProvider, + private val room: MatrixRoom, ) : Presenter { @Composable override fun present(): PollHistoryState { - val timeline by timelineProvider.activeTimelineFlow().collectAsState() + val timeline = room.liveTimeline val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState() val pollHistoryItemsFlow = remember { timeline.timelineItems.map { items -> diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/DerivedStateFlow.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/DerivedStateFlow.kt new file mode 100644 index 0000000000..4eb13be279 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/DerivedStateFlow.kt @@ -0,0 +1,55 @@ +/* + * 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 + * + * https://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.core.coroutine + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** + * A [StateFlow] that derives its value from a [Flow]. + * Useful when you want to apply transformations to a [Flow] and expose it as a [StateFlow]. + */ +class DerivedStateFlow( + private val getValue: () -> T, + private val flow: Flow +) : StateFlow { + + override val replayCache: List + get() = listOf(value) + + override val value: T + get() = getValue() + + override suspend fun collect(collector: FlowCollector): Nothing { + coroutineScope { flow.distinctUntilChanged().stateIn(this).collect(collector) } + } +} + +/** + * Maps the value of a [StateFlow] to a new value and returns a new [StateFlow] with the mapped value. + */ +fun StateFlow.mapState(transform: (a: T1) -> R): StateFlow { + return DerivedStateFlow( + getValue = { transform(this.value) }, + flow = this.map { a -> transform(a) } + ) +} diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts index b551dfa919..11a557dd6f 100644 --- a/libraries/matrix/api/build.gradle.kts +++ b/libraries/matrix/api/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { implementation(libs.serialization.json) api(projects.libraries.sessionStorage.api) implementation(libs.coroutines.core) + api(projects.libraries.architecture) testImplementation(libs.test.junit) testImplementation(libs.test.truth) 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 index ebfaca48be..d5e953a8ee 100644 --- 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 @@ -17,14 +17,16 @@ package io.element.android.libraries.matrix.api.timeline import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first /** * This interface defines a way to get the active timeline. - * It could be the current room timeline, or a timeline for a specific event. + * It could be the live timeline, a pinned timeline or a detached timeline. + * By default, the active timeline is the live timeline. */ interface TimelineProvider { - fun activeTimelineFlow(): StateFlow + fun activeTimelineFlow(): StateFlow } -suspend fun TimelineProvider.getActiveTimeline(): Timeline = activeTimelineFlow().first() +suspend fun TimelineProvider.getActiveTimeline(): Timeline = activeTimelineFlow().filterNotNull().first()