[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 356b4fb16c
commit de7bbbd5cf
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,
)
)
}