Add Forward action to MediaDetailsBottomSheet. Closes #5454

Improve API of Callback when forwarding Event.
This commit is contained in:
Benoit Marty 2025-10-28 16:02:37 +01:00 committed by Benoit Marty
parent e9cfce915a
commit 21bae4aee2
35 changed files with 190 additions and 36 deletions

View file

@ -342,10 +342,6 @@ class LoggedInFlowNode(
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames)) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames))
} }
override fun onForwardedToSingleRoom(roomId: RoomId) {
sessionCoroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false) }
}
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) { override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
when (data) { when (data) {
is PermalinkData.UserLink -> { is PermalinkData.UserLink -> {

View file

@ -16,6 +16,7 @@ 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
@ -24,6 +25,7 @@ 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.Inputs import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode.Inputs
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode.NavTarget import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode.NavTarget
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
@ -43,6 +45,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
@ -55,6 +59,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,
@ -72,7 +77,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()
} }
@ -130,8 +134,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)
@ -157,6 +161,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 onForwardDone(roomIds: List<RoomId>) {
backstack.pop()
roomIds.singleOrNull()?.let { roomId ->
callbacks.forEach { it.onOpenRoom(roomId, emptyList()) }
}
}
}
forwardEntryPoint.nodeBuilder(this, buildContext)
.params(params)
.callback(callback)
.build()
}
} }
} }
@ -193,8 +213,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(
@ -219,6 +243,9 @@ class JoinedRoomLoadedFlowNode(
@Parcelize @Parcelize
data class RoomMemberDetails(val userId: UserId) : NavTarget data class RoomMemberDetails(val userId: UserId) : NavTarget
@Parcelize
data class ForwardEvent(val eventId: EventId) : NavTarget
@Parcelize @Parcelize
data object RoomNotificationSettings : NavTarget data object RoomNotificationSettings : NavTarget
} }

View file

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

View file

@ -24,7 +24,7 @@ interface ForwardEntryPoint : FeatureEntryPoint {
} }
interface Callback : Plugin { interface Callback : Plugin {
fun onForwardedToSingleRoom(roomId: RoomId) fun onForwardDone(roomIds: List<RoomId>)
} }
data class Params( data class Params(

View file

@ -13,9 +13,9 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.SessionScope
@ContributesBinding(RoomScope::class) @ContributesBinding(SessionScope::class)
class DefaultForwardEntryPoint : ForwardEntryPoint { class DefaultForwardEntryPoint : ForwardEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): ForwardEntryPoint.NodeBuilder { override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): ForwardEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>() val plugins = ArrayList<Plugin>()

View file

@ -23,7 +23,7 @@ import io.element.android.annotations.ContributesNode
import io.element.android.features.forward.api.ForwardEntryPoint 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
@ -31,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,
@ -65,7 +65,7 @@ class ForwardMessagesNode(
} }
override fun onCancel() { override fun onCancel() {
navigateUp() onForwardDone(emptyList())
} }
} }
@ -86,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.onForwardDone(roomIds) }
if (roomIds.size == 1) {
val targetRoomId = roomIds.first()
callbacks.forEach { it.onForwardedToSingleRoom(targetRoomId) }
}
} }
} }

View file

@ -46,7 +46,7 @@ class DefaultForwardEntryPointTest {
) )
} }
val callback = object : ForwardEntryPoint.Callback { val callback = object : ForwardEntryPoint.Callback {
override fun onForwardedToSingleRoom(roomId: RoomId) = lambdaError() override fun onForwardDone(roomIds: List<RoomId>) = lambdaError()
} }
val params = ForwardEntryPoint.Params( val params = ForwardEntryPoint.Params(
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,

View file

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

View file

@ -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
@ -150,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
@ -306,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)
@ -336,8 +345,11 @@ class MessagesFlowNode(
} }
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider) val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
val callback = object : ForwardEntryPoint.Callback { val callback = object : ForwardEntryPoint.Callback {
override fun onForwardedToSingleRoom(roomId: RoomId) { override fun onForwardDone(roomIds: List<RoomId>) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) } backstack.pop()
roomIds.singleOrNull()?.let { roomId ->
callbacks.forEach { it.openRoom(roomId) }
}
} }
} }
forwardEntryPoint.nodeBuilder(this, buildContext) forwardEntryPoint.nodeBuilder(this, buildContext)
@ -489,6 +501,10 @@ class MessagesFlowNode(
callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) } callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
} }
private fun forwardEvent(eventId: EventId) {
callbacks.forEach { it.forwardEvent(eventId) }
}
private fun processEventClick( private fun processEventClick(
timelineMode: Timeline.Mode, timelineMode: Timeline.Mode,
event: TimelineItem.Event, event: TimelineItem.Event,

View file

@ -119,7 +119,8 @@ class DefaultMessagesEntryPointTest {
override fun onRoomDetailsClick() = lambdaError() override fun onRoomDetailsClick() = lambdaError()
override fun onUserDataClick(userId: UserId) = lambdaError() override fun onUserDataClick(userId: UserId) = lambdaError()
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun onForwardedToSingleRoom(roomId: RoomId) = lambdaError() override fun forwardEvent(eventId: EventId) = lambdaError()
override fun openRoom(roomId: RoomId) = lambdaError()
} }
val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID) val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID)
val params = MessagesEntryPoint.Params(initialTarget) val params = MessagesEntryPoint.Params(initialTarget)

View file

@ -13,6 +13,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
@ -36,7 +37,7 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
fun onOpenGlobalNotificationSettings() fun onOpenGlobalNotificationSettings()
fun onOpenRoom(roomId: RoomId, serverNames: List<String>) fun onOpenRoom(roomId: RoomId, serverNames: List<String>)
fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun onForwardedToSingleRoom(roomId: RoomId) fun forwardEvent(eventId: EventId)
} }
interface NodeBuilder { interface NodeBuilder {

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ import kotlinx.coroutines.flow.first
* It could be the live timeline, a pinned timeline or a detached timeline. * It could be the live timeline, a pinned timeline or a detached timeline.
* By default, the active timeline is the live timeline. * By default, the active timeline is the live timeline.
*/ */
interface TimelineProvider { fun interface TimelineProvider {
fun activeTimelineFlow(): StateFlow<Timeline?> fun activeTimelineFlow(): StateFlow<Timeline?>
} }

View file

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

View file

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

View file

@ -33,6 +33,7 @@ dependencies {
implementation(libs.telephoto.flick) implementation(libs.telephoto.flick)
implementation(projects.features.enterprise.api) implementation(projects.features.enterprise.api)
implementation(projects.features.forward.api)
implementation(projects.features.viewfolder.api) implementation(projects.features.viewfolder.api)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)

View file

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

View file

@ -17,6 +17,7 @@ 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 Forward(val eventId: EventId) : MediaGalleryEvents
data class SaveOnDisk(val eventId: EventId?) : MediaGalleryEvents data class SaveOnDisk(val eventId: EventId?) : MediaGalleryEvents
data class OpenInfo(val mediaItem: MediaItem.Event) : MediaGalleryEvents data class OpenInfo(val mediaItem: MediaItem.Event) : MediaGalleryEvents
data class ViewInTimeline(val eventId: EventId) : MediaGalleryEvents data class ViewInTimeline(val eventId: EventId) : MediaGalleryEvents

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
} }