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
commit 3ebcafa705
61 changed files with 539 additions and 89 deletions

View file

@ -342,10 +342,6 @@ class LoggedInFlowNode(
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames)) 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) { override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
when (data) { when (data) {
is PermalinkData.UserLink -> { is PermalinkData.UserLink -> {
@ -473,9 +469,8 @@ class LoggedInFlowNode(
.callback(object : ShareEntryPoint.Callback { .callback(object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) { override fun onDone(roomIds: List<RoomId>) {
navigateUp() navigateUp()
if (roomIds.size == 1) { roomIds.singleOrNull()?.let { roomId ->
val targetRoomId = roomIds.first() backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
backstack.push(NavTarget.Room(targetRoomId.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.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.RoomGraphFactory import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.appnav.room.RoomNavigationTarget 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.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint 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.ActiveRoomsHolder
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
@ -53,6 +57,7 @@ class JoinedRoomLoadedFlowNode(
private val messagesEntryPoint: MessagesEntryPoint, private val messagesEntryPoint: MessagesEntryPoint,
private val roomDetailsEntryPoint: RoomDetailsEntryPoint, private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val spaceEntryPoint: SpaceEntryPoint, private val spaceEntryPoint: SpaceEntryPoint,
private val forwardEntryPoint: ForwardEntryPoint,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
@SessionCoroutineScope @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
@ -70,7 +75,6 @@ class JoinedRoomLoadedFlowNode(
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId, serverNames: List<String>) fun onOpenRoom(roomId: RoomId, serverNames: List<String>)
fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun onForwardedToSingleRoom(roomId: RoomId)
fun onOpenGlobalNotificationSettings() fun onOpenGlobalNotificationSettings()
} }
@ -128,8 +132,8 @@ class JoinedRoomLoadedFlowNode(
callbacks.forEach { it.onPermalinkClick(data, pushToBackstack) } callbacks.forEach { it.onPermalinkClick(data, pushToBackstack) }
} }
override fun onForwardedToSingleRoom(roomId: RoomId) { override fun forwardEvent(eventId: EventId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) } backstack.push(NavTarget.ForwardEvent(eventId))
} }
} }
return roomDetailsEntryPoint.nodeBuilder(this, buildContext) return roomDetailsEntryPoint.nodeBuilder(this, buildContext)
@ -158,6 +162,22 @@ class JoinedRoomLoadedFlowNode(
NavTarget.Space -> { NavTarget.Space -> {
createSpaceNode(buildContext) 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) } callbacks.forEach { it.onPermalinkClick(data, pushToBackstack) }
} }
override fun onForwardedToSingleRoom(roomId: RoomId) { override fun forwardEvent(eventId: EventId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) } backstack.push(NavTarget.ForwardEvent(eventId))
}
override fun openRoom(roomId: RoomId) {
callbacks.forEach { it.onOpenRoom(roomId, emptyList()) }
} }
} }
val params = MessagesEntryPoint.Params( val params = MessagesEntryPoint.Params(
@ -227,6 +251,9 @@ class JoinedRoomLoadedFlowNode(
@Parcelize @Parcelize
data class RoomMemberDetails(val userId: UserId) : NavTarget data class RoomMemberDetails(val userId: UserId) : NavTarget
@Parcelize
data class ForwardEvent(val eventId: EventId) : NavTarget
@Parcelize @Parcelize
data object RoomNotificationSettings : NavTarget 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.di.RoomGraphFactory
import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode 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.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint 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( private fun TestScope.createJoinedRoomLoadedFlowNode(
plugins: List<Plugin>, plugins: List<Plugin>,
messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(), messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(), roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
spaceEntryPoint: SpaceEntryPoint = FakeSpaceEntryPoint(), spaceEntryPoint: SpaceEntryPoint = FakeSpaceEntryPoint(),
forwardEntryPoint: ForwardEntryPoint = FakeForwardEntryPoint(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(), activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
) = JoinedRoomLoadedFlowNode( ) = JoinedRoomLoadedFlowNode(
buildContext = BuildContext.root(savedStateMap = null), buildContext = BuildContext.root(savedStateMap = null),
@ -134,6 +146,7 @@ class JoinedRoomLoadedFlowNodeTest {
messagesEntryPoint = messagesEntryPoint, messagesEntryPoint = messagesEntryPoint,
roomDetailsEntryPoint = roomDetailsEntryPoint, roomDetailsEntryPoint = roomDetailsEntryPoint,
spaceEntryPoint = spaceEntryPoint, spaceEntryPoint = spaceEntryPoint,
forwardEntryPoint = forwardEntryPoint,
appNavigationStateService = FakeAppNavigationStateService(), appNavigationStateService = FakeAppNavigationStateService(),
sessionCoroutineScope = this, sessionCoroutineScope = this,
roomGraphFactory = FakeRoomGraphFactory(), 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. * 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 { sealed interface ForwardMessagesEvents {
data object ClearError : ForwardMessagesEvents data object ClearError : ForwardMessagesEvents

View file

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

View file

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

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * 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.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId 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. * 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * 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.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter 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. * 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.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
@ -32,7 +32,7 @@ class ForwardMessagesPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = aForwardMessagesPresenter() val presenter = createForwardMessagesPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -50,7 +50,7 @@ class ForwardMessagesPresenterTest {
this.forwardEventLambda = forwardEventLambda this.forwardEventLambda = forwardEventLambda
} }
val room = FakeJoinedRoom(liveTimeline = timeline) val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = aForwardMessagesPresenter(fakeRoom = room) val presenter = createForwardMessagesPresenter(fakeRoom = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -74,7 +74,7 @@ class ForwardMessagesPresenterTest {
this.forwardEventLambda = forwardEventLambda this.forwardEventLambda = forwardEventLambda
} }
val room = FakeJoinedRoom(liveTimeline = timeline) val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = aForwardMessagesPresenter(fakeRoom = room) val presenter = createForwardMessagesPresenter(fakeRoom = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -90,13 +90,13 @@ class ForwardMessagesPresenterTest {
forwardEventLambda.assertions().isCalledOnce() 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. * 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.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule

View file

@ -38,7 +38,8 @@ interface MessagesEntryPoint : FeatureEntryPoint {
fun onRoomDetailsClick() fun onRoomDetailsClick()
fun onUserDataClick(userId: UserId) fun onUserDataClick(userId: UserId)
fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) 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 data class Params(val initialTarget: InitialTarget) : NodeInputs

View file

@ -29,6 +29,7 @@ dependencies {
implementation(projects.appconfig) implementation(projects.appconfig)
implementation(projects.features.call.api) implementation(projects.features.call.api)
implementation(projects.features.enterprise.api) implementation(projects.features.enterprise.api)
implementation(projects.features.forward.api)
implementation(projects.features.location.api) implementation(projects.features.location.api)
implementation(projects.features.poll.api) implementation(projects.features.poll.api)
implementation(projects.features.roomcall.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.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject 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.annotations.ContributesNode
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint 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.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.api.LocationService 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.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment 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.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.PinnedEventsTimelineProvider
import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode 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.report.ReportMessageNode
@ -103,6 +104,7 @@ class MessagesFlowNode(
private val createPollEntryPoint: CreatePollEntryPoint, private val createPollEntryPoint: CreatePollEntryPoint,
private val elementCallEntryPoint: ElementCallEntryPoint, private val elementCallEntryPoint: ElementCallEntryPoint,
private val mediaViewerEntryPoint: MediaViewerEntryPoint, private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val forwardEntryPoint: ForwardEntryPoint,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val locationService: LocationService, private val locationService: LocationService,
private val room: BaseRoom, private val room: BaseRoom,
@ -149,7 +151,10 @@ class MessagesFlowNode(
data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget
@Parcelize @Parcelize
data class ForwardEvent(val eventId: EventId, val fromPinnedEvents: Boolean) : NavTarget data class ForwardEvent(
val eventId: EventId,
val fromPinnedEvents: Boolean,
) : NavTarget
@Parcelize @Parcelize
data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget
@ -305,6 +310,11 @@ class MessagesFlowNode(
override fun onViewInTimeline(eventId: EventId) { override fun onViewInTimeline(eventId: EventId) {
viewInTimeline(eventId) viewInTimeline(eventId)
} }
override fun onForwardEvent(eventId: EventId) {
// Need to go to the parent because of the overlay
forwardEvent(eventId)
}
} }
mediaViewerEntryPoint.nodeBuilder(this, buildContext) mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.params(params) .params(params)
@ -333,13 +343,19 @@ class MessagesFlowNode(
} else { } else {
timelineController timelineController
} }
val inputs = ForwardMessagesNode.Inputs(navTarget.eventId, timelineProvider) val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
val callback = object : ForwardMessagesNode.Callback { val callback = object : ForwardEntryPoint.Callback {
override fun onForwardedToSingleRoom(roomId: RoomId) { override fun onDone(roomIds: List<RoomId>) {
callbacks.forEach { it.onForwardedToSingleRoom(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 -> { is NavTarget.ReportMessage -> {
val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId) val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId)
@ -485,6 +501,10 @@ class MessagesFlowNode(
callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) } callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
} }
private fun forwardEvent(eventId: EventId) {
callbacks.forEach { it.forwardEvent(eventId) }
}
private fun processEventClick( private fun processEventClick(
timelineMode: Timeline.Mode, timelineMode: Timeline.Mode,
event: TimelineItem.Event, 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 com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint 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.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.location.api.SendLocationEntryPoint import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint
@ -90,6 +91,9 @@ class DefaultMessagesEntryPointTest {
mediaViewerEntryPoint = object : MediaViewerEntryPoint { mediaViewerEntryPoint = object : MediaViewerEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext) = lambdaError() override fun nodeBuilder(parentNode: Node, buildContext: BuildContext) = lambdaError()
}, },
forwardEntryPoint = object : ForwardEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext) = lambdaError()
},
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
locationService = FakeLocationService(), locationService = FakeLocationService(),
room = FakeBaseRoom(), room = FakeBaseRoom(),
@ -115,7 +119,8 @@ class DefaultMessagesEntryPointTest {
override fun onRoomDetailsClick() = lambdaError() override fun onRoomDetailsClick() = lambdaError()
override fun onUserDataClick(userId: UserId) = lambdaError() override fun onUserDataClick(userId: UserId) = lambdaError()
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = 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 initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID)
val params = MessagesEntryPoint.Params(initialTarget) 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 com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs 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.RoomId
import io.element.android.libraries.matrix.api.core.UserId 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.PermalinkData
@ -39,7 +40,7 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
fun onOpenGlobalNotificationSettings() fun onOpenGlobalNotificationSettings()
fun onOpenRoom(roomId: RoomId, serverNames: List<String>) fun onOpenRoom(roomId: RoomId, serverNames: List<String>)
fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun onForwardedToSingleRoom(roomId: RoomId) fun forwardEvent(eventId: EventId)
} }
interface NodeBuilder { interface NodeBuilder {

View file

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

View file

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

View file

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

View file

@ -101,6 +101,10 @@ class UserProfileFlowNode(
override fun onViewInTimeline(eventId: EventId) { override fun onViewInTimeline(eventId: EventId) {
// Cannot happen // Cannot happen
} }
override fun onForwardEvent(eventId: EventId) {
// Cannot happen
}
} }
mediaViewerEntryPoint.nodeBuilder(this, buildContext) mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.avatar( .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. * It could be the live timeline, a pinned timeline or a detached timeline.
* By default, the active timeline is the live timeline. * By default, the active timeline is the live timeline.
*/ */
interface TimelineProvider { fun interface TimelineProvider {
fun activeTimelineFlow(): StateFlow<Timeline?> 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 { interface Callback : Plugin {
fun onBackClick() fun onBackClick()
fun onViewInTimeline(eventId: EventId) fun onViewInTimeline(eventId: EventId)
fun forwardEvent(eventId: EventId)
} }
} }

View file

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

View file

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

View file

@ -16,8 +16,9 @@ import io.element.android.libraries.mediaviewer.impl.model.MediaItem
sealed interface MediaGalleryEvents { sealed interface MediaGalleryEvents {
data class ChangeMode(val mode: MediaGalleryMode) : MediaGalleryEvents data class ChangeMode(val mode: MediaGalleryMode) : MediaGalleryEvents
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaGalleryEvents data class LoadMore(val direction: Timeline.PaginationDirection) : MediaGalleryEvents
data class Share(val eventId: EventId?) : MediaGalleryEvents data class Share(val eventId: EventId) : MediaGalleryEvents
data class SaveOnDisk(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 OpenInfo(val mediaItem: MediaItem.Event) : MediaGalleryEvents
data class ViewInTimeline(val eventId: EventId) : 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 { interface MediaGalleryNavigator {
fun onViewInTimelineClick(eventId: EventId) fun onViewInTimelineClick(eventId: EventId)
fun onForwardClick(eventId: EventId)
} }

View file

@ -40,6 +40,7 @@ class MediaGalleryNode(
fun onBackClick() fun onBackClick()
fun onItemClick(item: MediaItem.Event) fun onItemClick(item: MediaItem.Event)
fun onViewInTimeline(eventId: EventId) fun onViewInTimeline(eventId: EventId)
fun onForward(eventId: EventId)
} }
private fun onBackClick() { 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) { private fun onItemClick(item: MediaItem.Event) {
plugins<Callback>().forEach { plugins<Callback>().forEach {
it.onItemClick(item) it.onItemClick(item)

View file

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

View file

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

View file

@ -44,7 +44,7 @@ import kotlinx.parcelize.Parcelize
class MediaGalleryFlowNode( class MediaGalleryFlowNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val mediaViewerEntryPoint: MediaViewerEntryPoint private val mediaViewerEntryPoint: MediaViewerEntryPoint,
) : BaseFlowNode<MediaGalleryFlowNode.NavTarget>( ) : BaseFlowNode<MediaGalleryFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, 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 { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Root -> { NavTarget.Root -> {
@ -94,6 +100,10 @@ class MediaGalleryFlowNode(
this@MediaGalleryFlowNode.onViewInTimeline(eventId) this@MediaGalleryFlowNode.onViewInTimeline(eventId)
} }
override fun onForward(eventId: EventId) {
forwardEvent(eventId)
}
override fun onItemClick(item: MediaItem.Event) { override fun onItemClick(item: MediaItem.Event) {
val mode = when (item) { val mode = when (item) {
is MediaItem.Audio, is MediaItem.Audio,
@ -124,6 +134,11 @@ class MediaGalleryFlowNode(
override fun onViewInTimeline(eventId: EventId) { override fun onViewInTimeline(eventId: EventId) {
this@MediaGalleryFlowNode.onViewInTimeline(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) mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.params( .params(

View file

@ -17,6 +17,7 @@ sealed interface MediaViewerEvents {
data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents data class ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ViewInTimeline(val eventId: EventId) : 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 OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ConfirmDelete( data class ConfirmDelete(
val eventId: EventId, val eventId: EventId,

View file

@ -11,5 +11,6 @@ import io.element.android.libraries.matrix.api.core.EventId
interface MediaViewerNavigator { interface MediaViewerNavigator {
fun onViewInTimelineClick(eventId: EventId) fun onViewInTimelineClick(eventId: EventId)
fun onForwardClick(eventId: EventId)
fun onItemDeleted() 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() { override fun onItemDeleted() {
onDone() onDone()
} }

View file

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

View file

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

View file

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

View file

@ -72,6 +72,7 @@ class DefaultMediaViewerEntryPointTest {
val callback = object : MediaViewerEntryPoint.Callback { val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() = lambdaError() override fun onDone() = lambdaError()
override fun onViewInTimeline(eventId: EventId) = lambdaError() override fun onViewInTimeline(eventId: EventId) = lambdaError()
override fun onForwardEvent(eventId: EventId) = lambdaError()
} }
val params = createMediaViewerEntryPointParams() val params = createMediaViewerEntryPointParams()
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null)) val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
@ -115,6 +116,7 @@ class DefaultMediaViewerEntryPointTest {
val callback = object : MediaViewerEntryPoint.Callback { val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() = lambdaError() override fun onDone() = lambdaError()
override fun onViewInTimeline(eventId: EventId) = lambdaError() override fun onViewInTimeline(eventId: EventId) = lambdaError()
override fun onForwardEvent(eventId: EventId) = lambdaError()
} }
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null)) val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.avatar( .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 @Test
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
fun `clicking on Save invokes expected callback`() { fun `clicking on Save invokes expected callback`() {
@ -100,6 +113,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
state: MediaBottomSheetState.MediaDetailsBottomSheetState, state: MediaBottomSheetState.MediaDetailsBottomSheetState,
onViewInTimeline: (EventId) -> Unit = EnsureNeverCalledWithParam(), onViewInTimeline: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onShare: (EventId) -> Unit = EnsureNeverCalledWithParam(), onShare: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onForward: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDownload: (EventId) -> Unit = EnsureNeverCalledWithParam(), onDownload: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(), onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDismiss: () -> Unit = EnsureNeverCalled(), onDismiss: () -> Unit = EnsureNeverCalled(),
@ -109,6 +123,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
state = state, state = state,
onViewInTimeline = onViewInTimeline, onViewInTimeline = onViewInTimeline,
onShare = onShare, onShare = onShare,
onForward = onForward,
onDownload = onDownload, onDownload = onDownload,
onDelete = onDelete, onDelete = onDelete,
onDismiss = onDismiss, 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 import io.element.android.tests.testutils.lambda.lambdaError
class FakeMediaGalleryNavigator( class FakeMediaGalleryNavigator(
private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() } private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() },
private val onForwardClickLambda: (EventId) -> Unit = { lambdaError() },
) : MediaGalleryNavigator { ) : MediaGalleryNavigator {
override fun onViewInTimelineClick(eventId: EventId) { override fun onViewInTimelineClick(eventId: EventId) {
onViewInTimelineClickLambda(eventId) onViewInTimelineClickLambda(eventId)
} }
override fun onForwardClick(eventId: EventId) {
onForwardClickLambda(eventId)
}
} }

View file

@ -345,7 +345,7 @@ class MediaGalleryPresenterTest {
} }
@Test @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 onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMediaGalleryNavigator( val navigator = FakeMediaGalleryNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda, onViewInTimelineClickLambda = onViewInTimelineClickLambda,
@ -353,16 +353,59 @@ class MediaGalleryPresenterTest {
val presenter = createMediaGalleryPresenter( val presenter = createMediaGalleryPresenter(
room = FakeJoinedRoom( room = FakeJoinedRoom(
createTimelineResult = { Result.success(FakeTimeline()) }, createTimelineResult = { Result.success(FakeTimeline()) },
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
),
), ),
navigator = navigator, navigator = navigator,
) )
presenter.test { presenter.test {
val initialState = awaitFirstItem() 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)) 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 @Test
fun `present - load more`() = runTest { fun `present - load more`() = runTest {
val loadMoreLambda = lambdaRecorder<Timeline.PaginationDirection, Unit> { } val loadMoreLambda = lambdaRecorder<Timeline.PaginationDirection, Unit> { }

View file

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

View file

@ -759,7 +759,7 @@ class MediaViewerPresenterTest {
} }
@Test @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 onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMediaViewerNavigator( val navigator = FakeMediaViewerNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda, 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 { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem() return awaitItem()
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:964bac6ba83961f303f2309438a81fb4a09a87a0bc269df1f644b97b52044192 oid sha256:81bdd5170870d3ea5874960b12d9548c36b105a95dcb018f648182b9206f17d6
size 38109 size 40196

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e423c47cee8f2c69b31d418a7a8db850542ee2b0040c99775c85a4260b286235 oid sha256:51594e2535dbe22f9085ff3443fa89ab7fc39c22beab5f592b1a4953bab3a1c9
size 36787 size 38931

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:090412a4d7df8699d54070a04f5514580d46adc497cdbc1b354631506eeeda3b oid sha256:0309be2e3e391a852cb00d5490c92b45255b64d088fd1ae8a5b8472a47ce9f88
size 40872 size 40129

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b7bba63c8d4018b9bba182e78397e94e85040f8a50596d1c528895278e219ea1 oid sha256:add239fbe0f1047435c7ad5df8b62ba765098eb0c5b9703192a7276b6c4e0ccc
size 39333 size 38692

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f47067ff716b6ada901650d138a7f1f88e1f1cb29d9a919d4d5af5d834b571ab oid sha256:d004bb0bb2e6dec6e5bc28038cfb2bddbd68f0c430b9bf3bc1c08de1d90a3301
size 38045 size 38738