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:
commit
3ebcafa705
61 changed files with 539 additions and 89 deletions
|
|
@ -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()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
19
features/forward/api/build.gradle.kts
Normal file
19
features/forward/api/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
38
features/forward/impl/build.gradle.kts
Normal file
38
features/forward/impl/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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?>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> { }
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue