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 b901f7e130..dacaa9abb0 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 @@ -22,7 +22,7 @@ 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 ToggleReaction(val emoji: String, val eventId: EventId) : MessagesEvents + data class ToggleReaction(val reaction: String, val eventId: EventId) : MessagesEvents data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents data object Dismiss : 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 579ea87868..5027f83f0f 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 @@ -196,7 +196,7 @@ class MessagesPresenter @AssistedInject constructor( ) } is MessagesEvents.ToggleReaction -> { - localCoroutineScope.toggleReaction(event.emoji, event.eventId) + localCoroutineScope.toggleReaction(event.reaction, event.eventId) } is MessagesEvents.InviteDialogDismissed -> { hasDismissedInviteDialog = true 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 abb0fb5cb7..9c04a57206 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 @@ -258,6 +258,9 @@ fun MessagesView( state = state.customReactionState, onEmojiSelected = { eventId, emoji -> state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId)) + }, + onReactionSelected = { eventId, reaction -> + state.eventSink(MessagesEvents.ToggleReaction(reaction, eventId)) } ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt index 7786a69db6..1a142bcbdc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt @@ -111,12 +111,16 @@ fun MessagesReactionButton( @Immutable sealed interface MessagesReactionsButtonContent { - data class Text(val text: String) : MessagesReactionsButtonContent + data class Text(val text: String, val highlight: Boolean = false) : MessagesReactionsButtonContent data class Icon(@DrawableRes val resourceId: Int) : MessagesReactionsButtonContent data class Reaction(val reaction: AggregatedReaction) : MessagesReactionsButtonContent - val isHighlighted get() = this is Reaction && reaction.isHighlighted + val isHighlighted get() = when(this) { + is Reaction -> reaction.isHighlighted + is Text -> highlight + else -> false + } } internal val REACTION_EMOJI_LINE_HEIGHT = 20.sp diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt index 5438c76dcc..48008dd4c1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt @@ -41,6 +41,7 @@ import io.element.android.libraries.matrix.api.core.EventId fun CustomReactionBottomSheet( state: CustomReactionState, onEmojiSelected: (EventId, Emoji) -> Unit, + onReactionSelected: (EventId, String) -> Unit, modifier: Modifier = Modifier, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = state.searchState.startActive) @@ -62,6 +63,14 @@ fun CustomReactionBottomSheet( } } + fun onReactionSelectedDismiss(reaction: String) { + if (target?.event?.eventId == null) return + sheetState.hide(coroutineScope) { + state.eventSink(CustomReactionEvents.DismissCustomReactionSheet) + onReactionSelected(target.event.eventId, reaction) + } + } + if (target?.emojibaseStore != null && target.event.eventId != null) { ModalBottomSheet( onDismissRequest = ::onDismiss, @@ -80,6 +89,7 @@ fun CustomReactionBottomSheet( ) { EmojiPicker( onEmojiSelected = ::onEmojiSelectedDismiss, + onReactionSelected = ::onReactionSelectedDismiss, emojibaseStore = target.emojibaseStore, selectedEmojis = state.selectedEmoji, state = state.searchState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt index fb4e22a567..1d06b56ce8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt @@ -73,7 +73,7 @@ class CustomReactionPresenter @Inject constructor( return CustomReactionState( target = target.value, selectedEmoji = selectedEmoji, - eventSink = { handleEvents(it) }, + eventSink = ::handleEvents, searchState = searchState, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt index 409ae9bea6..c13bae663b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -45,6 +46,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -62,16 +64,20 @@ import io.element.android.emojibasebindings.EmojibaseCategory import io.element.android.emojibasebindings.EmojibaseDatasource import io.element.android.emojibasebindings.EmojibaseStore import io.element.android.emojibasebindings.allEmojis +import io.element.android.features.messages.impl.timeline.components.MessagesReactionButton +import io.element.android.features.messages.impl.timeline.components.MessagesReactionsButtonContent +import io.element.android.features.messages.impl.timeline.model.MAX_REACTION_LENGTH_CHARS +import io.element.android.libraries.core.extensions.ellipsize import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toSp import io.element.android.libraries.designsystem.theme.components.ElementSearchBarDefaults +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.launch @@ -80,6 +86,7 @@ import kotlinx.coroutines.launch @Composable fun EmojiPicker( onEmojiSelected: (Emoji) -> Unit, + onReactionSelected: (String) -> Unit, emojibaseStore: EmojibaseStore, selectedEmojis: ImmutableSet, state: EmojiPickerState, @@ -111,54 +118,56 @@ fun EmojiPicker( } } ) { - if (state.searchQuery.isEmpty()) { - SecondaryTabRow( - selectedTabIndex = pagerState.currentPage, - ) { - EmojibaseCategory.entries.forEachIndexed { index, category -> - Tab(icon = { - Icon( - imageVector = category.icon, contentDescription = stringResource(id = category.title) - ) - }, selected = pagerState.currentPage == index, onClick = { - coroutineScope.launch { pagerState.animateScrollToPage(index) } - }) - } - } - - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxWidth(), - ) { index -> - val category = EmojibaseCategory.entries[index] - val emojis = categories[category] ?: listOf() - EmojiGrid(emojis = emojis, selectedEmojis = selectedEmojis, onEmojiSelected = onEmojiSelected) - } - } else { - when (state.searchResults) { - is SearchBarResultState.Results> -> { - EmojiGrid( - emojis = state.searchResults.results, - selectedEmojis = selectedEmojis, - onEmojiSelected = onEmojiSelected, - ) + when (state.searchResults) { + is SearchBarResultState.Initial -> { + SecondaryTabRow( + selectedTabIndex = pagerState.currentPage, + ) { + EmojibaseCategory.entries.forEachIndexed { index, category -> + Tab(icon = { + Icon( + imageVector = category.icon, contentDescription = stringResource(id = category.title) + ) + }, selected = pagerState.currentPage == index, onClick = { + coroutineScope.launch { pagerState.animateScrollToPage(index) } + }) + } } - is SearchBarResultState.NoResultsFound> -> { - // No results found, show a message - Spacer(Modifier.size(80.dp)) - - Text( - text = stringResource(CommonStrings.common_no_results), - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.fillMaxWidth() - ) + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + ) { index -> + val category = EmojibaseCategory.entries[index] + val emojis = categories[category] ?: listOf() + EmojiGrid(emojis = emojis, selectedEmojis = selectedEmojis, onEmojiSelected = onEmojiSelected) } + } + is SearchBarResultState.Results -> { + FreeformReaction( + searchQuery = state.searchQuery, + onReactionSelected = onReactionSelected + ) + EmojiGrid( + emojis = state.searchResults.results, + selectedEmojis = selectedEmojis, + onEmojiSelected = onEmojiSelected, + ) + } + is SearchBarResultState.NoResultsFound -> { + FreeformReaction( + searchQuery = state.searchQuery, + onReactionSelected = onReactionSelected + ) + // No results found, show a message + Spacer(Modifier.size(80.dp)) - else -> { - // Not searching - nothing to show. - } + Text( + text = stringResource(CommonStrings.common_no_results), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.fillMaxWidth() + ) } } } @@ -224,9 +233,9 @@ private fun EmojiPickerSearchBar( trailingIcon = when { query.isNotEmpty() -> { { - IconButton(onClick = { - onQueryChange("") - }) { + IconButton( + onClick = { onQueryChange("") }, + ) { Icon( imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_clear), @@ -267,11 +276,41 @@ private fun EmojiPickerSearchBar( } } +@Composable +private fun FreeformReaction( + searchQuery: String, + onReactionSelected: (String) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + val reaction = searchQuery.trim() + + Text(text = "Tap to react with ") + MessagesReactionButton( + content = MessagesReactionsButtonContent.Text( + text = reaction.ellipsize(MAX_REACTION_LENGTH_CHARS), + highlight = true, + ), + onClick = { onReactionSelected(reaction) }, + onLongClick = {}, + ) + } + HorizontalDivider( + modifier = Modifier + .padding(top = 12.dp, bottom = 4.dp) + .fillMaxWidth() + ) +} + @PreviewsDayNight @Composable internal fun EmojiPickerPreview() = ElementPreview { EmojiPicker( onEmojiSelected = {}, + onReactionSelected = {}, emojibaseStore = EmojibaseDatasource().load(LocalContext.current), selectedEmojis = persistentSetOf("😀", "😄", "😃"), state = EmojiPickerState( @@ -291,6 +330,7 @@ internal fun EmojiPickerSearchPreview() = ElementPreview { val query = "grin" EmojiPicker( onEmojiSelected = {}, + onReactionSelected = {}, emojibaseStore = emojibaseStore, selectedEmojis = persistentSetOf("😀", "😄", "😃"), state = EmojiPickerState( @@ -307,9 +347,11 @@ internal fun EmojiPickerSearchPreview() = ElementPreview { @Composable internal fun EmojiPickerSearchNoMatchPreview() = ElementPreview { val emojibaseStore = EmojibaseDatasource().load(LocalContext.current) - val query = "this is a very long string that won't match anything" + // padded with whitespace to test that it's trimmed + val query = " this is a very long string that won't match anything " EmojiPicker( onEmojiSelected = {}, + onReactionSelected = {}, emojibaseStore = emojibaseStore, selectedEmojis = persistentSetOf("😀", "😄", "😃"), state = EmojiPickerState( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerStatePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerStatePresenter.kt index bd0f95a229..febaf79426 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerStatePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerStatePresenter.kt @@ -72,10 +72,10 @@ class EmojiPickerStatePresenter @Inject constructor( } fun searchEmojis(searchQuery: String, allEmojis: List): SearchBarResultState> { - if (searchQuery == "") + val query = searchQuery.trim() + if (query == "") return SearchBarResultState.Initial() - val query = searchQuery.trim() val matches = allEmojis.filter { emoji -> emoji.unicode == query || emoji.label.contains(query, true) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt index 59c52ed8cf..3095a8b8d5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt @@ -25,7 +25,7 @@ import io.element.android.libraries.matrix.api.core.UserId * Reactions can be free text, so we need to limit the length * displayed on screen. */ -private const val MAX_DISPLAY_CHARS = 16 +internal const val MAX_REACTION_LENGTH_CHARS = 16 /** * @property currentUserId the ID of the currently logged in user @@ -40,10 +40,10 @@ data class AggregatedReaction( /** * The key to be displayed on screen. * - * See [MAX_DISPLAY_CHARS]. + * See [MAX_REACTION_LENGTH_CHARS]. */ val displayKey: String by lazy { - key.ellipsize(MAX_DISPLAY_CHARS) + key.ellipsize(MAX_REACTION_LENGTH_CHARS) } /**