[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

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