diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 68df90b408..a5ca187027 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -219,6 +219,8 @@ dependencies { implementation(libs.network.okhttp.logging) implementation(libs.serialization.json) + implementation(libs.vanniktech.emoji) + implementation(libs.dagger) kapt(libs.dagger.compiler) diff --git a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt index 31dab9ad48..ba8fe290e2 100644 --- a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt +++ b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.x.di.DaggerAppComponent import io.element.android.x.info.logApplicationInfo import io.element.android.x.initializer.CrashInitializer +import io.element.android.x.initializer.EmojiInitializer import io.element.android.x.initializer.MatrixInitializer import io.element.android.x.initializer.TimberInitializer @@ -40,6 +41,7 @@ class ElementXApplication : Application(), DaggerComponentOwner { initializeComponent(CrashInitializer::class.java) initializeComponent(TimberInitializer::class.java) initializeComponent(MatrixInitializer::class.java) + initializeComponent(EmojiInitializer::class.java) } logApplicationInfo() } diff --git a/app/src/main/kotlin/io/element/android/x/initializer/EmojiInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/EmojiInitializer.kt new file mode 100644 index 0000000000..dd1e7455c6 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/initializer/EmojiInitializer.kt @@ -0,0 +1,29 @@ +/* + * 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.x.initializer + +import androidx.startup.Initializer +import com.vanniktech.emoji.EmojiManager +import com.vanniktech.emoji.google.GoogleEmojiProvider + +class EmojiInitializer : Initializer { + override fun create(context: android.content.Context) { + EmojiManager.install(GoogleEmojiProvider()) + } + + override fun dependencies(): MutableList>> = mutableListOf() +} diff --git a/changelog.d/563.feature b/changelog.d/563.feature new file mode 100644 index 0000000000..d8b42567e9 --- /dev/null +++ b/changelog.d/563.feature @@ -0,0 +1 @@ +Add emoji reactions to the event context menu and allow sending custom reactions. diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 26f69ebf81..f8377733a6 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation(libs.accompanist.systemui) implementation(libs.vanniktech.blurhash) implementation(libs.telephoto.zoomableimage) + implementation(libs.vanniktech.emoji) testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) 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 249e94e0e1..4dbf6d42d9 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 @@ -18,7 +18,9 @@ package io.element.android.features.messages.impl import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.timeline.model.TimelineItem +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 } 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 932e89087d..de2916a6b4 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 @@ -45,10 +45,12 @@ import io.element.android.features.messages.impl.utils.messagesummary.MessageSum import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.handleSnackbarMessage +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType @@ -66,6 +68,7 @@ class MessagesPresenter @Inject constructor( private val networkMonitor: NetworkMonitor, private val snackbarDispatcher: SnackbarDispatcher, private val messageSummaryFormatter: MessageSummaryFormatter, + private val dispatchers: CoroutineDispatchers, ) : Presenter { @Composable @@ -103,6 +106,7 @@ class MessagesPresenter @Inject constructor( fun handleEvents(event: MessagesEvents) { when (event) { is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.event, composerState) + is MessagesEvents.SendReaction -> localCoroutineScope.sendReaction(event.emoji, event.eventId) } } return MessagesState( @@ -118,7 +122,7 @@ class MessagesPresenter @Inject constructor( ) } - fun CoroutineScope.handleTimelineAction( + private fun CoroutineScope.handleTimelineAction( action: TimelineItemAction, targetEvent: TimelineItem.Event, composerState: MessageComposerState, @@ -134,6 +138,14 @@ class MessagesPresenter @Inject constructor( } } + private fun CoroutineScope.sendReaction( + emoji: String, + eventId: EventId, + ) = launch(dispatchers.io) { + room.sendReaction(emoji, eventId) + .onFailure { Timber.e(it) } + } + private fun notImplementedYet() { Timber.v("NotImplementedYet") } 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 b2bc8afce3..542f48e1ae 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 @@ -34,8 +34,11 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -56,12 +59,14 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.element.android.features.messages.impl.actionlist.ActionListEvents +import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.MessageComposerView import io.element.android.features.messages.impl.timeline.TimelineView +import io.element.android.features.messages.impl.timeline.components.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.libraries.androidutils.ui.hideKeyboard @@ -85,7 +90,7 @@ import kotlinx.coroutines.launch import timber.log.Timber import io.element.android.libraries.ui.strings.R as StringsR -@OptIn(ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable fun MessagesView( state: MessagesState, @@ -97,8 +102,13 @@ fun MessagesView( onItemDebugInfoClicked: (EventId, TimelineItemDebugInfo) -> Unit, modifier: Modifier = Modifier, ) { + val coroutineScope = rememberCoroutineScope() + val actionListViewBottomSheetState = rememberModalBottomSheetState() + val customReactionBottomSheetState = rememberModalBottomSheetState() + LogCompositions(tag = "MessagesScreen", msg = "Root") var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) } + var isCustomReactionBottomSheetVisible by rememberSaveable { mutableStateOf(false) } AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments) @@ -121,8 +131,14 @@ fun MessagesView( isMessageActionsBottomSheetVisible = true } - fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) { + suspend fun onDismissActionListBottomSheet() { + state.actionListState.eventSink(ActionListEvents.Clear) + actionListViewBottomSheetState.hide() isMessageActionsBottomSheetVisible = false + } + + fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) { + coroutineScope.launch { onDismissActionListBottomSheet() } when (action) { is TimelineItemAction.Developer -> if (event.eventId != null && event.debugInfo != null) { onItemDebugInfoClicked(event.eventId, event.debugInfo) @@ -131,8 +147,10 @@ fun MessagesView( } } - fun onDismissActionListBottomSheet() { - isMessageActionsBottomSheetVisible = false + fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) { + if (event.eventId == null) return + coroutineScope.launch { onDismissActionListBottomSheet() } + state.eventSink(MessagesEvents.SendReaction(emoji, event.eventId)) } Scaffold( @@ -168,11 +186,44 @@ fun MessagesView( }, ) + var reactingToEventId: EventId? by remember { mutableStateOf(null) } ActionListView( state = state.actionListState, + sheetState = actionListViewBottomSheetState, isVisible = isMessageActionsBottomSheetVisible, - onDismiss = ::onDismissActionListBottomSheet, - onActionSelected = ::onActionSelected + onDismiss = { coroutineScope.launch { onDismissActionListBottomSheet() } }, + onActionSelected = ::onActionSelected, + onCustomReactionClicked = { event -> + reactingToEventId = event.eventId + coroutineScope.launch { + onDismissActionListBottomSheet() + isCustomReactionBottomSheetVisible = true + } + }, + onEmojiReactionClicked = ::onEmojiReactionClicked, + ) + + CustomReactionBottomSheet( + isVisible = isCustomReactionBottomSheetVisible, + sheetState = customReactionBottomSheetState, + onDismiss = { + reactingToEventId = null + coroutineScope.launch { + customReactionBottomSheetState.hide() + isCustomReactionBottomSheetVisible = false + } + }, + onEmojiSelected = { emoji -> + val eventId = reactingToEventId + if (eventId != null) { + state.eventSink(MessagesEvents.SendReaction(emoji.unicode, eventId)) + reactingToEventId = null + coroutineScope.launch { + customReactionBottomSheetState.hide() + isCustomReactionBottomSheetVisible = false + } + } + } ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index d7222be960..27ec05fb51 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -17,6 +17,7 @@ package io.element.android.features.messages.impl.actionlist import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -35,10 +36,12 @@ import androidx.compose.material.ListItem import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AddReaction +import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -81,29 +84,41 @@ fun ActionListView( state: ActionListState, isVisible: Boolean, onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit, + onEmojiReactionClicked: (String, TimelineItem.Event) -> Unit, + onCustomReactionClicked: (TimelineItem.Event) -> Unit, onDismiss: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState() ) { - LaunchedEffect(isVisible) { - if (!isVisible) { - state.eventSink(ActionListEvents.Clear) - } - } + val targetItem = (state.target as? ActionListState.Target.Success)?.event fun onItemActionClicked( - itemAction: TimelineItemAction, - targetItem: TimelineItem.Event + itemAction: TimelineItemAction ) { + if (targetItem == null) return onActionSelected(itemAction, targetItem) } + fun onEmojiReactionClicked(emoji: String) { + if (targetItem == null) return + onEmojiReactionClicked(emoji, targetItem) + } + + fun onCustomReactionClicked() { + if (targetItem == null) return + onCustomReactionClicked(targetItem) + } + if (isVisible) { ModalBottomSheet( + sheetState = sheetState, onDismissRequest = onDismiss ) { SheetContent( state = state, onActionClicked = ::onItemActionClicked, + onEmojiReactionClicked = ::onEmojiReactionClicked, + onCustomReactionClicked = ::onCustomReactionClicked, modifier = modifier .padding(bottom = 32.dp) // .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044 @@ -117,8 +132,10 @@ fun ActionListView( @Composable private fun SheetContent( state: ActionListState, + onActionClicked: (TimelineItemAction) -> Unit, + onEmojiReactionClicked: (String) -> Unit, + onCustomReactionClicked: () -> Unit, modifier: Modifier = Modifier, - onActionClicked: (TimelineItemAction, TimelineItem.Event) -> Unit = { _, _ -> }, ) { when (val target = state.target) { is ActionListState.Target.Loading, @@ -142,7 +159,11 @@ private fun SheetContent( } } item { - EmojiReactionsRow(Modifier.fillMaxWidth()) + EmojiReactionsRow( + onEmojiReactionClicked = onEmojiReactionClicked, + onCustomReactionClicked = onCustomReactionClicked, + modifier = Modifier.fillMaxWidth(), + ) Divider() } items( @@ -150,7 +171,7 @@ private fun SheetContent( ) { action -> ListItem( modifier = Modifier.clickable { - onActionClicked(action, target.event) + onActionClicked(action) }, text = { Text( @@ -265,18 +286,26 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif } } +private val emojiRippleRadius = 24.dp + @Composable -internal fun EmojiReactionsRow(modifier: Modifier = Modifier) { +internal fun EmojiReactionsRow( + onEmojiReactionClicked: (String) -> Unit, + onCustomReactionClicked: () -> Unit, + modifier: Modifier = Modifier, +) { Row( horizontalArrangement = Arrangement.SpaceBetween, modifier = modifier.padding(horizontal = 28.dp, vertical = 16.dp) ) { - // TODO use real emojis, have real interaction - Text("\uD83D\uDC4D", fontSize = 28.dpToSp()) - Text("\uD83D\uDC4E", fontSize = 28.dpToSp()) - Text("\uD83D\uDD25", fontSize = 28.dpToSp()) - Text("❤\uFE0F", fontSize = 28.dpToSp()) - Text("\uD83D\uDC4F", fontSize = 28.dpToSp()) + // TODO use most recently used emojis here when available from the Rust SDK + val defaultEmojis = sequenceOf( + "👍", "👎", "🔥", "❤️", "👏" + ) + for (emoji in defaultEmojis) { + EmojiButton(emoji, onEmojiReactionClicked) + } + Icon( imageVector = Icons.Outlined.AddReaction, contentDescription = "Emojis", @@ -284,10 +313,34 @@ internal fun EmojiReactionsRow(modifier: Modifier = Modifier) { modifier = Modifier .size(24.dp) .align(Alignment.CenterVertically) + .clickable( + enabled = true, + onClick = onCustomReactionClicked, + indication = rememberRipple(bounded = false, radius = emojiRippleRadius), + interactionSource = remember { MutableInteractionSource() } + ) ) } } +@Composable +private fun EmojiButton( + emoji: String, + onClicked: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Text( + emoji, + fontSize = 28.dpToSp(), + modifier = modifier.clickable( + enabled = true, + onClick = { onClicked(emoji) }, + indication = rememberRipple(bounded = false, radius = emojiRippleRadius), + interactionSource = remember { MutableInteractionSource() } + ) + ) +} + @Composable private fun Int.dpToSp(): TextUnit = with(LocalDensity.current) { return dp.toSp() @@ -305,5 +358,10 @@ fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) st @Composable private fun ContentToPreview(state: ActionListState) { - SheetContent(state = state) + SheetContent( + state = state, + onActionClicked = {}, + onEmojiReactionClicked = {}, + onCustomReactionClicked = {}, + ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt new file mode 100644 index 0000000000..ca96df7e7d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt @@ -0,0 +1,153 @@ +/* + * 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.features.messages.impl.timeline.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vanniktech.emoji.Emoji +import com.vanniktech.emoji.google.GoogleEmojiProvider +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.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Text +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomReactionBottomSheet( + isVisible: Boolean, + sheetState: SheetState, + onDismiss: () -> Unit, + onEmojiSelected: (Emoji) -> Unit, + modifier: Modifier = Modifier, +) { + if (isVisible) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) { + EmojiPicker(onEmojiSelected = onEmojiSelected, modifier = Modifier.fillMaxSize()) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EmojiPicker( + onEmojiSelected: (Emoji) -> Unit, + modifier: Modifier = Modifier, +) { + val coroutineScope = rememberCoroutineScope() + + val emojiProvider = remember { GoogleEmojiProvider() } + val categories = remember { emojiProvider.categories } + val pagerState = rememberPagerState() + Column (modifier) { + TabRow( + selectedTabIndex = pagerState.currentPage, + ) { + categories.forEachIndexed { index, category -> + Tab( + text = { + Icon( + resourceId = emojiProvider.getIcon(category), + contentDescription = category.categoryNames["en"] + ) + }, + selected = pagerState.currentPage == index, + onClick = { + coroutineScope.launch { pagerState.animateScrollToPage(index) } + } + ) + } + } + + HorizontalPager( + pageCount = categories.size, + state = pagerState, + modifier = Modifier.fillMaxWidth(), + ) { index -> + val category = categories[index] + LazyVerticalGrid( + modifier = Modifier.fillMaxSize(), + columns = GridCells.Adaptive(minSize = 40.dp), + contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + items(category.emojis, key = { it.unicode }) { item -> + Box( + modifier = Modifier + .size(40.dp) + .clickable( + enabled = true, + onClick = { onEmojiSelected(item) }, + indication = rememberRipple(bounded = false, radius = 20.dp), + interactionSource = remember { MutableInteractionSource() } + ), + contentAlignment = Alignment.Center + ) { + Text(text = item.unicode, fontSize = 20.sp) + } + } + } + } + } +} + +@Preview +@Composable +internal fun EmojiPickerLightPreview() { + ElementPreviewLight { ContentToPreview() } +} + +@Preview +@Composable +internal fun EmojiPickerDarkPreview() { + ElementPreviewDark { ContentToPreview() } +} + +@Composable +private fun ContentToPreview() { + EmojiPicker( + onEmojiSelected = {}, + modifier = Modifier.fillMaxWidth() + ) +} 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 4c0c62f7d8..d517b261e2 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 @@ -50,8 +50,10 @@ import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.textcomposer.MessageComposerMode +import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test @@ -71,6 +73,25 @@ class MessagesPresenterTest { } } + @Test + fun `present - handle sending a reaction`() = runTest { + val room = FakeMatrixRoom() + val presenter = createMessagePresenter(matrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.SendReaction("👍", AN_EVENT_ID)) + assertThat(room.sendReactionCount).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) + } + } + @Test fun `present - handle action forward`() = runTest { val presenter = createMessagePresenter() @@ -309,6 +330,7 @@ class MessagesPresenterTest { networkMonitor = FakeNetworkMonitor(), snackbarDispatcher = SnackbarDispatcher(), messageSummaryFormatter = FakeMessageSummaryFormatter(), + dispatchers = testCoroutineDispatchers(testScheduler), ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a69901e23..75772f75ec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -149,6 +149,7 @@ unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" gujun_span = "me.gujun.android:span:1.7" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0" +vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } # Analytics 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 a00561438a..92374b5b00 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 @@ -81,6 +81,8 @@ interface MatrixRoom : Closeable { suspend fun sendFile(file: File, fileInfo: FileInfo): Result + suspend fun sendReaction(emoji: String, eventId: EventId): Result + suspend fun leave(): Result suspend fun acceptInvitation(): Result 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 f24de577e8..63d54892f7 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 @@ -259,6 +259,12 @@ class RustMatrixRoom( } } + override suspend fun sendReaction(emoji: String, eventId: EventId): Result = withContext(Dispatchers.IO) { + runCatching { + innerRoom.sendReaction(key = emoji, eventId = eventId.value) + } + } + @OptIn(ExperimentalUnsignedTypes::class) override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result = withContext(Dispatchers.IO) { 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 b5770a653c..1199d94ac4 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 @@ -70,10 +70,14 @@ 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) var sendMediaCount = 0 private set + var sendReactionCount = 0 + private set + var isInviteAccepted: Boolean = false private set @@ -124,6 +128,11 @@ class FakeMatrixRoom( return Result.success(Unit) } + override suspend fun sendReaction(emoji: String, eventId: EventId): Result { + sendReactionCount++ + return sendReactionResult + } + var editMessageParameter: String? = null private set @@ -279,4 +288,8 @@ class FakeMatrixRoom( fun givenSetTopicResult(result: Result) { setTopicResult = result } + + fun givenSendReactionResult(result: Result) { + sendReactionResult = result + } } diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_EmojiPickerDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_EmojiPickerDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2cd9b799ca --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_EmojiPickerDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a70e089f9987f96a1f8bf4b690f38981d3c802f623e4a1420661e8aa10ad1566 +size 175771 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_EmojiPickerLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_EmojiPickerLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d0b9860232 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_EmojiPickerLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b0802887deebf06ca63678751c3553c7bac5765a13cbf85c45f89b76c324173 +size 175771