From 6fa81b8c41c36366d11fe9c084282e963704274e Mon Sep 17 00:00:00 2001 From: Chelsea Hilditch Date: Sun, 3 Mar 2024 21:09:33 +0000 Subject: [PATCH 1/2] Reaction emoji picker search/filtering Fixes: https://github.com/element-hq/element-x-android/issues/1611 Co-Authored-by: Chelsea Hilditch Signed-off-by: Joe Groocock --- .../messages/impl/MessagesStateProvider.kt | 3 + .../features/messages/impl/MessagesView.kt | 8 +- .../CustomReactionBottomSheet.kt | 25 +- .../customreaction/CustomReactionPresenter.kt | 11 +- .../customreaction/CustomReactionState.kt | 1 + .../components/customreaction/EmojiPicker.kt | 269 +++++++++++++++--- .../customreaction/EmojiPickerEvents.kt | 23 ++ .../customreaction/EmojiPickerState.kt | 29 ++ .../EmojiPickerStatePresenter.kt | 87 ++++++ .../impl/advanced/AdvancedSettingsEvents.kt | 1 + .../advanced/AdvancedSettingsPresenter.kt | 7 + .../impl/advanced/AdvancedSettingsState.kt | 1 + .../advanced/AdvancedSettingsStateProvider.kt | 2 + .../impl/advanced/AdvancedSettingsView.kt | 12 + .../impl/src/main/res/values/localazy.xml | 2 + .../api/store/SessionPreferencesStore.kt | 3 + .../store/DefaultSessionPreferencesStore.kt | 4 + .../src/main/res/values/localazy.xml | 1 + 18 files changed, 452 insertions(+), 37 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerEvents.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerState.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerStatePresenter.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index fd00eba04be..41a7bea42bf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.customreaction.EmojiPickerState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents @@ -42,6 +43,7 @@ import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMe import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.textcomposer.aRichTextEditorState import io.element.android.libraries.textcomposer.model.MessageComposerMode @@ -163,6 +165,7 @@ fun aCustomReactionState( target = target, selectedEmoji = persistentSetOf(), eventSink = eventSink, + searchState = EmojiPickerState(false, false, "", SearchBarResultState.Initial()) {} ) fun aReadReceiptBottomSheetState( 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 e7b65f353ff..abb0fb5cb76 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 @@ -72,6 +72,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet @@ -92,6 +93,7 @@ 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.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.modifiers.applyIf import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle @@ -332,7 +334,11 @@ private fun MessagesViewContent( modifier = modifier .fillMaxSize() .navigationBarsPadding() - .imePadding(), + .applyIf( + // Disable imePadding() when reaction picker is open to prevent the chat moving behind the bottom sheet + condition = state.customReactionState.target is CustomReactionState.Target.None, + ifTrue = { imePadding() } + ) ) { AttachmentsBottomSheet( state = state.composerState, 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 3fe739a592e..5438c76dcc5 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 @@ -16,13 +16,22 @@ package io.element.android.features.messages.impl.timeline.components.customreaction +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.heightIn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import io.element.android.emojibasebindings.Emoji +import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.hide import io.element.android.libraries.matrix.api.core.EventId @@ -34,15 +43,18 @@ fun CustomReactionBottomSheet( onEmojiSelected: (EventId, Emoji) -> Unit, modifier: Modifier = Modifier, ) { - val sheetState = rememberModalBottomSheetState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = state.searchState.startActive) val coroutineScope = rememberCoroutineScope() val target = state.target as? CustomReactionState.Target.Success + val localView = LocalView.current fun onDismiss() { + localView.hideKeyboard() state.eventSink(CustomReactionEvents.DismissCustomReactionSheet) } fun onEmojiSelectedDismiss(emoji: Emoji) { + localView.hideKeyboard() if (target?.event?.eventId == null) return sheetState.hide(coroutineScope) { state.eventSink(CustomReactionEvents.DismissCustomReactionSheet) @@ -55,11 +67,22 @@ fun CustomReactionBottomSheet( onDismissRequest = ::onDismiss, sheetState = sheetState, modifier = modifier + .heightIn(min = if (state.searchState.isSearchActive) (LocalConfiguration.current.screenHeightDp).dp else Dp.Unspecified) + .pointerInput(state.searchState.isSearchActive) { + awaitEachGesture { + // For any unconsumed pointer event in this sheet, deactivate the search field and hide the keyboard + awaitFirstDown(requireUnconsumed = true) + if (state.searchState.isSearchActive) { + state.searchState.eventSink(EmojiPickerEvents.OnSearchActiveChanged(false)) + } + } + } ) { EmojiPicker( onEmojiSelected = ::onEmojiSelectedDismiss, emojibaseStore = target.emojibaseStore, selectedEmojis = state.selectedEmoji, + state = state.searchState, modifier = Modifier.fillMaxSize(), ) } 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 a75e084abf5..fb4e22a5670 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 @@ -22,19 +22,23 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.architecture.Presenter import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.launch import javax.inject.Inject class CustomReactionPresenter @Inject constructor( - private val emojibaseProvider: EmojibaseProvider + private val emojibaseProvider: EmojibaseProvider, + private val emojiPickerStatePresenter: EmojiPickerStatePresenter, ) : Presenter { @Composable override fun present(): CustomReactionState { val target: MutableState = remember { mutableStateOf(CustomReactionState.Target.None) } + val searchState = emojiPickerStatePresenter.present() val localCoroutineScope = rememberCoroutineScope() fun handleShowCustomReactionSheet(event: TimelineItem.Event) { @@ -49,6 +53,7 @@ class CustomReactionPresenter @Inject constructor( fun handleDismissCustomReactionSheet() { target.value = CustomReactionState.Target.None + searchState.eventSink(EmojiPickerEvents.Reset) } fun handleEvents(event: CustomReactionEvents) { @@ -57,6 +62,7 @@ class CustomReactionPresenter @Inject constructor( is CustomReactionEvents.DismissCustomReactionSheet -> handleDismissCustomReactionSheet() } } + val event = (target.value as? CustomReactionState.Target.Success)?.event val selectedEmoji = event ?.reactionsState @@ -67,7 +73,8 @@ class CustomReactionPresenter @Inject constructor( return CustomReactionState( target = target.value, selectedEmoji = selectedEmoji, - eventSink = { handleEvents(it) } + eventSink = { handleEvents(it) }, + searchState = searchState, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt index 5474068df06..c468366426f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt @@ -24,6 +24,7 @@ data class CustomReactionState( val target: Target, val selectedEmoji: ImmutableSet, val eventSink: (CustomReactionEvents) -> Unit, + val searchState: EmojiPickerState, ) { sealed interface Target { data object None : Target 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 ddf524b656b..409ae9bea68 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 @@ -17,35 +17,61 @@ package io.element.android.features.messages.impl.timeline.components.customreaction import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.interaction.MutableInteractionSource +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.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.emojibasebindings.Emoji 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.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.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 @@ -56,54 +82,187 @@ fun EmojiPicker( onEmojiSelected: (Emoji) -> Unit, emojibaseStore: EmojibaseStore, selectedEmojis: ImmutableSet, + state: EmojiPickerState, modifier: Modifier = Modifier, ) { val coroutineScope = rememberCoroutineScope() val categories = remember { emojibaseStore.categories } val pagerState = rememberPagerState(pageCount = { EmojibaseCategory.entries.size }) + val searchFocusRequester = remember { FocusRequester() } + Column(modifier) { - SecondaryTabRow( - selectedTabIndex = pagerState.currentPage, + EmojiPickerSearchBar( + query = state.searchQuery, + active = state.isSearchActive, + onActiveChange = { state.eventSink(EmojiPickerEvents.OnSearchActiveChanged(it)) }, + onQueryChange = { state.eventSink(EmojiPickerEvents.UpdateSearchQuery(it)) }, + focusRequester = searchFocusRequester, + ) + + Column( + modifier = Modifier + .pointerInput(state.isSearchActive) { + awaitEachGesture { + // For any consumed pointer event in this column, deactivate the search field + awaitFirstDown(requireUnconsumed = false) + if (state.isSearchActive) { + state.eventSink(EmojiPickerEvents.OnSearchActiveChanged(false)) + } + } + } ) { - EmojibaseCategory.entries.forEachIndexed { index, category -> - Tab( - icon = { - Icon( - imageVector = category.icon, - contentDescription = stringResource(id = category.title) + 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, ) - }, - 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() + ) + } + + else -> { + // Not searching - nothing to show. + } + } } } + } + + // Automatically open the keyboard if search is active + LaunchedEffect(Unit) { + if (state.isSearchActive) { + searchFocusRequester.requestFocus() + } + } +} + +@Composable +private fun EmojiGrid( + emojis: List, + selectedEmojis: ImmutableSet, + onEmojiSelected: (Emoji) -> Unit, + modifier: Modifier = Modifier, +) { + LazyVerticalGrid( + modifier = modifier.fillMaxSize(), + columns = GridCells.Adaptive(minSize = 48.dp), + contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(emojis, key = { it.unicode }) { item -> + EmojiItem( + modifier = Modifier.aspectRatio(1f), + item = item, + isSelected = selectedEmojis.contains(item.unicode), + onEmojiSelected = onEmojiSelected, + emojiSize = 32.dp.toSp(), + ) + } + } +} - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxWidth(), - ) { index -> - val category = EmojibaseCategory.entries[index] - val emojis = categories[category] ?: listOf() - LazyVerticalGrid( - modifier = Modifier.fillMaxSize(), - columns = GridCells.Adaptive(minSize = 48.dp), - contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - items(emojis, key = { it.unicode }) { item -> - EmojiItem( - modifier = Modifier.aspectRatio(1f), - item = item, - isSelected = selectedEmojis.contains(item.unicode), - onEmojiSelected = onEmojiSelected, - emojiSize = 32.dp.toSp(), +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EmojiPickerSearchBar( + query: String, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + onQueryChange: (String) -> Unit, + focusRequester: FocusRequester = FocusRequester.Default, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focusManager = LocalFocusManager.current + + TextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .focusRequester(focusRequester) + .onFocusChanged { if (it.isFocused) onActiveChange(true) }, + placeholder = { + Text(text = stringResource(CommonStrings.common_search_for_emoji)) + }, + trailingIcon = when { + query.isNotEmpty() -> { + { + IconButton(onClick = { + onQueryChange("") + }) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_clear), + ) + } + } + } + + else -> { + { + Icon( + imageVector = CompoundIcons.Search(), + contentDescription = stringResource(CommonStrings.action_search), + tint = MaterialTheme.colorScheme.tertiary, ) } } + }, + shape = SearchBarDefaults.inputFieldShape, + singleLine = true, + colors = ( + if (active) ElementSearchBarDefaults.activeColors().inputFieldColors + else ElementSearchBarDefaults.inactiveColors().inputFieldColors + ) + .copy( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + interactionSource = interactionSource, + ) + + val isFocused = interactionSource.collectIsFocusedAsState().value + val shouldClearFocus = !active && isFocused + LaunchedEffect(active) { + if (shouldClearFocus) { + focusManager.clearFocus() } } } @@ -115,6 +274,50 @@ internal fun EmojiPickerPreview() = ElementPreview { onEmojiSelected = {}, emojibaseStore = EmojibaseDatasource().load(LocalContext.current), selectedEmojis = persistentSetOf("πŸ˜€", "πŸ˜„", "πŸ˜ƒ"), + state = EmojiPickerState( + startActive = false, + isSearchActive = false, + searchQuery = "", + searchResults = SearchBarResultState.Initial(), + ) {}, + modifier = Modifier.fillMaxWidth(), + ) +} + +@PreviewsDayNight +@Composable +internal fun EmojiPickerSearchPreview() = ElementPreview { + val emojibaseStore = EmojibaseDatasource().load(LocalContext.current) + val query = "grin" + EmojiPicker( + onEmojiSelected = {}, + emojibaseStore = emojibaseStore, + selectedEmojis = persistentSetOf("πŸ˜€", "πŸ˜„", "πŸ˜ƒ"), + state = EmojiPickerState( + startActive = false, + isSearchActive = true, + searchQuery = query, + searchResults = searchEmojis(query, emojibaseStore.allEmojis) + ) {}, + modifier = Modifier.fillMaxWidth(), + ) +} + +@PreviewsDayNight +@Composable +internal fun EmojiPickerSearchNoMatchPreview() = ElementPreview { + val emojibaseStore = EmojibaseDatasource().load(LocalContext.current) + val query = "this is a very long string that won't match anything" + EmojiPicker( + onEmojiSelected = {}, + emojibaseStore = emojibaseStore, + selectedEmojis = persistentSetOf("πŸ˜€", "πŸ˜„", "πŸ˜ƒ"), + state = EmojiPickerState( + startActive = false, + isSearchActive = true, + searchQuery = query, + searchResults = searchEmojis(query, emojibaseStore.allEmojis) + ) {}, modifier = Modifier.fillMaxWidth(), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerEvents.kt new file mode 100644 index 00000000000..9e07da01899 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 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.customreaction + +sealed interface EmojiPickerEvents { + data class OnSearchActiveChanged(val active: Boolean) : EmojiPickerEvents + data class UpdateSearchQuery(val query: String) : EmojiPickerEvents + data object Reset : EmojiPickerEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerState.kt new file mode 100644 index 00000000000..7fbf6adcec2 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 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.customreaction + +import io.element.android.emojibasebindings.Emoji +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import kotlinx.collections.immutable.ImmutableList + +data class EmojiPickerState( + val startActive: Boolean, + val isSearchActive: Boolean, + val searchQuery: String, + val searchResults: SearchBarResultState>, + val eventSink: (EmojiPickerEvents) -> Unit, +) 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 new file mode 100644 index 00000000000..bd0f95a229a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerStatePresenter.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2024 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.customreaction + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.element.android.emojibasebindings.Emoji +import io.element.android.emojibasebindings.allEmojis +import io.element.android.features.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import kotlinx.collections.immutable.toImmutableList +import javax.inject.Inject + +class EmojiPickerStatePresenter @Inject constructor( + private val emojibaseProvider: EmojibaseProvider, + private val sessionPreferencesStore: SessionPreferencesStore, +) : Presenter { + @Composable + override fun present(): EmojiPickerState { + val startActive by sessionPreferencesStore.isReactionPickerSearchEnabled().collectAsState(initial = false) + var searchQuery by rememberSaveable { mutableStateOf("") } + var searchActive by rememberSaveable(startActive) { mutableStateOf(startActive) } + val searchResults = remember { mutableStateOf>>(SearchBarResultState.Initial()) } + + LaunchedEffect(searchQuery) { + searchResults.value = searchEmojis(searchQuery, emojibaseProvider.emojibaseStore.allEmojis) + } + + return EmojiPickerState( + startActive = startActive, + isSearchActive = searchActive, + searchQuery = searchQuery, + searchResults = searchResults.value, + eventSink = { + when (it) { + is EmojiPickerEvents.OnSearchActiveChanged -> { + searchActive = it.active + } + is EmojiPickerEvents.UpdateSearchQuery -> { + searchQuery = it.query + } + is EmojiPickerEvents.Reset -> { + searchActive = startActive + searchQuery = "" + } + } + } + ) + } +} + +fun searchEmojis(searchQuery: String, allEmojis: List): SearchBarResultState> { + if (searchQuery == "") + return SearchBarResultState.Initial() + + val query = searchQuery.trim() + val matches = allEmojis.filter { emoji -> + emoji.unicode == query + || emoji.label.contains(query, true) + || emoji.tags?.any { it.contains(query, true) }.orFalse() + || emoji.shortcodes.any { it.contains(query, true) } + }.toImmutableList() + + return if (matches.isEmpty()) SearchBarResultState.NoResultsFound() else SearchBarResultState.Results(matches) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt index 4ed42775998..19a898c5622 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt @@ -21,6 +21,7 @@ import io.element.android.compound.theme.Theme sealed interface AdvancedSettingsEvents { data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents + data class SetReactionPickerSearchEnabled(val enabled: Boolean) : AdvancedSettingsEvents data object ChangeTheme : AdvancedSettingsEvents data object CancelChangeTheme : AdvancedSettingsEvents data class SetTheme(val theme: Theme) : AdvancedSettingsEvents diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt index 21d84567e16..6430c680800 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt @@ -55,6 +55,9 @@ class AdvancedSettingsPresenter @Inject constructor( val isSharePresenceEnabled by sessionPreferencesStore .isSharePresenceEnabled() .collectAsState(initial = true) + val isReactionPickerSearchEnabled by sessionPreferencesStore + .isReactionPickerSearchEnabled() + .collectAsState(initial = true) val theme by remember { appPreferencesStore.getThemeFlow().mapToTheme() } @@ -124,6 +127,9 @@ class AdvancedSettingsPresenter @Inject constructor( is AdvancedSettingsEvents.SetSharePresenceEnabled -> localCoroutineScope.launch { sessionPreferencesStore.setSharePresence(event.enabled) } + is AdvancedSettingsEvents.SetReactionPickerSearchEnabled -> localCoroutineScope.launch { + sessionPreferencesStore.setReactionPickerSearch(event.enabled) + } AdvancedSettingsEvents.CancelChangeTheme -> showChangeThemeDialog = false AdvancedSettingsEvents.ChangeTheme -> showChangeThemeDialog = true is AdvancedSettingsEvents.SetTheme -> localCoroutineScope.launch { @@ -139,6 +145,7 @@ class AdvancedSettingsPresenter @Inject constructor( return AdvancedSettingsState( isDeveloperModeEnabled = isDeveloperModeEnabled, isSharePresenceEnabled = isSharePresenceEnabled, + isReactionPickerSearchEnabled = isReactionPickerSearchEnabled, theme = theme, showChangeThemeDialog = showChangeThemeDialog, currentPushDistributor = currentDistributorName, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt index 23de2fda137..0114c8c480e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt @@ -23,6 +23,7 @@ import kotlinx.collections.immutable.ImmutableList data class AdvancedSettingsState( val isDeveloperModeEnabled: Boolean, val isSharePresenceEnabled: Boolean, + val isReactionPickerSearchEnabled: Boolean, val theme: Theme, val showChangeThemeDialog: Boolean, val currentPushDistributor: AsyncAction, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt index 5e6af364f2f..fdaa7c73a95 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt @@ -37,6 +37,7 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider = AsyncAction.Success("Firebase"), availablePushDistributors: List = listOf("Firebase", "ntfy"), @@ -45,6 +46,7 @@ fun aAdvancedSettingsState( ) = AdvancedSettingsState( isDeveloperModeEnabled = isDeveloperModeEnabled, isSharePresenceEnabled = isSendPublicReadReceiptsEnabled, + isReactionPickerSearchEnabled = isReactionPickerSearchEnabled, theme = Theme.System, showChangeThemeDialog = showChangeThemeDialog, currentPushDistributor = currentPushDistributor, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt index 9b82a87bbce..ebb50934f37 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt @@ -114,6 +114,18 @@ fun AdvancedSettingsView( } } ) + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_reaction_search)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_reaction_search_description)) + }, + trailingContent = ListItemContent.Switch( + checked = state.isReactionPickerSearchEnabled, + ), + onClick = { state.eventSink(AdvancedSettingsEvents.SetReactionPickerSearchEnabled(!state.isReactionPickerSearchEnabled)) } + ) } if (state.showChangeThemeDialog) { diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml index d9b6b583edf..8c52598fee1 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -13,6 +13,8 @@ "Share presence" "If turned off, you won’t be able to send or receive read receipts or typing notifications" "Enable option to view message source in the timeline." + "Emoji reaction search" + "Highlight the reaction search box by default when opening the reaction picker" "You have no blocked users" "Unblock" "You\'ll be able to see all messages from them again." diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/SessionPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/SessionPreferencesStore.kt index 9bbeadc7091..1e763da50d6 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/SessionPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/SessionPreferencesStore.kt @@ -34,6 +34,9 @@ interface SessionPreferencesStore { suspend fun setRenderTypingNotifications(enabled: Boolean) fun isRenderTypingNotificationsEnabled(): Flow + suspend fun setReactionPickerSearch(enabled: Boolean) + fun isReactionPickerSearchEnabled(): Flow + suspend fun setSkipSessionVerification(skip: Boolean) fun isSessionVerificationSkipped(): Flow diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt index 41b52d87f0a..69d33fef7eb 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt @@ -49,6 +49,7 @@ class DefaultSessionPreferencesStore( private val renderReadReceiptsKey = booleanPreferencesKey("renderReadReceipts") private val sendTypingNotificationsKey = booleanPreferencesKey("sendTypingNotifications") private val renderTypingNotificationsKey = booleanPreferencesKey("renderTypingNotifications") + private val reactionPickerSearchKey = booleanPreferencesKey("reactionPickerSearch") private val skipSessionVerification = booleanPreferencesKey("skipSessionVerification") private val dataStoreFile = storeFile(context, sessionId) @@ -87,6 +88,9 @@ class DefaultSessionPreferencesStore( override suspend fun setRenderTypingNotifications(enabled: Boolean) = update(renderTypingNotificationsKey, enabled) override fun isRenderTypingNotificationsEnabled(): Flow = get(renderTypingNotificationsKey) { true } + override suspend fun setReactionPickerSearch(enabled: Boolean) = update(reactionPickerSearchKey, enabled) + override fun isReactionPickerSearchEnabled(): Flow = get(reactionPickerSearchKey) { false } + override suspend fun setSkipSessionVerification(skip: Boolean) = update(skipSessionVerification, skip) override fun isSessionVerificationSkipped(): Flow = get(skipSessionVerification) { false } diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 8d46b6e5b16..5bba482e415 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -193,6 +193,7 @@ "Saved changes" "Saving" "Screen lock" + "Search for emoji" "Search for someone" "Search results" "Security" From 7ae884b59b00bd32bb7db90f4dadd3b6ef0db78d Mon Sep 17 00:00:00 2001 From: Joe Groocock Date: Tue, 21 May 2024 22:06:29 +0100 Subject: [PATCH 2/2] Add sending freeform reactions Signed-off-by: Joe Groocock --- .../features/messages/impl/MessagesEvents.kt | 2 +- .../messages/impl/MessagesPresenter.kt | 2 +- .../features/messages/impl/MessagesView.kt | 3 + .../components/MessagesReactionButton.kt | 8 +- .../CustomReactionBottomSheet.kt | 10 ++ .../customreaction/CustomReactionPresenter.kt | 2 +- .../components/customreaction/EmojiPicker.kt | 140 ++++++++++++------ .../EmojiPickerStatePresenter.kt | 4 +- .../impl/timeline/model/AggregatedReaction.kt | 6 +- 9 files changed, 118 insertions(+), 59 deletions(-) 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 b901f7e1304..dacaa9abb0d 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 579ea87868b..5027f83f0f3 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 abb0fb5cb76..9c04a572064 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 7786a69db6c..1a142bcbdc7 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 5438c76dcc5..48008dd4c17 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 fb4e22a5670..1d06b56ce86 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 409ae9bea68..c13bae663b7 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 bd0f95a229a..febaf794269 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 59c52ed8cf1..3095a8b8d5f 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) } /**