Toggle reactions from the timeline (#707)

This commit is contained in:
jonnyandrew 2023-06-28 13:02:04 +00:00 committed by GitHub
parent fe3deeec2f
commit 9e8257cc4c
13 changed files with 84 additions and 33 deletions

View file

@ -22,6 +22,6 @@ import io.element.android.libraries.matrix.api.core.EventId
sealed interface MessagesEvents { sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : 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 object Dismiss : MessagesEvents
} }

View file

@ -130,8 +130,8 @@ class MessagesPresenter @AssistedInject constructor(
is MessagesEvents.HandleAction -> { is MessagesEvents.HandleAction -> {
localCoroutineScope.handleTimelineAction(event.action, event.event, composerState) localCoroutineScope.handleTimelineAction(event.action, event.event, composerState)
} }
is MessagesEvents.SendReaction -> { is MessagesEvents.ToggleReaction -> {
localCoroutineScope.sendReaction(event.emoji, event.eventId) localCoroutineScope.toggleReaction(event.emoji, event.eventId)
} }
is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear) 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, emoji: String,
eventId: EventId, eventId: EventId,
) = launch(dispatchers.io) { ) = launch(dispatchers.io) {
room.sendReaction(emoji, eventId) room.toggleReaction(emoji, eventId)
.onFailure { Timber.e(it) } .onFailure { Timber.e(it) }
} }

View file

