Toggle reactions from the timeline (#707)
This commit is contained in:
parent
b66801a022
commit
366a800a2c
13 changed files with 84 additions and 33 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = { })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = { }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue