[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
7972e41b84
commit
e591a588b3
17 changed files with 388 additions and 27 deletions
|
|
@ -219,6 +219,8 @@ dependencies {
|
||||||
implementation(libs.network.okhttp.logging)
|
implementation(libs.network.okhttp.logging)
|
||||||
implementation(libs.serialization.json)
|
implementation(libs.serialization.json)
|
||||||
|
|
||||||
|
implementation(libs.vanniktech.emoji)
|
||||||
|
|
||||||
implementation(libs.dagger)
|
implementation(libs.dagger)
|
||||||
kapt(libs.dagger.compiler)
|
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.di.DaggerAppComponent
|
||||||
import io.element.android.x.info.logApplicationInfo
|
import io.element.android.x.info.logApplicationInfo
|
||||||
import io.element.android.x.initializer.CrashInitializer
|
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.MatrixInitializer
|
||||||
import io.element.android.x.initializer.TimberInitializer
|
import io.element.android.x.initializer.TimberInitializer
|
||||||
|
|
||||||
|
|
@ -40,6 +41,7 @@ class ElementXApplication : Application(), DaggerComponentOwner {
|
||||||
initializeComponent(CrashInitializer::class.java)
|
initializeComponent(CrashInitializer::class.java)
|
||||||
initializeComponent(TimberInitializer::class.java)
|
initializeComponent(TimberInitializer::class.java)
|
||||||
initializeComponent(MatrixInitializer::class.java)
|
initializeComponent(MatrixInitializer::class.java)
|
||||||
|
initializeComponent(EmojiInitializer::class.java)
|
||||||
}
|
}
|
||||||
logApplicationInfo()
|
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.accompanist.systemui)
|
||||||
implementation(libs.vanniktech.blurhash)
|
implementation(libs.vanniktech.blurhash)
|
||||||
implementation(libs.telephoto.zoomableimage)
|
implementation(libs.telephoto.zoomableimage)
|
||||||
|
implementation(libs.vanniktech.emoji)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.coroutines.test)
|
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.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
|
||||||
sealed interface MessagesEvents {
|
sealed interface MessagesEvents {
|
||||||
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
|
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
|
||||||
|
data class SendReaction(val emoji: String, val eventId: EventId) : MessagesEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.libraries.architecture.Presenter
|
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.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
|
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.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||||
|
|
@ -66,6 +68,7 @@ class MessagesPresenter @Inject constructor(
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
private val messageSummaryFormatter: MessageSummaryFormatter,
|
private val messageSummaryFormatter: MessageSummaryFormatter,
|
||||||
|
private val dispatchers: CoroutineDispatchers,
|
||||||
) : Presenter<MessagesState> {
|
) : Presenter<MessagesState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -103,6 +106,7 @@ class MessagesPresenter @Inject constructor(
|
||||||
fun handleEvents(event: MessagesEvents) {
|
fun handleEvents(event: MessagesEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.event, composerState)
|
is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.event, composerState)
|
||||||
|
is MessagesEvents.SendReaction -> localCoroutineScope.sendReaction(event.emoji, event.eventId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return MessagesState(
|
return MessagesState(
|
||||||
|
|
@ -118,7 +122,7 @@ class MessagesPresenter @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun CoroutineScope.handleTimelineAction(
|
private fun CoroutineScope.handleTimelineAction(
|
||||||
action: TimelineItemAction,
|
action: TimelineItemAction,
|
||||||
targetEvent: TimelineItem.Event,
|
targetEvent: TimelineItem.Event,
|
||||||
composerState: MessageComposerState,
|
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() {
|
private fun notImplementedYet() {
|
||||||
Timber.v("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.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SheetState
|
||||||
|
import androidx.compose.material3.SheetValue
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
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.ActionListView
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
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.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
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.messagecomposer.MessageComposerView
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineView
|
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.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
||||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||||
|
|
@ -85,7 +90,7 @@ import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import io.element.android.libraries.ui.strings.R as StringsR
|
import io.element.android.libraries.ui.strings.R as StringsR
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesView(
|
fun MessagesView(
|
||||||
state: MessagesState,
|
state: MessagesState,
|
||||||
|
|
@ -97,8 +102,13 @@ fun MessagesView(
|
||||||
onItemDebugInfoClicked: (EventId, TimelineItemDebugInfo) -> Unit,
|
onItemDebugInfoClicked: (EventId, TimelineItemDebugInfo) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val actionListViewBottomSheetState = rememberModalBottomSheetState()
|
||||||
|
val customReactionBottomSheetState = rememberModalBottomSheetState()
|
||||||
|
|
||||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||||
var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
|
var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var isCustomReactionBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
|
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
|
||||||
|
|
||||||
|
|
@ -121,8 +131,14 @@ fun MessagesView(
|
||||||
isMessageActionsBottomSheetVisible = true
|
isMessageActionsBottomSheetVisible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
|
suspend fun onDismissActionListBottomSheet() {
|
||||||
|
state.actionListState.eventSink(ActionListEvents.Clear)
|
||||||
|
actionListViewBottomSheetState.hide()
|
||||||
isMessageActionsBottomSheetVisible = false
|
isMessageActionsBottomSheetVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
|
||||||
|
coroutineScope.launch { onDismissActionListBottomSheet() }
|
||||||
when (action) {
|
when (action) {
|
||||||
is TimelineItemAction.Developer -> if (event.eventId != null && event.debugInfo != null) {
|
is TimelineItemAction.Developer -> if (event.eventId != null && event.debugInfo != null) {
|
||||||
onItemDebugInfoClicked(event.eventId, event.debugInfo)
|
onItemDebugInfoClicked(event.eventId, event.debugInfo)
|
||||||
|
|
@ -131,8 +147,10 @@ fun MessagesView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onDismissActionListBottomSheet() {
|
fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) {
|
||||||
isMessageActionsBottomSheetVisible = false
|
if (event.eventId == null) return
|
||||||
|
coroutineScope.launch { onDismissActionListBottomSheet() }
|
||||||
|
state.eventSink(MessagesEvents.SendReaction(emoji, event.eventId))
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -168,11 +186,44 @@ fun MessagesView(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var reactingToEventId: EventId? by remember { mutableStateOf(null) }
|
||||||
ActionListView(
|
ActionListView(
|
||||||
state = state.actionListState,
|
state = state.actionListState,
|
||||||
|
sheetState = actionListViewBottomSheetState,
|
||||||
isVisible = isMessageActionsBottomSheetVisible,
|
isVisible = isMessageActionsBottomSheetVisible,
|
||||||
onDismiss = ::onDismissActionListBottomSheet,
|
onDismiss = { coroutineScope.launch { onDismissActionListBottomSheet() } },
|
||||||
onActionSelected = ::onActionSelected
|
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
|
package io.element.android.features.messages.impl.actionlist
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -35,10 +36,12 @@ import androidx.compose.material.ListItem
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.AddReaction
|
import androidx.compose.material.icons.outlined.AddReaction
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SheetState
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -81,29 +84,41 @@ fun ActionListView(
|
||||||
state: ActionListState,
|
state: ActionListState,
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
|
onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
|
||||||
|
onEmojiReactionClicked: (String, TimelineItem.Event) -> Unit,
|
||||||
|
onCustomReactionClicked: (TimelineItem.Event) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
sheetState: SheetState = rememberModalBottomSheetState()
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(isVisible) {
|
val targetItem = (state.target as? ActionListState.Target.Success)?.event
|
||||||
if (!isVisible) {
|
|
||||||
state.eventSink(ActionListEvents.Clear)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onItemActionClicked(
|
fun onItemActionClicked(
|
||||||
itemAction: TimelineItemAction,
|
itemAction: TimelineItemAction
|
||||||
targetItem: TimelineItem.Event
|
|
||||||
) {
|
) {
|
||||||
|
if (targetItem == null) return
|
||||||
onActionSelected(itemAction, targetItem)
|
onActionSelected(itemAction, targetItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onEmojiReactionClicked(emoji: String) {
|
||||||
|
if (targetItem == null) return
|
||||||
|
onEmojiReactionClicked(emoji, targetItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onCustomReactionClicked() {
|
||||||
|
if (targetItem == null) return
|
||||||
|
onCustomReactionClicked(targetItem)
|
||||||
|
}
|
||||||
|
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
|
sheetState = sheetState,
|
||||||
onDismissRequest = onDismiss
|
onDismissRequest = onDismiss
|
||||||
) {
|
) {
|
||||||
SheetContent(
|
SheetContent(
|
||||||
state = state,
|
state = state,
|
||||||
onActionClicked = ::onItemActionClicked,
|
onActionClicked = ::onItemActionClicked,
|
||||||
|
onEmojiReactionClicked = ::onEmojiReactionClicked,
|
||||||
|
onCustomReactionClicked = ::onCustomReactionClicked,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(bottom = 32.dp)
|
.padding(bottom = 32.dp)
|
||||||
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
|
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
|
||||||
|
|
@ -117,8 +132,10 @@ fun ActionListView(
|
||||||
@Composable
|
@Composable
|
||||||
private fun SheetContent(
|
private fun SheetContent(
|
||||||
state: ActionListState,
|
state: ActionListState,
|
||||||
|
onActionClicked: (TimelineItemAction) -> Unit,
|
||||||
|
onEmojiReactionClicked: (String) -> Unit,
|
||||||
|
onCustomReactionClicked: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onActionClicked: (TimelineItemAction, TimelineItem.Event) -> Unit = { _, _ -> },
|
|
||||||
) {
|
) {
|
||||||
when (val target = state.target) {
|
when (val target = state.target) {
|
||||||
is ActionListState.Target.Loading,
|
is ActionListState.Target.Loading,
|
||||||
|
|
@ -142,7 +159,11 @@ private fun SheetContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
EmojiReactionsRow(Modifier.fillMaxWidth())
|
EmojiReactionsRow(
|
||||||
|
onEmojiReactionClicked = onEmojiReactionClicked,
|
||||||
|
onCustomReactionClicked = onCustomReactionClicked,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
items(
|
items(
|
||||||
|
|
@ -150,7 +171,7 @@ private fun SheetContent(
|
||||||
) { action ->
|
) { action ->
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
onActionClicked(action, target.event)
|
onActionClicked(action)
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -265,18 +286,26 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val emojiRippleRadius = 24.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun EmojiReactionsRow(modifier: Modifier = Modifier) {
|
internal fun EmojiReactionsRow(
|
||||||
|
onEmojiReactionClicked: (String) -> Unit,
|
||||||
|
onCustomReactionClicked: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = modifier.padding(horizontal = 28.dp, vertical = 16.dp)
|
modifier = modifier.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||||
) {
|
) {
|
||||||
// TODO use real emojis, have real interaction
|
// TODO use most recently used emojis here when available from the Rust SDK
|
||||||
Text("\uD83D\uDC4D", fontSize = 28.dpToSp())
|
val defaultEmojis = sequenceOf(
|
||||||
Text("\uD83D\uDC4E", fontSize = 28.dpToSp())
|
"👍", "👎", "🔥", "❤️", "👏"
|
||||||
Text("\uD83D\uDD25", fontSize = 28.dpToSp())
|
)
|
||||||
Text("❤\uFE0F", fontSize = 28.dpToSp())
|
for (emoji in defaultEmojis) {
|
||||||
Text("\uD83D\uDC4F", fontSize = 28.dpToSp())
|
EmojiButton(emoji, onEmojiReactionClicked)
|
||||||
|
}
|
||||||
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Outlined.AddReaction,
|
imageVector = Icons.Outlined.AddReaction,
|
||||||
contentDescription = "Emojis",
|
contentDescription = "Emojis",
|
||||||
|
|
@ -284,10 +313,34 @@ internal fun EmojiReactionsRow(modifier: Modifier = Modifier) {
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(24.dp)
|
.size(24.dp)
|
||||||
.align(Alignment.CenterVertically)
|
.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
|
@Composable
|
||||||
private fun Int.dpToSp(): TextUnit = with(LocalDensity.current) {
|
private fun Int.dpToSp(): TextUnit = with(LocalDensity.current) {
|
||||||
return dp.toSp()
|
return dp.toSp()
|
||||||
|
|
@ -305,5 +358,10 @@ fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) st
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview(state: ActionListState) {
|
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.api.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
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
|
@Test
|
||||||
fun `present - handle action forward`() = runTest {
|
fun `present - handle action forward`() = runTest {
|
||||||
val presenter = createMessagePresenter()
|
val presenter = createMessagePresenter()
|
||||||
|
|
@ -309,6 +330,7 @@ class MessagesPresenterTest {
|
||||||
networkMonitor = FakeNetworkMonitor(),
|
networkMonitor = FakeNetworkMonitor(),
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
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"
|
gujun_span = "me.gujun.android:span:1.7"
|
||||||
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
|
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
|
||||||
vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
|
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" }
|
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,8 @@ interface MatrixRoom : Closeable {
|
||||||
|
|
||||||
suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit>
|
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 leave(): Result<Unit>
|
||||||
|
|
||||||
suspend fun acceptInvitation(): 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)
|
@OptIn(ExperimentalUnsignedTypes::class)
|
||||||
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> =
|
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|
|
||||||
|
|
@ -70,10 +70,14 @@ class FakeMatrixRoom(
|
||||||
private var setTopicResult = Result.success(Unit)
|
private var setTopicResult = Result.success(Unit)
|
||||||
private var updateAvatarResult = Result.success(Unit)
|
private var updateAvatarResult = Result.success(Unit)
|
||||||
private var removeAvatarResult = Result.success(Unit)
|
private var removeAvatarResult = Result.success(Unit)
|
||||||
|
private var sendReactionResult = Result.success(Unit)
|
||||||
|
|
||||||
var sendMediaCount = 0
|
var sendMediaCount = 0
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var sendReactionCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
var isInviteAccepted: Boolean = false
|
var isInviteAccepted: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
|
@ -124,6 +128,11 @@ class FakeMatrixRoom(
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun sendReaction(emoji: String, eventId: EventId): Result<Unit> {
|
||||||
|
sendReactionCount++
|
||||||
|
return sendReactionResult
|
||||||
|
}
|
||||||
|
|
||||||
var editMessageParameter: String? = null
|
var editMessageParameter: String? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
|
@ -279,4 +288,8 @@ class FakeMatrixRoom(
|
||||||
fun givenSetTopicResult(result: Result<Unit>) {
|
fun givenSetTopicResult(result: Result<Unit>) {
|
||||||
setTopicResult = result
|
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