Merge pull request #5622 from element-hq/feature/bma/mediaForward

Add ability to forward a media from the media viewer and the gallery
This commit is contained in:
Benoit Marty
2025-10-29 13:15:42 +01:00
committed by GitHub
61 changed files with 539 additions and 89 deletions

View File

@@ -342,10 +342,6 @@ class LoggedInFlowNode(
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames))
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
sessionCoroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false) }
}
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
when (data) {
is PermalinkData.UserLink -> {
@@ -473,9 +469,8 @@ class LoggedInFlowNode(
.callback(object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
navigateUp()
if (roomIds.size == 1) {
val targetRoomId = roomIds.first()
backstack.push(NavTarget.Room(targetRoomId.toRoomIdOrAlias()))
roomIds.singleOrNull()?.let { roomId ->
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
}
}
})

View File

@@ -16,12 +16,14 @@ 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.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
@@ -41,6 +43,8 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@@ -53,6 +57,7 @@ class JoinedRoomLoadedFlowNode(
private val messagesEntryPoint: MessagesEntryPoint,
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val spaceEntryPoint: SpaceEntryPoint,
private val forwardEntryPoint: ForwardEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
@@ -70,7 +75,6 @@ class JoinedRoomLoadedFlowNode(
interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId, serverNames: List<String>)
fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun onForwardedToSingleRoom(roomId: RoomId)
fun onOpenGlobalNotificationSettings()
}
@@ -128,8 +132,8 @@ class JoinedRoomLoadedFlowNode(
callbacks.forEach { it.onPermalinkClick(data, pushToBackstack) }
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
override fun forwardEvent(eventId: EventId) {
backstack.push(NavTarget.ForwardEvent(eventId))
}
}
return roomDetailsEntryPoint.nodeBuilder(this, buildContext)
@@ -158,6 +162,22 @@ class JoinedRoomLoadedFlowNode(
NavTarget.Space -> {
createSpaceNode(buildContext)
}
is NavTarget.ForwardEvent -> {
val timelineProvider = { MutableStateFlow(inputs.room.liveTimeline).asStateFlow() }
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
backstack.pop()
roomIds.singleOrNull()?.let { roomId ->
callbacks.forEach { it.onOpenRoom(roomId, emptyList()) }
}
}
}
forwardEntryPoint.nodeBuilder(this, buildContext)
.params(params)
.callback(callback)
.build()
}
}
}
@@ -198,8 +218,12 @@ class JoinedRoomLoadedFlowNode(
callbacks.forEach { it.onPermalinkClick(data, pushToBackstack) }
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
override fun forwardEvent(eventId: EventId) {
backstack.push(NavTarget.ForwardEvent(eventId))
}
override fun openRoom(roomId: RoomId) {
callbacks.forEach { it.onOpenRoom(roomId, emptyList()) }
}
}
val params = MessagesEntryPoint.Params(
@@ -227,6 +251,9 @@ class JoinedRoomLoadedFlowNode(
@Parcelize
data class RoomMemberDetails(val userId: UserId) : NavTarget
@Parcelize
data class ForwardEvent(val eventId: EventId) : NavTarget
@Parcelize
data object RoomNotificationSettings : NavTarget
}

View File

@@ -20,6 +20,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
@@ -122,11 +123,22 @@ class JoinedRoomLoadedFlowNodeTest {
}
}
private class FakeForwardEntryPoint : ForwardEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): ForwardEntryPoint.NodeBuilder {
return object : ForwardEntryPoint.NodeBuilder {
override fun params(params: ForwardEntryPoint.Params) = this
override fun callback(callback: ForwardEntryPoint.Callback) = this
override fun build() = node(buildContext) {}
}
}
}
private fun TestScope.createJoinedRoomLoadedFlowNode(
plugins: List<Plugin>,
messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
spaceEntryPoint: SpaceEntryPoint = FakeSpaceEntryPoint(),
forwardEntryPoint: ForwardEntryPoint = FakeForwardEntryPoint(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
) = JoinedRoomLoadedFlowNode(
buildContext = BuildContext.root(savedStateMap = null),
@@ -134,6 +146,7 @@ class JoinedRoomLoadedFlowNodeTest {
messagesEntryPoint = messagesEntryPoint,
roomDetailsEntryPoint = roomDetailsEntryPoint,
spaceEntryPoint = spaceEntryPoint,
forwardEntryPoint = forwardEntryPoint,
appNavigationStateService = FakeAppNavigationStateService(),
sessionCoroutineScope = this,
roomGraphFactory = FakeRoomGraphFactory(),

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.forward.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.forward.api
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.timeline.TimelineProvider
interface ForwardEntryPoint : FeatureEntryPoint {
interface NodeBuilder {
fun params(params: Params): NodeBuilder
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onDone(roomIds: List<RoomId>)
}
data class Params(
val eventId: EventId,
val timelineProvider: TimelineProvider,
) : NodeInputs
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
}

View File

@@ -0,0 +1,38 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.forward.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
api(projects.features.forward.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.roomselect.api)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.testtags)
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.forward.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
class DefaultForwardEntryPoint : ForwardEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): ForwardEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : ForwardEntryPoint.NodeBuilder {
override fun params(params: ForwardEntryPoint.Params): ForwardEntryPoint.NodeBuilder {
plugins += ForwardMessagesNode.Inputs(
eventId = params.eventId,
timelineProvider = params.timelineProvider,
)
return this
}
override fun callback(callback: ForwardEntryPoint.Callback): ForwardEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<ForwardMessagesNode>(buildContext, plugins)
}
}
}
}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.forward
package io.element.android.features.forward.impl
sealed interface ForwardMessagesEvents {
data object ClearError : ForwardMessagesEvents

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.forward
package io.element.android.features.forward.impl
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
@@ -20,9 +20,10 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SessionScope
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
@@ -30,7 +31,7 @@ import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@ContributesNode(SessionScope::class)
@AssistedInject
class ForwardMessagesNode(
@Assisted buildContext: BuildContext,
@@ -48,10 +49,6 @@ class ForwardMessagesNode(
@Parcelize
object NavTarget : Parcelable
interface Callback : Plugin {
fun onForwardedToSingleRoom(roomId: RoomId)
}
data class Inputs(
val eventId: EventId,
val timelineProvider: TimelineProvider,
@@ -59,7 +56,7 @@ class ForwardMessagesNode(
private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.eventId.value, inputs.timelineProvider)
private val callbacks = plugins.filterIsInstance<Callback>()
private val callbacks = plugins.filterIsInstance<ForwardEntryPoint.Callback>()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback {
@@ -68,7 +65,7 @@ class ForwardMessagesNode(
}
override fun onCancel() {
navigateUp()
onForwardDone(emptyList())
}
}
@@ -89,16 +86,12 @@ class ForwardMessagesNode(
val state = presenter.present()
ForwardMessagesView(
state = state,
onForwardSuccess = ::onForwardSuccess,
onForwardSuccess = ::onForwardDone,
)
}
}
private fun onForwardSuccess(roomIds: List<RoomId>) {
navigateUp()
if (roomIds.size == 1) {
val targetRoomId = roomIds.first()
callbacks.forEach { it.onForwardedToSingleRoom(targetRoomId) }
}
private fun onForwardDone(roomIds: List<RoomId>) {
callbacks.forEach { it.onDone(roomIds) }
}
}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.forward
package io.element.android.features.forward.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
@@ -21,8 +21,6 @@ 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.matrix.api.timeline.getActiveTimeline
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -36,14 +34,14 @@ class ForwardMessagesPresenter(
private val eventId: EventId = EventId(eventId)
@AssistedFactory
interface Factory {
fun interface Factory {
fun create(eventId: String, timelineProvider: TimelineProvider): ForwardMessagesPresenter
}
private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
fun onRoomSelected(roomIds: List<RoomId>) {
sessionCoroutineScope.forwardEvent(eventId, roomIds.toImmutableList(), forwardingActionState)
sessionCoroutineScope.forwardEvent(eventId, roomIds)
}
@Composable
@@ -62,12 +60,11 @@ class ForwardMessagesPresenter(
private fun CoroutineScope.forwardEvent(
eventId: EventId,
roomIds: ImmutableList<RoomId>,
isForwardMessagesState: MutableState<AsyncAction<List<RoomId>>>,
roomIds: List<RoomId>,
) = launch {
suspend {
timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds).getOrThrow()
roomIds
}.runCatchingUpdatingState(isForwardMessagesState)
}.runCatchingUpdatingState(forwardingActionState)
}
}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.forward
package io.element.android.features.forward.impl
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.forward
package io.element.android.features.forward.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.forward
package io.element.android.features.forward.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter

View File

@@ -0,0 +1,68 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.forward.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DefaultForwardEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() = runTest {
val entryPoint = DefaultForwardEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
ForwardMessagesNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { _, _ -> createForwardMessagesPresenter() },
roomSelectEntryPoint = object : RoomSelectEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomSelectEntryPoint.NodeBuilder {
lambdaError()
}
}
)
}
val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) = lambdaError()
}
val params = ForwardEntryPoint.Params(
eventId = AN_EVENT_ID,
timelineProvider = FakeTimelineProvider(),
)
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.params(params)
.callback(callback)
.build()
assertThat(result).isInstanceOf(ForwardMessagesNode::class.java)
assertThat(result.plugins).contains(
ForwardMessagesNode.Inputs(
eventId = params.eventId,
timelineProvider = params.timelineProvider,
)
)
assertThat(result.plugins).contains(callback)
}
}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.forward
package io.element.android.features.forward.impl
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
@@ -32,7 +32,7 @@ class ForwardMessagesPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = aForwardMessagesPresenter()
val presenter = createForwardMessagesPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -50,7 +50,7 @@ class ForwardMessagesPresenterTest {
this.forwardEventLambda = forwardEventLambda
}
val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = aForwardMessagesPresenter(fakeRoom = room)
val presenter = createForwardMessagesPresenter(fakeRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -74,7 +74,7 @@ class ForwardMessagesPresenterTest {
this.forwardEventLambda = forwardEventLambda
}
val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = aForwardMessagesPresenter(fakeRoom = room)
val presenter = createForwardMessagesPresenter(fakeRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -90,13 +90,13 @@ class ForwardMessagesPresenterTest {
forwardEventLambda.assertions().isCalledOnce()
}
}
private fun TestScope.aForwardMessagesPresenter(
eventId: EventId = AN_EVENT_ID,
fakeRoom: FakeJoinedRoom = FakeJoinedRoom(),
) = ForwardMessagesPresenter(
eventId = eventId.value,
timelineProvider = LiveTimelineProvider(fakeRoom),
sessionCoroutineScope = this,
)
}
fun TestScope.createForwardMessagesPresenter(
eventId: EventId = AN_EVENT_ID,
fakeRoom: FakeJoinedRoom = FakeJoinedRoom(),
) = ForwardMessagesPresenter(
eventId = eventId.value,
timelineProvider = LiveTimelineProvider(fakeRoom),
sessionCoroutineScope = this,
)

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.forward
package io.element.android.features.forward.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule

