diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt index 2d8af99fcd..7e816791f6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt @@ -22,6 +22,6 @@ import io.element.android.libraries.matrix.api.core.EventId sealed interface MessagesEvents { data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents - data class SendReaction(val emoji: String, val eventId: EventId) : MessagesEvents + data class ToggleReaction(val emoji: String, val eventId: EventId) : MessagesEvents object Dismiss : MessagesEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index c2cf4978d1..a1acd09e02 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -130,8 +130,8 @@ class MessagesPresenter @AssistedInject constructor( is MessagesEvents.HandleAction -> { localCoroutineScope.handleTimelineAction(event.action, event.event, composerState) } - is MessagesEvents.SendReaction -> { - localCoroutineScope.sendReaction(event.emoji, event.eventId) + is MessagesEvents.ToggleReaction -> { + localCoroutineScope.toggleReaction(event.emoji, event.eventId) } is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear) } @@ -168,11 +168,11 @@ class MessagesPresenter @AssistedInject constructor( } } - private fun CoroutineScope.sendReaction( + private fun CoroutineScope.toggleReaction( emoji: String, eventId: EventId, ) = launch(dispatchers.io) { - room.sendReaction(emoji, eventId) + room.toggleReaction(emoji, eventId) .onFailure { Timber.e(it) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 898db2a829..0ea9caf29c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -120,7 +120,7 @@ fun MessagesView( fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) { if (event.eventId == null) return - state.eventSink(MessagesEvents.SendReaction(emoji, event.eventId)) + state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId)) } Scaffold( @@ -150,7 +150,8 @@ fun MessagesView( if (event.sendState is EventSendState.SendingFailed) { state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event)) } - } + }, + onReactionClicked = ::onEmojiReactionClicked ) }, snackbarHost = { @@ -174,7 +175,7 @@ fun MessagesView( state = state.customReactionState, onEmojiSelected = { emoji -> state.customReactionState.selectedEventId?.let { eventId -> - state.eventSink(MessagesEvents.SendReaction(emoji.unicode, eventId)) + state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId)) state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) } } @@ -204,6 +205,7 @@ fun MessagesViewContent( state: MessagesState, onMessageClicked: (TimelineItem.Event) -> Unit, onUserDataClicked: (UserId) -> Unit, + onReactionClicked: (key: String, TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, @@ -223,6 +225,7 @@ fun MessagesViewContent( onMessageLongClicked = onMessageLongClicked, onUserDataClicked = onUserDataClicked, onTimestampClicked = onTimestampClicked, + onReactionClicked = onReactionClicked, ) } if (state.userHasPermissionToSendMessage) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index a0c1bfa4aa..6e50d8e638 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -72,6 +72,7 @@ fun TimelineView( onMessageClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, + onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, ) { fun onReachedLoadMore() { @@ -103,6 +104,7 @@ fun TimelineView( onLongClick = onMessageLongClicked, onUserDataClick = onUserDataClicked, inReplyToClick = ::inReplyToClicked, + onReactionClick = onReactionClicked, onTimestampClicked = onTimestampClicked, ) if (index == state.timelineItems.lastIndex) { @@ -127,6 +129,7 @@ fun TimelineItemRow( onClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, + onReactionClick: (key: String, TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier ) { @@ -162,6 +165,7 @@ fun TimelineItemRow( onLongClick = ::onLongClick, onUserDataClick = onUserDataClick, inReplyToClick = inReplyToClick, + onReactionClick = onReactionClick, onTimestampClicked = onTimestampClicked, modifier = modifier, ) @@ -196,6 +200,7 @@ fun TimelineItemRow( inReplyToClick = inReplyToClick, onUserDataClick = onUserDataClick, onTimestampClicked = onTimestampClicked, + onReactionClick = onReactionClick, ) } } @@ -286,5 +291,6 @@ private fun ContentToPreview(content: TimelineItemEventContent) { onTimestampClicked = {}, onUserDataClicked = {}, onMessageLongClicked = {}, + onReactionClicked = { _, _ -> }, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt index f730717b7b..0afc1e23e2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt @@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.components import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -43,10 +44,10 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.theme.ElementTheme @Composable -fun MessagesReactionButton(reaction: AggregatedReaction, modifier: Modifier = Modifier) { +fun MessagesReactionButton(reaction: AggregatedReaction, modifier: Modifier = Modifier, onClick: () -> Unit) { // First Surface is to render a border with the same background color as the background Surface( - modifier = modifier, + modifier = modifier.clickable(onClick = onClick::invoke), // TODO Should use compound.bgSubtlePrimary color = ElementTheme.legacyColors.gray300, border = BorderStroke(2.dp, MaterialTheme.colorScheme.background), @@ -90,5 +91,5 @@ internal fun MessagesReactionButtonDarkPreview(@PreviewParameter(AggregatedReact @Composable private fun ContentToPreview(reaction: AggregatedReaction) { - MessagesReactionButton(reaction) + MessagesReactionButton(reaction, onClick = { }) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 9f4f35d74b..b42b534dd2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -87,6 +87,7 @@ fun TimelineItemEventRow( onUserDataClick: (UserId) -> Unit, inReplyToClick: (EventId) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, + onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, modifier: Modifier = Modifier ) { val interactionSource = remember { MutableInteractionSource() } @@ -95,6 +96,9 @@ fun TimelineItemEventRow( onUserDataClick(event.senderId) } + fun onReactionClicked(emoji: String) = + onReactionClick(emoji, event) + fun inReplyToClicked() { val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return inReplyToClick(inReplyToEventId) @@ -157,6 +161,7 @@ fun TimelineItemEventRow( if (event.reactionsState.reactions.isNotEmpty()) { TimelineItemReactionsView( reactionsState = event.reactionsState, + onReactionClicked = ::onReactionClicked, modifier = Modifier .align(if (event.isMine) Alignment.BottomEnd else Alignment.BottomStart) .padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp) @@ -422,6 +427,7 @@ private fun ContentToPreview() { onLongClick = {}, onUserDataClick = {}, inReplyToClick = {}, + onReactionClick = { _, _ -> }, onTimestampClicked = {}, ) TimelineItemEventRow( @@ -436,6 +442,7 @@ private fun ContentToPreview() { onLongClick = {}, onUserDataClick = {}, inReplyToClick = {}, + onReactionClick = { _, _ -> }, onTimestampClicked = {}, ) } @@ -476,6 +483,7 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) { onLongClick = {}, onUserDataClick = {}, inReplyToClick = {}, + onReactionClick = { _, _ -> }, onTimestampClicked = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt index 21ca15ab24..e275cf0d55 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight fun TimelineItemReactionsView( reactionsState: TimelineItemReactions, modifier: Modifier = Modifier, + onReactionClicked: (emoji: String) -> Unit ) { FlowRow( modifier = modifier, @@ -37,7 +38,10 @@ fun TimelineItemReactionsView( crossAxisSpacing = 8.dp, ) { reactionsState.reactions.forEach { reaction -> - MessagesReactionButton(reaction = reaction) + MessagesReactionButton( + reaction = reaction, + onClick = { onReactionClicked(reaction.key) } + ) } } } @@ -55,6 +59,7 @@ internal fun TimelineItemReactionsViewDarkPreview() = @Composable private fun ContentToPreview() { TimelineItemReactionsView( - reactionsState = aTimelineItemReactions() + reactionsState = aTimelineItemReactions(), + onReactionClicked = { } ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index 8ec76c6aa4..8279b47a86 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -80,7 +80,7 @@ class MessagesPresenterTest { } @Test - fun `present - handle sending a reaction`() = runTest { + fun `present - handle toggling a reaction`() = runTest { val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) val room = FakeMatrixRoom() val presenter = createMessagePresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers) @@ -89,17 +89,35 @@ class MessagesPresenterTest { }.test { skipItems(1) val initialState = awaitItem() - initialState.eventSink.invoke(MessagesEvents.SendReaction("👍", AN_EVENT_ID)) - assertThat(room.sendReactionCount).isEqualTo(1) + initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID)) + assertThat(room.myReactions.count()).isEqualTo(1) // No crashes when sending a reaction failed - room.givenSendReactionResult(Result.failure(IllegalStateException("Failed to send reaction"))) - initialState.eventSink.invoke(MessagesEvents.SendReaction("👍", AN_EVENT_ID)) - assertThat(room.sendReactionCount).isEqualTo(2) + room.givenToggleReactionResult(Result.failure(IllegalStateException("Failed to send reaction"))) + initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID)) + assertThat(room.myReactions.count()).isEqualTo(1) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } + @Test + fun `present - handle toggling a reaction twice`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val room = FakeMatrixRoom() + val presenter = createMessagePresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID)) + assertThat(room.myReactions.count()).isEqualTo(1) + + initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID)) + assertThat(room.myReactions.count()).isEqualTo(0) + } + } + @Test fun `present - handle action forward`() = runTest { val navigator = FakeMessagesNavigator() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dea291fc58..17adb0b0da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -142,7 +142,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } timber = "com.jakewharton.timber:timber:5.0.1" -matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.25" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.26" sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 53a80e4723..f3618beaf3 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -26,7 +26,6 @@ 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 @@ -83,7 +82,7 @@ interface MatrixRoom : Closeable { suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result - suspend fun sendReaction(emoji: String, eventId: EventId): Result + suspend fun toggleReaction(emoji: String, eventId: EventId): Result suspend fun forwardEvent(eventId: EventId, rooms: List): Result diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index d599029923..4ff56ad755 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -63,7 +63,8 @@ class RustMatrixAuthenticationService @Inject constructor( passphrase = null, // TODO Oidc // oidcClientMetadata = oidcClientMetadata, - customSlidingSyncProxy = null + customSlidingSyncProxy = null, + userAgent = null, // TODO ) private var currentHomeserver = MutableStateFlow(null) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 5d42a3552b..be402b6ea5 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -273,9 +273,9 @@ class RustMatrixRoom( } } - override suspend fun sendReaction(emoji: String, eventId: EventId): Result = withContext(coroutineDispatchers.io) { + override suspend fun toggleReaction(emoji: String, eventId: EventId): Result = withContext(coroutineDispatchers.io) { runCatching { - innerRoom.sendReaction(key = emoji, eventId = eventId.value) + innerRoom.toggleReaction(key = emoji, eventId = eventId.value) } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 6fab4cb053..d329ca349e 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -72,7 +72,7 @@ class FakeMatrixRoom( private var setTopicResult = Result.success(Unit) private var updateAvatarResult = Result.success(Unit) private var removeAvatarResult = Result.success(Unit) - private var sendReactionResult = Result.success(Unit) + private var toggleReactionResult = Result.success(Unit) private var retrySendMessageResult = Result.success(Unit) private var cancelSendResult = Result.success(Unit) private var forwardEventResult = Result.success(Unit) @@ -82,8 +82,8 @@ class FakeMatrixRoom( var sendMediaCount = 0 private set - var sendReactionCount = 0 - private set + private val _myReactions = mutableSetOf() + val myReactions: Set = _myReactions var retrySendMessageCount: Int = 0 private set @@ -146,9 +146,19 @@ class FakeMatrixRoom( Result.success(Unit) } - override suspend fun sendReaction(emoji: String, eventId: EventId): Result { - sendReactionCount++ - return sendReactionResult + override suspend fun toggleReaction(emoji: String, eventId: EventId): Result { + if (toggleReactionResult.isFailure) { + // Don't do the toggle if we failed + return toggleReactionResult + } + + if(_myReactions.contains(emoji)) { + _myReactions.remove(emoji) + } else { + _myReactions.add(emoji) + } + + return toggleReactionResult } override suspend fun retrySendMessage(transactionId: String): Result { @@ -348,8 +358,8 @@ class FakeMatrixRoom( setTopicResult = result } - fun givenSendReactionResult(result: Result) { - sendReactionResult = result + fun givenToggleReactionResult(result: Result) { + toggleReactionResult = result } fun givenRetrySendMessageResult(result: Result) {