[Message Actions] Forward messages (#635)
* Add forwarding messages base * Make forwarding single-selection --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
356b4fb16c
commit
de7bbbd5cf
46 changed files with 1300 additions and 25 deletions
|
|
@ -66,6 +66,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
|
|
@ -238,8 +239,13 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
|
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
|
||||||
|
val callback = object : RoomFlowNode.Callback {
|
||||||
|
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||||
|
coroutineScope.launch { attachRoom(roomId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
val inputs = RoomFlowNode.Inputs(room, initialElement = navTarget.initialElement)
|
val inputs = RoomFlowNode.Inputs(room, initialElement = navTarget.initialElement)
|
||||||
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs) + nodeLifecycleCallbacks)
|
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NavTarget.Settings -> {
|
NavTarget.Settings -> {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
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.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
|
|
@ -67,6 +68,10 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||||
|
}
|
||||||
|
|
||||||
interface LifecycleCallback : NodeLifecycleCallback {
|
interface LifecycleCallback : NodeLifecycleCallback {
|
||||||
fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit
|
fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit
|
||||||
fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit
|
fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit
|
||||||
|
|
@ -78,6 +83,7 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
private val inputs: Inputs = inputs()
|
||||||
|
private val callbacks = plugins.filterIsInstance<Callback>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
lifecycle.subscribe(
|
lifecycle.subscribe(
|
||||||
|
|
@ -124,6 +130,10 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
override fun onUserDataClicked(userId: UserId) {
|
override fun onUserDataClicked(userId: UserId) {
|
||||||
backstack.push(NavTarget.RoomMemberDetails(userId))
|
backstack.push(NavTarget.RoomMemberDetails(userId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||||
|
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
messagesEntryPoint.createNode(this, buildContext, callback)
|
messagesEntryPoint.createNode(this, buildContext, callback)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
changelog.d/486.feature
Normal file
1
changelog.d/486.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Allow forawrding messages from one room to another
|
||||||
|
|
@ -20,6 +20,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 io.element.android.libraries.architecture.FeatureEntryPoint
|
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
|
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
|
||||||
|
|
||||||
interface MessagesEntryPoint : FeatureEntryPoint {
|
interface MessagesEntryPoint : FeatureEntryPoint {
|
||||||
|
|
@ -32,5 +33,6 @@ interface MessagesEntryPoint : FeatureEntryPoint {
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onRoomDetailsClicked()
|
fun onRoomDetailsClicked()
|
||||||
fun onUserDataClicked(userId: UserId)
|
fun onUserDataClicked(userId: UserId)
|
||||||
|
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import io.element.android.anvilannotations.ContributesNode
|
||||||
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.media.local.MediaInfo
|
import io.element.android.features.messages.impl.media.local.MediaInfo
|
||||||
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
|
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
|
||||||
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
||||||
|
|
@ -43,6 +44,7 @@ import io.element.android.libraries.architecture.BackstackNode
|
||||||
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.RoomScope
|
||||||
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.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
|
|
@ -78,6 +80,9 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class EventDebugInfo(val eventId: EventId, val debugInfo: TimelineItemDebugInfo) : NavTarget
|
data class EventDebugInfo(val eventId: EventId, val debugInfo: TimelineItemDebugInfo) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class ForwardEvent(val eventId: EventId) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
|
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
|
||||||
|
|
@ -105,6 +110,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
||||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onForwardEventClicked(eventId: EventId) {
|
||||||
|
backstack.push(NavTarget.ForwardEvent(eventId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createNode<MessagesNode>(buildContext, listOf(callback))
|
createNode<MessagesNode>(buildContext, listOf(callback))
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +133,15 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
|
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
|
||||||
createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
|
createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
|
||||||
}
|
}
|
||||||
|
is NavTarget.ForwardEvent -> {
|
||||||
|
val inputs = ForwardMessagesNode.Inputs(navTarget.eventId)
|
||||||
|
val callback = object : ForwardMessagesNode.Callback {
|
||||||
|
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||||
|
this@MessagesFlowNode.callback?.onForwardedToSingleRoom(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createNode<ForwardMessagesNode>(buildContext, listOf(inputs, callback))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
|
|
||||||
|
interface MessagesNavigator {
|
||||||
|
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
|
||||||
|
fun onForwardEventClicked(eventId: EventId)
|
||||||
|
}
|
||||||
|
|
@ -37,9 +37,10 @@ import kotlinx.collections.immutable.ImmutableList
|
||||||
class MessagesNode @AssistedInject constructor(
|
class MessagesNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val presenter: MessagesPresenter,
|
private val presenterFactory: MessagesPresenter.Factory,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||||
|
|
||||||
|
private val presenter = presenterFactory.create(this)
|
||||||
private val callback = plugins<Callback>().firstOrNull()
|
private val callback = plugins<Callback>().firstOrNull()
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
|
|
@ -48,6 +49,7 @@ class MessagesNode @AssistedInject constructor(
|
||||||
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
|
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
|
||||||
fun onUserDataClicked(userId: UserId)
|
fun onUserDataClicked(userId: UserId)
|
||||||
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
|
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
|
||||||
|
fun onForwardEventClicked(eventId: EventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onRoomDetailsClicked() {
|
private fun onRoomDetailsClicked() {
|
||||||
|
|
@ -65,11 +67,14 @@ class MessagesNode @AssistedInject constructor(
|
||||||
private fun onUserDataClicked(userId: UserId) {
|
private fun onUserDataClicked(userId: UserId) {
|
||||||
callback?.onUserDataClicked(userId)
|
callback?.onUserDataClicked(userId)
|
||||||
}
|
}
|
||||||
|
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
||||||
private fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
|
||||||
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
|
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onForwardEventClicked(eventId: EventId) {
|
||||||
|
callback?.onForwardEventClicked(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
|
@ -80,7 +85,6 @@ class MessagesNode @AssistedInject constructor(
|
||||||
onEventClicked = this::onEventClicked,
|
onEventClicked = this::onEventClicked,
|
||||||
onPreviewAttachments = this::onPreviewAttachments,
|
onPreviewAttachments = this::onPreviewAttachments,
|
||||||
onUserDataClicked = this::onUserDataClicked,
|
onUserDataClicked = this::onUserDataClicked,
|
||||||
onItemDebugInfoClicked = this::onShowEventDebugInfoClicked,
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
|
@ -65,7 +68,7 @@ import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class MessagesPresenter @Inject constructor(
|
class MessagesPresenter @AssistedInject constructor(
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val composerPresenter: MessageComposerPresenter,
|
private val composerPresenter: MessageComposerPresenter,
|
||||||
private val timelinePresenter: TimelinePresenter,
|
private val timelinePresenter: TimelinePresenter,
|
||||||
|
|
@ -76,8 +79,14 @@ class MessagesPresenter @Inject constructor(
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
private val messageSummaryFormatter: MessageSummaryFormatter,
|
private val messageSummaryFormatter: MessageSummaryFormatter,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
@Assisted private val navigator: MessagesNavigator,
|
||||||
) : Presenter<MessagesState> {
|
) : Presenter<MessagesState> {
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(navigator: MessagesNavigator): MessagesPresenter
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): MessagesState {
|
override fun present(): MessagesState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
@ -147,11 +156,11 @@ class MessagesPresenter @Inject constructor(
|
||||||
) = launch {
|
) = launch {
|
||||||
when (action) {
|
when (action) {
|
||||||
TimelineItemAction.Copy -> notImplementedYet()
|
TimelineItemAction.Copy -> notImplementedYet()
|
||||||
TimelineItemAction.Forward -> notImplementedYet()
|
|
||||||
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
||||||
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
|
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
|
||||||
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
|
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
|
||||||
TimelineItemAction.Developer -> Unit // Handled at UI level
|
TimelineItemAction.Developer -> handleShowDebugInfoAction(targetEvent)
|
||||||
|
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
|
||||||
TimelineItemAction.ReportContent -> notImplementedYet()
|
TimelineItemAction.ReportContent -> notImplementedYet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -222,4 +231,14 @@ class MessagesPresenter @Inject constructor(
|
||||||
MessageComposerEvents.SetMode(composerMode)
|
MessageComposerEvents.SetMode(composerMode)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleShowDebugInfoAction(event: TimelineItem.Event) {
|
||||||
|
if (event.eventId == null) return
|
||||||
|
navigator.onShowEventDebugInfoClicked(event.eventId, event.debugInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleForwardAction(event: TimelineItem.Event) {
|
||||||
|
if (event.eventId == null) return
|
||||||
|
navigator.onForwardEventClicked(event.eventId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,6 @@ fun MessagesView(
|
||||||
onEventClicked: (event: TimelineItem.Event) -> Unit,
|
onEventClicked: (event: TimelineItem.Event) -> Unit,
|
||||||
onUserDataClicked: (UserId) -> Unit,
|
onUserDataClicked: (UserId) -> Unit,
|
||||||
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
||||||
onItemDebugInfoClicked: (EventId, TimelineItemDebugInfo) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||||
|
|
@ -121,12 +120,7 @@ fun MessagesView(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
|
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
|
||||||
when (action) {
|
state.eventSink(MessagesEvents.HandleAction(action, event))
|
||||||
is TimelineItemAction.Developer -> if (event.eventId != null) {
|
|
||||||
onItemDebugInfoClicked(event.eventId, event.debugInfo)
|
|
||||||
}
|
|
||||||
else -> state.eventSink(MessagesEvents.HandleAction(action, event))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) {
|
fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) {
|
||||||
|
|
@ -331,6 +325,5 @@ private fun ContentToPreview(state: MessagesState) {
|
||||||
onEventClicked = {},
|
onEventClicked = {},
|
||||||
onPreviewAttachments = {},
|
onPreviewAttachments = {},
|
||||||
onUserDataClicked = {},
|
onUserDataClicked = {},
|
||||||
onItemDebugInfoClicked = { _, _ -> },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.forward
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||||
|
|
||||||
|
sealed interface ForwardMessagesEvents {
|
||||||
|
data class SetSelectedRoom(val room: RoomSummaryDetails) : ForwardMessagesEvents
|
||||||
|
// TODO remove to restore multi-selection
|
||||||
|
object RemoveSelectedRoom : ForwardMessagesEvents
|
||||||
|
object ToggleSearchActive : ForwardMessagesEvents
|
||||||
|
data class UpdateQuery(val query: String) : ForwardMessagesEvents
|
||||||
|
object ForwardEvent : ForwardMessagesEvents
|
||||||
|
object ClearError : ForwardMessagesEvents
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.forward
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
import io.element.android.libraries.architecture.inputs
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
@ContributesNode(RoomScope::class)
|
||||||
|
class ForwardMessagesNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
presenterFactory: ForwardMessagesPresenter.Factory,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Inputs(val eventId: EventId) : NodeInputs
|
||||||
|
|
||||||
|
private val inputs = inputs<Inputs>()
|
||||||
|
private val presenter = presenterFactory.create(inputs.eventId.value)
|
||||||
|
private val callbacks = plugins.filterIsInstance<Callback>()
|
||||||
|
|
||||||
|
private fun onSucceeded(roomIds: ImmutableList<RoomId>) {
|
||||||
|
navigateUp()
|
||||||
|
if (roomIds.size == 1) {
|
||||||
|
val targetRoomId = roomIds.first()
|
||||||
|
callbacks.forEach { it.onForwardedToSingleRoom(targetRoomId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
ForwardMessagesView(
|
||||||
|
state = state,
|
||||||
|
onDismiss = ::navigateUp,
|
||||||
|
onForwardingSucceeded = ::onSucceeded,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.forward
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.architecture.isLoading
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
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.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummary
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ForwardMessagesPresenter @AssistedInject constructor(
|
||||||
|
@Assisted eventId: String,
|
||||||
|
private val room: MatrixRoom,
|
||||||
|
private val matrixCoroutineScope: CoroutineScope,
|
||||||
|
private val client: MatrixClient,
|
||||||
|
) : Presenter<ForwardMessagesState> {
|
||||||
|
|
||||||
|
private val eventId: EventId = EventId(eventId)
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(eventId: String): ForwardMessagesPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): ForwardMessagesState {
|
||||||
|
var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummaryDetails>()) }
|
||||||
|
var query by remember { mutableStateOf<String>("") }
|
||||||
|
var isSearchActive by remember { mutableStateOf(false) }
|
||||||
|
var results: SearchBarResultState<ImmutableList<RoomSummaryDetails>> by remember { mutableStateOf(SearchBarResultState.NotSearching()) }
|
||||||
|
val forwardingActionState: MutableState<Async<ImmutableList<RoomId>>> = remember { mutableStateOf(Async.Uninitialized) }
|
||||||
|
|
||||||
|
val summaries by client.roomSummaryDataSource.roomSummaries().collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(query, summaries) {
|
||||||
|
val filteredSummaries = summaries.filterIsInstance<RoomSummary.Filled>()
|
||||||
|
.map { it.details }
|
||||||
|
.filter { it.name.contains(query, ignoreCase = true) }
|
||||||
|
.distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received
|
||||||
|
.toPersistentList()
|
||||||
|
results = if (filteredSummaries.isNotEmpty()) {
|
||||||
|
SearchBarResultState.Results(filteredSummaries)
|
||||||
|
} else {
|
||||||
|
SearchBarResultState.NoResults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val forwardingSucceeded by remember {
|
||||||
|
derivedStateOf { forwardingActionState.value.dataOrNull() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvents(event: ForwardMessagesEvents) {
|
||||||
|
when (event) {
|
||||||
|
is ForwardMessagesEvents.SetSelectedRoom -> {
|
||||||
|
selectedRooms = persistentListOf(event.room)
|
||||||
|
// Restore for multi-selection
|
||||||
|
// val index = selectedRooms.indexOfFirst { it.roomId == event.room.roomId }
|
||||||
|
// selectedRooms = if (index >= 0) {
|
||||||
|
// selectedRooms.removeAt(index)
|
||||||
|
// } else {
|
||||||
|
// selectedRooms.add(event.room)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
ForwardMessagesEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf()
|
||||||
|
is ForwardMessagesEvents.UpdateQuery -> query = event.query
|
||||||
|
ForwardMessagesEvents.ToggleSearchActive -> isSearchActive = !isSearchActive
|
||||||
|
ForwardMessagesEvents.ForwardEvent -> {
|
||||||
|
isSearchActive = false
|
||||||
|
val roomIds = selectedRooms.map { it.roomId }.toPersistentList()
|
||||||
|
matrixCoroutineScope.forwardEvent(eventId, roomIds, forwardingActionState)
|
||||||
|
}
|
||||||
|
ForwardMessagesEvents.ClearError -> forwardingActionState.value = Async.Uninitialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ForwardMessagesState(
|
||||||
|
resultState = results,
|
||||||
|
query = query,
|
||||||
|
isSearchActive = isSearchActive,
|
||||||
|
selectedRooms = selectedRooms,
|
||||||
|
isForwarding = forwardingActionState.value.isLoading(),
|
||||||
|
error = (forwardingActionState.value as? Async.Failure)?.error,
|
||||||
|
forwardingSucceeded = forwardingSucceeded,
|
||||||
|
eventSink = { handleEvents(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.forwardEvent(
|
||||||
|
eventId: EventId,
|
||||||
|
roomIds: ImmutableList<RoomId>,
|
||||||
|
isForwardMessagesState: MutableState<Async<ImmutableList<RoomId>>>,
|
||||||
|
) = launch {
|
||||||
|
isForwardMessagesState.value = Async.Loading()
|
||||||
|
room.forwardEvent(eventId, roomIds).fold(
|
||||||
|
{ isForwardMessagesState.value = Async.Success(roomIds) },
|
||||||
|
{ isForwardMessagesState.value = Async.Failure(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.forward
|
||||||
|
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
data class ForwardMessagesState(
|
||||||
|
val resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>>,
|
||||||
|
val query: String,
|
||||||
|
val isSearchActive: Boolean,
|
||||||
|
val selectedRooms: ImmutableList<RoomSummaryDetails>,
|
||||||
|
val isForwarding: Boolean,
|
||||||
|
val error: Throwable?,
|
||||||
|
val forwardingSucceeded: ImmutableList<RoomId>?,
|
||||||
|
val eventSink: (ForwardMessagesEvents) -> Unit
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.forward
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||||
|
import io.element.android.libraries.matrix.api.room.message.RoomMessage
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessagesState> {
|
||||||
|
override val values: Sequence<ForwardMessagesState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aForwardMessagesState(),
|
||||||
|
aForwardMessagesState(query = "Test"),
|
||||||
|
aForwardMessagesState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())),
|
||||||
|
aForwardMessagesState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), query = "Test"),
|
||||||
|
aForwardMessagesState(
|
||||||
|
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
|
||||||
|
query = "Test",
|
||||||
|
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain")))
|
||||||
|
),
|
||||||
|
aForwardMessagesState(
|
||||||
|
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
|
||||||
|
query = "Test",
|
||||||
|
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))),
|
||||||
|
isForwarding = true,
|
||||||
|
),
|
||||||
|
aForwardMessagesState(
|
||||||
|
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
|
||||||
|
query = "Test",
|
||||||
|
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))),
|
||||||
|
forwardingSucceeded = persistentListOf(RoomId("!room2:domain")),
|
||||||
|
),
|
||||||
|
aForwardMessagesState(
|
||||||
|
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
|
||||||
|
query = "Test",
|
||||||
|
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))),
|
||||||
|
error = Throwable("error"),
|
||||||
|
),
|
||||||
|
// Add other states here
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aForwardMessagesState(
|
||||||
|
resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>> = SearchBarResultState.NotSearching(),
|
||||||
|
query: String = "",
|
||||||
|
isSearchActive: Boolean = false,
|
||||||
|
selectedRooms: ImmutableList<RoomSummaryDetails> = persistentListOf(),
|
||||||
|
isForwarding: Boolean = false,
|
||||||
|
error: Throwable? = null,
|
||||||
|
forwardingSucceeded: ImmutableList<RoomId>? = null,
|
||||||
|
) = ForwardMessagesState(
|
||||||
|
resultState = resultState,
|
||||||
|
query = query,
|
||||||
|
isSearchActive = isSearchActive,
|
||||||
|
selectedRooms = selectedRooms,
|
||||||
|
isForwarding = isForwarding,
|
||||||
|
error = error,
|
||||||
|
forwardingSucceeded = forwardingSucceeded,
|
||||||
|
eventSink = {}
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun aForwardMessagesRoomList() = listOf(
|
||||||
|
aRoomDetailsState(),
|
||||||
|
aRoomDetailsState(roomId = RoomId("!room2:domain"), canonicalAlias = "#element-x-room:matrix.org"),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aRoomDetailsState(
|
||||||
|
roomId: RoomId = RoomId("!room:domain"),
|
||||||
|
name: String = "roomName",
|
||||||
|
canonicalAlias: String? = null,
|
||||||
|
isDirect: Boolean = true,
|
||||||
|
avatarURLString: String? = null,
|
||||||
|
lastMessage: RoomMessage? = null,
|
||||||
|
lastMessageTimestamp: Long? = null,
|
||||||
|
unreadNotificationCount: Int = 0,
|
||||||
|
inviter: RoomMember? = null,
|
||||||
|
) = RoomSummaryDetails(
|
||||||
|
roomId = roomId,
|
||||||
|
name = name,
|
||||||
|
canonicalAlias = canonicalAlias,
|
||||||
|
isDirect = isDirect,
|
||||||
|
avatarURLString = avatarURLString,
|
||||||
|
lastMessage = lastMessage,
|
||||||
|
lastMessageTimestamp = lastMessageTimestamp,
|
||||||
|
unreadNotificationCount = unreadNotificationCount,
|
||||||
|
inviter = inviter,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,292 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.forward
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||||
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Divider
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.RadioButton
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
|
import io.element.android.libraries.designsystem.theme.roomListRoomMessage
|
||||||
|
import io.element.android.libraries.designsystem.theme.roomListRoomName
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||||
|
import io.element.android.libraries.matrix.ui.components.SelectedRoom
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ForwardMessagesView(
|
||||||
|
state: ForwardMessagesState,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onForwardingSucceeded: (ImmutableList<RoomId>) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (state.forwardingSucceeded != null) {
|
||||||
|
onForwardingSucceeded(state.forwardingSucceeded)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) {
|
||||||
|
// TODO toggle selection when multi-selection is enabled
|
||||||
|
state.eventSink(ForwardMessagesEvents.RemoveSelectedRoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList<RoomSummaryDetails>) {
|
||||||
|
if (isForwarding) return
|
||||||
|
SelectedRooms(
|
||||||
|
selectedRooms = selectedRooms,
|
||||||
|
onRoomRemoved = ::onRoomRemoved,
|
||||||
|
modifier = Modifier.padding(vertical = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onBackButton(state: ForwardMessagesState) {
|
||||||
|
if (state.isSearchActive) {
|
||||||
|
state.eventSink(ForwardMessagesEvents.ToggleSearchActive)
|
||||||
|
} else {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(onBack = { onBackButton(state) })
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
title = { Text(stringResource(StringR.string.common_forward_message), style = ElementTextStyles.Bold.callout) },
|
||||||
|
navigationIcon = {
|
||||||
|
BackButton(onClick = { onBackButton(state) })
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
TextButton(
|
||||||
|
enabled = state.selectedRooms.isNotEmpty(),
|
||||||
|
onClick = { state.eventSink(ForwardMessagesEvents.ForwardEvent) }
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(StringR.string.action_send))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
.consumeWindowInsets(paddingValues)
|
||||||
|
) {
|
||||||
|
SearchBar<ImmutableList<RoomSummaryDetails>>(
|
||||||
|
placeHolderTitle = stringResource(StringR.string.action_search),
|
||||||
|
query = state.query,
|
||||||
|
onQueryChange = { state.eventSink(ForwardMessagesEvents.UpdateQuery(it)) },
|
||||||
|
active = state.isSearchActive,
|
||||||
|
onActiveChange = { state.eventSink(ForwardMessagesEvents.ToggleSearchActive) },
|
||||||
|
resultState = state.resultState,
|
||||||
|
showBackButton = false,
|
||||||
|
) { summaries ->
|
||||||
|
LazyColumn {
|
||||||
|
item {
|
||||||
|
SelectedRoomsHelper(
|
||||||
|
isForwarding = state.isForwarding,
|
||||||
|
selectedRooms = state.selectedRooms
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(summaries, key = { it.roomId.value }) { roomSummary ->
|
||||||
|
Column {
|
||||||
|
RoomSummaryView(
|
||||||
|
roomSummary,
|
||||||
|
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
|
||||||
|
onSelection = { roomSummary ->
|
||||||
|
state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Divider(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.isSearchActive) {
|
||||||
|
// TODO restore for multi-selection
|
||||||
|
// SelectedRoomsHelper(
|
||||||
|
// isForwarding = state.isForwarding,
|
||||||
|
// selectedRooms = state.selectedRooms
|
||||||
|
// )
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
if (state.resultState is SearchBarResultState.Results) {
|
||||||
|
LazyColumn {
|
||||||
|
items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
|
||||||
|
Column {
|
||||||
|
RoomSummaryView(
|
||||||
|
roomSummary,
|
||||||
|
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
|
||||||
|
onSelection = { roomSummary ->
|
||||||
|
state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Divider(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isForwarding) {
|
||||||
|
ProgressDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error != null) {
|
||||||
|
ForwardingErrorDialog(onDismiss = { state.eventSink(ForwardMessagesEvents.ClearError) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SelectedRooms(
|
||||||
|
selectedRooms: ImmutableList<RoomSummaryDetails>,
|
||||||
|
onRoomRemoved: (RoomSummaryDetails) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
modifier,
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(32.dp)
|
||||||
|
) {
|
||||||
|
items(selectedRooms, key = { it.roomId.value }) { roomSummary ->
|
||||||
|
SelectedRoom(roomSummary = roomSummary, onRoomRemoved = onRoomRemoved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RoomSummaryView(
|
||||||
|
summary: RoomSummaryDetails,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onSelection: (RoomSummaryDetails) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.clickable { onSelection(summary) }
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.height(IntrinsicSize.Min),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
val roomAlias = summary.canonicalAlias ?: summary.roomId.value
|
||||||
|
Avatar(
|
||||||
|
avatarData = AvatarData(id = roomAlias, name = summary.name, url = summary.avatarURLString),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 12.dp, end = 4.dp, top = 8.dp, bottom = 8.dp)
|
||||||
|
.alignByBaseline()
|
||||||
|
.weight(1f)
|
||||||
|
) {
|
||||||
|
// Name
|
||||||
|
Text(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
text = summary.name,
|
||||||
|
color = MaterialTheme.roomListRoomName(),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
// Id
|
||||||
|
Text(
|
||||||
|
text = roomAlias,
|
||||||
|
color = MaterialTheme.roomListRoomMessage(),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RadioButton(selected = isSelected, onClick = { onSelection(summary) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ForwardingErrorDialog(onDismiss: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
ErrorDialog(
|
||||||
|
content = ErrorDialogDefaults.title,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ForwardMessagesViewLightPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) =
|
||||||
|
ElementPreviewLight { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ForwardMessagesViewDarkPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) =
|
||||||
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: ForwardMessagesState) {
|
||||||
|
ForwardMessagesView(
|
||||||
|
state = state,
|
||||||
|
onDismiss = {},
|
||||||
|
onForwardingSucceeded = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages
|
||||||
|
|
||||||
|
import io.element.android.features.messages.impl.MessagesNavigator
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
|
|
||||||
|
class FakeMessagesNavigator : MessagesNavigator {
|
||||||
|
var onShowEventDebugInfoClickedCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
var onForwardEventClickedCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
||||||
|
onShowEventDebugInfoClickedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onForwardEventClicked(eventId: EventId) {
|
||||||
|
onForwardEventClickedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -100,7 +100,8 @@ class MessagesPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - handle action forward`() = runTest {
|
fun `present - handle action forward`() = runTest {
|
||||||
val presenter = createMessagePresenter()
|
val navigator = FakeMessagesNavigator()
|
||||||
|
val presenter = createMessagePresenter(navigator = navigator)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -108,6 +109,7 @@ class MessagesPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
|
assertThat(navigator.onForwardEventClickedCount).isEqualTo(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -308,7 +310,8 @@ class MessagesPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - handle action show developer info`() = runTest {
|
fun `present - handle action show developer info`() = runTest {
|
||||||
val presenter = createMessagePresenter()
|
val navigator = FakeMessagesNavigator()
|
||||||
|
val presenter = createMessagePresenter(navigator = navigator)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -316,6 +319,7 @@ class MessagesPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
|
assertThat(navigator.onShowEventDebugInfoClickedCount).isEqualTo(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,7 +351,8 @@ class MessagesPresenterTest {
|
||||||
|
|
||||||
private fun TestScope.createMessagePresenter(
|
private fun TestScope.createMessagePresenter(
|
||||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||||
matrixRoom: MatrixRoom = FakeMatrixRoom()
|
matrixRoom: MatrixRoom = FakeMatrixRoom(),
|
||||||
|
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
|
||||||
): MessagesPresenter {
|
): MessagesPresenter {
|
||||||
val messageComposerPresenter = MessageComposerPresenter(
|
val messageComposerPresenter = MessageComposerPresenter(
|
||||||
appCoroutineScope = this,
|
appCoroutineScope = this,
|
||||||
|
|
@ -388,6 +393,7 @@ class MessagesPresenterTest {
|
||||||
networkMonitor = FakeNetworkMonitor(),
|
networkMonitor = FakeNetworkMonitor(),
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
||||||
|
navigator = navigator,
|
||||||
dispatchers = coroutineDispatchers,
|
dispatchers = coroutineDispatchers,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.forward
|
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionClock
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.messages.impl.forward.ForwardMessagesEvents
|
||||||
|
import io.element.android.features.messages.impl.forward.ForwardMessagesPresenter
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummary
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ForwardMessagesPresenterTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
val presenter = aPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.selectedRooms).isEmpty()
|
||||||
|
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.NotSearching::class.java)
|
||||||
|
assertThat(initialState.isSearchActive).isFalse()
|
||||||
|
assertThat(initialState.isForwarding).isFalse()
|
||||||
|
assertThat(initialState.error).isNull()
|
||||||
|
assertThat(initialState.forwardingSucceeded).isNull()
|
||||||
|
|
||||||
|
// Search is run automatically
|
||||||
|
val searchState = awaitItem()
|
||||||
|
assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResults::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - toggle search active`() = runTest {
|
||||||
|
val presenter = aPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
val summary = aRoomSummaryDetail()
|
||||||
|
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive)
|
||||||
|
assertThat(awaitItem().isSearchActive).isTrue()
|
||||||
|
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive)
|
||||||
|
assertThat(awaitItem().isSearchActive).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - update query`() = runTest {
|
||||||
|
val roomSummaryDataSource = FakeRoomSummaryDataSource().apply {
|
||||||
|
postRoomSummary(listOf(RoomSummary.Filled(aRoomSummaryDetail())))
|
||||||
|
}
|
||||||
|
val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
|
||||||
|
val presenter = aPresenter(client = client)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetail())))
|
||||||
|
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.UpdateQuery("string not contained"))
|
||||||
|
assertThat(awaitItem().query).isEqualTo("string not contained")
|
||||||
|
assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResults::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - select a room and forward successful`() = runTest {
|
||||||
|
val presenter = aPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
val summary = aRoomSummaryDetail()
|
||||||
|
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary))
|
||||||
|
awaitItem()
|
||||||
|
|
||||||
|
// Test successful forwarding
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.ForwardEvent)
|
||||||
|
|
||||||
|
val forwardingState = awaitItem()
|
||||||
|
assertThat(forwardingState.isSearchActive).isFalse()
|
||||||
|
assertThat(forwardingState.isForwarding).isTrue()
|
||||||
|
|
||||||
|
val successfulForwardState = awaitItem()
|
||||||
|
assertThat(successfulForwardState.isForwarding).isFalse()
|
||||||
|
assertThat(successfulForwardState.forwardingSucceeded).isNotNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - select a room and forward failed, then clear`() = runTest {
|
||||||
|
val room = FakeMatrixRoom()
|
||||||
|
val presenter = aPresenter(fakeMatrixRoom = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
val summary = aRoomSummaryDetail()
|
||||||
|
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary))
|
||||||
|
awaitItem()
|
||||||
|
|
||||||
|
// Test failed forwarding
|
||||||
|
room.givenForwardEventResult(Result.failure(Throwable("error")))
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.ForwardEvent)
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val failedForwardState = awaitItem()
|
||||||
|
assertThat(failedForwardState.isForwarding).isFalse()
|
||||||
|
assertThat(failedForwardState.error).isNotNull()
|
||||||
|
|
||||||
|
// Then clear error
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.ClearError)
|
||||||
|
assertThat(awaitItem().error).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - select and remove a room`() = runTest {
|
||||||
|
val presenter = aPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
val summary = aRoomSummaryDetail()
|
||||||
|
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary))
|
||||||
|
assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary))
|
||||||
|
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.RemoveSelectedRoom)
|
||||||
|
assertThat(awaitItem().selectedRooms).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.aPresenter(
|
||||||
|
eventId: EventId = AN_EVENT_ID,
|
||||||
|
fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
|
||||||
|
coroutineScope: CoroutineScope = this,
|
||||||
|
client: FakeMatrixClient = FakeMatrixClient(),
|
||||||
|
) = ForwardMessagesPresenter(eventId.value, fakeMatrixRoom, coroutineScope, client)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
class RoomListNode @AssistedInject constructor(
|
class RoomListNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val presenter: RoomListPresenter,
|
private val presenter: RoomListPresenter,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
private fun onRoomClicked(roomId: RoomId) {
|
private fun onRoomClicked(roomId: RoomId) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.room
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
||||||
|
class ForwardEventException(
|
||||||
|
val roomIds: List<RoomId>
|
||||||
|
) : Exception() {
|
||||||
|
|
||||||
|
override val message: String? = "Failed to deliver event to $roomIds rooms"
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo
|
||||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
|
|
@ -84,6 +85,8 @@ interface MatrixRoom : Closeable {
|
||||||
|
|
||||||
suspend fun sendReaction(emoji: String, eventId: EventId): Result<Unit>
|
suspend fun sendReaction(emoji: String, eventId: EventId): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun forwardEvent(eventId: EventId, rooms: List<RoomId>): Result<Unit>
|
||||||
|
|
||||||
suspend fun retrySendMessage(transactionId: String): Result<Unit>
|
suspend fun retrySendMessage(transactionId: String): Result<Unit>
|
||||||
|
|
||||||
suspend fun cancelSend(transactionId: String): Result<Unit>
|
suspend fun cancelSend(transactionId: String): Result<Unit>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.createroom.RoomVisibility
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||||
import io.element.android.libraries.matrix.api.pusher.PushersService
|
import io.element.android.libraries.matrix.api.pusher.PushersService
|
||||||
|
import io.element.android.libraries.matrix.api.room.ForwardEventException
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
|
||||||
|
|
@ -40,6 +41,7 @@ import io.element.android.libraries.matrix.impl.core.toProgressWatcher
|
||||||
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
|
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
|
||||||
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
|
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
|
||||||
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
|
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
|
||||||
|
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
|
||||||
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
|
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
|
||||||
import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource
|
import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource
|
||||||
import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy
|
import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy
|
||||||
|
|
@ -52,6 +54,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
@ -62,6 +65,7 @@ import kotlinx.coroutines.withTimeout
|
||||||
import org.matrix.rustcomponents.sdk.Client
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
import org.matrix.rustcomponents.sdk.ClientDelegate
|
import org.matrix.rustcomponents.sdk.ClientDelegate
|
||||||
import org.matrix.rustcomponents.sdk.RequiredState
|
import org.matrix.rustcomponents.sdk.RequiredState
|
||||||
|
import org.matrix.rustcomponents.sdk.RoomMessageEventContent
|
||||||
import org.matrix.rustcomponents.sdk.SlidingSyncList
|
import org.matrix.rustcomponents.sdk.SlidingSyncList
|
||||||
import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder
|
import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder
|
||||||
import org.matrix.rustcomponents.sdk.SlidingSyncListOnceBuilt
|
import org.matrix.rustcomponents.sdk.SlidingSyncListOnceBuilt
|
||||||
|
|
@ -199,6 +203,8 @@ class RustMatrixClient constructor(
|
||||||
|
|
||||||
private val roomMembershipObserver = RoomMembershipObserver()
|
private val roomMembershipObserver = RoomMembershipObserver()
|
||||||
|
|
||||||
|
private val roomContentForwarder = RoomContentForwarder(slidingSync)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
client.setDelegate(clientDelegate)
|
client.setDelegate(clientDelegate)
|
||||||
rustRoomSummaryDataSource.init()
|
rustRoomSummaryDataSource.init()
|
||||||
|
|
@ -220,6 +226,7 @@ class RustMatrixClient constructor(
|
||||||
coroutineScope = coroutineScope,
|
coroutineScope = coroutineScope,
|
||||||
coroutineDispatchers = dispatchers,
|
coroutineDispatchers = dispatchers,
|
||||||
clock = clock,
|
clock = clock,
|
||||||
|
roomContentForwarder = roomContentForwarder,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.room
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.coroutine.parallelMap
|
||||||
|
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.room.ForwardEventException
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import org.matrix.rustcomponents.sdk.Room
|
||||||
|
import org.matrix.rustcomponents.sdk.SlidingSync
|
||||||
|
import org.matrix.rustcomponents.sdk.TimelineDiff
|
||||||
|
import org.matrix.rustcomponents.sdk.TimelineListener
|
||||||
|
import org.matrix.rustcomponents.sdk.genTransactionId
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to forward event contents from a room to a set of other rooms.
|
||||||
|
* @param slidingSync the [SlidingSync] to fetch room instances to forward the event to
|
||||||
|
*/
|
||||||
|
class RoomContentForwarder(
|
||||||
|
private val slidingSync: SlidingSync,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forwards the event with the given [eventId] from the [fromRoom] to the given [toRoomIds].
|
||||||
|
* @param fromRoom the room to forward the event from
|
||||||
|
* @param eventId the id of the event to forward
|
||||||
|
* @param toRoomIds the ids of the rooms to forward the event to
|
||||||
|
* @param timeoutMs the maximum time in milliseconds to wait for the event to be sent to a room
|
||||||
|
*/
|
||||||
|
suspend fun forward(
|
||||||
|
fromRoom: Room,
|
||||||
|
eventId: EventId,
|
||||||
|
toRoomIds: List<RoomId>,
|
||||||
|
timeoutMs: Long = 5000L
|
||||||
|
) {
|
||||||
|
val content = fromRoom.getTimelineEventContentByEventId(eventId.value)
|
||||||
|
val targetSlidingSyncRooms = toRoomIds.mapNotNull { roomId -> slidingSync.getRoom(roomId.value) }
|
||||||
|
val targetRooms = targetSlidingSyncRooms.mapNotNull { slidingSyncRoom -> slidingSyncRoom.use { it.fullRoom() } }
|
||||||
|
val failedForwardingTo = mutableSetOf<RoomId>()
|
||||||
|
targetRooms.parallelMap { room ->
|
||||||
|
room.use { targetRoom ->
|
||||||
|
val result = runCatching {
|
||||||
|
// Sending a message requires a registered timeline listener
|
||||||
|
targetRoom.addTimelineListener(NoOpTimelineListener)
|
||||||
|
withTimeout(timeoutMs.milliseconds) {
|
||||||
|
targetRoom.send(content, genTransactionId())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// After sending, we remove the timeline
|
||||||
|
targetRoom.removeTimeline()
|
||||||
|
result
|
||||||
|
}.onFailure {
|
||||||
|
failedForwardingTo.add(RoomId(room.id()))
|
||||||
|
if (it is CancellationException) {
|
||||||
|
throw it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedForwardingTo.isNotEmpty()) {
|
||||||
|
throw ForwardEventException(toRoomIds.toList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object NoOpTimelineListener: TimelineListener {
|
||||||
|
override fun onUpdate(diff: TimelineDiff) = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,6 +50,7 @@ import org.matrix.rustcomponents.sdk.SlidingSyncRoom
|
||||||
import org.matrix.rustcomponents.sdk.UpdateSummary
|
import org.matrix.rustcomponents.sdk.UpdateSummary
|
||||||
import org.matrix.rustcomponents.sdk.genTransactionId
|
import org.matrix.rustcomponents.sdk.genTransactionId
|
||||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
||||||
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class RustMatrixRoom(
|
class RustMatrixRoom(
|
||||||
|
|
@ -60,6 +61,7 @@ class RustMatrixRoom(
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
private val clock: SystemClock,
|
private val clock: SystemClock,
|
||||||
|
private val roomContentForwarder: RoomContentForwarder,
|
||||||
) : MatrixRoom {
|
) : MatrixRoom {
|
||||||
|
|
||||||
override val membersStateFlow: StateFlow<MatrixRoomMembersState>
|
override val membersStateFlow: StateFlow<MatrixRoomMembersState>
|
||||||
|
|
@ -277,6 +279,14 @@ class RustMatrixRoom(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
|
runCatching {
|
||||||
|
roomContentForwarder.forward(fromRoom = innerRoom, eventId = eventId, toRoomIds = roomIds)
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun retrySendMessage(transactionId: String): Result<Unit> =
|
override suspend fun retrySendMessage(transactionId: String): Result<Unit> =
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,9 @@ internal class RustRoomSummaryDataSource(
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
|
|
||||||
slidingSyncList.state(this)
|
slidingSyncList.state(this)
|
||||||
.onEach { slidingSyncState ->
|
.onEach { SlidingSyncListLoadingState ->
|
||||||
Timber.v("New sliding sync state: $slidingSyncState")
|
Timber.v("New sliding sync state: $SlidingSyncListLoadingState")
|
||||||
state.value = slidingSyncState
|
state.value = SlidingSyncListLoadingState
|
||||||
}.launchIn(this)
|
}.launchIn(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ class FakeMatrixRoom(
|
||||||
private var sendReactionResult = Result.success(Unit)
|
private var sendReactionResult = Result.success(Unit)
|
||||||
private var retrySendMessageResult = Result.success(Unit)
|
private var retrySendMessageResult = Result.success(Unit)
|
||||||
private var cancelSendResult = Result.success(Unit)
|
private var cancelSendResult = Result.success(Unit)
|
||||||
|
private var forwardEventResult = Result.success(Unit)
|
||||||
|
|
||||||
var sendMediaCount = 0
|
var sendMediaCount = 0
|
||||||
private set
|
private set
|
||||||
|
|
@ -218,6 +219,10 @@ class FakeMatrixRoom(
|
||||||
|
|
||||||
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<Unit> = fakeSendMedia()
|
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<Unit> = fakeSendMedia()
|
||||||
|
|
||||||
|
override suspend fun forwardEvent(eventId: EventId, rooms: List<RoomId>): Result<Unit> = simulateLongTask {
|
||||||
|
forwardEventResult
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun fakeSendMedia(): Result<Unit> = simulateLongTask {
|
private suspend fun fakeSendMedia(): Result<Unit> = simulateLongTask {
|
||||||
sendMediaResult.onSuccess {
|
sendMediaResult.onSuccess {
|
||||||
sendMediaCount++
|
sendMediaCount++
|
||||||
|
|
@ -329,4 +334,8 @@ class FakeMatrixRoom(
|
||||||
fun givenCancelSendResult(result: Result<Unit>) {
|
fun givenCancelSendResult(result: Result<Unit>) {
|
||||||
cancelSendResult = result
|
cancelSendResult = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun givenForwardEventResult(result: Result<Unit>) {
|
||||||
|
forwardEventResult = result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SelectedRoom(
|
||||||
|
roomSummary: RoomSummaryDetails,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onRoomRemoved: (RoomSummaryDetails) -> Unit = {},
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier
|
||||||
|
.width(56.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Avatar(AvatarData(roomSummary.roomId.value, roomSummary.name, roomSummary.avatarURLString, AvatarSize.Custom(56.dp)))
|
||||||
|
Text(
|
||||||
|
text = roomSummary.name,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.size(20.dp)
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.clickable(
|
||||||
|
indication = rememberRipple(),
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
onClick = { onRoomRemoved(roomSummary) }
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(id = StringR.string.action_remove),
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.padding(2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun SelectedRoomLightPreview() = ElementPreviewLight { ContentToPreview() }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun SelectedRoomDarkPreview() = ElementPreviewDark { ContentToPreview() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview() {
|
||||||
|
SelectedRoom(roomSummary =
|
||||||
|
RoomSummaryDetails(
|
||||||
|
roomId = RoomId("!room:domain"),
|
||||||
|
name = "roomName",
|
||||||
|
canonicalAlias = null,
|
||||||
|
isDirect = true,
|
||||||
|
avatarURLString = null,
|
||||||
|
lastMessage = null,
|
||||||
|
lastMessageTimestamp = null,
|
||||||
|
unreadNotificationCount = 0,
|
||||||
|
inviter = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b410a2cd4cdadf7fb69fc0eb307882a1eedb70710ea3a2b8fefff9fe0f4ff3a9
|
||||||
|
size 13266
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c046931631c1d1ee9abc55e5c03d16ec7fb88d1829973342e3c358b2bd99d6c4
|
||||||
|
size 12809
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ec44a976cb2a7572df5d18858ccfef5d0c8fe77ef0ed3a0c1d2bd7615aa32324
|
||||||
|
size 33230
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ad6debeeb7774b50e8f578c0b8c1b91f92ce15d99ac5ccd8401eec7286e098fb
|
||||||
|
size 32766
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b27c645192fd1e2909483c56d6e4b0251cd1b2cb1e39d7b3d5987eb5a4aad851
|
||||||
|
size 33038
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b27c645192fd1e2909483c56d6e4b0251cd1b2cb1e39d7b3d5987eb5a4aad851
|
||||||
|
size 33038
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
|
||||||
|
size 4457
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b27c645192fd1e2909483c56d6e4b0251cd1b2cb1e39d7b3d5987eb5a4aad851
|
||||||
|
size 33038
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9d415f55a0fc2e463c53c2182a70fc56d08e969fd01492b5ba4dd712653aede3
|
||||||
|
size 13018
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:83fb3a09e70802c385ccda4cb46763cc9b949eaeaf9f572c4a38cb8bb1ab6516
|
||||||
|
size 12518
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:42042704bffae9e397b139effe9fa6213ec9c2167c2139db11b2611a6ebfd9cc
|
||||||
|
size 31965
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5726218ec996aff3e52aa6539ece216dfe20294d0ee13939b7f0c9da7bd7555f
|
||||||
|
size 31504
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0a78aa398ce214169e551470b5a45d84c19739c4f222d6bf4bd0307f1c97d0cc
|
||||||
|
size 32230
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0a78aa398ce214169e551470b5a45d84c19739c4f222d6bf4bd0307f1c97d0cc
|
||||||
|
size 32230
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
|
||||||
|
size 4457
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0a78aa398ce214169e551470b5a45d84c19739c4f222d6bf4bd0307f1c97d0cc
|
||||||
|
size 32230
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fbd3d24533bfa534b3379c8243c2a5af3744a6ef73ed294e1d78faea3ef855fa
|
||||||
|
size 12949
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:31a47c956fac22a7add0ede634c327112d42c65ffea42e19afdb75d157b55788
|
||||||
|
size 12390
|
||||||
Loading…
Add table
Add a link
Reference in a new issue