Add emoji search to the reaction emoji picker (#5255)

* Add emoji search to the reaction emoji picker

* Update screenshots

* Fix tests and lint issues.

Fixing the tests required addressing some underlying issues in `SearchBar`

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2025-09-05 19:11:40 +02:00 committed by GitHub
parent d977ed25a4
commit a050f64196
18 changed files with 395 additions and 121 deletions

View file

@ -11,9 +11,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.element.android.emojibasebindings.Emoji import io.element.android.emojibasebindings.Emoji
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.EmojiPicker
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.EmojiPickerPresenter
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.hide import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@ -47,9 +50,10 @@ fun CustomReactionBottomSheet(
sheetState = sheetState, sheetState = sheetState,
modifier = modifier modifier = modifier
) { ) {
val presenter = remember { EmojiPickerPresenter(target.emojibaseStore) }
EmojiPicker( EmojiPicker(
onSelectEmoji = ::onEmojiSelectedDismiss, onSelectEmoji = ::onEmojiSelectedDismiss,
emojibaseStore = target.emojibaseStore, state = presenter.present(),
selectedEmojis = state.selectedEmoji, selectedEmojis = state.selectedEmoji,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )

View file

@ -1,110 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
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.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.Icon
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmojiPicker(
onSelectEmoji: (Emoji) -> Unit,
emojibaseStore: EmojibaseStore,
selectedEmojis: ImmutableSet<String>,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
val categories = remember { emojibaseStore.categories }
val pagerState = rememberPagerState(pageCount = { EmojibaseCategory.entries.size })
Column(modifier) {
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()
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),
onSelectEmoji = onSelectEmoji,
emojiSize = 32.dp.toSp(),
)
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun EmojiPickerPreview() = ElementPreview {
EmojiPicker(
onSelectEmoji = {},
emojibaseStore = EmojibaseDatasource().load(LocalContext.current),
selectedEmojis = persistentSetOf("😀", "😄", "😃"),
modifier = Modifier.fillMaxWidth(),
)
}

View file

@ -0,0 +1,158 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
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.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.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.features.messages.impl.timeline.components.customreaction.EmojiItem
import io.element.android.features.messages.impl.timeline.components.customreaction.icon
import io.element.android.features.messages.impl.timeline.components.customreaction.title
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.Icon
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmojiPicker(
onSelectEmoji: (Emoji) -> Unit,
state: EmojiPickerState,
selectedEmojis: ImmutableSet<String>,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
val categories = state.categories
val pagerState = rememberPagerState(pageCount = { EmojibaseCategory.entries.size })
Column(modifier) {
SearchBar(
modifier = Modifier.padding(bottom = 10.dp),
query = state.searchQuery,
onQueryChange = { state.eventSink(EmojiPickerEvents.UpdateSearchQuery(it)) },
resultState = state.searchResults,
active = state.isSearchActive,
onActiveChange = { state.eventSink(EmojiPickerEvents.ToggleSearchActive(it)) },
windowInsets = WindowInsets(0, 0, 0, 0),
placeHolderTitle = stringResource(CommonStrings.emoji_picker_search_placeholder),
) { results ->
val emojis = results
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 ->
SelectableEmojiItem(
item = item,
selectedEmojis = selectedEmojis,
onSelectEmoji = onSelectEmoji,
)
}
}
}
if (!state.isSearchActive) {
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()
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 ->
SelectableEmojiItem(
item = item,
selectedEmojis = selectedEmojis,
onSelectEmoji = onSelectEmoji,
)
}
}
}
}
}
}
@Composable
private fun SelectableEmojiItem(
item: Emoji,
selectedEmojis: ImmutableSet<String>,
onSelectEmoji: (Emoji) -> Unit,
) {
EmojiItem(
modifier = Modifier.aspectRatio(1f),
item = item,
isSelected = selectedEmojis.contains(item.unicode),
onSelectEmoji = onSelectEmoji,
emojiSize = 32.dp.toSp(),
)
}
@PreviewsDayNight
@Composable
internal fun EmojiPickerPreview(@PreviewParameter(EmojiPickerStateProvider::class) state: EmojiPickerState) = ElementPreview {
EmojiPicker(
onSelectEmoji = {},
state = state,
selectedEmojis = persistentSetOf("😀", "😄", "😃"),
modifier = Modifier.fillMaxWidth(),
)
}

View file

@ -0,0 +1,13 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
sealed interface EmojiPickerEvents {
data class ToggleSearchActive(val isActive: Boolean) : EmojiPickerEvents
data class UpdateSearchQuery(val query: String) : EmojiPickerEvents
}

View file

