[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:
Jorge Martin Espinosa 2023-06-22 13:27:59 +02:00 committed by GitHub
parent d96ecaed94
commit 42827206b3
46 changed files with 1300 additions and 25 deletions

View file

@ -66,6 +66,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@ -238,8 +239,13 @@ class LoggedInFlowNode @AssistedInject constructor(
}
} else {
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)
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs) + nodeLifecycleCallbacks)
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks)
}
}
NavTarget.Settings -> {

View file

@ -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.inputs
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.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
@ -67,6 +68,10 @@ class RoomFlowNode @AssistedInject constructor(
plugins = plugins,
) {
interface Callback : Plugin {
fun onForwardedToSingleRoom(roomId: RoomId)
}
interface LifecycleCallback : NodeLifecycleCallback {
fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit
fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit
@ -78,6 +83,7 @@ class RoomFlowNode @AssistedInject constructor(
) : NodeInputs
private val inputs: Inputs = inputs()
private val callbacks = plugins.filterIsInstance<Callback>()
init {
lifecycle.subscribe(
@ -124,6 +130,10 @@ class RoomFlowNode @AssistedInject constructor(
override fun onUserDataClicked(userId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(userId))
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
}
}
messagesEntryPoint.createNode(this, buildContext, callback)
}

1
changelog.d/486.feature Normal file
View file

@ -0,0 +1 @@
Allow forawrding messages from one room to another

View file

@ -38,4 +38,4 @@
<string name="screen_login_password_hint">"Password"</string>
<string name="screen_login_submit">"Continue"</string>
<string name="screen_login_username_hint">"Username"</string>
</resources>
</resources>

View file

@ -20,6 +20,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
interface MessagesEntryPoint : FeatureEntryPoint {
@ -32,5 +33,6 @@ interface MessagesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onRoomDetailsClicked()
fun onUserDataClicked(userId: UserId)
fun onForwardedToSingleRoom(roomId: RoomId)
}
}

View file

@ -32,6 +32,7 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
import io.element.android.features.messages.impl.forward.ForwardMessagesNode
import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
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.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
@ -78,6 +80,9 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize
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()
@ -105,6 +110,10 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
override fun onForwardEventClicked(eventId: EventId) {
backstack.push(NavTarget.ForwardEvent(eventId))
}
}
createNode<MessagesNode>(buildContext, listOf(callback))
}
@ -124,6 +133,15 @@ class MessagesFlowNode @AssistedInject constructor(
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
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))
}
}
}

View file

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

View file

