Extract code for forwarding Event to its own modules.

This commit is contained in:
Benoit Marty 2025-10-28 13:28:39 +01:00
parent d4de8224c0
commit e9cfce915a
17 changed files with 263 additions and 30 deletions

View file

@ -25,6 +25,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 +34,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 +103,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,
@ -333,13 +334,16 @@ class MessagesFlowNode(
} else {
timelineController
}
val inputs = ForwardMessagesNode.Inputs(navTarget.eventId, timelineProvider)
val callback = object : ForwardMessagesNode.Callback {
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
val callback = object : ForwardEntryPoint.Callback {
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(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)

View file

@ -1,12 +0,0 @@
/*
* Copyright 2023, 2024 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.messages.impl.forward
sealed interface ForwardMessagesEvents {
data object ClearError : ForwardMessagesEvents
}

View file

@ -1,104 +0,0 @@
/*
* Copyright 2023, 2024 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.messages.impl.forward
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
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.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@AssistedInject
class ForwardMessagesNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: ForwardMessagesPresenter.Factory,
private val roomSelectEntryPoint: RoomSelectEntryPoint,
) : ParentNode<ForwardMessagesNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
@Parcelize
object NavTarget : Parcelable
interface Callback : Plugin {
fun onForwardedToSingleRoom(roomId: RoomId)
}
data class Inputs(
val eventId: EventId,
val timelineProvider: TimelineProvider,
) : NodeInputs
private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.eventId.value, inputs.timelineProvider)
private val callbacks = plugins.filterIsInstance<Callback>()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(roomIds: List<RoomId>) {
presenter.onRoomSelected(roomIds)
}
override fun onCancel() {
navigateUp()
}
}
return roomSelectEntryPoint.nodeBuilder(this, buildContext)
.callback(callback)
.params(RoomSelectEntryPoint.Params(mode = RoomSelectMode.Forward))
.build()
}
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {
// Will render to room select screen
Children(
navModel = navModel,
)
val state = presenter.present()
ForwardMessagesView(
state = state,
onForwardSuccess = ::onForwardSuccess,
)
}
}
private fun onForwardSuccess(roomIds: List<RoomId>) {
navigateUp()
if (roomIds.size == 1) {
val targetRoomId = roomIds.first()
callbacks.forEach { it.onForwardedToSingleRoom(targetRoomId) }
}
}
}

View file

@ -1,73 +0,0 @@
/*
* Copyright 2023, 2024 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.messages.impl.forward
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.di.annotations.SessionCoroutineScope
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
@AssistedInject
class ForwardMessagesPresenter(
@Assisted eventId: String,
@Assisted private val timelineProvider: TimelineProvider,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<ForwardMessagesState> {
private val eventId: EventId = EventId(eventId)
@AssistedFactory
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)
}
@Composable
override fun present(): ForwardMessagesState {
fun handleEvents(event: ForwardMessagesEvents) {
when (event) {
ForwardMessagesEvents.ClearError -> forwardingActionState.value = AsyncAction.Uninitialized
}
}
return ForwardMessagesState(
forwardAction = forwardingActionState.value,
eventSink = { handleEvents(it) }
)
}
private fun CoroutineScope.forwardEvent(
eventId: EventId,
roomIds: ImmutableList<RoomId>,
isForwardMessagesState: MutableState<AsyncAction<List<RoomId>>>,
) = launch {
suspend {
timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds).getOrThrow()
roomIds
}.runCatchingUpdatingState(isForwardMessagesState)
}
}

View file

@ -1,16 +0,0 @@
/*
* Copyright 2023, 2024 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.messages.impl.forward
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
data class ForwardMessagesState(
val forwardAction: AsyncAction<List<RoomId>>,
val eventSink: (ForwardMessagesEvents) -> Unit
)

View file

@ -1,38 +0,0 @@
/*
* Copyright 2023, 2024 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.messages.impl.forward
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessagesState> {
override val values: Sequence<ForwardMessagesState>
get() = sequenceOf(
aForwardMessagesState(),
aForwardMessagesState(
forwardAction = AsyncAction.Loading,
),
aForwardMessagesState(
forwardAction = AsyncAction.Success(
listOf(RoomId("!room2:domain")),
)
),
aForwardMessagesState(
forwardAction = AsyncAction.Failure(RuntimeException("error")),
),
)
}
fun aForwardMessagesState(
forwardAction: AsyncAction<List<RoomId>> = AsyncAction.Uninitialized,
eventSink: (ForwardMessagesEvents) -> Unit = {}
) = ForwardMessagesState(
forwardAction = forwardAction,
eventSink = eventSink
)

View file

@ -1,40 +0,0 @@
/*
* Copyright 2023, 2024 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.messages.impl.forward
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.RoomId
@Composable
fun ForwardMessagesView(
state: ForwardMessagesState,
onForwardSuccess: (List<RoomId>) -> Unit,
) {
AsyncActionView(
async = state.forwardAction,
onSuccess = {
onForwardSuccess(it)
},
onErrorDismiss = {
state.eventSink(ForwardMessagesEvents.ClearError)
},
)
}
@PreviewsDayNight
@Composable
internal fun ForwardMessagesViewPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = ElementPreview {
ForwardMessagesView(
state = state,
onForwardSuccess = {}
)
}

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(),

View file

@ -1,102 +0,0 @@
/*
* Copyright 2023, 2024 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.messages.impl.forward
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.EventId
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.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ForwardMessagesPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = aForwardMessagesPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.forwardAction.isUninitialized()).isTrue()
}
}
@Test
fun `present - forward successful`() = runTest {
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
this.forwardEventLambda = forwardEventLambda
}
val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = aForwardMessagesPresenter(fakeRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val summary = aRoomSummary()
presenter.onRoomSelected(listOf(summary.roomId))
val forwardingState = awaitItem()
assertThat(forwardingState.forwardAction.isLoading()).isTrue()
val successfulForwardState = awaitItem()
assertThat(successfulForwardState.forwardAction).isEqualTo(AsyncAction.Success(listOf(summary.roomId)))
forwardEventLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - select a room and forward failed, then clear`() = runTest {
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
Result.failure<Unit>(IllegalStateException("error"))
}
val timeline = FakeTimeline().apply {
this.forwardEventLambda = forwardEventLambda
}
val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = aForwardMessagesPresenter(fakeRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val summary = aRoomSummary()
presenter.onRoomSelected(listOf(summary.roomId))
skipItems(1)
val failedForwardState = awaitItem()
assertThat(failedForwardState.forwardAction.isFailure()).isTrue()
// Then clear error
failedForwardState.eventSink(ForwardMessagesEvents.ClearError)
assertThat(awaitItem().forwardAction.isUninitialized()).isTrue()
forwardEventLambda.assertions().isCalledOnce()
}
}
private fun TestScope.aForwardMessagesPresenter(
eventId: EventId = AN_EVENT_ID,
fakeRoom: FakeJoinedRoom = FakeJoinedRoom(),
) = ForwardMessagesPresenter(
eventId = eventId.value,
timelineProvider = LiveTimelineProvider(fakeRoom),
sessionCoroutineScope = this,
)
}

View file

@ -1,71 +0,0 @@
/*
* Copyright 2024 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.messages.impl.forward
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.testtags.TestTags
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressTag
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ForwardMessagesViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `cancel error emits the expected event`() {
val eventsRecorder = EventsRecorder<ForwardMessagesEvents>()
rule.setForwardMessagesView(
aForwardMessagesState(
forwardAction = AsyncAction.Failure(AN_EXCEPTION),
eventSink = eventsRecorder
),
)
rule.pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(ForwardMessagesEvents.ClearError)
}
@Test
fun `success invokes onForwardSuccess`() {
val data = listOf(A_ROOM_ID)
val eventsRecorder = EventsRecorder<ForwardMessagesEvents>(expectEvents = false)
ensureCalledOnceWithParam<List<RoomId>?>(data) { callback ->
rule.setForwardMessagesView(
aForwardMessagesState(
forwardAction = AsyncAction.Success(data),
eventSink = eventsRecorder
),
onForwardSuccess = callback,
)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setForwardMessagesView(
state: ForwardMessagesState,
onForwardSuccess: (List<RoomId>) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
ForwardMessagesView(
state = state,
onForwardSuccess = onForwardSuccess,
)
}
}