@ -120,7 +120,7 @@ fun MessagesView(
fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) { fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) {
if (event.eventId == null) return if (event.eventId == null) return
state.eventSink(MessagesEvents.SendReaction(emoji, event.eventId)) state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId))
} }
Scaffold( Scaffold(
@ -150,7 +150,8 @@ fun MessagesView(
if (event.sendState is EventSendState.SendingFailed) { if (event.sendState is EventSendState.SendingFailed) {
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event)) state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
} }
} },
onReactionClicked = ::onEmojiReactionClicked
) )
}, },
snackbarHost = { snackbarHost = {
@ -174,7 +175,7 @@ fun MessagesView(
state = state.customReactionState, state = state.customReactionState,
onEmojiSelected = { emoji -> onEmojiSelected = { emoji ->
state.customReactionState.selectedEventId?.let { eventId -> 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)) state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
} }
} }
@ -204,6 +205,7 @@ fun MessagesViewContent(
state: MessagesState, state: MessagesState,
onMessageClicked: (TimelineItem.Event) -> Unit, onMessageClicked: (TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit, onUserDataClicked: (UserId) -> Unit,
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -223,6 +225,7 @@ fun MessagesViewContent(
onMessageLongClicked = onMessageLongClicked, onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked, onUserDataClicked = onUserDataClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onReactionClicked = onReactionClicked,
) )
} }
if (state.userHasPermissionToSendMessage) { if (state.userHasPermissionToSendMessage) {

View file

@ -72,6 +72,7 @@ fun TimelineView(
onMessageClicked: (TimelineItem.Event) -> Unit, onMessageClicked: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onReachedLoadMore() { fun onReachedLoadMore() {
@ -103,6 +104,7 @@ fun TimelineView(
onLongClick = onMessageLongClicked, onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked, onUserDataClick = onUserDataClicked,
inReplyToClick = ::inReplyToClicked, inReplyToClick = ::inReplyToClicked,
onReactionClick = onReactionClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
) )
if (index == state.timelineItems.lastIndex) { if (index == state.timelineItems.lastIndex) {
@ -127,6 +129,7 @@ fun TimelineItemRow(
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -162,6 +165,7 @@ fun TimelineItemRow(
onLongClick = ::onLongClick, onLongClick = ::onLongClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
modifier = modifier, modifier = modifier,
) )
@ -196,6 +200,7 @@ fun TimelineItemRow(
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
) )
} }
} }
@ -286,5 +291,6 @@ private fun ContentToPreview(content: TimelineItemEventContent) {
onTimestampClicked = {}, onTimestampClicked = {},
onUserDataClicked = {}, onUserDataClicked = {},
onMessageLongClicked = {}, onMessageLongClicked = {},
onReactionClicked = { _, _ -> },
) )
} }

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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 import io.element.android.libraries.theme.ElementTheme
@Composable @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 // First Surface is to render a border with the same background color as the background
Surface( Surface(
modifier = modifier, modifier = modifier.clickable(onClick = onClick::invoke),
// TODO Should use compound.bgSubtlePrimary // TODO Should use compound.bgSubtlePrimary
color = ElementTheme.legacyColors.gray300, color = ElementTheme.legacyColors.gray300,
border = BorderStroke(2.dp, MaterialTheme.colorScheme.background), border = BorderStroke(2.dp, MaterialTheme.colorScheme.background),
@ -90,5 +91,5 @@ internal fun MessagesReactionButtonDarkPreview(@PreviewParameter(AggregatedReact
@Composable @Composable
private fun ContentToPreview(reaction: AggregatedReaction) { private fun ContentToPreview(reaction: AggregatedReaction) {
MessagesReactionButton(reaction) MessagesReactionButton(reaction, onClick = { })
} }

View file

@ -87,6 +87,7 @@ fun TimelineItemEventRow(
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
@ -95,6 +96,9 @@ fun TimelineItemEventRow(
onUserDataClick(event.senderId) onUserDataClick(event.senderId)
} }
fun onReactionClicked(emoji: String) =
onReactionClick(emoji, event)
fun inReplyToClicked() { fun inReplyToClicked() {
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
inReplyToClick(inReplyToEventId) inReplyToClick(inReplyToEventId)
@ -157,6 +161,7 @@ fun TimelineItemEventRow(
if (event.reactionsState.reactions.isNotEmpty()) { if (event.reactionsState.reactions.isNotEmpty()) {
TimelineItemReactionsView( TimelineItemReactionsView(
reactionsState = event.reactionsState, reactionsState = event.reactionsState,
onReactionClicked = ::onReactionClicked,
modifier = Modifier modifier = Modifier
.align(if (event.isMine) Alignment.BottomEnd else Alignment.BottomStart) .align(if (event.isMine) Alignment.BottomEnd else Alignment.BottomStart)
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp) .padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
@ -422,6 +427,7 @@ private fun ContentToPreview() {
onLongClick = {}, onLongClick = {},
onUserDataClick = {}, onUserDataClick = {},
inReplyToClick = {}, inReplyToClick = {},
onReactionClick = { _, _ -> },
onTimestampClicked = {}, onTimestampClicked = {},
) )
TimelineItemEventRow( TimelineItemEventRow(
@ -436,6 +442,7 @@ private fun ContentToPreview() {
onLongClick = {}, onLongClick = {},
onUserDataClick = {}, onUserDataClick = {},
inReplyToClick = {}, inReplyToClick = {},
onReactionClick = { _, _ -> },
onTimestampClicked = {}, onTimestampClicked = {},
) )
} }
@ -476,6 +483,7 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
onLongClick = {}, onLongClick = {},
onUserDataClick = {}, onUserDataClick = {},
inReplyToClick = {}, inReplyToClick = {},
onReactionClick = { _, _ -> },
onTimestampClicked = {}, onTimestampClicked = {},
) )
} }

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
fun TimelineItemReactionsView( fun TimelineItemReactionsView(
reactionsState: TimelineItemReactions, reactionsState: TimelineItemReactions,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onReactionClicked: (emoji: String) -> Unit
) { ) {
FlowRow( FlowRow(
modifier = modifier, modifier = modifier,
@ -37,7 +38,10 @@ fun TimelineItemReactionsView(
crossAxisSpacing = 8.dp, crossAxisSpacing = 8.dp,
) { ) {
reactionsState.reactions.forEach { reaction -> reactionsState.reactions.forEach { reaction ->
MessagesReactionButton(reaction = reaction) MessagesReactionButton(
reaction = reaction,
onClick = { onReactionClicked(reaction.key) }
)
} }
} }
} }
@ -55,6 +59,7 @@ internal fun TimelineItemReactionsViewDarkPreview() =
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
TimelineItemReactionsView( TimelineItemReactionsView(
reactionsState = aTimelineItemReactions() reactionsState = aTimelineItemReactions(),
onReactionClicked = { }
) )
} }

View file

@ -80,7 +80,7 @@ class MessagesPresenterTest {
} }
@Test @Test
fun `present - handle sending a reaction`() = runTest { fun `present - handle toggling a reaction`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = createMessagePresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers) val presenter = createMessagePresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
@ -89,17 +89,35 @@ class MessagesPresenterTest {
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.SendReaction("👍", AN_EVENT_ID)) initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
assertThat(room.sendReactionCount).isEqualTo(1) assertThat(room.myReactions.count()).isEqualTo(1)
// No crashes when sending a reaction failed // No crashes when sending a reaction failed
room.givenSendReactionResult(Result.failure(IllegalStateException("Failed to send reaction"))) room.givenToggleReactionResult(Result.failure(IllegalStateException("Failed to send reaction")))
initialState.eventSink.invoke(MessagesEvents.SendReaction("👍", AN_EVENT_ID)) initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
assertThat(room.sendReactionCount).isEqualTo(2) assertThat(room.myReactions.count()).isEqualTo(1)
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) 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 @Test
fun `present - handle action forward`() = runTest { fun `present - handle action forward`() = runTest {
val navigator = FakeMessagesNavigator() val navigator = FakeMessagesNavigator()

View file

@ -142,7 +142,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1" 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-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-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" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }

View file

@ -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.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable import java.io.Closeable
@ -83,7 +82,7 @@ interface MatrixRoom : Closeable {
suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<Unit> suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<Unit>
suspend fun sendReaction(emoji: String, eventId: EventId): Result<Unit> suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit>
suspend fun forwardEvent(eventId: EventId, rooms: List<RoomId>): Result<Unit> suspend fun forwardEvent(eventId: EventId, rooms: List<RoomId>): Result<Unit>

View file

@ -63,7 +63,8 @@ class RustMatrixAuthenticationService @Inject constructor(
passphrase = null, passphrase = null,
// TODO Oidc // TODO Oidc
// oidcClientMetadata = oidcClientMetadata, // oidcClientMetadata = oidcClientMetadata,
customSlidingSyncProxy = null customSlidingSyncProxy = null,
userAgent = null, // TODO
) )
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null) private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)