View File

@@ -38,7 +38,8 @@ interface MessagesEntryPoint : FeatureEntryPoint {
fun onRoomDetailsClick()
fun onUserDataClick(userId: UserId)
fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun onForwardedToSingleRoom(roomId: RoomId)
fun forwardEvent(eventId: EventId)
fun openRoom(roomId: RoomId)
}
data class Params(val initialTarget: InitialTarget) : NodeInputs

View File

@@ -29,6 +29,7 @@ dependencies {
implementation(projects.appconfig)
implementation(projects.features.call.api)
implementation(projects.features.enterprise.api)
implementation(projects.features.forward.api)
implementation(projects.features.location.api)
implementation(projects.features.poll.api)
implementation(projects.features.roomcall.api)

View File

@@ -18,6 +18,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
@@ -25,6 +26,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.annotations.ContributesNode
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.LocationService
@@ -33,7 +35,6 @@ import io.element.android.features.location.api.ShowLocationEntryPoint
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
@@ -103,6 +104,7 @@ class MessagesFlowNode(
private val createPollEntryPoint: CreatePollEntryPoint,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val forwardEntryPoint: ForwardEntryPoint,
private val analyticsService: AnalyticsService,
private val locationService: LocationService,
private val room: BaseRoom,
@@ -149,7 +151,10 @@ class MessagesFlowNode(
data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget
@Parcelize
data class ForwardEvent(val eventId: EventId, val fromPinnedEvents: Boolean) : NavTarget
data class ForwardEvent(
val eventId: EventId,
val fromPinnedEvents: Boolean,
) : NavTarget
@Parcelize
data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget
@@ -305,6 +310,11 @@ class MessagesFlowNode(
override fun onViewInTimeline(eventId: EventId) {
viewInTimeline(eventId)
}
override fun onForwardEvent(eventId: EventId) {
// Need to go to the parent because of the overlay
forwardEvent(eventId)
}
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.params(params)
@@ -333,13 +343,19 @@ class MessagesFlowNode(
} else {
timelineController
}
val inputs = ForwardMessagesNode.Inputs(navTarget.eventId, timelineProvider)
val callback = object : ForwardMessagesNode.Callback {
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
backstack.pop()
roomIds.singleOrNull()?.let { roomId ->
callbacks.forEach { it.openRoom(roomId) }
}
}
}
createNode<ForwardMessagesNode>(buildContext, listOf(inputs, callback))
forwardEntryPoint.nodeBuilder(this, buildContext)
.params(params)
.callback(callback)
.build()
}
is NavTarget.ReportMessage -> {
val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId)
@@ -485,6 +501,10 @@ class MessagesFlowNode(
callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
}
private fun forwardEvent(eventId: EventId) {
callbacks.forEach { it.forwardEvent(eventId) }
}
private fun processEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,

View File

@@ -15,6 +15,7 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
@@ -90,6 +91,9 @@ class DefaultMessagesEntryPointTest {
mediaViewerEntryPoint = object : MediaViewerEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext) = lambdaError()
},
forwardEntryPoint = object : ForwardEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext) = lambdaError()
},
analyticsService = FakeAnalyticsService(),
locationService = FakeLocationService(),
room = FakeBaseRoom(),
@@ -115,7 +119,8 @@ class DefaultMessagesEntryPointTest {
override fun onRoomDetailsClick() = lambdaError()
override fun onUserDataClick(userId: UserId) = lambdaError()
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun onForwardedToSingleRoom(roomId: RoomId) = lambdaError()
override fun forwardEvent(eventId: EventId) = lambdaError()
override fun openRoom(roomId: RoomId) = lambdaError()
}
val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID)
val params = MessagesEntryPoint.Params(initialTarget)