@ -0,0 +1,80 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
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.setValue
import androidx.compose.ui.platform.LocalInspectionMode
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds
class EmojiPickerPresenter(
private val emojibaseStore: EmojibaseStore,
) : Presenter<EmojiPickerState> {
@Composable
override fun present(): EmojiPickerState {
var searchQuery by remember { mutableStateOf("") }
var isSearchActive by remember { mutableStateOf(false) }
var emojiResults by remember { mutableStateOf<SearchBarResultState<ImmutableList<Emoji>>>(SearchBarResultState.Initial()) }
val categories = remember { emojibaseStore.categories }
LaunchedEffect(searchQuery) {
emojiResults = if (searchQuery.isEmpty()) {
SearchBarResultState.Initial()
} else {
// Add a small delay to avoid doing too many computations when the user is typing quickly
delay(100.milliseconds)
val lowercaseQuery = searchQuery.lowercase()
val results = withContext(Dispatchers.Default) {
emojibaseStore.allEmojis
.asSequence()
.filter { emoji ->
emoji.tags.orEmpty().any { it.contains(lowercaseQuery) } ||
emoji.shortcodes.any { it.contains(lowercaseQuery) }
}
.take(60)
.toImmutableList()
}
SearchBarResultState.Results(results)
}
}
val isInPreview = LocalInspectionMode.current
fun handleEvents(event: EmojiPickerEvents) {
when (event) {
// For some reason, in preview mode the SearchBar emits this event with an `isActive = true` value automatically
is EmojiPickerEvents.ToggleSearchActive -> if (!isInPreview) {
isSearchActive = event.isActive
}
is EmojiPickerEvents.UpdateSearchQuery -> searchQuery = event.query
}
}
return EmojiPickerState(
categories = categories,
searchQuery = searchQuery,
isSearchActive = isSearchActive,
searchResults = emojiResults,
eventSink = ::handleEvents,
)
}
}

View file

@ -0,0 +1,22 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
data class EmojiPickerState(
val categories: ImmutableMap<EmojibaseCategory, ImmutableList<Emoji>>,
val searchQuery: String,
val isSearchActive: Boolean,
val searchResults: SearchBarResultState<ImmutableList<Emoji>>,
val eventSink: (EmojiPickerEvents) -> Unit,
)

View file

@ -0,0 +1,83 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableMap
class EmojiPickerStateProvider : PreviewParameterProvider<EmojiPickerState> {
override val values: Sequence<EmojiPickerState>
get() = sequenceOf(
anEmojiPickerState(),
anEmojiPickerState(isSearchActive = true),
anEmojiPickerState(isSearchActive = true, searchQuery = "smile"),
anEmojiPickerState(
isSearchActive = true,
searchQuery = "smile",
searchResults = SearchBarResultState.Results(
persistentListOf(
Emoji(
"0x00",
"grinning face",
persistentListOf("grinning"),
persistentListOf("smile, grin"),
"😀",
null
),
Emoji(
"0x01",
"crying face",
persistentListOf("crying"),
persistentListOf("smile, crying"),
"\uD83E\uDD72",
null
),
)
)
),
)
}
internal fun anEmojiPickerState(
categories: ImmutableMap<EmojibaseCategory, ImmutableList<Emoji>> = EmojibaseCategory.entries.associateWith {
persistentListOf(
Emoji(
"0x00",
"grinning face",
persistentListOf("grinning"),
persistentListOf("smile, grin"),
"😀",
null
),
Emoji(
"0x01",
"crying face",
persistentListOf("crying"),
persistentListOf("smile, crying"),
"\uD83E\uDD72",
null
),
)
}.toImmutableMap(),
searchQuery: String = "",
isSearchActive: Boolean = false,
searchResults: SearchBarResultState<ImmutableList<Emoji>> = SearchBarResultState.Initial(),
eventSink: (EmojiPickerEvents) -> Unit = {},
) = EmojiPickerState(
categories = categories,
searchQuery = searchQuery,
isSearchActive = isSearchActive,
searchResults = searchResults,
eventSink = eventSink,
)

View file

@ -23,7 +23,10 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
@ -67,16 +70,19 @@ fun <T> SearchBar(
) { ) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
if (!active) { val updatedOnQueryChange by rememberUpdatedState(onQueryChange)
onQueryChange("") LaunchedEffect(active) {
focusManager.clearFocus() if (!active) {
updatedOnQueryChange("")
focusManager.clearFocus()
}
} }
SearchBar( SearchBar(
inputField = { inputField = {
SearchBarDefaults.InputField( SearchBarDefaults.InputField(
query = query, query = query,
onQueryChange = onQueryChange, onQueryChange = updatedOnQueryChange,
onSearch = { focusManager.clearFocus() }, onSearch = { focusManager.clearFocus() },
expanded = active, expanded = active,
onExpandedChange = onActiveChange, onExpandedChange = onActiveChange,

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:af699468bfa61662538fd3b187ebb1490f6a8e692c40bd13fc44beeb26744c93
size 21790

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9b7fdc137bb92ef23da15f9caad89a095355c719a4151b10d3f6156d04ab7055
size 6807

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a9ea09c61e22ba6bf59a93f06f89ae738d4cde1199fe79d2b0aed86805ddabab
size 5636

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da07b6a6c81f2d819247b77cb6452bf808a6ba9097143e8ca6acf85025ab5b3d
size 14438

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d79e3b8f699dab27d180942bc8f68f0193148e0e04602c03ec7592e4b73278f0
size 20876

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:12be2dd55a8f06cb5fe3ffcd0364a0cfee77a4ea502a31044c2c201ace026e49
size 6702

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ffe383d6b566d8ea19d335012e8aeff51de67fb75b208532a3c07bc02d76464
size 5523

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e8b0196422fbee4892e4909c027de98ca9f98c1d4d9c5d09fe8606b120f0e4de
size 14264

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5bbf091e13abc9a1d23fb01419d2fd17cf8c2e9e87cdd25d45e52d3c97ae04f4
size 231965

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cf9a76ede9a298829f4df8f1b6d5abcc25d841dd16be2d9331b1c4da8e84e8f6
size 234898