View file

@ -273,9 +273,9 @@ class RustMatrixRoom(
} }
} }
override suspend fun sendReaction(emoji: String, eventId: EventId): Result<Unit> = withContext(coroutineDispatchers.io) { override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching { runCatching {
innerRoom.sendReaction(key = emoji, eventId = eventId.value) innerRoom.toggleReaction(key = emoji, eventId = eventId.value)
} }
} }

View file

@ -72,7 +72,7 @@ class FakeMatrixRoom(
private var setTopicResult = Result.success(Unit) private var setTopicResult = Result.success(Unit)
private var updateAvatarResult = Result.success(Unit) private var updateAvatarResult = Result.success(Unit)
private var removeAvatarResult = 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 retrySendMessageResult = Result.success(Unit)
private var cancelSendResult = Result.success(Unit) private var cancelSendResult = Result.success(Unit)
private var forwardEventResult = Result.success(Unit) private var forwardEventResult = Result.success(Unit)
@ -82,8 +82,8 @@ class FakeMatrixRoom(
var sendMediaCount = 0 var sendMediaCount = 0
private set private set
var sendReactionCount = 0 private val _myReactions = mutableSetOf<String>()
private set val myReactions: Set<String> = _myReactions
var retrySendMessageCount: Int = 0 var retrySendMessageCount: Int = 0
private set private set
@ -146,9 +146,19 @@ class FakeMatrixRoom(
Result.success(Unit) Result.success(Unit)
} }
override suspend fun sendReaction(emoji: String, eventId: EventId): Result<Unit> { override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> {
sendReactionCount++ if (toggleReactionResult.isFailure) {
return sendReactionResult // 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<Unit> { override suspend fun retrySendMessage(transactionId: String): Result<Unit> {
@ -348,8 +358,8 @@ class FakeMatrixRoom(
setTopicResult = result setTopicResult = result
} }
fun givenSendReactionResult(result: Result<Unit>) { fun givenToggleReactionResult(result: Result<Unit>) {
sendReactionResult = result toggleReactionResult = result
} }
fun givenRetrySendMessageResult(result: Result<Unit>) { fun givenRetrySendMessageResult(result: Result<Unit>) {