View File

@@ -13,6 +13,7 @@ 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
@@ -39,7 +40,7 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
fun onOpenGlobalNotificationSettings()
fun onOpenRoom(roomId: RoomId, serverNames: List<String>)
fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun onForwardedToSingleRoom(roomId: RoomId)
fun forwardEvent(eventId: EventId)
}
interface NodeBuilder {

View File

@@ -294,6 +294,10 @@ class RoomDetailsFlowNode(
override fun onViewInTimeline(eventId: EventId) {
// Cannot happen
}
override fun onForwardEvent(eventId: EventId) {
// Cannot happen
}
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.avatar(
@@ -321,6 +325,10 @@ class RoomDetailsFlowNode(
it.onPermalinkClick(permalinkData, pushToBackstack = false)
}
}
override fun forwardEvent(eventId: EventId) {
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.forwardEvent(eventId) }
}
}
mediaGalleryEntryPoint.nodeBuilder(this, buildContext)
.callback(callback)
@@ -343,8 +351,12 @@ class RoomDetailsFlowNode(
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.onPermalinkClick(data, pushToBackstack) }
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.onForwardedToSingleRoom(roomId) }
override fun forwardEvent(eventId: EventId) {
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.forwardEvent(eventId) }
}
override fun openRoom(roomId: RoomId) {
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.onOpenRoom(roomId, emptyList()) }
}
}
return messagesEntryPoint.nodeBuilder(this, buildContext)

