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 4edc943a730..33fe593aa5e 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 @@ -208,7 +208,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/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 00f02aed5df..26ee42ab064 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 @@ -160,6 +162,7 @@ fun aCustomReactionState( target = target, selectedEmoji = persistentSetOf(), eventSink = eventSink, + searchState = EmojiPickerState(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 9cf4375769b..24b713378bd 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 @@ -256,6 +256,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 b5e15d0d6c0..6bdbdd65be8 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 3fe739a592e..539820c8ad5 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 @@ -32,6 +32,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() @@ -50,16 +51,26 @@ 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, sheetState = sheetState, - modifier = modifier + modifier = modifier, ) { EmojiPicker( onEmojiSelected = ::onEmojiSelectedDismiss, + onReactionSelected = ::onReactionSelectedDismiss, 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..56e1d754ef5 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 @@ -28,13 +28,15 @@ 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 +51,7 @@ class CustomReactionPresenter @Inject constructor( fun handleDismissCustomReactionSheet() { target.value = CustomReactionState.Target.None + searchState.eventSink(EmojiPickerEvents.Reset) } fun handleEvents(event: CustomReactionEvents) { @@ -57,6 +60,7 @@ class CustomReactionPresenter @Inject constructor( is CustomReactionEvents.DismissCustomReactionSheet -> handleDismissCustomReactionSheet() } } + val event = (target.value as? CustomReactionState.Target.Success)?.event val selectedEmoji = event ?.reactionsState @@ -67,7 +71,8 @@ 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/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..5d50649df41 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,67 @@ 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.Row +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.Alignment 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.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.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.launch @@ -54,56 +86,219 @@ import kotlinx.coroutines.launch @Composable fun EmojiPicker( onEmojiSelected: (Emoji) -> Unit, + onReactionSelected: (String) -> 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(Unit) { + 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 = { + 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) } + }) + } + } + + 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)) + + Text( + text = stringResource(CommonStrings.common_no_results), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + + // Automatically open the keyboard if search is active + LaunchedEffect(Unit) { + if (state.isSearchActive) { + searchFocusRequester.requestFocus() + } + } +} + +@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 = category.icon, - contentDescription = stringResource(id = category.title) + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_clear), ) - }, - 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() - 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(), + 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() + } + } +} + +@Composable +private fun FreeformReaction( + searchQuery: String, + onReactionSelected: (String) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Tap to react with ") + MessagesReactionButton( + content = MessagesReactionsButtonContent.Text( + text = searchQuery.ellipsize(MAX_REACTION_LENGTH_CHARS), + highlight = true, + ), + onClick = { onReactionSelected(searchQuery) }, + onLongClick = {}, + ) + } + HorizontalDivider( + modifier = Modifier + .padding(top = 12.dp, bottom = 4.dp) + .fillMaxWidth() + ) +} + +@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(), + ) } } } @@ -113,8 +308,48 @@ fun EmojiPicker( internal fun EmojiPickerPreview() = ElementPreview { EmojiPicker( onEmojiSelected = {}, + onReactionSelected = {}, emojibaseStore = EmojibaseDatasource().load(LocalContext.current), selectedEmojis = persistentSetOf("😀", "😄", "😃"), + state = EmojiPickerState(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 = {}, + onReactionSelected = {}, + emojibaseStore = emojibaseStore, + selectedEmojis = persistentSetOf("😀", "😄", "😃"), + state = EmojiPickerState( + 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 = {}, + onReactionSelected = {}, + emojibaseStore = emojibaseStore, + selectedEmojis = persistentSetOf("😀", "😄", "😃"), + state = EmojiPickerState( + 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..a5d952e952e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerState.kt @@ -0,0 +1,27 @@ +/* + * 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 + +data class EmojiPickerState ( + 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..39266ab7b35 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPickerStatePresenter.kt @@ -0,0 +1,83 @@ +/* + * 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.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.libraries.architecture.Presenter +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import timber.log.Timber +import javax.inject.Inject + +class EmojiPickerStatePresenter @Inject constructor( + private val emojibaseProvider: EmojibaseProvider, +) : Presenter { + @Composable + override fun present(): EmojiPickerState { + var searchQuery by rememberSaveable { mutableStateOf("") } + var searchActive by rememberSaveable { mutableStateOf(true) } + val searchResults = remember { mutableStateOf>>(SearchBarResultState.Initial()) } + + LaunchedEffect(searchQuery) { + searchResults.value = searchEmojis(searchQuery, emojibaseProvider.emojibaseStore.allEmojis) + } + + return EmojiPickerState( + isSearchActive = searchActive, + searchQuery = searchQuery, + searchResults = searchResults.value, + eventSink = { + when (it) { + is EmojiPickerEvents.OnSearchActiveChanged -> { + searchActive = it.active + Timber.tag("EmojiPicker").d("Search active changed: ${it.active}") + } + is EmojiPickerEvents.UpdateSearchQuery -> { + searchQuery = it.query + } + is EmojiPickerEvents.Reset -> { + searchActive = true + 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) } + } + return if (matches.isEmpty()) SearchBarResultState.NoResultsFound() else SearchBarResultState.Results(matches) +} + 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) } /** diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 97ddec4b3b2..8d7236a5090 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -188,6 +188,7 @@ "Saved changes" "Saving" "Screen lock" + "Search for emoji" "Search for someone" "Search results" "Security"