[Message Actions] Add emoji reactions option (#568)
* Add logic to send message reactions * Add emoji library, create EmojiPicker component * Fix bottom sheet behaviors --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
cabedb5f7a
commit
9fa261e393
17 changed files with 388 additions and 27 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Unit> {
|
||||
override fun create(context: android.content.Context) {
|
||||
EmojiManager.install(GoogleEmojiProvider())
|
||||
}
|
||||
|
||||
override fun dependencies(): MutableList<Class<out Initializer<*>>> = mutableListOf()
|
||||
}
|
||||
1
changelog.d/563.feature
Normal file
1
changelog.d/563.feature
Normal file
|
|
@ -0,0 +1 @@
|
|||
Add emoji reactions to the event context menu and allow sending custom reactions.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MessagesState> {
|
||||
|
||||
@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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ interface MatrixRoom : Closeable {
|
|||
|
||||
suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit>
|
||||
|
||||
suspend fun sendReaction(emoji: String, eventId: EventId): Result<Unit>
|
||||
|
||||
suspend fun leave(): Result<Unit>
|
||||
|
||||
suspend fun acceptInvitation(): Result<Unit>
|
||||
|
|
|
|||
|
|
@ -259,6 +259,12 @@ class RustMatrixRoom(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun sendReaction(emoji: String, eventId: EventId): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
innerRoom.sendReaction(key = emoji, eventId = eventId.value)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUnsignedTypes::class)
|
||||
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
|
|
|
|||
|
|
@ -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<Unit> {
|
||||
sendReactionCount++
|
||||
return sendReactionResult
|
||||
}
|
||||
|
||||
var editMessageParameter: String? = null
|
||||
private set
|
||||
|
||||
|
|
@ -279,4 +288,8 @@ class FakeMatrixRoom(
|
|||
fun givenSetTopicResult(result: Result<Unit>) {
|
||||
setTopicResult = result
|
||||
}
|
||||
|
||||
fun givenSendReactionResult(result: Result<Unit>) {
|
||||
sendReactionResult = result
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a70e089f9987f96a1f8bf4b690f38981d3c802f623e4a1420661e8aa10ad1566
|
||||
size 175771
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2b0802887deebf06ca63678751c3553c7bac5765a13cbf85c45f89b76c324173
|
||||
size 175771
|
||||
Loading…
Add table
Add a link
Reference in a new issue