diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index a7101e4f21..1d1fa2bea6 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -85,6 +85,7 @@ import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import timber.log.Timber import java.util.Optional +import java.util.UUID @ContributesNode(SessionScope::class) class LoggedInFlowNode @AssistedInject constructor( @@ -203,7 +204,8 @@ class LoggedInFlowNode @AssistedInject constructor( val serverNames: List = emptyList(), val trigger: JoinedRoom.Trigger? = null, val roomDescription: RoomDescription? = null, - val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages() + val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(), + val targetId: UUID = UUID.randomUUID(), ) : NavTarget @Parcelize @@ -294,21 +296,24 @@ class LoggedInFlowNode @AssistedInject constructor( coroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias()) } } - override fun onPermalinkClick(data: PermalinkData) { + override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) { when (data) { is PermalinkData.UserLink -> { // Should not happen (handled by MessagesNode) Timber.e("User link clicked: ${data.userId}.") } is PermalinkData.RoomLink -> { - backstack.push( - NavTarget.Room( - roomIdOrAlias = data.roomIdOrAlias, - serverNames = data.viaParameters, - trigger = JoinedRoom.Trigger.Timeline, - initialElement = RoomNavigationTarget.Messages(data.eventId), - ) + val target = NavTarget.Room( + roomIdOrAlias = data.roomIdOrAlias, + serverNames = data.viaParameters, + trigger = JoinedRoom.Trigger.Timeline, + initialElement = RoomNavigationTarget.Messages(data.eventId), ) + if (pushToBackstack) { + backstack.push(target) + } else { + backstack.replace(target) + } } is PermalinkData.FallbackLink, is PermalinkData.RoomEmailInviteLink -> { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt index d181b22bed..7794dab216 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt @@ -77,7 +77,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor( ), DaggerComponentOwner { interface Callback : Plugin { fun onOpenRoom(roomId: RoomId) - fun onPermalinkClick(data: PermalinkData) + fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun onForwardedToSingleRoom(roomId: RoomId) fun onOpenGlobalNotificationSettings() } @@ -128,6 +128,14 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor( override fun onOpenRoom(roomId: RoomId) { callbacks.forEach { it.onOpenRoom(roomId) } } + + override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) { + callbacks.forEach { it.onPermalinkClick(data, pushToBackstack) } + } + + override fun onForwardedToSingleRoom(roomId: RoomId) { + callbacks.forEach { it.onForwardedToSingleRoom(roomId) } + } } return roomDetailsEntryPoint.nodeBuilder(this, buildContext) .params(RoomDetailsEntryPoint.Params(initialTarget)) @@ -138,27 +146,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { is NavTarget.Messages -> { - val callback = object : MessagesEntryPoint.Callback { - override fun onRoomDetailsClick() { - backstack.push(NavTarget.RoomDetails) - } - - override fun onUserDataClick(userId: UserId) { - backstack.push(NavTarget.RoomMemberDetails(userId)) - } - - override fun onPermalinkClick(data: PermalinkData) { - callbacks.forEach { it.onPermalinkClick(data) } - } - - override fun onForwardedToSingleRoom(roomId: RoomId) { - callbacks.forEach { it.onForwardedToSingleRoom(roomId) } - } - } - messagesEntryPoint.nodeBuilder(this, buildContext) - .params(MessagesEntryPoint.Params(navTarget.focusedEventId)) - .callback(callback) - .build() + createMessagesNode(buildContext, navTarget) } NavTarget.RoomDetails -> { createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails) @@ -172,6 +160,36 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor( } } + private fun createMessagesNode( + buildContext: BuildContext, + navTarget: NavTarget.Messages, + ): Node { + val callback = object : MessagesEntryPoint.Callback { + override fun onRoomDetailsClick() { + backstack.push(NavTarget.RoomDetails) + } + + override fun onUserDataClick(userId: UserId) { + backstack.push(NavTarget.RoomMemberDetails(userId)) + } + + override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) { + callbacks.forEach { it.onPermalinkClick(data, pushToBackstack) } + } + + override fun onForwardedToSingleRoom(roomId: RoomId) { + callbacks.forEach { it.onForwardedToSingleRoom(roomId) } + } + } + val params = MessagesEntryPoint.Params( + MessagesEntryPoint.InitialTarget.Messages(navTarget.focusedEventId) + ) + return messagesEntryPoint.nodeBuilder(this, buildContext) + .params(params) + .callback(callback) + .build() + } + sealed interface NavTarget : Parcelable { @Parcelize data class Messages(val focusedEventId: EventId? = null) : NavTarget diff --git a/features/messages/api/build.gradle.kts b/features/messages/api/build.gradle.kts index 5e15d8f38d..4eff3ebcb5 100644 --- a/features/messages/api/build.gradle.kts +++ b/features/messages/api/build.gradle.kts @@ -16,6 +16,7 @@ plugins { id("io.element.android-compose-library") + id("kotlin-parcelize") } android { diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt index 6bd1045c12..219faae6b3 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt @@ -16,17 +16,26 @@ package io.element.android.features.messages.api +import android.os.Parcelable import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkData +import kotlinx.parcelize.Parcelize interface MessagesEntryPoint : FeatureEntryPoint { - fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + sealed interface InitialTarget : Parcelable { + @Parcelize + data class Messages(val focusedEventId: EventId?) : InitialTarget + + @Parcelize + data object PinnedMessages : InitialTarget + } interface NodeBuilder { fun params(params: Params): NodeBuilder @@ -34,14 +43,14 @@ interface MessagesEntryPoint : FeatureEntryPoint { fun build(): Node } - data class Params( - val focusedEventId: EventId?, - ) - interface Callback : Plugin { fun onRoomDetailsClick() fun onUserDataClick(userId: UserId) - fun onPermalinkClick(data: PermalinkData) + fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun onForwardedToSingleRoom(roomId: RoomId) } + + data class Params(val initialTarget: InitialTarget) : NodeInputs + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder } diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/IsPinnedMessagesFeatureEnabled.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/IsPinnedMessagesFeatureEnabled.kt new file mode 100644 index 0000000000..829683d89d --- /dev/null +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/IsPinnedMessagesFeatureEnabled.kt @@ -0,0 +1,24 @@ +/* + * 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.api.pinned + +import androidx.compose.runtime.Composable + +fun interface IsPinnedMessagesFeatureEnabled { + @Composable + operator fun invoke(): Boolean +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt index 0f6a6358d3..047677d543 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt @@ -32,7 +32,7 @@ class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint { return object : MessagesEntryPoint.NodeBuilder { override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder { - plugins += MessagesFlowNode.Inputs(focusedEventId = params.focusedEventId) + plugins += MessagesEntryPoint.Params(params.initialTarget) return this } @@ -47,3 +47,8 @@ class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint { } } } + +internal fun MessagesEntryPoint.InitialTarget.toNavTarget() = when (this) { + is MessagesEntryPoint.InitialTarget.Messages -> MessagesFlowNode.NavTarget.Messages(focusedEventId) + MessagesEntryPoint.InitialTarget.PinnedMessages -> MessagesFlowNode.NavTarget.PinnedMessagesList +} 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 f022fd0caf..1f1b3f9b5d 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,6 +21,7 @@ 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 @@ -41,7 +42,10 @@ import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode 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 @@ -54,7 +58,6 @@ import io.element.android.features.poll.api.create.CreatePollEntryPoint import io.element.android.features.poll.api.create.CreatePollMode 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 @@ -64,9 +67,11 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias 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 @@ -81,7 +86,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.parcelize.Parcelize -import timber.log.Timber @ContributesNode(RoomScope::class) class MessagesFlowNode @AssistedInject constructor( @@ -96,9 +100,11 @@ class MessagesFlowNode @AssistedInject constructor( private val room: MatrixRoom, private val roomMemberProfilesCache: RoomMemberProfilesCache, private val mentionSpanTheme: MentionSpanTheme, + private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider, + private val timelineController: TimelineController, ) : BaseFlowNode( backstack = BackStack( - initialElement = NavTarget.Messages, + initialElement = plugins.filterIsInstance().first().initialTarget.toNavTarget(), savedStateMap = buildContext.savedStateMap, ), overlay = Overlay( @@ -107,16 +113,12 @@ class MessagesFlowNode @AssistedInject constructor( buildContext = buildContext, plugins = plugins ) { - data class Inputs(val focusedEventId: EventId?) : NodeInputs - - private val inputs = inputs() - sealed interface NavTarget : Parcelable { @Parcelize data object Empty : NavTarget @Parcelize - data object Messages : NavTarget + data class Messages(val focusedEventId: EventId?) : NavTarget @Parcelize data class MediaViewer( @@ -135,7 +137,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 @@ -148,18 +150,27 @@ class MessagesFlowNode @AssistedInject constructor( @Parcelize data class EditPoll(val eventId: EventId) : NavTarget + + @Parcelize + data object PinnedMessagesList : NavTarget } private val callbacks = plugins() override fun onBuilt() { super.onBuilt() - + lifecycle.subscribe( + onDestroy = { + timelineController.close() + } + ) room.membersStateFlow .onEach { membersState -> roomMemberProfilesCache.replace(membersState.joinedRoomMembers()) } .launchIn(lifecycleScope) + + pinnedEventsTimelineProvider.launchIn(lifecycleScope) } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -183,7 +194,7 @@ class MessagesFlowNode @AssistedInject constructor( } override fun onPermalinkClick(data: PermalinkData) { - callbacks.forEach { it.onPermalinkClick(data) } + callbacks.forEach { it.onPermalinkClick(data, pushToBackstack = true) } } override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { @@ -191,7 +202,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) { @@ -220,12 +231,10 @@ class MessagesFlowNode @AssistedInject constructor( } override fun onViewAllPinnedEvents() { - Timber.d("On View All Pinned Events not implemented yet.") + backstack.push(NavTarget.PinnedMessagesList) } } - val inputs = MessagesNode.Inputs( - focusedEventId = inputs.focusedEventId, - ) + val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId) createNode(buildContext, listOf(callback, inputs)) } is NavTarget.MediaViewer -> { @@ -251,7 +260,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) } @@ -276,6 +290,38 @@ class MessagesFlowNode @AssistedInject constructor( .params(CreatePollEntryPoint.Params(mode = CreatePollMode.EditPoll(eventId = navTarget.eventId))) .build() } + NavTarget.PinnedMessagesList -> { + val callback = object : PinnedMessagesListNode.Callback { + override fun onEventClick(event: TimelineItem.Event) { + processEventClick(event) + } + + override fun onUserDataClick(userId: UserId) { + callbacks.forEach { it.onUserDataClick(userId) } + } + + override fun onViewInTimelineClick(eventId: EventId) { + val permalinkData = PermalinkData.RoomLink( + roomIdOrAlias = room.roomId.toRoomIdOrAlias(), + eventId = eventId, + ) + callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) } + } + + override fun onRoomPermalinkClick(data: PermalinkData.RoomLink) { + callbacks.forEach { it.onPermalinkClick(data, pushToBackstack = !room.matches(data.roomIdOrAlias)) } + } + + 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)) + } NavTarget.Empty -> { node(buildContext) {} } 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..4499ea70cf 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() } ) 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 a7f3dd682a..70810a2c45 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 ee5c4c4ad3..f84e90edb4 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,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled 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 @@ -36,8 +42,7 @@ import io.element.android.features.messages.impl.timeline.model.event.canBeCopie import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded import io.element.android.features.messages.impl.timeline.model.event.canReact import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.preferences.api.store.AppPreferencesStore @@ -47,13 +52,26 @@ 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( +interface ActionListPresenter : Presenter { + interface Factory { + fun create(postProcessor: TimelineItemActionPostProcessor): ActionListPresenter + } +} + +class DefaultActionListPresenter @AssistedInject constructor( + @Assisted + private val postProcessor: TimelineItemActionPostProcessor, private val appPreferencesStore: AppPreferencesStore, - private val featureFlagsService: FeatureFlagService, + private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled, private val room: MatrixRoom, -) : Presenter { +) : ActionListPresenter { + @AssistedFactory + @ContributesBinding(RoomScope::class) + interface Factory : ActionListPresenter.Factory { + override fun create(postProcessor: TimelineItemActionPostProcessor): DefaultActionListPresenter + } + @Composable override fun present(): ActionListState { val localCoroutineScope = rememberCoroutineScope() @@ -63,7 +81,7 @@ class ActionListPresenter @Inject constructor( } val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false) - val isPinnedEventsEnabled by featureFlagsService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents).collectAsState(initial = false) + val isPinnedEventsEnabled = isPinnedMessagesFeatureEnabled() val pinnedEventIds by remember { room.roomInfoFlow.map { it.pinnedEventIds } }.collectAsState(initial = persistentListOf()) @@ -105,6 +123,7 @@ class ActionListPresenter @Inject constructor( isPinnedEventsEnabled = isPinnedEventsEnabled, isEventPinned = pinnedEventIds.contains(timelineItem.eventId), ) + val displayEmojiReactions = usersEventPermissions.canSendReaction && timelineItem.content.canReact() if (actions.isNotEmpty() || displayEmojiReactions) { @@ -117,57 +136,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..12dcf15353 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..884429358c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionPostProcessor.kt @@ -0,0 +1,27 @@ +/* + * 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..2ae9b35e0a 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,6 +34,7 @@ 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.TimelineProvider import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode import kotlinx.parcelize.Parcelize @@ -59,10 +60,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..a2b0238839 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 @@ -36,14 +36,14 @@ import kotlinx.coroutines.launch 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/IsPinnedMessagesFeatureEnabled.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DefaultIsPinnedMessagesFeatureEnabled.kt similarity index 94% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/IsPinnedMessagesFeatureEnabled.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DefaultIsPinnedMessagesFeatureEnabled.kt index 5ef5e2c793..71ae30c1a4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/IsPinnedMessagesFeatureEnabled.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DefaultIsPinnedMessagesFeatureEnabled.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled import io.element.android.libraries.di.AppScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags @@ -30,11 +31,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import javax.inject.Inject -fun interface IsPinnedMessagesFeatureEnabled { - @Composable - operator fun invoke(): Boolean -} - @ContributesBinding(AppScope::class) class DefaultIsPinnedMessagesFeatureEnabled @Inject constructor( private val featureFlagService: FeatureFlagService, 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 new file mode 100644 index 0000000000..6791080fa8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/PinnedEventsTimelineProvider.kt @@ -0,0 +1,97 @@ +/* + * 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 + +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +@SingleIn(RoomScope::class) +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) { + combine( + featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents), + networkMonitor.connectivity + ) { + // do not use connectivity here as data can be loaded from cache, it's just to trigger retry if needed + isEnabled, _ -> + isEnabled + } + .onEach { isFeatureEnabled -> + if (isFeatureEnabled) { + loadTimelineIfNeeded() + } else { + _timelineStateFlow.value = AsyncData.Uninitialized + } + } + .onCompletion { + invokeOnTimeline { close() } + } + .launchIn(scope) + } + + suspend fun invokeOnTimeline(action: suspend Timeline.() -> Unit) { + when (val asyncTimeline = timelineStateFlow.value) { + is AsyncData.Success -> action(asyncTimeline.data) + else -> Unit + } + } + + private suspend fun loadTimelineIfNeeded() { + when (timelineStateFlow.value) { + is AsyncData.Uninitialized, is AsyncData.Failure -> { + timelineStateFlow.emit(AsyncData.Loading()) + room.pinnedEventsTimeline() + .fold( + { timelineStateFlow.emit(AsyncData.Success(it)) }, + { timelineStateFlow.emit(AsyncData.Failure(it)) } + ) + } + else -> Unit + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt index 13c45e0092..4c9238da00 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt @@ -26,18 +26,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import io.element.android.features.messages.impl.pinned.IsPinnedMessagesFeatureEnabled -import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.room.MatrixRoom import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -45,46 +46,38 @@ import kotlin.time.Duration.Companion.milliseconds class PinnedMessagesBannerPresenter @Inject constructor( private val room: MatrixRoom, private val itemFactory: PinnedMessagesBannerItemFactory, - private val isFeatureEnabled: IsPinnedMessagesFeatureEnabled, - private val networkMonitor: NetworkMonitor, + private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider, ) : Presenter { - private val pinnedItems = mutableStateOf>(persistentListOf()) + private val pinnedItems = mutableStateOf>>(AsyncData.Uninitialized) @Composable override fun present(): PinnedMessagesBannerState { - val isFeatureEnabled = isFeatureEnabled() val expectedPinnedMessagesCount by remember { room.roomInfoFlow.map { roomInfo -> roomInfo.pinnedEventIds.size } }.collectAsState(initial = 0) - var hasTimelineFailedToLoad by rememberSaveable { mutableStateOf(false) } var currentPinnedMessageIndex by rememberSaveable { mutableIntStateOf(-1) } PinnedMessagesBannerItemsEffect( - isFeatureEnabled = isFeatureEnabled, onItemsChange = { newItems -> - val pinnedMessageCount = newItems.size + val pinnedMessageCount = newItems.dataOrNull().orEmpty().size if (currentPinnedMessageIndex >= pinnedMessageCount || currentPinnedMessageIndex < 0) { currentPinnedMessageIndex = pinnedMessageCount - 1 } pinnedItems.value = newItems }, - onTimelineFail = { hasTimelineFailed -> - hasTimelineFailedToLoad = hasTimelineFailed - } ) fun handleEvent(event: PinnedMessagesBannerEvents) { when (event) { is PinnedMessagesBannerEvents.MoveToNextPinned -> { - currentPinnedMessageIndex = (currentPinnedMessageIndex - 1).mod(pinnedItems.value.size) + val loadedCount = pinnedItems.value.dataOrNull().orEmpty().size + currentPinnedMessageIndex = (currentPinnedMessageIndex - 1).mod(loadedCount) } } } return pinnedMessagesBannerState( - isFeatureEnabled = isFeatureEnabled, - hasTimelineFailed = hasTimelineFailedToLoad, expectedPinnedMessagesCount = expectedPinnedMessagesCount, pinnedItems = pinnedItems.value, currentPinnedMessageIndex = currentPinnedMessageIndex, @@ -94,63 +87,65 @@ class PinnedMessagesBannerPresenter @Inject constructor( @Composable private fun pinnedMessagesBannerState( - isFeatureEnabled: Boolean, - hasTimelineFailed: Boolean, expectedPinnedMessagesCount: Int, - pinnedItems: ImmutableList, + pinnedItems: AsyncData>, currentPinnedMessageIndex: Int, eventSink: (PinnedMessagesBannerEvents) -> Unit ): PinnedMessagesBannerState { - val currentPinnedMessage = pinnedItems.getOrNull(currentPinnedMessageIndex) - return when { - !isFeatureEnabled -> PinnedMessagesBannerState.Hidden - hasTimelineFailed -> PinnedMessagesBannerState.Hidden - currentPinnedMessage != null -> PinnedMessagesBannerState.Loaded( - currentPinnedMessage = currentPinnedMessage, - currentPinnedMessageIndex = currentPinnedMessageIndex, - loadedPinnedMessagesCount = pinnedItems.size, - eventSink = eventSink - ) - expectedPinnedMessagesCount == 0 -> PinnedMessagesBannerState.Hidden - else -> PinnedMessagesBannerState.Loading(expectedPinnedMessagesCount = expectedPinnedMessagesCount) + return when (pinnedItems) { + is AsyncData.Failure, is AsyncData.Uninitialized -> PinnedMessagesBannerState.Hidden + is AsyncData.Loading -> { + if (expectedPinnedMessagesCount == 0) { + PinnedMessagesBannerState.Hidden + } else { + PinnedMessagesBannerState.Loading(expectedPinnedMessagesCount = expectedPinnedMessagesCount) + } + } + is AsyncData.Success -> { + val currentPinnedMessage = pinnedItems.data.getOrNull(currentPinnedMessageIndex) + if (currentPinnedMessage == null) { + PinnedMessagesBannerState.Hidden + } else { + PinnedMessagesBannerState.Loaded( + loadedPinnedMessagesCount = pinnedItems.data.size, + currentPinnedMessageIndex = currentPinnedMessageIndex, + currentPinnedMessage = currentPinnedMessage, + eventSink = eventSink + ) + } + } } } - @OptIn(FlowPreview::class) + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @Composable private fun PinnedMessagesBannerItemsEffect( - isFeatureEnabled: Boolean, - onItemsChange: (ImmutableList) -> Unit, - onTimelineFail: (Boolean) -> Unit, + onItemsChange: (AsyncData>) -> Unit, ) { val updatedOnItemsChange by rememberUpdatedState(onItemsChange) - val updatedOnTimelineFail by rememberUpdatedState(onTimelineFail) - val networkStatus by networkMonitor.connectivity.collectAsState() + LaunchedEffect(Unit) { + pinnedEventsTimelineProvider.timelineStateFlow + .flatMapLatest { asyncTimeline -> + when (asyncTimeline) { + AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized) + is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error)) + is AsyncData.Loading -> flowOf(AsyncData.Loading()) + is AsyncData.Success -> { + asyncTimeline.data.timelineItems + .debounce(300.milliseconds) + .map { timelineItems -> + val pinnedItems = timelineItems.mapNotNull { timelineItem -> + itemFactory.create(timelineItem) + }.toImmutableList() - LaunchedEffect(isFeatureEnabled, networkStatus) { - if (!isFeatureEnabled) { - updatedOnItemsChange(persistentListOf()) - return@LaunchedEffect - } - val pinnedEventsTimeline = room.pinnedEventsTimeline() - .onFailure { updatedOnTimelineFail(true) } - .onSuccess { updatedOnTimelineFail(false) } - .getOrNull() - ?: return@LaunchedEffect - - pinnedEventsTimeline.timelineItems - .debounce(300.milliseconds) - .map { timelineItems -> - timelineItems.mapNotNull { timelineItem -> - itemFactory.create(timelineItem) - }.toImmutableList() + AsyncData.Success(pinnedItems) + } + } + } } .onEach { newItems -> updatedOnItemsChange(newItems) } - .onCompletion { - pinnedEventsTimeline.close() - } .launchIn(this) } } 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 new file mode 100644 index 0000000000..e785e3c8ae --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvents.kt @@ -0,0 +1,24 @@ +/* + * 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.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 new file mode 100644 index 0000000000..072fa1c6d1 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt @@ -0,0 +1,116 @@ +/* + * 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 android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +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.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.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, + presenterFactory: PinnedMessagesListPresenter.Factory, + private val timelineItemPresenterFactories: TimelineItemPresenterFactories, + private val permalinkParser: PermalinkParser, +) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator { + interface Callback : Plugin { + fun onEventClick(event: TimelineItem.Event) + fun onUserDataClick(userId: UserId) + 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) { + return callbacks.forEach { it.onEventClick(event) } + } + + private fun onUserDataClick(userId: UserId) { + callbacks.forEach { it.onUserDataClick(userId) } + } + + private fun onLinkClick(context: Context, url: String) { + when (val permalink = permalinkParser.parse(url)) { + is PermalinkData.UserLink -> { + // Open the room member profile, it will fallback to + // the user profile if the user is not in the room + callbacks.forEach { it.onUserDataClick(permalink.userId) } + } + is PermalinkData.RoomLink -> { + callbacks.forEach { it.onRoomPermalinkClick(permalink) } + } + is PermalinkData.FallbackLink, + is PermalinkData.RoomEmailInviteLink -> { + context.openUrlInExternalApp(url) + } + } + } + + 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( + LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, + ) { + val context = LocalContext.current + val state = presenter.present() + PinnedMessagesListView( + state = state, + onBackClick = ::navigateUp, + onEventClick = ::onEventClick, + onUserDataClick = ::onUserDataClick, + onLinkClick = { url -> onLinkClick(context, url) }, + modifier = modifier + ) + } + } +} 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 new file mode 100644 index 0000000000..c2e1a3baca --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -0,0 +1,234 @@ +/* + * 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 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.FlowPreview +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.time.Duration.Companion.milliseconds + +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, + // We don't need to compute those values + userHasPermissionToSendMessage = false, + userHasPermissionToSendReaction = false, + isCallOngoing = false, + ) + } + + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val userEventPermissions by userEventPermissions(syncUpdateFlow.value) + + var pinnedMessageItems by remember { + mutableStateOf>>(AsyncData.Uninitialized) + } + + PinnedMessagesListEffect( + onItemsChange = { newItems -> + pinnedMessageItems = newItems + } + ) + + 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 }, + ) + } + } + + @OptIn(FlowPreview::class) + @Composable + private fun PinnedMessagesListEffect(onItemsChange: (AsyncData>) -> Unit) { + val updatedOnItemsChange by rememberUpdatedState(onItemsChange) + + val timelineState by timelineProvider.timelineStateFlow.collectAsState() + + LaunchedEffect(timelineState) { + when (val asyncTimeline = timelineState) { + AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized) + is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error)) + is AsyncData.Loading -> flowOf(AsyncData.Loading()) + is AsyncData.Success -> { + val timelineItemsFlow = asyncTimeline.data.timelineItems.debounce(300.milliseconds) + combine(timelineItemsFlow, room.membersStateFlow) { items, membersState -> + timelineItemsFactory.replaceWith( + timelineItems = items, + roomMembers = membersState.roomMembers().orEmpty() + ) + }.launchIn(this) + + timelineItemsFactory.timelineItems.map { timelineItems -> + AsyncData.Success(timelineItems) + } + } + } + .onEach { items -> + updatedOnItemsChange(items) + } + .launchIn(this) + } + } + + @Composable + private fun pinnedMessagesListState( + timelineRoomInfo: TimelineRoomInfo, + userEventPermissions: UserEventPermissions, + timelineItems: AsyncData>, + eventSink: (PinnedMessagesListEvents) -> Unit + ): PinnedMessagesListState { + return when (timelineItems) { + AsyncData.Uninitialized, is AsyncData.Loading -> PinnedMessagesListState.Loading + is AsyncData.Failure -> PinnedMessagesListState.Failed + is AsyncData.Success -> { + 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 new file mode 100644 index 0000000000..78e6304512 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.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.features.messages.impl.pinned.list + +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 +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@Immutable +sealed interface PinnedMessagesListState { + data object Failed : PinnedMessagesListState + data object Loading : 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 } + } + + @Composable + fun title(): String { + return when (this) { + is Filled -> { + pluralStringResource(id = CommonPlurals.screen_pinned_timeline_screen_title, loadedPinnedMessagesCount, loadedPinnedMessagesCount) + } + else -> stringResource(id = CommonStrings.screen_pinned_timeline_screen_title_empty) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt new file mode 100644 index 0000000000..eff0ebf961 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt @@ -0,0 +1,105 @@ +/* + * 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 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.aTimelineItemDaySeparator +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.aTimelineItemReactions +import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +open class PinnedMessagesListStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aFailedPinnedMessagesListState(), + aLoadingPinnedMessagesListState(), + anEmptyPinnedMessagesListState(), + aLoadedPinnedMessagesListState( + timelineItems = persistentListOf( + aTimelineItemEvent( + isMine = false, + content = aTimelineItemTextContent("A pinned message"), + groupPosition = TimelineItemGroupPosition.Last, + timelineItemReactions = aTimelineItemReactions(0) + ), + aTimelineItemEvent( + isMine = false, + content = aTimelineItemAudioContent("A pinned file"), + groupPosition = TimelineItemGroupPosition.Middle, + timelineItemReactions = aTimelineItemReactions(0) + ), + aTimelineItemEvent( + isMine = false, + content = aTimelineItemPollContent("A pinned poll?"), + groupPosition = TimelineItemGroupPosition.First, + timelineItemReactions = aTimelineItemReactions(0) + ), + aTimelineItemDaySeparator(), + aTimelineItemEvent( + isMine = true, + content = aTimelineItemTextContent("A pinned message"), + groupPosition = TimelineItemGroupPosition.Last, + timelineItemReactions = aTimelineItemReactions(0) + ), + aTimelineItemEvent( + isMine = true, + content = aTimelineItemFileContent("A pinned file?"), + groupPosition = TimelineItemGroupPosition.Middle, + timelineItemReactions = aTimelineItemReactions(0) + ), + aTimelineItemEvent( + isMine = true, + content = aTimelineItemPollContent("A pinned poll?"), + groupPosition = TimelineItemGroupPosition.First, + timelineItemReactions = aTimelineItemReactions(0) + ), + ) + ) + ) +} + +fun aFailedPinnedMessagesListState() = PinnedMessagesListState.Failed + +fun aLoadingPinnedMessagesListState() = PinnedMessagesListState.Loading + +fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty + +fun aLoadedPinnedMessagesListState( + timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), + timelineItems: List = emptyList(), + actionListState: ActionListState = anActionListState(), + aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT, + eventSink: (PinnedMessagesListEvents) -> Unit = {} +) = PinnedMessagesListState.Filled( + timelineRoomInfo = timelineRoomInfo, + timelineItems = timelineItems.toImmutableList(), + actionListState = actionListState, + userEventPermissions = aUserEventPermissions, + eventSink = eventSink, +) 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..dad590e84d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListTimelineActionPostProcessor.kt @@ -0,0 +1,32 @@ +/* + * 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 + +class PinnedMessagesListTimelineActionPostProcessor : TimelineItemActionPostProcessor { + override fun process(actions: List): List { + return buildList { + add(TimelineItemAction.ViewInTimeline) + actions.firstOrNull { it is TimelineItemAction.Unpin }?.let(::add) + actions.firstOrNull { it is TimelineItemAction.Forward }?.let(::add) + actions.firstOrNull { it is TimelineItemAction.ViewSource }?.let(::add) + actions.firstOrNull { it is TimelineItemAction.Redact }?.let(::add) + } + } +} 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 new file mode 100644 index 0000000000..754419ea57 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -0,0 +1,272 @@ +/* + * 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 androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.components.event.TimelineItemEventContentView +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +import io.element.android.features.poll.api.pollcontent.PollTitleView +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.icons.CompoundDrawables +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.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun PinnedMessagesListView( + state: PinnedMessagesListState, + onBackClick: () -> Unit, + onEventClick: (event: TimelineItem.Event) -> Unit, + onUserDataClick: (UserId) -> Unit, + onLinkClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + PinnedMessagesListTopBar(state, onBackClick) + }, + content = { padding -> + PinnedMessagesListContent( + state = state, + onEventClick = onEventClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onErrorDismiss = onBackClick, + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding), + ) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PinnedMessagesListTopBar( + state: PinnedMessagesListState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + title = { + Text( + text = state.title(), + style = ElementTheme.typography.fontBodyLgMedium + ) + }, + navigationIcon = { BackButton(onClick = onBackClick) }, + modifier = modifier, + ) +} + +@Composable +private fun PinnedMessagesListContent( + state: PinnedMessagesListState, + onEventClick: (event: TimelineItem.Event) -> Unit, + onUserDataClick: (UserId) -> Unit, + onLinkClick: (String) -> Unit, + onErrorDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier.fillMaxSize()) { + when (state) { + PinnedMessagesListState.Failed -> { + ErrorDialog( + title = stringResource(id = CommonStrings.error_unknown), + content = stringResource(id = CommonStrings.error_failed_loading_messages), + onDismiss = onErrorDismiss + ) + } + PinnedMessagesListState.Empty -> PinnedMessagesListEmpty() + is PinnedMessagesListState.Filled -> PinnedMessagesListLoaded( + state = state, + onEventClick = onEventClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + ) + PinnedMessagesListState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } + } +} + +@Composable +private fun PinnedMessagesListEmpty( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.padding( + horizontal = 32.dp, + vertical = 48.dp, + ), + contentAlignment = Alignment.Center, + ) { + val pinActionText = stringResource(id = CommonStrings.action_pin) + IconTitleSubtitleMolecule( + title = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_headline), + subTitle = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_description, pinActionText), + iconResourceId = CompoundDrawables.ic_compound_pin, + ) + } +} + +@Composable +private fun PinnedMessagesListLoaded( + state: PinnedMessagesListState.Filled, + onEventClick: (event: TimelineItem.Event) -> Unit, + onUserDataClick: (UserId) -> Unit, + 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(), + reverseLayout = true, + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items( + items = state.timelineItems, + contentType = { timelineItem -> timelineItem.contentType() }, + key = { timelineItem -> timelineItem.identifier() }, + ) { timelineItem -> + TimelineItemRow( + timelineItem = timelineItem, + timelineRoomInfo = state.timelineRoomInfo, + renderReadReceipts = false, + isLastOutgoingMessage = false, + focusedEventId = null, + onClick = onEventClick, + onLongClick = ::onMessageLongClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + inReplyToClick = {}, + onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, + onMoreReactionsClick = {}, + onReadReceiptClick = {}, + eventSink = {}, + onSwipeToReply = {}, + onJoinCallClick = {}, + onShieldClick = {}, + eventContentView = { event, contentModifier, onContentLayoutChange -> + TimelineItemEventContentViewWrapper( + event = event, + onLinkClick = onLinkClick, + modifier = contentModifier, + onContentLayoutChange = onContentLayoutChange + ) + }, + ) + } + } +} + +@Composable +private fun TimelineItemEventContentViewWrapper( + event: TimelineItem.Event, + onLinkClick: (String) -> Unit, + modifier: Modifier = Modifier, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, +) { + if (event.content is TimelineItemPollContent) { + PollTitleView( + title = event.content.question, + isPollEnded = event.content.isEnded, + modifier = modifier + ) + } else { + TimelineItemEventContentView( + content = event.content, + onLinkClick = onLinkClick, + eventSink = { }, + modifier = modifier, + onContentLayoutChange = onContentLayoutChange + ) + } +} + +@PreviewsDayNight +@Composable +internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListStateProvider::class) state: PinnedMessagesListState) = + ElementPreview { + PinnedMessagesListView( + state = state, + onBackClick = {}, + onEventClick = { }, + onUserDataClick = {}, + onLinkClick = {}, + ) + } 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 6815da82f3..ae1f542258 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 @@ -51,6 +51,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemE import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine @@ -88,7 +89,7 @@ class TimelinePresenter @AssistedInject constructor( val lastReadReceiptId = rememberSaveable { mutableStateOf(null) } - val timelineItems by timelineItemsFactory.collectItemsAsState() + val timelineItems by timelineItemsFactory.timelineItems.collectAsState(initial = persistentListOf()) val roomInfo by room.roomInfoFlow.collectAsState(initial = null) val syncUpdateFlow = room.syncUpdateFlow.collectAsState() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 8a6fccf2eb..15caee8091 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -129,7 +129,16 @@ fun TimelineItemEventRow( onReadReceiptClick: (event: TimelineItem.Event) -> Unit, onSwipeToReply: () -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange -> + TimelineItemEventContentView( + content = event.content, + onLinkClick = onLinkClick, + eventSink = eventSink, + modifier = contentModifier, + onContentLayoutChange = onContentLayoutChange + ) + }, ) { val coroutineScope = rememberCoroutineScope() val interactionSource = remember { MutableInteractionSource() } @@ -188,8 +197,7 @@ fun TimelineItemEventRow( onReactionClick = { emoji -> onReactionClick(emoji, event) }, onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) }, onMoreReactionsClick = { onMoreReactionsClick(event) }, - onLinkClick = onLinkClick, - eventSink = eventSink, + eventContentView = eventContentView, ) } } @@ -207,8 +215,7 @@ fun TimelineItemEventRow( onReactionClick = { emoji -> onReactionClick(emoji, event) }, onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) }, onMoreReactionsClick = { onMoreReactionsClick(event) }, - onLinkClick = onLinkClick, - eventSink = eventSink, + eventContentView = eventContentView, ) } // Read receipts / Send state @@ -263,9 +270,8 @@ private fun TimelineItemEventRowContent( onReactionClick: (emoji: String) -> Unit, onReactionLongClick: (emoji: String) -> Unit, onMoreReactionsClick: (event: TimelineItem.Event) -> Unit, - onLinkClick: (String) -> Unit, - eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier, + eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit, ) { fun ConstrainScope.linkStartOrEnd(event: TimelineItem.Event) = if (event.isMine) { end.linkTo(parent.end) @@ -328,8 +334,7 @@ private fun TimelineItemEventRowContent( onShieldClick = onShieldClick, onMessageLongClick = onLongClick, inReplyToClick = inReplyToClick, - onLinkClick = onLinkClick, - eventSink = eventSink, + eventContentView = eventContentView, ) } @@ -389,12 +394,11 @@ private fun MessageEventBubbleContent( onShieldClick: (MessageShield) -> Unit, onMessageLongClick: () -> Unit, inReplyToClick: () -> Unit, - onLinkClick: (String) -> Unit, - eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, @SuppressLint("ModifierParameter") // need to rename this modifier to prevent linter false positives @Suppress("ModifierNaming") bubbleModifier: Modifier = Modifier, + eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit, ) { // Long clicks are not not automatically propagated from a `clickable` // to its `combinedClickable` parent so we do it manually @@ -521,15 +525,10 @@ private fun MessageEventBubbleContent( onShieldClick = onShieldClick, canShrinkContent = canShrinkContent, modifier = timestampLayoutModifier, - ) { onContentLayoutChange -> - TimelineItemEventContentView( - content = event.content, - onLinkClick = onLinkClick, - eventSink = eventSink, - onContentLayoutChange = onContentLayoutChange, - modifier = contentModifier - ) - } + content = { onContentLayoutChange -> + eventContentView(contentModifier, onContentLayoutChange) + } + ) } val inReplyTo = @Composable { inReplyTo: InReplyToDetails -> diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index 874d69013a..c6baa6d69a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -28,7 +28,9 @@ 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.aGroupedEvents import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo +import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -56,7 +58,17 @@ fun TimelineItemGroupedEventsRow( onMoreReactionsClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = + { event, contentModifier, onContentLayoutChange -> + TimelineItemEventContentView( + content = event.content, + onLinkClick = onLinkClick, + eventSink = eventSink, + modifier = contentModifier, + onContentLayoutChange = onContentLayoutChange + ) + }, ) { val isExpanded = rememberSaveable(key = timelineItem.identifier().value) { mutableStateOf(false) } @@ -84,6 +96,7 @@ fun TimelineItemGroupedEventsRow( onReadReceiptClick = onReadReceiptClick, eventSink = eventSink, modifier = modifier, + eventContentView = eventContentView, ) } @@ -108,6 +121,16 @@ private fun TimelineItemGroupedEventsRowContent( onReadReceiptClick: (TimelineItem.Event) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier, + eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = + { event, contentModifier, onContentLayoutChange -> + TimelineItemEventContentView( + content = event.content, + onLinkClick = onLinkClick, + eventSink = eventSink, + modifier = contentModifier, + onContentLayoutChange = onContentLayoutChange + ) + }, ) { Column(modifier = modifier.animateContentSize()) { GroupHeaderView( @@ -142,6 +165,7 @@ private fun TimelineItemGroupedEventsRowContent( eventSink = eventSink, onSwipeToReply = {}, onJoinCallClick = {}, + eventContentView = eventContentView, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 9cbcf20bef..84e7b35225 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -29,6 +29,8 @@ 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.components.event.TimelineItemEventContentView +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData 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.TimelineItemLegacyCallInviteContent @@ -59,7 +61,17 @@ internal fun TimelineItemRow( onSwipeToReply: (TimelineItem.Event) -> Unit, onJoinCallClick: () -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = + { event, contentModifier, onContentLayoutChange -> + TimelineItemEventContentView( + content = event.content, + onLinkClick = onLinkClick, + eventSink = eventSink, + modifier = contentModifier, + onContentLayoutChange = onContentLayoutChange + ) + }, ) { val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) { val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) { @@ -122,6 +134,9 @@ internal fun TimelineItemRow( onReadReceiptClick = onReadReceiptClick, onSwipeToReply = { onSwipeToReply(timelineItem) }, eventSink = eventSink, + eventContentView = { contentModifier, onContentLayoutChange -> + eventContentView(timelineItem, contentModifier, onContentLayoutChange) + }, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt index d3e21d0042..ce882099ad 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -16,9 +16,6 @@ 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 @@ -31,9 +28,10 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -46,7 +44,7 @@ class TimelineItemsFactory @Inject constructor( private val timelineItemGrouper: TimelineItemGrouper, private val timelineItemIndexer: TimelineItemIndexer, ) { - private val timelineItems = MutableStateFlow(persistentListOf()) + private val _timelineItems = MutableSharedFlow>(replay = 1) private val lock = Mutex() private val diffCache = MutableListDiffCache() private val diffCacheUpdater = DiffCacheUpdater( @@ -61,10 +59,7 @@ class TimelineItemsFactory @Inject constructor( } } - @Composable - fun collectItemsAsState(): State> { - return timelineItems.collectAsState() - } + val timelineItems: Flow> = _timelineItems.distinctUntilChanged() suspend fun replaceWith( timelineItems: List, @@ -102,7 +97,7 @@ class TimelineItemsFactory @Inject constructor( } val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList() timelineItemIndexer.process(result) - this.timelineItems.emit(result) + this._timelineItems.emit(result) } private suspend fun buildAndCacheItem( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index f20dcacc7c..49b807c568 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -22,8 +22,8 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.draft.FakeComposerDraftService import io.element.android.features.messages.impl.fixtures.aMessageEvent @@ -100,7 +100,6 @@ import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory -import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.model.MessageComposerMode @@ -996,7 +995,6 @@ class MessagesPresenterTest { ): MessagesPresenter { val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom) val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) - val appPreferencesStore = InMemoryAppPreferencesStore() val sessionPreferencesStore = InMemorySessionPreferencesStore() val mentionSpanProvider = MentionSpanProvider(FakePermalinkParser()) val messageComposerPresenter = MessageComposerPresenter( @@ -1053,11 +1051,6 @@ class MessagesPresenterTest { } } val featureFlagService = FakeFeatureFlagService() - val actionListPresenter = ActionListPresenter( - appPreferencesStore = appPreferencesStore, - featureFlagsService = featureFlagService, - room = matrixRoom, - ) val typingNotificationPresenter = TypingNotificationPresenter( room = matrixRoom, sessionPreferencesStore = sessionPreferencesStore, @@ -1073,7 +1066,7 @@ class MessagesPresenterTest { voiceMessageComposerPresenter = voiceMessageComposerPresenter, timelinePresenterFactory = timelinePresenterFactory, typingNotificationPresenter = typingNotificationPresenter, - actionListPresenter = actionListPresenter, + actionListPresenterFactory = FakeActionListPresenter.Factory, customReactionPresenter = customReactionPresenter, reactionSummaryPresenter = reactionSummaryPresenter, readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt index 5b34784c95..33a1133c67 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt @@ -22,6 +22,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.messages.impl.aUserEventPermissions 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.fixtures.aMessageEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent @@ -32,8 +33,6 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList -import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_MESSAGE @@ -974,14 +973,10 @@ private fun createActionListPresenter( room: MatrixRoom = FakeMatrixRoom(), ): ActionListPresenter { val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled) - val featureFlagsService = FakeFeatureFlagService( - initialState = mapOf( - FeatureFlags.PinnedEvents.key to isPinFeatureEnabled, - ) - ) - return ActionListPresenter( + return DefaultActionListPresenter( + postProcessor = TimelineItemActionPostProcessor.Default, appPreferencesStore = preferencesStore, - featureFlagsService = featureFlagsService, + isPinnedMessagesFeatureEnabled = { isPinFeatureEnabled }, room = room ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/FakeActionListPresenter.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/FakeActionListPresenter.kt new file mode 100644 index 0000000000..0fab60f520 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/FakeActionListPresenter.kt @@ -0,0 +1,33 @@ +/* + * 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 + +import androidx.compose.runtime.Composable +import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor + +class FakeActionListPresenter : ActionListPresenter { + object Factory : ActionListPresenter.Factory { + override fun create(postProcessor: TimelineItemActionPostProcessor): ActionListPresenter { + return FakeActionListPresenter() + } + } + + @Composable + override fun present(): ActionListState { + return anActionListState() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt index a94467e820..8b389633c6 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt @@ -17,9 +17,12 @@ package io.element.android.features.messages.impl.pinned.banner import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBannerFormatter +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -67,7 +70,7 @@ class PinnedMessagesBannerPresenterTest { } val presenter = createPinnedMessagesBannerPresenter(room = room) presenter.test { - skipItems(1) + skipItems(2) val loadingState = awaitItem() assertThat(loadingState).isEqualTo(PinnedMessagesBannerState.Loading(1)) assertThat(loadingState.pinnedMessagesCount()).isEqualTo(1) @@ -98,7 +101,7 @@ class PinnedMessagesBannerPresenterTest { } val presenter = createPinnedMessagesBannerPresenter(room = room) presenter.test { - skipItems(2) + skipItems(3) val loadedState = awaitItem() as PinnedMessagesBannerState.Loaded assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(0) assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(1) @@ -137,7 +140,7 @@ class PinnedMessagesBannerPresenterTest { } val presenter = createPinnedMessagesBannerPresenter(room = room) presenter.test { - skipItems(2) + skipItems(3) awaitItem().also { loadedState -> loadedState as PinnedMessagesBannerState.Loaded assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(1) @@ -172,7 +175,7 @@ class PinnedMessagesBannerPresenterTest { } val presenter = createPinnedMessagesBannerPresenter(room = room) presenter.test { - skipItems(1) + skipItems(2) awaitItem().also { loadingState -> assertThat(loadingState).isEqualTo(PinnedMessagesBannerState.Loading(1)) assertThat(loadingState.pinnedMessagesCount()).isEqualTo(1) @@ -195,11 +198,19 @@ class PinnedMessagesBannerPresenterTest { networkMonitor: NetworkMonitor = FakeNetworkMonitor(), isFeatureEnabled: Boolean = true, ): PinnedMessagesBannerPresenter { + val timelineProvider = PinnedEventsTimelineProvider( + room = room, + networkMonitor = networkMonitor, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.PinnedEvents.key to isFeatureEnabled) + ) + ) + timelineProvider.launchIn(backgroundScope) + return PinnedMessagesBannerPresenter( room = room, itemFactory = itemFactory, - isFeatureEnabled = { isFeatureEnabled }, - networkMonitor = networkMonitor, + pinnedEventsTimelineProvider = timelineProvider, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/FakePinnedMessagesListNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/FakePinnedMessagesListNavigator.kt new file mode 100644 index 0000000000..c37287055b --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/FakePinnedMessagesListNavigator.kt @@ -0,0 +1,37 @@ +/* + * 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 + +class FakePinnedMessagesListNavigator : PinnedMessagesListNavigator { + var onViewInTimelineClickLambda: ((EventId) -> Unit)? = null + override fun onViewInTimelineClick(eventId: EventId) { + onViewInTimelineClickLambda?.invoke(eventId) + } + + var onShowEventDebugInfoClickLambda: ((EventId?, TimelineItemDebugInfo) -> Unit)? = null + override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + onShowEventDebugInfoClickLambda?.invoke(eventId, debugInfo) + } + + var onForwardEventClickLambda: ((EventId) -> Unit)? = null + override fun onForwardEventClick(eventId: EventId) { + onForwardEventClickLambda?.invoke(eventId) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt new file mode 100644 index 0000000000..6f927123d8 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt @@ -0,0 +1,336 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory +import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +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.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_THROWABLE +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.room.aRoomInfo +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.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.test +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PinnedMessagesListPresenterTest { + @Test + fun `present - initial state feature disabled`() = runTest { + val room = FakeMatrixRoom( + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ) + val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = false) + presenter.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(PinnedMessagesListState.Loading) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - initial state feature enabled`() = runTest { + val room = FakeMatrixRoom( + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + } + val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true) + presenter.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(PinnedMessagesListState.Loading) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - timeline failure state`() = runTest { + val room = FakeMatrixRoom( + pinnedEventsTimelineResult = { Result.failure(RuntimeException()) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + } + val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true) + presenter.test { + skipItems(3) + val failureState = awaitItem() + assertThat(failureState).isEqualTo(PinnedMessagesListState.Failed) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - empty state`() = runTest { + val room = FakeMatrixRoom( + pinnedEventsTimelineResult = { Result.success(FakeTimeline()) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf())) + } + val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true) + presenter.test { + skipItems(3) + val emptyState = awaitItem() + assertThat(emptyState).isEqualTo(PinnedMessagesListState.Empty) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - filled state`() = runTest { + val pinnedEventsTimeline = createPinnedMessagesTimeline() + val room = FakeMatrixRoom( + pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + } + val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true) + presenter.test { + skipItems(3) + val filledState = awaitItem() as PinnedMessagesListState.Filled + assertThat(filledState.timelineItems).hasSize(1) + assertThat(filledState.loadedPinnedMessagesCount).isEqualTo(1) + assertThat(filledState.userEventPermissions.canRedactOwn).isTrue() + assertThat(filledState.userEventPermissions.canRedactOther).isTrue() + assertThat(filledState.userEventPermissions.canPinUnpin).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - redact event`() = runTest { + val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) } + val pinnedEventsTimeline = createPinnedMessagesTimeline().apply { + this.redactEventLambda = redactEventLambda + } + val room = FakeMatrixRoom( + pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + } + val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true) + presenter.test { + skipItems(3) + val filledState = awaitItem() as PinnedMessagesListState.Filled + val eventItem = filledState.timelineItems.first() as TimelineItem.Event + filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Redact, eventItem)) + cancelAndIgnoreRemainingEvents() + assert(redactEventLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID), value(null), value(null)) + } + } + + @Test + fun `present - unpin event`() = runTest { + val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) } + val failureUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.failure(A_THROWABLE) } + val pinnedEventsTimeline = createPinnedMessagesTimeline() + + val room = FakeMatrixRoom( + pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + } + val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true) + presenter.test { + skipItems(3) + val filledState = awaitItem() as PinnedMessagesListState.Filled + val eventItem = filledState.timelineItems.first() as TimelineItem.Event + + pinnedEventsTimeline.unpinEventLambda = successUnpinEventLambda + filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem)) + + pinnedEventsTimeline.unpinEventLambda = failureUnpinEventLambda + filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem)) + + cancelAndIgnoreRemainingEvents() + + assert(successUnpinEventLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID)) + + assert(failureUnpinEventLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID)) + } + } + + @Test + fun `present - navigate to event`() = runTest { + val onViewInTimelineClickLambda = lambdaRecorder { _: EventId -> } + val navigator = FakePinnedMessagesListNavigator().apply { + this.onViewInTimelineClickLambda = onViewInTimelineClickLambda + } + val pinnedEventsTimeline = createPinnedMessagesTimeline() + val room = FakeMatrixRoom( + pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + } + val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator, isFeatureEnabled = true) + presenter.test { + skipItems(3) + val filledState = awaitItem() as PinnedMessagesListState.Filled + val eventItem = filledState.timelineItems.first() as TimelineItem.Event + filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewInTimeline, eventItem)) + cancelAndIgnoreRemainingEvents() + assert(onViewInTimelineClickLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID)) + } + } + + @Test + fun `present - show view source action`() = runTest { + val onShowEventDebugInfoClickLambda = lambdaRecorder { _: EventId?, _: TimelineItemDebugInfo -> } + val navigator = FakePinnedMessagesListNavigator().apply { + this.onShowEventDebugInfoClickLambda = onShowEventDebugInfoClickLambda + } + val pinnedEventsTimeline = createPinnedMessagesTimeline() + val room = FakeMatrixRoom( + pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + } + val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator, isFeatureEnabled = true) + presenter.test { + skipItems(3) + val filledState = awaitItem() as PinnedMessagesListState.Filled + val eventItem = filledState.timelineItems.first() as TimelineItem.Event + filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewSource, eventItem)) + cancelAndIgnoreRemainingEvents() + assert(onShowEventDebugInfoClickLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID), value(eventItem.debugInfo)) + } + } + + @Test + fun `present - forward event`() = runTest { + val onForwardEventClickLambda = lambdaRecorder { _: EventId -> } + val navigator = FakePinnedMessagesListNavigator().apply { + this.onForwardEventClickLambda = onForwardEventClickLambda + } + val pinnedEventsTimeline = createPinnedMessagesTimeline() + val room = FakeMatrixRoom( + pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + } + val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator, isFeatureEnabled = true) + presenter.test { + skipItems(3) + val filledState = awaitItem() as PinnedMessagesListState.Filled + val eventItem = filledState.timelineItems.first() as TimelineItem.Event + filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Forward, eventItem)) + cancelAndIgnoreRemainingEvents() + assert(onForwardEventClickLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID)) + } + } + + private fun createPinnedMessagesTimeline(): FakeTimeline { + val messageContent = aMessageContent("A message") + return FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + eventId = AN_EVENT_ID, + content = messageContent, + ), + ) + ) + ) + ) + } + + private fun TestScope.createPinnedMessagesListPresenter( + navigator: PinnedMessagesListNavigator = FakePinnedMessagesListNavigator(), + room: MatrixRoom = FakeMatrixRoom(), + networkMonitor: NetworkMonitor = FakeNetworkMonitor(), + isFeatureEnabled: Boolean = true, + ): PinnedMessagesListPresenter { + val timelineProvider = PinnedEventsTimelineProvider( + room = room, + networkMonitor = networkMonitor, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.PinnedEvents.key to isFeatureEnabled) + ) + ) + timelineProvider.launchIn(backgroundScope) + return PinnedMessagesListPresenter( + navigator = navigator, + room = room, + timelineItemsFactory = aTimelineItemsFactory(), + timelineProvider = timelineProvider, + snackbarDispatcher = SnackbarDispatcher(), + actionListPresenterFactory = FakeActionListPresenter.Factory, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt new file mode 100644 index 0000000000..d446e8514b --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt @@ -0,0 +1,121 @@ +/* + * 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 androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.messages.impl.actionlist.ActionListEvents +import io.element.android.features.messages.impl.actionlist.anActionListState +import io.element.android.features.messages.impl.timeline.aTimelineItemList +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PinnedMessagesListViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back calls the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aLoadedPinnedMessagesListState( + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setPinnedMessagesListView( + state = state, + onBackClick = callback + ) + rule.pressBack() + } + } + + @Test + fun `click on an event calls the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val content = aTimelineItemFileContent() + val state = aLoadedPinnedMessagesListState( + timelineItems = aTimelineItemList(content), + eventSink = eventsRecorder + ) + + val event = state.timelineItems.first() as TimelineItem.Event + ensureCalledOnceWithParam(event) { callback -> + rule.setPinnedMessagesListView( + state = state, + onEventClick = callback + ) + rule.onAllNodesWithText(content.body).onFirst().performClick() + } + } + + @Test + fun `long click on an event emits the expected event`() { + val eventsRecorder = EventsRecorder(expectEvents = true) + val content = aTimelineItemFileContent() + val state = aLoadedPinnedMessagesListState( + timelineItems = aTimelineItemList(content), + actionListState = anActionListState(eventSink = eventsRecorder) + ) + + rule.setPinnedMessagesListView( + state = state, + ) + rule.onAllNodesWithText(content.body).onFirst() + .performTouchInput { + longClick() + } + val event = state.timelineItems.first() as TimelineItem.Event + eventsRecorder.assertSingle(ActionListEvents.ComputeForMessage(event, state.userEventPermissions)) + } +} + +private fun AndroidComposeTestRule.setPinnedMessagesListView( + state: PinnedMessagesListState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), + onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), +) { + setContent { + PinnedMessagesListView( + state = state, + onBackClick = onBackClick, + onEventClick = onEventClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + ) + } +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt index e5c445bd4e..7d8394f30d 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt @@ -19,10 +19,8 @@ package io.element.android.features.poll.api.pollcontent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.runtime.Composable @@ -36,12 +34,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role 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.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button -import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.poll.PollAnswer @@ -117,7 +113,7 @@ fun PollContentView( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - PollTitle(title = question, isPollEnded = isPollEnded) + PollTitleView(title = question, isPollEnded = isPollEnded) PollAnswers(answerItems = answerItems, onSelectAnswer = ::onSelectAnswer) @@ -139,34 +135,6 @@ fun PollContentView( } } -@Composable -private fun PollTitle( - title: String, - isPollEnded: Boolean, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (isPollEnded) { - Icon( - imageVector = CompoundIcons.PollsEnd(), - contentDescription = stringResource(id = CommonStrings.a11y_poll_end), - modifier = Modifier.size(22.dp) - ) - } else { - Icon( - imageVector = CompoundIcons.Polls(), - contentDescription = stringResource(id = CommonStrings.a11y_poll), - modifier = Modifier.size(22.dp) - ) - } - Text( - text = title, - style = ElementTheme.typography.fontBodyLgMedium - ) - } -} - @Composable private fun PollAnswers( answerItems: ImmutableList, diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollTitleView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollTitleView.kt new file mode 100644 index 0000000000..5a772dec26 --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollTitleView.kt @@ -0,0 +1,71 @@ +/* + * 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.poll.api.pollcontent + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +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.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun PollTitleView( + title: String, + isPollEnded: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isPollEnded) { + Icon( + imageVector = CompoundIcons.PollsEnd(), + contentDescription = stringResource(id = CommonStrings.a11y_poll_end), + modifier = Modifier.size(22.dp) + ) + } else { + Icon( + imageVector = CompoundIcons.Polls(), + contentDescription = stringResource(id = CommonStrings.a11y_poll), + modifier = Modifier.size(22.dp) + ) + } + Text( + text = title, + style = ElementTheme.typography.fontBodyLgMedium + ) + } +} + +@PreviewsDayNight +@Composable +internal fun PollTitleViewPreview() = ElementPreview { + PollTitleView( + title = "What is your favorite color?", + isPollEnded = false + ) +} 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..6ec7a5d722 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,8 +32,8 @@ 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 import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -44,11 +44,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/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt index 652aafef4f..302f903a2d 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt @@ -38,7 +38,6 @@ 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 @@ -180,7 +179,7 @@ class PollHistoryPresenterTest { sendPollResponseAction = sendPollResponseAction, endPollAction = endPollAction, pollHistoryItemFactory = pollHistoryItemFactory, - timelineProvider = LiveTimelineProvider(room), + room = room, ) } } diff --git a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt index bde560189b..7b027849c4 100644 --- a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt +++ b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt @@ -24,6 +24,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkData import kotlinx.parcelize.Parcelize interface RoomDetailsEntryPoint : FeatureEntryPoint { @@ -43,6 +44,8 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onOpenGlobalNotificationSettings() fun onOpenRoom(roomId: RoomId) + fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) + fun onForwardedToSingleRoom(roomId: RoomId) } interface NodeBuilder { diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 07302b4876..def1e0d2d5 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { implementation(projects.features.userprofile.shared) implementation(projects.services.analytics.api) implementation(projects.features.poll.api) + implementation(projects.features.messages.api) testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index 4a34d64ad0..0ab0608566 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -31,6 +31,7 @@ import im.vector.app.features.analytics.plan.Interaction import io.element.android.anvilannotations.ContributesNode import io.element.android.features.call.api.CallType import io.element.android.features.call.api.ElementCallEntryPoint +import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.poll.api.history.PollHistoryEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode @@ -49,6 +50,7 @@ import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.RoomId 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.mediaviewer.api.local.MediaInfo import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode @@ -64,6 +66,7 @@ class RoomDetailsFlowNode @AssistedInject constructor( private val elementCallEntryPoint: ElementCallEntryPoint, private val room: MatrixRoom, private val analyticsService: AnalyticsService, + private val messagesEntryPoint: MessagesEntryPoint, ) : BaseFlowNode( backstack = BackStack( initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), @@ -105,6 +108,9 @@ class RoomDetailsFlowNode @AssistedInject constructor( @Parcelize data object AdminSettings : NavTarget + + @Parcelize + data object PinnedMessagesList : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -139,6 +145,10 @@ class RoomDetailsFlowNode @AssistedInject constructor( backstack.push(NavTarget.AdminSettings) } + override fun openPinnedMessagesList() { + backstack.push(NavTarget.PinnedMessagesList) + } + override fun onJoinCall() { val inputs = CallType.RoomCall( sessionId = room.sessionId, @@ -224,6 +234,28 @@ class RoomDetailsFlowNode @AssistedInject constructor( is NavTarget.AdminSettings -> { createNode(buildContext) } + NavTarget.PinnedMessagesList -> { + val params = MessagesEntryPoint.Params( + MessagesEntryPoint.InitialTarget.PinnedMessages + ) + val callback = object : MessagesEntryPoint.Callback { + override fun onRoomDetailsClick() = Unit + + override fun onUserDataClick(userId: UserId) = Unit + + override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) { + plugins().forEach { it.onPermalinkClick(data, pushToBackstack) } + } + + override fun onForwardedToSingleRoom(roomId: RoomId) { + plugins().forEach { it.onForwardedToSingleRoom(roomId) } + } + } + return messagesEntryPoint.nodeBuilder(this, buildContext) + .params(params) + .callback(callback) + .build() + } } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt index d55966315e..d1e4797a62 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt @@ -55,6 +55,7 @@ class RoomDetailsNode @AssistedInject constructor( fun openAvatarPreview(name: String, url: String) fun openPollHistory() fun openAdminSettings() + fun openPinnedMessagesList() fun onJoinCall() } @@ -115,6 +116,10 @@ class RoomDetailsNode @AssistedInject constructor( callbacks.forEach { it.openAdminSettings() } } + private fun openPinnedMessages() { + callbacks.forEach { it.openPinnedMessagesList() } + } + @Composable override fun View(modifier: Modifier) { val context = LocalContext.current @@ -144,6 +149,7 @@ class RoomDetailsNode @AssistedInject constructor( openPollHistory = ::openPollHistory, openAdminSettings = this::openAdminSettings, onJoinCallClick = ::onJoinCall, + onPinnedMessagesClick = ::openPinnedMessages ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index 163e466e1f..fe15beb983 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.bool.orFalse @@ -67,6 +68,7 @@ class RoomDetailsPresenter @Inject constructor( private val leaveRoomPresenter: LeaveRoomPresenter, private val dispatchers: CoroutineDispatchers, private val analyticsService: AnalyticsService, + private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled, ) : Presenter { @Composable override fun present(): RoomDetailsState { @@ -83,6 +85,9 @@ class RoomDetailsPresenter @Inject constructor( val isFavorite by remember { derivedStateOf { roomInfo?.isFavorite.orFalse() } } val isPublic by remember { derivedStateOf { roomInfo?.isPublic.orFalse() } } + val canShowPinnedMessages = isPinnedMessagesFeatureEnabled() + val pinnedMessagesCount by remember { derivedStateOf { roomInfo?.pinnedEventIds?.size } } + LaunchedEffect(Unit) { canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings) if (canShowNotificationSettings.value) { @@ -156,6 +161,8 @@ class RoomDetailsPresenter @Inject constructor( displayRolesAndPermissionsSettings = !room.isDm && isUserAdmin, isPublic = isPublic, heroes = roomInfo?.heroes.orEmpty().toPersistentList(), + canShowPinnedMessages = canShowPinnedMessages, + pinnedMessagesCount = pinnedMessagesCount, eventSink = ::handleEvents, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt index f6a63d70f5..afce4f879a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt @@ -46,6 +46,8 @@ data class RoomDetailsState( val displayRolesAndPermissionsSettings: Boolean, val isPublic: Boolean, val heroes: ImmutableList, + val canShowPinnedMessages: Boolean, + val pinnedMessagesCount: Int?, val eventSink: (RoomDetailsEvent) -> Unit ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt index 45bdf3163d..5c881787e4 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -53,6 +53,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider aRoomDetailsState(canCall = false, canInvite = false), aRoomDetailsState(isPublic = false), aRoomDetailsState(heroes = aMatrixUserList()), + aRoomDetailsState(pinnedMessagesCount = 3), // Add other state here ) } @@ -105,6 +106,8 @@ fun aRoomDetailsState( displayAdminSettings: Boolean = false, isPublic: Boolean = true, heroes: List = emptyList(), + canShowPinnedMessages: Boolean = true, + pinnedMessagesCount: Int? = null, eventSink: (RoomDetailsEvent) -> Unit = {}, ) = RoomDetailsState( roomId = roomId, @@ -126,6 +129,8 @@ fun aRoomDetailsState( displayRolesAndPermissionsSettings = displayAdminSettings, isPublic = isPublic, heroes = heroes.toPersistentList(), + canShowPinnedMessages = canShowPinnedMessages, + pinnedMessagesCount = pinnedMessagesCount, eventSink = eventSink ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index fc2a051dc6..a0519aa3fa 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -66,6 +67,7 @@ import io.element.android.libraries.designsystem.components.preferences.Preferen import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.DropdownMenu import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem import io.element.android.libraries.designsystem.theme.components.Icon @@ -103,6 +105,7 @@ fun RoomDetailsView( openPollHistory: () -> Unit, openAdminSettings: () -> Unit, onJoinCallClick: () -> Unit, + onPinnedMessagesClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -183,6 +186,13 @@ fun RoomDetailsView( } ) + if (state.canShowPinnedMessages) { + PinnedMessagesItem( + pinnedMessagesCount = state.pinnedMessagesCount, + onPinnedMessagesClick = onPinnedMessagesClick + ) + } + if (state.displayRolesAndPermissionsSettings) { ListItem( headlineContent = { Text(stringResource(R.string.screen_room_details_roles_and_permissions)) }, @@ -503,6 +513,26 @@ private fun MembersItem( ) } +@Composable +private fun PinnedMessagesItem( + pinnedMessagesCount: Int?, + onPinnedMessagesClick: () -> Unit, +) { + ListItem( + headlineContent = { Text(stringResource(CommonStrings.screen_room_details_pinned_events_row_title)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())), + trailingContent = + if (pinnedMessagesCount == null) { + ListItemContent.Custom { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp)) + } + } else { + ListItemContent.Text(pinnedMessagesCount.toString()) + }, + onClick = onPinnedMessagesClick, + ) +} + @Composable private fun PollsSection( openPollHistory: () -> Unit, @@ -573,5 +603,6 @@ private fun ContentToPreview(state: RoomDetailsState) { openPollHistory = {}, openAdminSettings = {}, onJoinCallClick = {}, + onPinnedMessagesClick = {}, ) } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt index ff89aba9be..d2f01498c3 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt @@ -42,6 +42,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -81,6 +82,7 @@ class RoomDetailsPresenterTest { dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), analyticsService: AnalyticsService = FakeAnalyticsService(), + isPinnedMessagesFeatureEnabled: Boolean = true, ): RoomDetailsPresenter { val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory { @@ -99,6 +101,7 @@ class RoomDetailsPresenterTest { roomMembersDetailsPresenterFactory = roomMemberDetailsPresenterFactory, leaveRoomPresenter = leaveRoomPresenter, dispatchers = dispatchers, + isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled }, analyticsService = analyticsService, ) } @@ -127,14 +130,15 @@ class RoomDetailsPresenterTest { assertThat(initialState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!)) assertThat(initialState.memberCount).isEqualTo(room.joinedMemberCount) assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted) - + assertThat(initialState.canShowPinnedMessages).isTrue() + assertThat(initialState.pinnedMessagesCount).isNull() cancelAndIgnoreRemainingEvents() } } @Test fun `present - initial state is updated with roomInfo if it exists`() = runTest { - val roomInfo = aRoomInfo(name = "A room name", topic = "A topic", avatarUrl = "https://matrix.org/avatar.jpg") + val roomInfo = aRoomInfo(name = "A room name", topic = "A topic", avatarUrl = "https://matrix.org/avatar.jpg", pinnedEventIds = listOf(AN_EVENT_ID)) val room = aMatrixRoom( canInviteResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, @@ -149,7 +153,7 @@ class RoomDetailsPresenterTest { assertThat(updatedState.roomName).isEqualTo(roomInfo.name) assertThat(updatedState.roomAvatarUrl).isEqualTo(roomInfo.avatarUrl) assertThat(updatedState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(roomInfo.topic!!)) - + assertThat(updatedState.pinnedMessagesCount).isEqualTo(roomInfo.pinnedEventIds.size) cancelAndIgnoreRemainingEvents() } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt index b3bbb21cf6..6f663f10b2 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt @@ -127,6 +127,21 @@ class RoomDetailsViewTest { } } + @Config(qualifiers = "h1024dp") + @Test + fun `click on pinned messages invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + state = aRoomDetailsState( + eventSink = EventsRecorder(expectEvents = false), + canInvite = true, + ), + onPinnedMessagesClick = callback, + ) + rule.clickOn(CommonStrings.screen_room_details_pinned_events_row_title) + } + } + @Config(qualifiers = "h1024dp") @Test fun `click on add topic emit expected event`() { @@ -263,6 +278,7 @@ private fun AndroidComposeTestRule.setRoomD openPollHistory: () -> Unit = EnsureNeverCalled(), openAdminSettings: () -> Unit = EnsureNeverCalled(), onJoinCallClick: () -> Unit = EnsureNeverCalled(), + onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(), ) { setContent { RoomDetailsView( @@ -277,6 +293,7 @@ private fun AndroidComposeTestRule.setRoomD openPollHistory = openPollHistory, openAdminSettings = openAdminSettings, onJoinCallClick = onJoinCallClick, + onPinnedMessagesClick = onPinnedMessagesClick, ) } } 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..8b6bd801e7 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/DerivedStateFlow.kt @@ -0,0 +1,54 @@ +/* + * 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/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index 1bfa8e8336..b4bc54d825 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -47,6 +47,12 @@ interface Timeline : AutoCloseable { FORWARDS } + enum class Mode { + LIVE, + FOCUSED_ON_EVENT, + PINNED_EVENTS + } + val membershipChangeEventReceived: Flow suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result suspend fun paginate(direction: PaginationDirection): Result 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() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 4d7888fbf9..abd1442c11 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -154,7 +154,7 @@ class RustMatrixRoom( private val _roomNotificationSettingsStateFlow = MutableStateFlow(MatrixRoomNotificationSettingsState.Unknown) override val roomNotificationSettingsStateFlow: StateFlow = _roomNotificationSettingsStateFlow - override val liveTimeline = createTimeline(innerTimeline, isLive = true) { + override val liveTimeline = createTimeline(innerTimeline, mode = Timeline.Mode.LIVE) { _syncUpdateFlow.value = systemClock.epochMillis() } @@ -182,7 +182,7 @@ class RustMatrixRoom( numContextEvents = 50u, internalIdPrefix = "focus_$eventId", ).let { inner -> - createTimeline(inner, isLive = false) + createTimeline(inner, mode = Timeline.Mode.FOCUSED_ON_EVENT) } }.mapFailure { it.toFocusEventException() @@ -199,7 +199,7 @@ class RustMatrixRoom( internalIdPrefix = "pinned_events", maxEventsToLoad = 100u, ).let { inner -> - createTimeline(inner, isLive = false) + createTimeline(inner, mode = Timeline.Mode.PINNED_EVENTS) } }.onFailure { if (it is CancellationException) { @@ -656,13 +656,13 @@ class RustMatrixRoom( private fun createTimeline( timeline: InnerTimeline, - isLive: Boolean, + mode: Timeline.Mode, onNewSyncedEvent: () -> Unit = {}, ): Timeline { val timelineCoroutineScope = roomCoroutineScope.childScope(coroutineDispatchers.main, "TimelineScope-$roomId-$timeline") return RustTimeline( isKeyBackupEnabled = isKeyBackupEnabled, - isLive = isLive, + mode = mode, matrixRoom = this, systemClock = systemClock, coroutineScope = timelineCoroutineScope, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 8d88713dc6..58a66c4db4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -86,7 +86,7 @@ private const val PAGINATION_SIZE = 50 class RustTimeline( private val inner: InnerTimeline, - private val isLive: Boolean, + mode: Timeline.Mode, systemClock: SystemClock, isKeyBackupEnabled: Boolean, private val matrixRoom: MatrixRoom, @@ -132,21 +132,21 @@ class RustTimeline( onNewSyncedEvent = onNewSyncedEvent, ) - private val roomBeginningPostProcessor = RoomBeginningPostProcessor() + private val roomBeginningPostProcessor = RoomBeginningPostProcessor(mode) private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock) - private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(isLive) + private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode) private val backPaginationStatus = MutableStateFlow( - Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true) + Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PINNED_EVENTS) ) private val forwardPaginationStatus = MutableStateFlow( - Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = !isLive) + Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode == Timeline.Mode.FOCUSED_ON_EVENT) ) init { coroutineScope.fetchMembers() - if (isLive) { + if (mode == Timeline.Mode.LIVE) { // When timeline is live, we need to listen to the back pagination status as // sdk can automatically paginate backwards. coroutineScope.registerBackPaginationStatusListener() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessor.kt index d10dde519f..5eed6bb997 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessor.kt @@ -18,21 +18,22 @@ package io.element.android.libraries.matrix.impl.timeline.postprocessor import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem /** * This post processor is responsible for adding virtual items to indicate all the previous last forward item. */ class LastForwardIndicatorsPostProcessor( - private val isTimelineLive: Boolean, + private val mode: Timeline.Mode, ) { private val lastForwardIdentifiers = LinkedHashSet() fun process( items: List, ): List { - // If the timeline is live, we don't have any last forward indicator to display - if (isTimelineLive) { + // We don't need to add the last forward indicator if we are not in the FOCUSED_ON_EVENT mode + if (mode != Timeline.Mode.FOCUSED_ON_EVENT) { return items } else { return buildList { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt index 441982a4b7..fa53de6d2a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.impl.timeline.postprocessor import androidx.annotation.VisibleForTesting import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.matrix.api.timeline.item.event.OtherState import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent @@ -29,13 +30,14 @@ import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTime * This timeline post-processor removes the room creation event and the self-join event from the timeline for DMs * or add the RoomBeginning item for non DM room. */ -class RoomBeginningPostProcessor { +class RoomBeginningPostProcessor(private val mode: Timeline.Mode) { fun process( items: List, isDm: Boolean, hasMoreToLoadBackwards: Boolean ): List { return when { + mode == Timeline.Mode.PINNED_EVENTS -> items hasMoreToLoadBackwards -> items isDm -> processForDM(items) else -> processForRoom(items) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt index d46655834d..a2815c6998 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.impl.timeline.postprocessor import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.matrix.api.timeline.item.event.OtherState import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent @@ -37,7 +38,7 @@ class RoomBeginningPostProcessorTest { MatrixTimelineItem.Event(UniqueId("m.room.create"), anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))), MatrixTimelineItem.Event(UniqueId("m.room.member"), anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))), ) - val processor = RoomBeginningPostProcessor() + val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false) assertThat(processedItems).isEmpty() } @@ -60,7 +61,7 @@ class RoomBeginningPostProcessorTest { ), MatrixTimelineItem.Event(UniqueId("m.room.message"), anEventTimelineItem(content = aMessageContent("hi"))), ) - val processor = RoomBeginningPostProcessor() + val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false) assertThat(processedItems).isEqualTo(expected) } @@ -71,7 +72,7 @@ class RoomBeginningPostProcessorTest { MatrixTimelineItem.Event(UniqueId("m.room.create"), anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))), MatrixTimelineItem.Event(UniqueId("m.room.member"), anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))), ) - val processor = RoomBeginningPostProcessor() + val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = false) assertThat(processedItems).isEqualTo( listOf(processor.createRoomBeginningItem()) + timelineItems @@ -83,7 +84,7 @@ class RoomBeginningPostProcessorTest { val timelineItems = listOf( MatrixTimelineItem.Virtual(UniqueId("EncryptedHistoryBanner"), VirtualTimelineItem.EncryptedHistoryBanner), ) - val processor = RoomBeginningPostProcessor() + val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = false) assertThat(processedItems).isEqualTo(timelineItems) } @@ -94,7 +95,7 @@ class RoomBeginningPostProcessorTest { MatrixTimelineItem.Event(UniqueId("m.room.create"), anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))), MatrixTimelineItem.Event(UniqueId("m.room.member"), anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))), ) - val processor = RoomBeginningPostProcessor() + val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true) assertThat(processedItems).isEqualTo(timelineItems) } @@ -104,7 +105,7 @@ class RoomBeginningPostProcessorTest { val timelineItems = listOf( MatrixTimelineItem.Event(UniqueId("m.room.member"), anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))), ) - val processor = RoomBeginningPostProcessor() + val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true) assertThat(processedItems).isEqualTo(timelineItems) } @@ -118,7 +119,7 @@ class RoomBeginningPostProcessorTest { anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, null, MembershipChange.JOINED)) ), ) - val processor = RoomBeginningPostProcessor() + val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true) assertThat(processedItems).isEqualTo(timelineItems) } diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Day_0_en.png new file mode 100644 index 0000000000..bb1d148b29 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da5fed3d33c60f471b19fcf9761c5feb1e8fff303d84e951ecd53e8cd54613d0 +size 19938 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Day_1_en.png new file mode 100644 index 0000000000..df40973190 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fea6dbb47a656e0d39181a40b32277e17f0fe52c99e8c1519d5f09ff9423e98c +size 8983 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Day_2_en.png new file mode 100644 index 0000000000..e74640592c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54a495cd353317e667d667d3f98c4b844568b0375d83b7cb4091bafd30dec194 +size 31738 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Day_3_en.png new file mode 100644 index 0000000000..517e3104a9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fee1e714cc759547034e8e688e69ee525336e18febbbc118b7e1fdabe159a60 +size 42698 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Night_0_en.png new file mode 100644 index 0000000000..0c8f789e7a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93fe898626703045ac2efc2c5c9c2a404a9fa446333649ab77cf1acd7da4f1e7 +size 18057 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Night_1_en.png new file mode 100644 index 0000000000..3b2f1ee82b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f221c0af78be8d43a481ff0b122ea0587e0e0618c6f6fec78cb91368e940f701 +size 8720 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Night_2_en.png new file mode 100644 index 0000000000..d689a95fc8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97ae5c5a01f94e35d2d05d3b1a6f3be992fcf49f9674bdf913753c5430d3e0bf +size 30749 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Night_3_en.png new file mode 100644 index 0000000000..3db0b8ae49 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.list_PinnedMessagesListView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a16fcbc220880ccd0280e734bcdb6184b5a34a7505708f8f7795b6431cf6f41 +size 41511 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollTitleView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollTitleView_Day_0_en.png new file mode 100644 index 0000000000..a2e90f7926 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollTitleView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73d64e6f6fd0f979c1929f3f1118d26890e55c301031dd3ca0f64647429dd42c +size 9358 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollTitleView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollTitleView_Night_0_en.png new file mode 100644 index 0000000000..dac28412c2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollTitleView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e5b23e028c2df95e19ee96b0715b3da7f32ea15d7b50a6bd6068add0263c0c2a +size 9081 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_0_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_0_en.png index f3becc3f46..d5a8b351c4 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d2e96d71fd2e0e67415ab5d69b2712661f4e453980e202e60717cc73e1e3fa81 -size 45821 +oid sha256:8cef0a991d7b6a66dac0687e278231dcd4c9084e3ea04fc5e28d7d15f8b30795 +size 40901 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_10_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_10_en.png index 9ad15d62b1..0d46da4eb2 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1884020edf5182e4be0ef7408ae09d3ebdc79e4f482c0ed22c47864fb4ca3b6d -size 43682 +oid sha256:1481831145c4a32c046f63748a1ac2eccfe8ff954b1926b22115110e9f4c2e44 +size 45041 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_11_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_11_en.png index 17e9510513..e25d3bb89e 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:50940f08a015ca4ecbd4f88810b183d1632daa34e8f79c38017f20ad5a1ce90a -size 42637 +oid sha256:75d9e78aec05eacb67872f036490d2dbed6174e160bfc1f983123e043c00d056 +size 44003 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_12_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_12_en.png index 9ca537357d..2702d14999 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:259aced4d75ec473abf8f4fb41c3c734e69618a552b2cf0b9ceeb98d59deec25 -size 45717 +oid sha256:0a6a3a952c2b2e5304cd83674fb209d11f07a366375aec36cc4e4f981a541b03 +size 47082 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_13_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_13_en.png new file mode 100644 index 0000000000..231dd5fe5d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_13_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2ed5843b61db2188b6673a07f899966ed041cd5acfe12dcf40539d96e77eaab +size 45455 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_1_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_1_en.png index e42f829f62..2aca438058 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4885f85102e8e8a509586fb48e7a26d5ceb1dd7aa6897b955cb781eed87b9f30 -size 35023 +oid sha256:85e674e93c73ddbe77c71bfa8ca6f117b9f0d6863fa5137f12f0e9ed62a6e4b7 +size 37414 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_2_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_2_en.png index b262ff6214..9d0de360ee 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f6d8d1c7aa92f526398221aeff77cc44ebdad36bc4a1259ebb3c9635483af1f5 -size 37117 +oid sha256:de1debdc3ff7a00a7de3b7b381a5bd1102016d39e072af2325c4ffb40988d054 +size 38281 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_3_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_3_en.png index e44dff5529..54109a1d99 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3679bf87660a8bd799ae9c4397266227a0f46559bdcae6ddecec0930d5c78e95 -size 35993 +oid sha256:86a5148af36166fd0cb8fb022b85645e64f0cccb9760848d75af4927f715cdc6 +size 38413 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_4_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_4_en.png index 38cccf98e2..ea0e3a147d 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0ae27c470cb9be1fa75b85b607bcc3666839801376e00b9c0ec8b86d229d907b -size 43236 +oid sha256:9893ea709177f3f0101bfaed241de44e85d179d99f133f6d4057a251b88c3a9d +size 44234 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_5_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_5_en.png index ce9eec3248..f926d67237 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ea6ba2ec613561a26331c72ca89a6bd44b96c160bee75bc9a9b9a06339426275 -size 41660 +oid sha256:c1ca7803057968031f909b1311b023bfb9ae1978003c8beb80a85d065968bd23 +size 42324 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_6_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_6_en.png index ce9eec3248..f926d67237 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ea6ba2ec613561a26331c72ca89a6bd44b96c160bee75bc9a9b9a06339426275 -size 41660 +oid sha256:c1ca7803057968031f909b1311b023bfb9ae1978003c8beb80a85d065968bd23 +size 42324 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_7_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_7_en.png index 308b3bc8f1..7b67afcccb 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf9ea4e85c5dc2d9d5c609b29e6232a5932c9b3bee81c459e9214ac16f7a3764 -size 44986 +oid sha256:cfcee02543f55d395112fd662d785ef553260a4457b5ab753b6198c0c13a2068 +size 46345 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_8_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_8_en.png index c2416c95b3..61b685445c 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:569a0dfdc4c5c77f44999b0e9e7a1b2458cac6b01dfd56800d3e7c3b5f8c0347 -size 43930 +oid sha256:50023e5cd9260b3db79fa1ab15489e165dc7df9663584317d0fe2243f1622ea2 +size 45288 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_9_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_9_en.png index 12c112d879..b9ef9dfb9a 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:45e0e9952c3ca0fb805b5e53b990c3ec54732a971ecc658e25ef73254b3f1ca4 -size 43871 +oid sha256:487ecdc6e7bbb2389e8f21a99b348939f371c6fa8fc090fcff46f5a51e2390cb +size 45230 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_0_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_0_en.png index fea3ce3463..025e87bcc1 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dcf1f93aa20a0acd4c32f761fdd39abf46016c84072f0a22c9b9cf0dd7f2c6f2 -size 46788 +oid sha256:38f6a49907dfbafdeb18184ea70315882f26729353975748be33246dd06de810 +size 41776 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_10_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_10_en.png index 42fce9b5d6..c445743cf2 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc8fb59e77e8e09e79f5d89e039f302f28e2451faa025e9f1ce3b0ac48f75f8d -size 44404 +oid sha256:13189861bd0b613aabe126a7064277695f9e0a31ce6e7d0104c1b786900d855c +size 45869 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_11_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_11_en.png index 661f82e060..d11d9a6363 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e3166a62b24bfaf55d3dfa5d6e9fcd6dca2ee2789928efd66d8147e4e11d0ce0 -size 43377 +oid sha256:f6231ddd26d71291a3acfe347ccaa5b5213809969d3a21667f5c872da145bbe6 +size 44844 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_12_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_12_en.png index a71a604612..76b488e522 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f799d30b5c3d847cc8011ce4aefbb8710b34df8f11116842af035b05d233cfa1 -size 46485 +oid sha256:f4841522e54ab49cf7f2ac4e06223addf050c4a0f9c7a3e351d73fbd0fbb62b9 +size 47959 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_13_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_13_en.png new file mode 100644 index 0000000000..c221989a49 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_13_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f346764da31583076508550c339be523d15f338b00f2f412e1b117bd1e3b5062 +size 46333 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_1_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_1_en.png index 3221ed0029..61e99bf7c0 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:09ce1743e0e3c5629fab6d0c2d06cc15be7229e446673b05f49f0e031a1c2823 -size 35776 +oid sha256:17a68abaa010d037c00b3ae24ab470c9cf2e7b42d1c45391819b5e46e3b3c2c7 +size 38265 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_2_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_2_en.png index 54067184f1..624a512f20 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:84ef09f2163f10a583853a52008f5a53f26c90350136a970fd55e0199f5fd47f -size 37907 +oid sha256:3d67989cc90311a6810882f1cc9f63d4849c9768892403c6366f260b6f01782a +size 39097 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_3_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_3_en.png index a5bd17734c..68d3eceab3 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9941f55b35128067bd7560ed24d44bc0bbe8e13a6702d6ec2d35304462f755a2 -size 36503 +oid sha256:777cecce6d855418ade591c3f02d97dbc82bc81f3317bc64cc76ae8f0e94a792 +size 39004 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_4_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_4_en.png index 103d53664c..74d9ecb427 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b300a88051346537beac5096895ef87b007e56fdddc33ad63db092f4c813a454 -size 44041 +oid sha256:2fb04b9ecf34bd1f9218b137ad213a385abd03b0a4767f7c88ef04ee496f8e4c +size 45084 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_5_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_5_en.png index 9030577759..de70559d50 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fe90dffd26521d58e0956b9ea967c641f75a7cdeb39052abbfcdf85754c5dd3 -size 42281 +oid sha256:31eba128b859c1a5b4da1f62de323d29f62a36dd962877fbf3fdea0a51b81fde +size 42948 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_6_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_6_en.png index 9030577759..de70559d50 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fe90dffd26521d58e0956b9ea967c641f75a7cdeb39052abbfcdf85754c5dd3 -size 42281 +oid sha256:31eba128b859c1a5b4da1f62de323d29f62a36dd962877fbf3fdea0a51b81fde +size 42948 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_7_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_7_en.png index 88bdc93f10..3accfb7391 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:41b3d79ae55b26478c2e6d1910b37a98eb22ac37e9bfd3a33de4dc805e82028b -size 45850 +oid sha256:47260328f01cc55a7825ab8bebe540aac41efcee77cc52a0d82deb3fb1e496b3 +size 47321 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_8_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_8_en.png index 371a43df04..24d346dcdf 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:02a966757cc01f8707738d3cd29d05df0bf3842069fec812c7223e2e29da5dbf -size 44761 +oid sha256:9cb0fb71d0e3433bd9512d435f47c3f116627202d1f81debd5f3c43eece64a5d +size 46223 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_9_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_9_en.png index 816e803108..bd5aaf721e 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5a2148c28660bd9fd3a45b1f06b331966f79c77166b40bb1b7afb5b926407817 -size 44647 +oid sha256:6939bdfecb6177a7c180bf94d18d80aea0ebceb16f06c87d0cc598068bc73765 +size 46114