View File

@@ -97,7 +97,7 @@ class DefaultRoomDetailsEntryPointTest {
override fun onOpenGlobalNotificationSettings() = lambdaError()
override fun onOpenRoom(roomId: RoomId, serverNames: List<String>) = lambdaError()
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun onForwardedToSingleRoom(roomId: RoomId) = lambdaError()
override fun forwardEvent(eventId: EventId) = lambdaError()
}
val params = RoomDetailsEntryPoint.Params(
initialElement = RoomDetailsEntryPoint.InitialTarget.RoomDetails,

View File

@@ -61,7 +61,7 @@ class ShareNode(
}
override fun onCancel() {
navigateUp()
onShareDone(emptyList())
}
}
@@ -82,12 +82,12 @@ class ShareNode(
val state = presenter.present()
ShareView(
state = state,
onShareSuccess = ::onShareSuccess,
onShareSuccess = ::onShareDone,
)
}
}
private fun onShareSuccess(roomIds: List<RoomId>) {
private fun onShareDone(roomIds: List<RoomId>) {
callbacks.forEach { it.onDone(roomIds) }
}
}

View File

@@ -101,6 +101,10 @@ class UserProfileFlowNode(
override fun onViewInTimeline(eventId: EventId) {
// Cannot happen
}
override fun onForwardEvent(eventId: EventId) {
// Cannot happen
}
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.avatar(

View File

@@ -16,7 +16,7 @@ import kotlinx.coroutines.flow.first
* 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 interface TimelineProvider {
fun activeTimelineFlow(): StateFlow<Timeline?>
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.test.timeline
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeTimelineProvider(
initialTimeline: Timeline? = null,
) : TimelineProvider {
private val timelineFlow = MutableStateFlow(initialTimeline)
override fun activeTimelineFlow(): StateFlow<Timeline?> {
return timelineFlow.asStateFlow()
}
}

View File

@@ -24,5 +24,6 @@ interface MediaGalleryEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onBackClick()
fun onViewInTimeline(eventId: EventId)
fun forwardEvent(eventId: EventId)
}
}

View File

@@ -31,6 +31,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onDone()
fun onViewInTimeline(eventId: EventId)
fun onForwardEvent(eventId: EventId)
}
data class Params(

View File

@@ -49,6 +49,7 @@ fun MediaDetailsBottomSheet(
state: MediaBottomSheetState.MediaDetailsBottomSheetState,
onViewInTimeline: (EventId) -> Unit,
onShare: (EventId) -> Unit,
onForward: (EventId) -> Unit,
onDownload: (EventId) -> Unit,
onDelete: (EventId) -> Unit,
onDismiss: () -> Unit,
@@ -102,6 +103,14 @@ fun MediaDetailsBottomSheet(
onShare(state.eventId)
}
)
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Forward())),
headlineContent = { Text(stringResource(CommonStrings.action_forward)) },
style = ListItemStyle.Primary,
onClick = {
onForward(state.eventId)
}
)
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Download())),
headlineContent = { Text(stringResource(CommonStrings.action_save)) },
@@ -216,6 +225,7 @@ internal fun MediaDetailsBottomSheetPreview() = ElementPreview {
state = aMediaDetailsBottomSheetState(),
onViewInTimeline = {},
onShare = {},
onForward = {},
onDownload = {},
onDelete = {},
onDismiss = {},

View File

@@ -16,8 +16,9 @@ import io.element.android.libraries.mediaviewer.impl.model.MediaItem
sealed interface MediaGalleryEvents {
data class ChangeMode(val mode: MediaGalleryMode) : MediaGalleryEvents
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaGalleryEvents
data class Share(val eventId: EventId?) : MediaGalleryEvents
data class SaveOnDisk(val eventId: EventId?) : MediaGalleryEvents
data class Share(val eventId: EventId) : MediaGalleryEvents
data class Forward(val eventId: EventId) : MediaGalleryEvents
data class SaveOnDisk(val eventId: EventId) : MediaGalleryEvents
data class OpenInfo(val mediaItem: MediaItem.Event) : MediaGalleryEvents
data class ViewInTimeline(val eventId: EventId) : MediaGalleryEvents

View File

@@ -11,4 +11,5 @@ import io.element.android.libraries.matrix.api.core.EventId
interface MediaGalleryNavigator {
fun onViewInTimelineClick(eventId: EventId)
fun onForwardClick(eventId: EventId)
}

View File

@@ -40,6 +40,7 @@ class MediaGalleryNode(
fun onBackClick()
fun onItemClick(item: MediaItem.Event)
fun onViewInTimeline(eventId: EventId)
fun onForward(eventId: EventId)
}
private fun onBackClick() {
@@ -54,6 +55,12 @@ class MediaGalleryNode(
}
}
override fun onForwardClick(eventId: EventId) {
plugins<Callback>().forEach {
it.onForward(eventId)
}
}
private fun onItemClick(item: MediaItem.Event) {
plugins<Callback>().forEach {
it.onItemClick(item)

View File

@@ -105,6 +105,10 @@ class MediaGalleryPresenter(
share(it)
}
}
is MediaGalleryEvents.Forward -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onForwardClick(event.eventId)
}
is MediaGalleryEvents.ViewInTimeline -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onViewInTimelineClick(event.eventId)

View File

@@ -166,6 +166,9 @@ fun MediaGalleryView(
onShare = { eventId ->
state.eventSink(MediaGalleryEvents.Share(eventId))
},
onForward = { eventId ->
state.eventSink(MediaGalleryEvents.Forward(eventId))
},
onDownload = { eventId ->
state.eventSink(MediaGalleryEvents.SaveOnDisk(eventId))
},

View File

@@ -44,7 +44,7 @@ import kotlinx.parcelize.Parcelize
class MediaGalleryFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val mediaViewerEntryPoint: MediaViewerEntryPoint
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
) : BaseFlowNode<MediaGalleryFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@@ -82,6 +82,12 @@ class MediaGalleryFlowNode(
}
}
private fun forwardEvent(eventId: EventId) {
plugins<MediaGalleryEntryPoint.Callback>().forEach {
it.forwardEvent(eventId)
}
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
@@ -94,6 +100,10 @@ class MediaGalleryFlowNode(
this@MediaGalleryFlowNode.onViewInTimeline(eventId)
}
override fun onForward(eventId: EventId) {
forwardEvent(eventId)
}
override fun onItemClick(item: MediaItem.Event) {
val mode = when (item) {
is MediaItem.Audio,
@@ -124,6 +134,11 @@ class MediaGalleryFlowNode(
override fun onViewInTimeline(eventId: EventId) {
this@MediaGalleryFlowNode.onViewInTimeline(eventId)
}
override fun onForwardEvent(eventId: EventId) {
// Need to go to the parent because of the overlay
forwardEvent(eventId)
}
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.params(

View File

@@ -17,6 +17,7 @@ sealed interface MediaViewerEvents {
data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents
data class Forward(val eventId: EventId) : MediaViewerEvents
data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ConfirmDelete(
val eventId: EventId,

View File

@@ -11,5 +11,6 @@ import io.element.android.libraries.matrix.api.core.EventId
interface MediaViewerNavigator {
fun onViewInTimelineClick(eventId: EventId)
fun onForwardClick(eventId: EventId)
fun onItemDeleted()
}

View File

@@ -71,6 +71,12 @@ class MediaViewerNode(
}
}
override fun onForwardClick(eventId: EventId) {
plugins<MediaViewerEntryPoint.Callback>().forEach {
it.onForwardEvent(eventId)
}
}
override fun onItemDeleted() {
onDone()
}

View File

@@ -117,6 +117,10 @@ class MediaViewerPresenter(
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onViewInTimelineClick(event.eventId)
}
is MediaViewerEvents.Forward -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onForwardClick(event.eventId)
}
is MediaViewerEvents.OpenInfo -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState(
eventId = event.data.eventId,

View File

@@ -247,6 +247,9 @@ fun MediaViewerView(
state.eventSink(MediaViewerEvents.Share(currentData))
}
},
onForward = {
state.eventSink(MediaViewerEvents.Forward(it))
},
onDownload = {
(currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(MediaViewerEvents.SaveOnDisk(currentData))

View File

@@ -37,12 +37,13 @@ class DefaultMediaGalleryEntryPointTest {
plugins = plugins,
mediaViewerEntryPoint = object : MediaViewerEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext) = lambdaError()
}
},
)
}
val callback = object : MediaGalleryEntryPoint.Callback {
override fun onBackClick() = lambdaError()
override fun onViewInTimeline(eventId: EventId) = lambdaError()
override fun forwardEvent(eventId: EventId) = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)

View File

@@ -72,6 +72,7 @@ class DefaultMediaViewerEntryPointTest {
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() = lambdaError()
override fun onViewInTimeline(eventId: EventId) = lambdaError()
override fun onForwardEvent(eventId: EventId) = lambdaError()
}
val params = createMediaViewerEntryPointParams()
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
@@ -115,6 +116,7 @@ class DefaultMediaViewerEntryPointTest {
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() = lambdaError()
override fun onViewInTimeline(eventId: EventId) = lambdaError()
override fun onForwardEvent(eventId: EventId) = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.avatar(

View File

@@ -56,6 +56,19 @@ class MediaDetailsBottomSheetTest {
}
}
@Test
@Config(qualifiers = "h1024dp")
fun `clicking on Forward invokes expected callback`() {
val state = aMediaDetailsBottomSheetState()
ensureCalledOnceWithParam(state.eventId) { callback ->
rule.setMediaDetailsBottomSheet(
state = state,
onForward = callback,
)
rule.clickOn(CommonStrings.action_forward)
}
}
@Test
@Config(qualifiers = "h1024dp")
fun `clicking on Save invokes expected callback`() {
@@ -100,6 +113,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
state: MediaBottomSheetState.MediaDetailsBottomSheetState,
onViewInTimeline: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onShare: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onForward: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDownload: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDismiss: () -> Unit = EnsureNeverCalled(),
@@ -109,6 +123,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
state = state,
onViewInTimeline = onViewInTimeline,
onShare = onShare,
onForward = onForward,
onDownload = onDownload,
onDelete = onDelete,
onDismiss = onDismiss,

View File

@@ -11,9 +11,14 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.tests.testutils.lambda.lambdaError
class FakeMediaGalleryNavigator(
private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() }
private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() },
private val onForwardClickLambda: (EventId) -> Unit = { lambdaError() },
) : MediaGalleryNavigator {
override fun onViewInTimelineClick(eventId: EventId) {
onViewInTimelineClickLambda(eventId)
}
override fun onForwardClick(eventId: EventId) {
onForwardClickLambda(eventId)
}
}

View File

@@ -345,7 +345,7 @@ class MediaGalleryPresenterTest {
}
@Test
fun `present - view in timeline invokes the navigator`() = runTest {
fun `present - view in timeline closes the bottom sheet and invokes the navigator`() = runTest {
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMediaGalleryNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
@@ -353,16 +353,59 @@ class MediaGalleryPresenterTest {
val presenter = createMediaGalleryPresenter(
room = FakeJoinedRoom(
createTimelineResult = { Result.success(FakeTimeline()) },
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
),
),
navigator = navigator,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID))
val item = aMediaItemImage(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
)
initialState.eventSink(MediaGalleryEvents.OpenInfo(item))
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
withBottomSheetState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
@Test
fun `present - forward closes the bottom sheet and invokes the navigator`() = runTest {
val onForwardClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMediaGalleryNavigator(
onForwardClickLambda = onForwardClickLambda,
)
val presenter = createMediaGalleryPresenter(
room = FakeJoinedRoom(
createTimelineResult = { Result.success(FakeTimeline()) },
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
),
),
navigator = navigator,
)
presenter.test {
val initialState = awaitFirstItem()
val item = aMediaItemImage(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
)
initialState.eventSink(MediaGalleryEvents.OpenInfo(item))
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
withBottomSheetState.eventSink(MediaGalleryEvents.Forward(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
onForwardClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
@Test
fun `present - load more`() = runTest {
val loadMoreLambda = lambdaRecorder<Timeline.PaginationDirection, Unit> { }

View File

@@ -12,12 +12,17 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakeMediaViewerNavigator(
private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() },
private val onForwardClickLambda: (EventId) -> Unit = { lambdaError() },
private val onItemDeletedLambda: () -> Unit = { lambdaError() },
) : MediaViewerNavigator {
override fun onViewInTimelineClick(eventId: EventId) {
onViewInTimelineClickLambda(eventId)
}
override fun onForwardClick(eventId: EventId) {
onForwardClickLambda(eventId)
}
override fun onItemDeleted() {
onItemDeletedLambda()
}

View File

@@ -759,7 +759,7 @@ class MediaViewerPresenterTest {
}
@Test
fun `present - view in timeline hide the bottom sheet and invokes the navigator`() = runTest {
fun `present - view in timeline hides the bottom sheet and invokes the navigator`() = runTest {
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMediaViewerNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
@@ -783,6 +783,31 @@ class MediaViewerPresenterTest {
}
}
@Test
fun `present - forward hides the bottom sheet and invokes the navigator`() = runTest {
val onForwardClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMediaViewerNavigator(
onForwardClickLambda = onForwardClickLambda,
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mediaViewerNavigator = navigator,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }),
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData()))
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
initialState.eventSink(MediaViewerEvents.Forward(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
onForwardClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem()
}