Toggle reactions from the timeline (#707)

This commit is contained in:
jonnyandrew 2023-06-28 13:02:04 +00:00 committed by GitHub
parent b66801a022
commit 366a800a2c
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 {
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
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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.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<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>

View file

@ -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<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 {
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 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<String>()
val myReactions: Set<String> = _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<Unit> {
sendReactionCount++
return sendReactionResult
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> {
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<Unit> {
@ -348,8 +358,8 @@ class FakeMatrixRoom(
setTopicResult = result
}
fun givenSendReactionResult(result: Result<Unit>) {
sendReactionResult = result
fun givenToggleReactionResult(result: Result<Unit>) {
toggleReactionResult = result
}
fun givenRetrySendMessageResult(result: Result<Unit>) {