@ -37,9 +37,10 @@ import kotlinx.collections.immutable.ImmutableList
class MessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: MessagesPresenter,
) : Node(buildContext, plugins = plugins) {
private val presenterFactory: MessagesPresenter.Factory,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this)
private val callback = plugins<Callback>().firstOrNull()
interface Callback : Plugin {
@ -48,6 +49,7 @@ class MessagesNode @AssistedInject constructor(
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
fun onUserDataClicked(userId: UserId)
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClicked(eventId: EventId)
}
private fun onRoomDetailsClicked() {
@ -65,11 +67,14 @@ class MessagesNode @AssistedInject constructor(
private fun onUserDataClicked(userId: UserId) {
callback?.onUserDataClicked(userId)
}
private fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
}
override fun onForwardEventClicked(eventId: EventId) {
callback?.onForwardEventClicked(eventId)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@ -80,7 +85,6 @@ class MessagesNode @AssistedInject constructor(
onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments,
onUserDataClicked = this::onUserDataClicked,
onItemDebugInfoClicked = this::onShowEventDebugInfoClicked,
modifier = modifier,
)
}

View file

@ -25,6 +25,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -65,7 +68,7 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class MessagesPresenter @Inject constructor(
class MessagesPresenter @AssistedInject constructor(
private val room: MatrixRoom,
private val composerPresenter: MessageComposerPresenter,
private val timelinePresenter: TimelinePresenter,
@ -76,8 +79,14 @@ class MessagesPresenter @Inject constructor(
private val snackbarDispatcher: SnackbarDispatcher,
private val messageSummaryFormatter: MessageSummaryFormatter,
private val dispatchers: CoroutineDispatchers,
@Assisted private val navigator: MessagesNavigator,
) : Presenter<MessagesState> {
@AssistedFactory
interface Factory {
fun create(navigator: MessagesNavigator): MessagesPresenter
}
@Composable
override fun present(): MessagesState {
val localCoroutineScope = rememberCoroutineScope()
@ -147,11 +156,11 @@ class MessagesPresenter @Inject constructor(
) = launch {
when (action) {
TimelineItemAction.Copy -> notImplementedYet()
TimelineItemAction.Forward -> notImplementedYet()
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(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()
}
}
@ -222,4 +231,14 @@ class MessagesPresenter @Inject constructor(
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)
}
}

View file

@ -95,7 +95,6 @@ fun MessagesView(
onEventClicked: (event: TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onItemDebugInfoClicked: (EventId, TimelineItemDebugInfo) -> Unit,
modifier: Modifier = Modifier,
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
@ -121,12 +120,7 @@ fun MessagesView(
}
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
when (action) {
is TimelineItemAction.Developer -> if (event.eventId != null) {
onItemDebugInfoClicked(event.eventId, event.debugInfo)
}
else -> state.eventSink(MessagesEvents.HandleAction(action, event))
}
state.eventSink(MessagesEvents.HandleAction(action, event))
}
fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) {
@ -331,6 +325,5 @@ private fun ContentToPreview(state: MessagesState) {
onEventClicked = {},
onPreviewAttachments = {},
onUserDataClicked = {},
onItemDebugInfoClicked = { _, _ -> },
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = {}
)
}

View file

@ -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++
}
}

View file

@ -100,7 +100,8 @@ class MessagesPresenterTest {
@Test
fun `present - handle action forward`() = runTest {
val presenter = createMessagePresenter()
val navigator = FakeMessagesNavigator()
val presenter = createMessagePresenter(navigator = navigator)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -108,6 +109,7 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(navigator.onForwardEventClickedCount).isEqualTo(1)
}
}
@ -308,7 +310,8 @@ class MessagesPresenterTest {
@Test
fun `present - handle action show developer info`() = runTest {
val presenter = createMessagePresenter()
val navigator = FakeMessagesNavigator()
val presenter = createMessagePresenter(navigator = navigator)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -316,6 +319,7 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(navigator.onShowEventDebugInfoClickedCount).isEqualTo(1)
}
}
@ -347,7 +351,8 @@ class MessagesPresenterTest {
private fun TestScope.createMessagePresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixRoom: MatrixRoom = FakeMatrixRoom()
matrixRoom: MatrixRoom = FakeMatrixRoom(),
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
): MessagesPresenter {
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
@ -388,6 +393,7 @@ class MessagesPresenterTest {
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),
messageSummaryFormatter = FakeMessageSummaryFormatter(),
navigator = navigator,
dispatchers = coroutineDispatchers,
)
}

View file

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

View file

@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
class RoomListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomListPresenter,
private val presenter: RoomListPresenter,
) : Node(buildContext, plugins = plugins) {
private fun onRoomClicked(roomId: RoomId) {

View file

@ -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"
}

View file

@ -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.VideoInfo
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.StateFlow
import java.io.Closeable
@ -84,6 +85,8 @@ interface MatrixRoom : Closeable {
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 cancelSend(transactionId: String): Result<Unit>

View file

@ -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.notification.NotificationService
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.RoomMembershipObserver
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.notification.RustNotificationService
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.RustRoomSummaryDataSource
import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy
@ -52,6 +54,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
@ -62,6 +65,7 @@ import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.RequiredState
import org.matrix.rustcomponents.sdk.RoomMessageEventContent
import org.matrix.rustcomponents.sdk.SlidingSyncList
import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder
import org.matrix.rustcomponents.sdk.SlidingSyncListOnceBuilt
@ -199,6 +203,8 @@ class RustMatrixClient constructor(
private val roomMembershipObserver = RoomMembershipObserver()
private val roomContentForwarder = RoomContentForwarder(slidingSync)
init {
client.setDelegate(clientDelegate)
rustRoomSummaryDataSource.init()
@ -220,6 +226,7 @@ class RustMatrixClient constructor(
coroutineScope = coroutineScope,
coroutineDispatchers = dispatchers,
clock = clock,
roomContentForwarder = roomContentForwarder,
)
}

View file

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

View file

@ -50,6 +50,7 @@ import org.matrix.rustcomponents.sdk.SlidingSyncRoom
import org.matrix.rustcomponents.sdk.UpdateSummary
import org.matrix.rustcomponents.sdk.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import timber.log.Timber
import java.io.File
class RustMatrixRoom(
@ -60,6 +61,7 @@ class RustMatrixRoom(
private val coroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers,
private val clock: SystemClock,
private val roomContentForwarder: RoomContentForwarder,
) : MatrixRoom {
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> =
withContext(coroutineDispatchers.io) {
runCatching {

View file

@ -75,9 +75,9 @@ internal class RustRoomSummaryDataSource(
.launchIn(this)
slidingSyncList.state(this)
.onEach { slidingSyncState ->
Timber.v("New sliding sync state: $slidingSyncState")
state.value = slidingSyncState
.onEach { SlidingSyncListLoadingState ->
Timber.v("New sliding sync state: $SlidingSyncListLoadingState")
state.value = SlidingSyncListLoadingState
}.launchIn(this)
}

View file

@ -75,6 +75,7 @@ class FakeMatrixRoom(
private var sendReactionResult = Result.success(Unit)
private var retrySendMessageResult = Result.success(Unit)
private var cancelSendResult = Result.success(Unit)
private var forwardEventResult = Result.success(Unit)
var sendMediaCount = 0
private set
@ -218,6 +219,10 @@ class FakeMatrixRoom(
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 {
sendMediaResult.onSuccess {
sendMediaCount++
@ -329,4 +334,8 @@ class FakeMatrixRoom(
fun givenCancelSendResult(result: Result<Unit>) {
cancelSendResult = result
}
fun givenForwardEventResult(result: Result<Unit>) {
forwardEventResult = result
}
}

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b410a2cd4cdadf7fb69fc0eb307882a1eedb70710ea3a2b8fefff9fe0f4ff3a9
size 13266

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c046931631c1d1ee9abc55e5c03d16ec7fb88d1829973342e3c358b2bd99d6c4
size 12809

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ec44a976cb2a7572df5d18858ccfef5d0c8fe77ef0ed3a0c1d2bd7615aa32324
size 33230

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ad6debeeb7774b50e8f578c0b8c1b91f92ce15d99ac5ccd8401eec7286e098fb
size 32766

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b27c645192fd1e2909483c56d6e4b0251cd1b2cb1e39d7b3d5987eb5a4aad851
size 33038

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b27c645192fd1e2909483c56d6e4b0251cd1b2cb1e39d7b3d5987eb5a4aad851
size 33038

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
size 4457

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b27c645192fd1e2909483c56d6e4b0251cd1b2cb1e39d7b3d5987eb5a4aad851
size 33038

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9d415f55a0fc2e463c53c2182a70fc56d08e969fd01492b5ba4dd712653aede3
size 13018

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:83fb3a09e70802c385ccda4cb46763cc9b949eaeaf9f572c4a38cb8bb1ab6516
size 12518

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:42042704bffae9e397b139effe9fa6213ec9c2167c2139db11b2611a6ebfd9cc
size 31965

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5726218ec996aff3e52aa6539ece216dfe20294d0ee13939b7f0c9da7bd7555f
size 31504

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0a78aa398ce214169e551470b5a45d84c19739c4f222d6bf4bd0307f1c97d0cc
size 32230

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0a78aa398ce214169e551470b5a45d84c19739c4f222d6bf4bd0307f1c97d0cc
size 32230

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
size 4457

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0a78aa398ce214169e551470b5a45d84c19739c4f222d6bf4bd0307f1c97d0cc
size 32230

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fbd3d24533bfa534b3379c8243c2a5af3744a6ef73ed294e1d78faea3ef855fa
size 12949

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:31a47c956fac22a7add0ede634c327112d42c65ffea42e19afdb75d157b55788
size 12390